Compare commits

...

62 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
Daniel Luiz Alves
b4bf227603 v3.1.1-beta (#145) 2025-07-11 10:20:52 -03:00
Daniel Luiz Alves
90c0300d77 chore: update package versions to v3.1.1-beta for docs, server, and web applications 2025-07-11 09:53:00 -03:00
Daniel Luiz Alves
a5a22ca5c4 feat(profile): implement image editing functionality with cropping and zooming
- Added a new ImageEditModal component for cropping and adjusting images.
- Integrated image editing capabilities into the ProfilePicture component, allowing users to edit their profile images.
- Updated translations for image editing features in multiple languages.
- Introduced a Skeleton component for loading states during image processing.
- Enhanced file upload handling with chunked uploads for better performance.
2025-07-11 01:03:45 -03:00
Daniel Luiz Alves
f1ef32b5d4 refactor(auth): remove unused import in useLogin hook
- Cleaned up the useLogin hook by removing the unused getAppInfo import, streamlining the code for better readability and maintainability.
2025-07-11 00:21:13 -03:00
Daniel Luiz Alves
a4bc5ec015 feat(auth): improve user data fetching on authentication
- Updated AuthCallbackPage to fetch user data after successful authentication and set user context.
- Removed redundant initialization logic in useLogin hook and streamlined user data retrieval post-login.
- Enhanced error handling for user data fetching to improve user experience during authentication.
2025-07-11 00:19:48 -03:00
Daniel Luiz Alves
2e56b7e59f feat(auth): enhance client header handling in proxy requests
- Introduced a new utility function `getClientHeaders` to extract real client IP and user agent from requests.
- Updated authentication routes to utilize the new utility for improved header management.
- Refactored `getClientInfo` method in AuthController to support additional proxy headers.
2025-07-10 23:57:49 -03:00
Daniel Luiz Alves
5672d25bce Merge branch 'feat/chunked-uploads' into next 2025-07-10 18:03:29 -03:00
Daniel Luiz Alves
edf20e6190 Feat: Add trusted device support for 2FA (#138) 2025-07-10 00:11:26 -03:00
Daniel Luiz Alves
dc3da45c2d fix: update dependencies in hooks for improved functionality 2025-07-09 23:45:43 -03:00
Daniel Luiz Alves
f3f792e053 feat(auth): enhance trusted device management for 2FA
- Added lastUsedAt timestamp to the TrustedDevice model for tracking device usage.
- Implemented new endpoints for retrieving and removing trusted devices.
- Updated AuthService to manage trusted devices, including methods for getting and removing devices.
- Enhanced the user interface to support trusted device management, including modals for removing devices.
- Added translations for new messages related to trusted devices in multiple languages.
2025-07-09 23:43:57 -03:00
Daniel Luiz Alves
ad689bd6d9 feat(auth): add trusted device support for 2FA
implement remember device option for two-factor authentication
add trusted device service to manage device trust
update login flow to check for trusted devices
2025-07-09 00:34:56 -03:00
Daniel Luiz Alves
ffd5005c8b feat: Add Pocket ID as a new OIDC provider (#133) 2025-07-08 18:31:19 -03:00
Daniel Luiz Alves
e9ae414a6e feat: add Pocket ID as a new OIDC provider
- Updated the OIDC authentication meta.json to include Pocket ID in the list of supported pages.
- Created a new documentation file for Pocket ID authentication, detailing setup, configuration, and troubleshooting.
- Added relevant images to support the Pocket ID documentation.
- Updated the OIDC provider cards to display Pocket ID.
- Configured Pocket ID in the server's authentication provider settings, including necessary endpoints and metadata.
- Enhanced provider patterns and scopes to support Pocket ID integration.
2025-07-08 18:27:55 -03:00
Daniel Luiz Alves
a3389b8b0d feat: implement chunked file upload and progress tracking
- Introduced a new ChunkManager class to handle chunked uploads, including methods for processing chunks, tracking upload progress, and cleaning up temporary files.
- Updated the FilesystemController to support chunked uploads and provide endpoints for checking upload progress and canceling uploads.
- Added a ChunkedUploader utility to manage chunked uploads on the client side, optimizing file uploads based on size.
- Enhanced the API with new routes for upload progress and cancellation, improving user experience during file uploads.
- Updated frontend components to utilize chunked upload functionality, ensuring efficient handling of large files.
2025-07-08 15:40:25 -03:00
Daniel Luiz Alves
199dd9ffd4 chore: add .eslintignore file and update TypeScript configuration
- Created a new .eslintignore file to exclude Next.js build artifacts and node_modules from linting.
- Modified the TypeScript configuration to skip library checks and refined the exclude/include patterns for better clarity and performance.
2025-07-08 09:31:13 -03:00
Daniel Luiz Alves
233ea0da41 fix: update ESLint configuration to include .next directory in ignores
- Added ".next/**/*" to the ignores array in the ESLint configuration to prevent linting of build artifacts, improving the linting process.
2025-07-08 09:26:42 -03:00
Daniel Luiz Alves
1134beb6a6 fix: update French translations for file sharing feature
- Changed the title from "Receber Arquivos" to "Recevoir des Fichiers" and updated the description to "Créez des liens pour que d'autres puissent vous envoyer des fichiers" for better localization accuracy.
2025-07-08 09:22:46 -03:00
Daniel Luiz Alves
b26450d277 Feat: Add 2FA/TOPT Support (#130) 2025-07-08 00:51:42 -03:00
Daniel Luiz Alves
61255b5e19 fix: update translation key for backup codes instructions in two-factor form
- Changed the translation key from "backupCodes.instructions" to "twoFactor.backupCodes.instructions" to ensure consistency with the new localization structure.
2025-07-08 00:43:23 -03:00
Daniel Luiz Alves
e4bdfb8432 fix: update translations and clean up imports in various components
- Translated SMTP connection test messages in French and Polish for better localization.
- Removed unused icon imports in the two-factor verification and profile components to streamline the code.
- Simplified user data extraction in the login hook for clarity and consistency.
2025-07-08 00:40:26 -03:00
Daniel Luiz Alves
7f76d48314 feat: implement two-factor authentication (2FA) functionality
- Added two-factor authentication support to the login process, enhancing security for user accounts.
- Introduced new routes and services for managing 2FA setup, verification, and backup codes.
- Updated user model to include fields for 2FA status and backup codes.
- Enhanced login and profile pages to accommodate 2FA input and management.
- Added translations for 2FA-related messages in multiple languages.
- Integrated QR code generation for 2FA setup, improving user experience during authentication.
2025-07-08 00:23:50 -03:00
Daniel Luiz Alves
4d101fbdeb feat: add LogoInput component to settings input for app logo configuration
- Integrated LogoInput component into the SettingsInput to allow users to upload and manage the application logo.
- Updated the renderInput function to handle the new appLogo configuration, enhancing the settings interface.
2025-07-07 16:38:25 -03:00
168 changed files with 12807 additions and 3512 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

@@ -1,6 +1,18 @@
{
"defaultOpen": false,
"icon": "Key",
"pages": ["index", "google", "discord", "github", "zitadel", "auth0", "authentik", "frontegg", "kinde-auth", "other"],
"pages": [
"index",
"google",
"discord",
"github",
"zitadel",
"auth0",
"authentik",
"frontegg",
"kinde-auth",
"pocket-id",
"other"
],
"title": "OIDC Authentication"
}
}

View File

@@ -0,0 +1,279 @@
---
title: Pocket ID
icon: IdCardLanyard
---
import { ZoomableImage } from "@/components/ui/zoomable-image";
Pocket ID is one of Palmr's officially supported OIDC providers, offering a robust and flexible identity management solution. This integration allows users to sign in to Palmr using Pocket ID's authentication system, making it perfect for organizations that need a self-hosted identity provider with OIDC support.
<ZoomableImage src="/assets/v3/oidc/pocket-id/sign-in-with-pocket-id.png" alt="Sign in with Pocket ID" />
## Why use Pocket ID authentication?
Pocket ID authentication provides several advantages for organizations seeking a self-hosted identity solution:
- **Self-hosted control** - Full control over your authentication infrastructure and data
- **OIDC compliance** - Standard OpenID Connect implementation for seamless integration
- **Flexible deployment** - Deploy on any infrastructure that suits your needs
- **Automatic discovery** - Supports OIDC discovery for streamlined configuration
- **Simple configuration** - Intuitive setup process with minimal complexity
- **Data sovereignty** - Keep all authentication data within your infrastructure
- **Cost-effective** - No per-user pricing, perfect for growing organizations
---
## Prerequisites
Before configuring Pocket ID authentication, ensure you have:
- **Pocket ID instance** - A running Pocket ID server accessible via HTTPS
- **Admin privileges in Palmr** - Required to configure OIDC settings
- **Domain configuration** - For production deployments with custom domains
> **Note:** Pocket ID is pre-configured as an official provider in Palmr, which means the technical configuration is handled automatically. You only need to provide your OAuth credentials.
---
## Setting up Pocket ID Application
### Creating a Pocket ID application
To get started with Pocket ID authentication, you'll need to create an application in your Pocket ID admin interface.
1. **Navigate to Pocket ID Admin**: Go to your Pocket ID instance URL (e.g., `https://your-pocket-id.domain.com`)
<ZoomableImage src="/assets/v3/oidc/pocket-id/pocket-id-console.png" alt="Pocket ID Console" />
2. **Navigate to OIDC Clients**: Click **"OIDC Clients"** in the applications in the left sidebar, you will be redirected to the OIDC Clients page
<ZoomableImage src="/assets/v3/oidc/pocket-id/oidc-clients.png" alt="OIDC Clients" />
3. **Create a new OIDC Client**: Click **"Add OIDC Client"** button in the OIDC Clients page
<ZoomableImage src="/assets/v3/oidc/pocket-id/create-oidc-client-button.png" alt="Create OIDC Client Button" />
Configure the following settings:
- **Name**: "Palmr File Sharing" (or your preferred name)
- **Public Client**: "Diasabled"
- **PKCE**: "Disabled"
- **Logo**: "Upload a logo image"
<ZoomableImage src="/assets/v3/oidc/pocket-id/create-oidc-client.png" alt="Create OIDC Client" />
### Configuring application URLs
You'll need to configure several URLs in your Pocket ID application settings. Here's what to add for each environment:
### Redirect URIs
| Environment | URL |
| ----------- | ------------------------------------------------------------------ |
| Production | `https://yourdomain.com/api/auth/providers/pocketid/callback` |
| Development | `http://localhost:3000/api/auth/providers/pocketid/callback` |
| Custom Port | `https://yourdomain.com:5487/api/auth/providers/pocketid/callback` |
### Post Logout Redirect URIs
| Environment | URL |
| ----------- | ----------------------------- |
| Production | `https://yourdomain.com` |
| Development | `http://localhost:3000` |
| Custom Port | `https://yourdomain.com:5487` |
> **Note:** Replace `yourdomain.com` with your actual domain name in all production and custom port URLs.
> **Note:** You can add multiple redirect URIs for different environments (development, staging, production).
<ZoomableImage src="/assets/v3/oidc/pocket-id/config-urls.png" alt="Pocket ID Application URLs Configuration" />
### Getting OAuth credentials
After creating your application, you'll receive your OAuth credentials:
<ZoomableImage
src="/assets/v3/oidc/pocket-id/credentials.png"
alt="Pocket ID OAuth Credentials"
legend="The client ID and client secret shown in the image are examples only (fake credentials). You must use your own credentials from Pocket ID."
/>
Save these credentials securely - you'll need them to configure Palmr:
- Client ID
- Client Secret
- Provider URL (your Pocket ID instance URL)
---
## Configuring Palmr
### Accessing OIDC settings
To configure Pocket ID authentication in Palmr:
1. **Login as administrator**: Sign in to Palmr with an admin account
2. **Access settings**: Click your profile picture in the header and select **Settings**
3. **Navigate to authentication**: Find and click on the **Authentication Providers** section
<ZoomableImage src="/assets/v3/oidc/auth-providers.png" alt="Palmr Authentication Providers" />
### Enabling Pocket ID provider
1. **Locate Pocket ID**: Find Pocket ID in the list of available providers
2. **Enable the provider**: Toggle the status to **Enabled**
<ZoomableImage src="/assets/v3/oidc/pocket-id/enabled-pocket-id.png" alt="Palmr Pocket ID Provider Enabled" />
3. **Configure credentials**:
- **Provider URL**: Your Pocket ID server URL (e.g., `https://auth.yourdomain.com`)
- **Client ID**: Paste the Client ID from your Pocket ID application
- **Client Secret**: Paste the Client Secret from your Pocket ID application
<ZoomableImage
src="/assets/v3/oidc/pocket-id/edit-pocket-id.png"
alt="Edit Pocket ID Provider"
legend="This is a fake application, you have to use your own credentials."
/>
### Advanced configuration options
Configure additional settings to customize the authentication behavior:
**Auto Registration**: Enable to automatically create user accounts when someone authenticates for the first time.
**Sort Order**: Control where the Pocket ID login button appears relative to other authentication providers.
**Icon**: Choose a custom icon for the Pocket ID login button (default is `Key`).
<ZoomableImage src="/assets/v3/oidc/pocket-id/pocket-id-icon.png" alt="Pocket ID Icon" />
---
## Account linking
By default, if a user is already registered in Palmr with their Pocket ID email, they will be automatically linked to their Palmr account.
> **Note:** You can't disable account linking. If you want to unlink a user from their Pocket ID account, you need to delete the user from Palmr.
---
## Technical configuration
Pocket ID's technical configuration is handled automatically through OIDC discovery, but understanding the setup can help with troubleshooting:
```yaml
Provider Type: OAuth 2.0 with OIDC Discovery
Issuer URL: https://your-pocket-id.domain.com
Authorization Endpoint: /authorize
Token Endpoint: /api/oidc/token
UserInfo Endpoint: /api/oidc/userinfo
Scopes: openid profile email
```
### Field mappings
Palmr automatically maps Pocket ID user information to local user accounts:
- **User ID**: Maps from Pocket ID's `sub` field
- **Email**: Maps from Pocket ID's `email` field
- **Name**: Maps from Pocket ID's `name` field, falls back to `preferred_username`
- **First Name**: Maps from Pocket ID's `given_name` field
- **Last Name**: Maps from Pocket ID's `family_name` field
- **Avatar**: Maps from Pocket ID's `picture` field
---
## Testing the configuration
### Verifying the setup
After configuring Pocket ID authentication, test the integration:
1. **Check login page**: Verify the "Sign in with Pocket ID" button appears
2. **Test authentication flow**: Click the button and complete authentication
3. **Verify user creation**: Confirm new user account creation (if auto-registration is enabled)
### Login flow verification
The complete authentication process should work as follows:
1. User clicks "Sign in with Pocket ID"
2. User is redirected to Pocket ID login page
3. User authenticates with their credentials
4. Pocket ID redirects back to Palmr
5. Palmr creates or updates the user account
6. User gains access to Palmr
---
## Troubleshooting common issues
### Redirect URI mismatch
**Error**: `invalid_redirect_uri`
**Solution**:
1. Verify the exact callback URL in your Pocket ID application
2. Check for protocol mismatches (http vs https)
3. Ensure no trailing slashes unless specified
4. Add development URLs if testing locally
### Authentication failures
**Error**: `access_denied` or `unauthorized_client`
**Solution**:
1. Verify Client ID and Secret are correct
2. Check if the application is enabled in Pocket ID
3. Ensure required scopes are configured
4. Verify the user has necessary permissions
### Discovery endpoint issues
**Error**: Cannot fetch OIDC configuration
**Solution**:
1. Verify your Pocket ID server is accessible
2. Check if the discovery endpoint (`/.well-known/openid-configuration`) is available
3. Ensure SSL certificates are valid
4. Check network connectivity and firewall rules
---
## Security best practices
### Credential management
- **Secure storage**: Keep Client Secret secure and never commit to version control
- **Regular rotation**: Periodically rotate Client Secret
- **Environment variables**: Store credentials in environment variables
- **Access monitoring**: Regular review of authentication logs
### Production considerations
- **HTTPS required**: Always use HTTPS in production
- **Valid certificates**: Ensure SSL certificates are valid
- **Regular updates**: Keep Pocket ID server updated
- **Backup strategy**: Regular backups of Pocket ID configuration
---
## Next steps
After configuring Pocket ID authentication:
- **Monitor usage**: Track authentication patterns
- **Configure MFA**: Set up multi-factor authentication if needed
- **User management**: Review auto-registration settings
- **Backup verification**: Test backup and restore procedures
For more information about OIDC authentication in Palmr, see the [OIDC Authentication overview](/docs/3.1-beta/oidc-authentication).
## Useful resources
- [Pocket ID Documentation](https://docs.pocket-id.org)
- [OIDC Specification](https://openid.net/specs/openid-connect-core-1_0.html)
- [Palmr OIDC Overview](/docs/3.1-beta/oidc-authentication)

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": "3.1-beta",
"version": "3.1.4-beta",
"description": "Docs for Palmr",
"private": true,
"author": "Daniel Luiz Alves <daniel@kyantech.com.br>",

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 843 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 130 KiB

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

@@ -15,7 +15,6 @@ export default async function Page(props: { params: Promise<{ slug?: string[] }>
const MDXContent = page.data.body;
// Check if this is an older version page that needs a warning
const shouldShowWarning = page.url.startsWith("/docs/2.0.0-beta");
return (

View File

@@ -51,6 +51,12 @@ const providers = [
href: "/docs/3.1-beta/oidc-authentication/kinde-auth",
icon: <Users className="w-4 h-" />,
},
{
name: "Pocket ID",
description: "Open-source identity provider with OIDC support",
href: "/docs/3.1-beta/oidc-authentication/pocket-id",
icon: <Key className="w-4 h-4" />,
},
{
name: "Other",
description: "Configure any other OIDC-compliant identity provider",

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": "3.1-beta",
"version": "3.1.4-beta",
"description": "API for Palmr",
"private": true,
"author": "Daniel Luiz Alves <daniel@kyantech.com.br>",
@@ -51,7 +51,9 @@
"node-fetch": "^3.3.2",
"nodemailer": "^6.10.0",
"openid-client": "^6.6.2",
"qrcode": "^1.5.4",
"sharp": "^0.34.2",
"speakeasy": "^2.0.0",
"zod": "^3.25.67"
},
"devDependencies": {
@@ -61,6 +63,8 @@
"@types/bcryptjs": "^2.4.6",
"@types/node": "^22.13.4",
"@types/nodemailer": "^6.4.17",
"@types/qrcode": "^1.5.5",
"@types/speakeasy": "^2.0.10",
"@typescript-eslint/eslint-plugin": "8.35.1",
"@typescript-eslint/parser": "8.35.1",
"eslint": "9.30.0",

File diff suppressed because it is too large Load Diff

View File

@@ -20,6 +20,11 @@ model User {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
twoFactorEnabled Boolean @default(false)
twoFactorSecret String?
twoFactorBackupCodes String?
twoFactorVerified Boolean @default(false)
files File[]
shares Share[]
reverseShares ReverseShare[]
@@ -28,6 +33,7 @@ model User {
passwordResets PasswordReset[]
authProviders UserAuthProvider[]
trustedDevices TrustedDevice[]
@@map("users")
}
@@ -263,3 +269,19 @@ enum PageLayout {
DEFAULT
WETRANSFER
}
model TrustedDevice {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
deviceHash String @unique
deviceName String?
userAgent String?
ipAddress String?
lastUsedAt DateTime @default(now())
expiresAt DateTime
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("trusted_devices")
}

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",
@@ -303,6 +309,24 @@ const defaultAuthProviders = [
supportsDiscovery: true,
}),
},
{
name: "pocketid",
displayName: "Pocket ID",
type: "oidc",
icon: "BsFillPSquareFill",
enabled: false,
issuerUrl: "https://your-pocket-id.domain.com",
authorizationEndpoint: "/authorize",
tokenEndpoint: "/api/oidc/token",
userInfoEndpoint: "/api/oidc/userinfo",
scope: "openid profile email",
sortOrder: 9,
metadata: JSON.stringify({
description: "Sign in with Pocket ID - Replace with your Pocket ID instance URL",
docs: "https://docs.pocket-id.org",
supportsDiscovery: true,
}),
},
];
async function main() {

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

@@ -13,6 +13,7 @@ export const PROVIDER_PATTERNS = [
{ pattern: "okta.com", type: "okta" },
{ pattern: "kinde.com", type: "kinde" },
{ pattern: "zitadel.com", type: "zitadel" },
{ pattern: "pocketid", type: "pocketid" },
] as const;
export const DEFAULT_SCOPES_BY_TYPE: Record<string, string[]> = {
@@ -28,6 +29,7 @@ export const DEFAULT_SCOPES_BY_TYPE: Record<string, string[]> = {
okta: ["openid", "profile", "email"],
kinde: ["openid", "profile", "email"],
zitadel: ["openid", "profile", "email"],
pocketid: ["openid", "profile", "email"],
} as const;
export const DISCOVERY_SUPPORTED_PROVIDERS = [
@@ -41,6 +43,7 @@ export const DISCOVERY_SUPPORTED_PROVIDERS = [
"microsoft",
"kinde",
"zitadel",
"pocketid",
] as const;
export const DISCOVERY_PATHS = [
@@ -75,6 +78,11 @@ export const FALLBACK_ENDPOINTS: Record<string, any> = {
tokenEndpoint: "/oauth2/token",
userInfoEndpoint: "/oauth2/userinfo",
},
pocketid: {
authorizationEndpoint: "/authorize",
tokenEndpoint: "/api/oidc/token",
userInfoEndpoint: "/api/oidc/userinfo",
},
} as const;
/**
@@ -232,6 +240,29 @@ const fronteggConfig: ProviderConfig = {
},
};
/**
* Configuração técnica oficial do Pocket ID
* OIDC com discovery automático
* Endpoints vêm do banco de dados
*/
const pocketidConfig: ProviderConfig = {
supportsDiscovery: true,
discoveryEndpoint: "/.well-known/openid-configuration",
authMethod: "body",
fieldMappings: {
id: ["sub"],
email: ["email"],
name: ["name", "preferred_username"],
firstName: ["given_name"],
lastName: ["family_name"],
avatar: ["picture"],
},
specialHandling: {
emailFetchRequired: false,
responseFormat: "json",
},
};
/**
* Template genérico ULTRA-INTELIGENTE para providers customizados
* Detecta automaticamente padrões comuns e se adapta
@@ -275,6 +306,7 @@ export const providersConfig: ProvidersConfigFile = {
zitadel: zitadelConfig,
authentik: authentikConfig,
frontegg: fronteggConfig,
pocketid: pocketidConfig,
},
genericProviderTemplate,
};

View File

@@ -18,7 +18,6 @@ import {
TokenResponse,
} from "./types";
// Constants
const DEFAULT_BASE_URL = "http://localhost:3000";
const STATE_EXPIRY_TIME = 600000; // 10 minutes
const CLEANUP_INTERVAL = 5 * 60 * 1000; // 5 minutes
@@ -43,7 +42,6 @@ export class AuthProvidersService {
setInterval(() => this.cleanupExpiredStates(), CLEANUP_INTERVAL);
}
// Utility methods
private buildBaseUrl(requestContext?: RequestContextService): string {
return requestContext ? `${requestContext.protocol}://${requestContext.host}` : DEFAULT_BASE_URL;
}
@@ -87,7 +85,6 @@ export class AuthProvidersService {
}
}
// Provider configuration methods
private isOfficial(providerName: string): boolean {
return providerName in providersConfig.officialProviders;
}
@@ -114,7 +111,6 @@ export class AuthProvidersService {
}
private async resolveEndpoints(provider: any, config: ProviderConfig): Promise<ProviderEndpoints> {
// Use custom endpoints if all are provided
if (provider.authorizationEndpoint && provider.tokenEndpoint && provider.userInfoEndpoint) {
return {
authorizationEndpoint: this.resolveEndpointUrl(provider.authorizationEndpoint, provider.issuerUrl),
@@ -123,7 +119,6 @@ export class AuthProvidersService {
};
}
// Try discovery if supported
if (config.supportsDiscovery && provider.issuerUrl) {
const discoveredEndpoints = await this.attemptDiscovery(provider.issuerUrl);
if (discoveredEndpoints) {
@@ -131,7 +126,6 @@ export class AuthProvidersService {
}
}
// Fallback to intelligent endpoints
const baseUrl = provider.issuerUrl?.replace(/\/$/, "") || "";
const detectedType = detectProviderType(provider.issuerUrl || "");
const fallbackPattern = getFallbackEndpoints(detectedType);
@@ -224,7 +218,6 @@ export class AuthProvidersService {
return config.specialHandling?.emailEndpoint || null;
}
// PKCE and OAuth setup methods
private setupPkceIfNeeded(provider: any): { codeVerifier?: string; codeChallenge?: string } {
const needsPkce = provider.type === DEFAULT_PROVIDER_TYPE;
@@ -263,7 +256,6 @@ export class AuthProvidersService {
return authUrl.toString();
}
// Callback handling methods
private validateAndGetPendingState(state: string): PendingState {
const pendingState = this.pendingStates.get(state);
@@ -299,7 +291,6 @@ export class AuthProvidersService {
};
}
// Public methods
async getEnabledProviders(requestContext?: RequestContextService) {
const providers = await prisma.authProvider.findMany({
where: { enabled: true },
@@ -605,16 +596,13 @@ export class AuthProvidersService {
throw new Error(ERROR_MESSAGES.MISSING_USER_INFO);
}
// First, check if there's already an auth provider entry for this external ID
const existingAuthProvider = await this.findExistingAuthProvider(provider.id, String(externalId));
if (existingAuthProvider) {
return await this.updateExistingUserFromProvider(existingAuthProvider.user, userInfo);
}
// Check if there's a user with this email
const existingUser = await this.findExistingUserByEmail(userInfo.email);
if (existingUser) {
// Check if this user already has this provider linked
const existingUserProvider = await prisma.userAuthProvider.findFirst({
where: {
userId: existingUser.id,

View File

@@ -1,16 +1,70 @@
import { FastifyReply, FastifyRequest } from "fastify";
import { env } from "../../env";
import { createResetPasswordSchema, LoginSchema, RequestPasswordResetSchema } from "./dto";
import { ConfigService } from "../config/service";
import {
CompleteTwoFactorLoginSchema,
createResetPasswordSchema,
LoginSchema,
RequestPasswordResetSchema,
} from "./dto";
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;
const realUserAgent = request.headers["x-user-agent"] as string;
const userAgent = realUserAgent || request.headers["user-agent"] || "";
const ipAddress = realIP || request.ip || request.socket.remoteAddress || "";
return { userAgent, ipAddress };
}
async login(request: FastifyRequest, reply: FastifyReply) {
try {
const input = LoginSchema.parse(request.body);
const user = await this.authService.login(input);
const { userAgent, ipAddress } = this.getClientInfo(request);
const result = await this.authService.login(input, userAgent, ipAddress);
if ("requiresTwoFactor" in result) {
return reply.send(result);
}
const user = result;
const token = await request.jwtSign({
userId: user.id,
isAdmin: user.isAdmin,
});
reply.setCookie("token", token, {
httpOnly: true,
path: "/",
secure: env.SECURE_SITE === "true" ? true : false,
sameSite: env.SECURE_SITE === "true" ? "lax" : "strict",
});
return reply.send({ user });
} catch (error: any) {
return reply.status(400).send({ error: error.message });
}
}
async completeTwoFactorLogin(request: FastifyRequest, reply: FastifyReply) {
try {
const input = CompleteTwoFactorLoginSchema.parse(request.body);
const { userAgent, ipAddress } = this.getClientInfo(request);
const user = await this.authService.completeTwoFactorLogin(
input.userId,
input.token,
input.rememberDevice,
userAgent,
ipAddress
);
const token = await request.jwtSign({
userId: user.id,
isAdmin: user.isAdmin,
@@ -74,4 +128,58 @@ export class AuthController {
return reply.status(400).send({ error: error.message });
}
}
async getTrustedDevices(request: FastifyRequest, reply: FastifyReply) {
try {
const userId = (request as any).user?.userId;
if (!userId) {
return reply.status(401).send({ error: "Unauthorized: a valid token is required to access this resource." });
}
const devices = await this.authService.getTrustedDevices(userId);
return reply.send({ devices });
} catch (error: any) {
return reply.status(400).send({ error: error.message });
}
}
async removeTrustedDevice(request: FastifyRequest, reply: FastifyReply) {
try {
const userId = (request as any).user?.userId;
if (!userId) {
return reply.status(401).send({ error: "Unauthorized: a valid token is required to access this resource." });
}
const { id } = request.params as { id: string };
await this.authService.removeTrustedDevice(userId, id);
return reply.send({ success: true, message: "Trusted device removed successfully" });
} catch (error: any) {
return reply.status(400).send({ error: error.message });
}
}
async removeAllTrustedDevices(request: FastifyRequest, reply: FastifyReply) {
try {
const userId = (request as any).user?.userId;
if (!userId) {
return reply.status(401).send({ error: "Unauthorized: a valid token is required to access this resource." });
}
const result = await this.authService.removeAllTrustedDevices(userId);
return reply.send(result);
} catch (error: any) {
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

@@ -36,3 +36,11 @@ export const createResetPasswordSchema = async () => {
export type ResetPasswordInput = BaseResetPasswordInput & {
password: string;
};
export const CompleteTwoFactorLoginSchema = z.object({
userId: z.string().min(1, "User ID is required").describe("User ID"),
token: z.string().min(6, "Two-factor authentication code must be at least 6 characters").describe("2FA token"),
rememberDevice: z.boolean().optional().default(false).describe("Remember this device for 30 days"),
});
export type CompleteTwoFactorLoginInput = z.infer<typeof CompleteTwoFactorLoginSchema>;

View File

@@ -4,7 +4,7 @@ import { z } from "zod";
import { ConfigService } from "../config/service";
import { validatePasswordMiddleware } from "../user/middleware";
import { AuthController } from "./controller";
import { createResetPasswordSchema, RequestPasswordResetSchema } from "./dto";
import { CompleteTwoFactorLoginSchema, createResetPasswordSchema, RequestPasswordResetSchema } from "./dto";
const configService = new ConfigService();
@@ -31,6 +31,43 @@ export async function authRoutes(app: FastifyInstance) {
summary: "Login",
description: "Performs login and returns user data",
body: loginSchema,
response: {
200: z.union([
z.object({
user: z.object({
id: z.string().describe("User ID"),
firstName: z.string().describe("User first name"),
lastName: z.string().describe("User last name"),
username: z.string().describe("User username"),
email: z.string().email().describe("User email"),
isAdmin: z.boolean().describe("User is admin"),
isActive: z.boolean().describe("User is active"),
createdAt: z.date().describe("User creation date"),
updatedAt: z.date().describe("User last update date"),
}),
}),
z.object({
requiresTwoFactor: z.boolean().describe("Whether 2FA is required"),
userId: z.string().describe("User ID for 2FA verification"),
message: z.string().describe("2FA required message"),
}),
]),
400: z.object({ error: z.string().describe("Error message") }),
},
},
},
authController.login.bind(authController)
);
app.post(
"/auth/2fa/login",
{
schema: {
tags: ["Authentication"],
operationId: "completeTwoFactorLogin",
summary: "Complete Two-Factor Login",
description: "Complete login process with 2FA verification",
body: CompleteTwoFactorLoginSchema,
response: {
200: z.object({
user: z.object({
@@ -49,7 +86,7 @@ export async function authRoutes(app: FastifyInstance) {
},
},
},
authController.login.bind(authController)
authController.completeTwoFactorLogin.bind(authController)
);
app.post(
@@ -146,4 +183,120 @@ export async function authRoutes(app: FastifyInstance) {
},
authController.getCurrentUser.bind(authController)
);
app.get(
"/auth/trusted-devices",
{
schema: {
tags: ["Authentication"],
operationId: "getTrustedDevices",
summary: "Get Trusted Devices",
description: "Get all trusted devices for the current user",
response: {
200: z.object({
devices: z.array(
z.object({
id: z.string().describe("Device ID"),
deviceName: z.string().nullable().describe("Device name"),
userAgent: z.string().nullable().describe("User agent"),
ipAddress: z.string().nullable().describe("IP address"),
createdAt: z.date().describe("Creation date"),
lastUsedAt: z.date().describe("Last used date"),
expiresAt: z.date().describe("Expiration date"),
})
),
}),
401: z.object({ error: z.string().describe("Error message") }),
},
},
preValidation: async (request: FastifyRequest, reply: FastifyReply) => {
try {
await request.jwtVerify();
} catch (err) {
console.error(err);
reply.status(401).send({ error: "Unauthorized: a valid token is required to access this resource." });
}
},
},
authController.getTrustedDevices.bind(authController)
);
app.delete(
"/auth/trusted-devices/:id",
{
schema: {
tags: ["Authentication"],
operationId: "removeTrustedDevice",
summary: "Remove Trusted Device",
description: "Remove a specific trusted device",
params: z.object({
id: z.string().describe("Device ID"),
}),
response: {
200: z.object({
success: z.boolean().describe("Success status"),
message: z.string().describe("Success message"),
}),
401: z.object({ error: z.string().describe("Error message") }),
},
},
preValidation: async (request: FastifyRequest, reply: FastifyReply) => {
try {
await request.jwtVerify();
} catch (err) {
console.error(err);
reply.status(401).send({ error: "Unauthorized: a valid token is required to access this resource." });
}
},
},
authController.removeTrustedDevice.bind(authController)
);
app.delete(
"/auth/trusted-devices",
{
schema: {
tags: ["Authentication"],
operationId: "removeAllTrustedDevices",
summary: "Remove All Trusted Devices",
description: "Remove all trusted devices for the current user",
response: {
200: z.object({
success: z.boolean().describe("Success status"),
message: z.string().describe("Success message"),
removedCount: z.number().describe("Number of devices removed"),
}),
401: z.object({ error: z.string().describe("Error message") }),
},
},
preValidation: async (request: FastifyRequest, reply: FastifyReply) => {
try {
await request.jwtVerify();
} catch (err) {
console.error(err);
reply.status(401).send({ error: "Unauthorized: a valid token is required to access this resource." });
}
},
},
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

@@ -4,16 +4,25 @@ import bcrypt from "bcryptjs";
import { prisma } from "../../shared/prisma";
import { ConfigService } from "../config/service";
import { EmailService } from "../email/service";
import { TwoFactorService } from "../two-factor/service";
import { UserResponseSchema } from "../user/dto";
import { PrismaUserRepository } from "../user/repository";
import { LoginInput } from "./dto";
import { TrustedDeviceService } from "./trusted-device.service";
export class AuthService {
private userRepository = new PrismaUserRepository();
private configService = new ConfigService();
private emailService = new EmailService();
private twoFactorService = new TwoFactorService();
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.");
}
async login(data: LoginInput) {
const user = await this.userRepository.findUserByEmailOrUsername(data.emailOrUsername);
if (!user) {
throw new Error("Invalid credentials");
@@ -77,10 +86,76 @@ export class AuthService {
});
}
const has2FA = await this.twoFactorService.isEnabled(user.id);
if (has2FA) {
if (userAgent && ipAddress) {
const isDeviceTrusted = await this.trustedDeviceService.isDeviceTrusted(user.id, userAgent, ipAddress);
if (isDeviceTrusted) {
// Update last used timestamp for trusted device
await this.trustedDeviceService.updateLastUsed(user.id, userAgent, ipAddress);
return UserResponseSchema.parse(user);
}
}
return {
requiresTwoFactor: true,
userId: user.id,
message: "Two-factor authentication required",
};
}
return UserResponseSchema.parse(user);
}
async completeTwoFactorLogin(
userId: string,
token: string,
rememberDevice: boolean = false,
userAgent?: string,
ipAddress?: string
) {
const user = await prisma.user.findUnique({
where: { id: userId },
});
if (!user) {
throw new Error("User not found");
}
if (!user.isActive) {
throw new Error("Account is inactive. Please contact an administrator.");
}
const verificationResult = await this.twoFactorService.verifyToken(userId, token);
if (!verificationResult.success) {
throw new Error("Invalid two-factor authentication code");
}
await prisma.loginAttempt.deleteMany({
where: { userId },
});
if (rememberDevice && userAgent && ipAddress) {
await this.trustedDeviceService.addTrustedDevice(userId, userAgent, ipAddress);
} else if (userAgent && ipAddress) {
// Update last used timestamp if this is already a trusted device
const isDeviceTrusted = await this.trustedDeviceService.isDeviceTrusted(userId, userAgent, ipAddress);
if (isDeviceTrusted) {
await this.trustedDeviceService.updateLastUsed(userId, userAgent, ipAddress);
}
}
return UserResponseSchema.parse(user);
}
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;
@@ -106,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,
@@ -146,4 +226,21 @@ export class AuthService {
}
return UserResponseSchema.parse(user);
}
async getTrustedDevices(userId: string) {
return await this.trustedDeviceService.getUserTrustedDevices(userId);
}
async removeTrustedDevice(userId: string, deviceId: string) {
return await this.trustedDeviceService.removeTrustedDevice(userId, deviceId);
}
async removeAllTrustedDevices(userId: string) {
const result = await this.trustedDeviceService.removeAllTrustedDevices(userId);
return {
success: true,
message: "All trusted devices removed successfully",
removedCount: result.count,
};
}
}

View File

@@ -0,0 +1,109 @@
import crypto from "node:crypto";
import { prisma } from "../../shared/prisma";
export class TrustedDeviceService {
private generateDeviceHash(userAgent: string, ipAddress: string): string {
const deviceInfo = `${userAgent}-${ipAddress}`;
return crypto.createHash("sha256").update(deviceInfo).digest("hex");
}
async isDeviceTrusted(userId: string, userAgent: string, ipAddress: string): Promise<boolean> {
const deviceHash = this.generateDeviceHash(userAgent, ipAddress);
const trustedDevice = await prisma.trustedDevice.findFirst({
where: {
userId,
deviceHash,
expiresAt: {
gt: new Date(),
},
},
});
return !!trustedDevice;
}
async addTrustedDevice(userId: string, userAgent: string, ipAddress: string, deviceName?: string): Promise<void> {
const deviceHash = this.generateDeviceHash(userAgent, ipAddress);
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + 30); // 30 dias
await prisma.trustedDevice.upsert({
where: {
deviceHash,
},
create: {
userId,
deviceHash,
deviceName,
userAgent,
ipAddress,
expiresAt,
lastUsedAt: new Date(),
},
update: {
expiresAt,
userAgent,
ipAddress,
lastUsedAt: new Date(),
},
});
}
async cleanupExpiredDevices(): Promise<void> {
await prisma.trustedDevice.deleteMany({
where: {
expiresAt: {
lt: new Date(),
},
},
});
}
async getUserTrustedDevices(userId: string) {
return prisma.trustedDevice.findMany({
where: {
userId,
expiresAt: {
gt: new Date(),
},
},
orderBy: {
createdAt: "desc",
},
});
}
async removeTrustedDevice(userId: string, deviceId: string): Promise<void> {
await prisma.trustedDevice.deleteMany({
where: {
id: deviceId,
userId,
},
});
}
async removeAllTrustedDevices(userId: string): Promise<{ count: number }> {
const result = await prisma.trustedDevice.deleteMany({
where: {
userId,
},
});
return { count: result.count };
}
async updateLastUsed(userId: string, userAgent: string, ipAddress: string): Promise<void> {
const deviceHash = this.generateDeviceHash(userAgent, ipAddress);
await prisma.trustedDevice.updateMany({
where: {
userId,
deviceHash,
},
data: {
lastUsedAt: new Date(),
},
});
}
}

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

@@ -72,10 +72,8 @@ export class EmailService {
let smtpConfig: SmtpConfig;
if (config) {
// Use provided configuration
smtpConfig = config;
} else {
// Fallback to saved configuration
smtpConfig = {
smtpEnabled: await this.configService.getValue("smtpEnabled"),
smtpHost: await this.configService.getValue("smtpHost"),
@@ -169,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");
@@ -180,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

@@ -0,0 +1,345 @@
import * as fs from "fs";
import * as path from "path";
import { getTempFilePath } from "../../config/directories.config";
import { FilesystemStorageProvider } from "../../providers/filesystem-storage.provider";
export interface ChunkMetadata {
fileId: string;
chunkIndex: number;
totalChunks: number;
chunkSize: number;
totalSize: number;
fileName: string;
isLastChunk: boolean;
}
export interface ChunkInfo {
fileId: string;
fileName: string;
totalSize: number;
totalChunks: number;
uploadedChunks: Set<number>;
tempPath: string;
createdAt: number;
}
export class ChunkManager {
private static instance: ChunkManager;
private activeUploads = new Map<string, ChunkInfo>();
private finalizingUploads = new Set<string>(); // Track uploads currently being finalized
private cleanupInterval: NodeJS.Timeout;
private constructor() {
// Cleanup expired uploads every 30 minutes
this.cleanupInterval = setInterval(
() => {
this.cleanupExpiredUploads();
},
30 * 60 * 1000
);
}
public static getInstance(): ChunkManager {
if (!ChunkManager.instance) {
ChunkManager.instance = new ChunkManager();
}
return ChunkManager.instance;
}
/**
* Process a chunk upload with streaming
*/
async processChunk(
metadata: ChunkMetadata,
inputStream: NodeJS.ReadableStream,
originalObjectName: string
): Promise<{ isComplete: boolean; finalPath?: string }> {
const startTime = Date.now();
const { fileId, chunkIndex, totalChunks, fileName, totalSize, isLastChunk } = metadata;
console.log(`Processing chunk ${chunkIndex + 1}/${totalChunks} for file ${fileName} (${fileId})`);
let chunkInfo = this.activeUploads.get(fileId);
if (!chunkInfo) {
if (chunkIndex !== 0) {
throw new Error("First chunk must be chunk 0");
}
const tempPath = getTempFilePath(fileId);
chunkInfo = {
fileId,
fileName,
totalSize,
totalChunks,
uploadedChunks: new Set(),
tempPath,
createdAt: Date.now(),
};
this.activeUploads.set(fileId, chunkInfo);
console.log(`Created new upload session for ${fileName} at ${tempPath}`);
}
console.log(
`Validating chunk ${chunkIndex} (total: ${totalChunks}, uploaded: ${Array.from(chunkInfo.uploadedChunks).join(",")})`
);
if (chunkIndex < 0 || chunkIndex >= totalChunks) {
throw new Error(`Invalid chunk index: ${chunkIndex} (must be 0-${totalChunks - 1})`);
}
if (chunkInfo.uploadedChunks.has(chunkIndex)) {
console.log(`Chunk ${chunkIndex} already uploaded, treating as success`);
if (isLastChunk && chunkInfo.uploadedChunks.size === totalChunks) {
if (this.finalizingUploads.has(fileId)) {
console.log(`Upload ${fileId} is already being finalized, waiting...`);
return { isComplete: false };
}
console.log(`All chunks uploaded, finalizing ${fileName}`);
return await this.finalizeUpload(chunkInfo, metadata, originalObjectName);
}
return { isComplete: false };
}
const tempDir = path.dirname(chunkInfo.tempPath);
await fs.promises.mkdir(tempDir, { recursive: true });
console.log(`Temp directory ensured: ${tempDir}`);
await this.writeChunkToFile(chunkInfo.tempPath, inputStream, chunkIndex === 0);
chunkInfo.uploadedChunks.add(chunkIndex);
try {
const stats = await fs.promises.stat(chunkInfo.tempPath);
const processingTime = Date.now() - startTime;
console.log(
`Chunk ${chunkIndex + 1}/${totalChunks} uploaded successfully in ${processingTime}ms. Temp file size: ${stats.size} bytes`
);
} catch (error) {
console.warn(`Could not get temp file stats:`, error);
}
console.log(
`Checking completion: isLastChunk=${isLastChunk}, uploadedChunks.size=${chunkInfo.uploadedChunks.size}, totalChunks=${totalChunks}`
);
if (isLastChunk && chunkInfo.uploadedChunks.size === totalChunks) {
if (this.finalizingUploads.has(fileId)) {
console.log(`Upload ${fileId} is already being finalized, waiting...`);
return { isComplete: false };
}
console.log(`All chunks uploaded, finalizing ${fileName}`);
const uploadedChunksArray = Array.from(chunkInfo.uploadedChunks).sort((a, b) => a - b);
console.log(`Uploaded chunks in order: ${uploadedChunksArray.join(", ")}`);
const expectedChunks = Array.from({ length: totalChunks }, (_, i) => i);
const missingChunks = expectedChunks.filter((chunk) => !chunkInfo.uploadedChunks.has(chunk));
if (missingChunks.length > 0) {
throw new Error(`Missing chunks: ${missingChunks.join(", ")}`);
}
return await this.finalizeUpload(chunkInfo, metadata, originalObjectName);
} else {
console.log(
`Not ready for finalization: isLastChunk=${isLastChunk}, uploadedChunks.size=${chunkInfo.uploadedChunks.size}, totalChunks=${totalChunks}`
);
}
return { isComplete: false };
}
/**
* Write chunk to file using streaming
*/
private async writeChunkToFile(
filePath: string,
inputStream: NodeJS.ReadableStream,
isFirstChunk: boolean
): Promise<void> {
return new Promise((resolve, reject) => {
console.log(`Writing chunk to ${filePath} (first: ${isFirstChunk})`);
if (isFirstChunk) {
const writeStream = fs.createWriteStream(filePath, {
highWaterMark: 64 * 1024 * 1024, // 64MB buffer for better performance
});
writeStream.on("error", (error) => {
console.error("Write stream error:", error);
reject(error);
});
writeStream.on("finish", () => {
console.log("Write stream finished successfully");
resolve();
});
inputStream.pipe(writeStream);
} else {
const writeStream = fs.createWriteStream(filePath, {
flags: "a",
highWaterMark: 64 * 1024 * 1024, // 64MB buffer for better performance
});
writeStream.on("error", (error) => {
console.error("Write stream error:", error);
reject(error);
});
writeStream.on("finish", () => {
console.log("Write stream finished successfully");
resolve();
});
inputStream.pipe(writeStream);
}
});
}
/**
* Finalize upload by moving temp file to final location and encrypting (if enabled)
*/
private async finalizeUpload(
chunkInfo: ChunkInfo,
metadata: ChunkMetadata,
originalObjectName: string
): Promise<{ isComplete: boolean; finalPath: string }> {
// Mark as finalizing to prevent race conditions
this.finalizingUploads.add(chunkInfo.fileId);
try {
console.log(`Finalizing upload for ${chunkInfo.fileName}`);
const tempStats = await fs.promises.stat(chunkInfo.tempPath);
console.log(`Temp file size: ${tempStats.size} bytes, expected: ${chunkInfo.totalSize} bytes`);
if (tempStats.size !== chunkInfo.totalSize) {
console.warn(`Size mismatch! Temp: ${tempStats.size}, Expected: ${chunkInfo.totalSize}`);
}
const provider = FilesystemStorageProvider.getInstance();
const finalObjectName = originalObjectName;
const filePath = provider.getFilePath(finalObjectName);
const dir = path.dirname(filePath);
console.log(`Starting finalization: ${finalObjectName}`);
await fs.promises.mkdir(dir, { recursive: true });
const tempReadStream = fs.createReadStream(chunkInfo.tempPath, {
highWaterMark: 64 * 1024 * 1024, // 64MB buffer for better performance
});
const writeStream = fs.createWriteStream(filePath, {
highWaterMark: 64 * 1024 * 1024,
});
const encryptStream = provider.createEncryptStream();
await new Promise<void>((resolve, reject) => {
const startTime = Date.now();
tempReadStream
.pipe(encryptStream)
.pipe(writeStream)
.on("finish", () => {
const duration = Date.now() - startTime;
console.log(`File processed and saved to: ${filePath} in ${duration}ms`);
resolve();
})
.on("error", (error) => {
console.error("Error during processing:", error);
reject(error);
});
});
console.log(`File successfully uploaded and processed: ${finalObjectName}`);
await this.cleanupTempFile(chunkInfo.tempPath);
this.activeUploads.delete(chunkInfo.fileId);
this.finalizingUploads.delete(chunkInfo.fileId);
return { isComplete: true, finalPath: finalObjectName };
} catch (error) {
console.error("Error during finalization:", error);
await this.cleanupTempFile(chunkInfo.tempPath);
this.activeUploads.delete(chunkInfo.fileId);
this.finalizingUploads.delete(chunkInfo.fileId);
throw error;
}
}
/**
* Cleanup temporary file
*/
private async cleanupTempFile(tempPath: string): Promise<void> {
try {
await fs.promises.access(tempPath);
await fs.promises.unlink(tempPath);
console.log(`Temp file cleaned up: ${tempPath}`);
} catch (error: any) {
if (error.code === "ENOENT") {
console.log(`Temp file already cleaned up: ${tempPath}`);
} else {
console.warn(`Failed to cleanup temp file ${tempPath}:`, error);
}
}
}
/**
* Cleanup expired uploads (older than 2 hours)
*/
private async cleanupExpiredUploads(): Promise<void> {
const now = Date.now();
const maxAge = 2 * 60 * 60 * 1000; // 2 hours
for (const [fileId, chunkInfo] of this.activeUploads.entries()) {
if (now - chunkInfo.createdAt > maxAge) {
console.log(`Cleaning up expired upload: ${fileId}`);
await this.cleanupTempFile(chunkInfo.tempPath);
this.activeUploads.delete(fileId);
this.finalizingUploads.delete(fileId);
}
}
}
/**
* Get upload progress
*/
getUploadProgress(fileId: string): { uploaded: number; total: number; percentage: number } | null {
const chunkInfo = this.activeUploads.get(fileId);
if (!chunkInfo) return null;
return {
uploaded: chunkInfo.uploadedChunks.size,
total: chunkInfo.totalChunks,
percentage: Math.round((chunkInfo.uploadedChunks.size / chunkInfo.totalChunks) * 100),
};
}
/**
* Cancel upload
*/
async cancelUpload(fileId: string): Promise<void> {
const chunkInfo = this.activeUploads.get(fileId);
if (chunkInfo) {
await this.cleanupTempFile(chunkInfo.tempPath);
this.activeUploads.delete(fileId);
this.finalizingUploads.delete(fileId);
}
}
/**
* Cleanup on shutdown
*/
destroy(): void {
if (this.cleanupInterval) {
clearInterval(this.cleanupInterval);
}
for (const [fileId, chunkInfo] of this.activeUploads.entries()) {
this.cleanupTempFile(chunkInfo.tempPath);
}
this.activeUploads.clear();
this.finalizingUploads.clear();
}
}

View File

@@ -1,13 +1,12 @@
import * as fs from "fs";
import * as path from "path";
import { pipeline } from "stream/promises";
import { FastifyReply, FastifyRequest } from "fastify";
import { FilesystemStorageProvider } from "../../providers/filesystem-storage.provider";
import { FileService } from "../file/service";
import { ChunkManager, ChunkMetadata } from "./chunk-manager";
export class FilesystemController {
private fileService = new FileService();
private chunkManager = ChunkManager.getInstance();
/**
* Safely encode filename for Content-Disposition header
@@ -65,22 +64,121 @@ export class FilesystemController {
return reply.status(400).send({ error: "Invalid or expired upload token" });
}
// Use streaming for all files to avoid loading into RAM
await this.uploadFileStream(request, provider, tokenData.objectName);
const chunkMetadata = this.extractChunkMetadata(request);
provider.consumeUploadToken(token);
reply.status(200).send({ message: "File uploaded successfully" });
if (chunkMetadata) {
try {
const result = await this.handleChunkedUpload(request, chunkMetadata, tokenData.objectName);
if (result.isComplete) {
provider.consumeUploadToken(token);
reply.status(200).send({
message: "File uploaded successfully",
objectName: result.finalPath,
finalObjectName: result.finalPath,
});
} else {
reply.status(200).send({
message: "Chunk uploaded successfully",
progress: this.chunkManager.getUploadProgress(chunkMetadata.fileId),
});
}
} catch (chunkError: any) {
return reply.status(400).send({
error: chunkError.message || "Chunked upload failed",
details: chunkError.toString(),
});
}
} else {
await this.uploadFileStream(request, provider, tokenData.objectName);
provider.consumeUploadToken(token);
reply.status(200).send({ message: "File uploaded successfully" });
}
} catch (error) {
console.error("Error in filesystem upload:", error);
return reply.status(500).send({ error: "Internal server error" });
}
}
private async uploadFileStream(request: FastifyRequest, provider: FilesystemStorageProvider, objectName: string) {
// Use the provider's streaming upload method directly
await provider.uploadFileFromStream(objectName, request.raw);
}
/**
* Extract chunk metadata from request headers
*/
private extractChunkMetadata(request: FastifyRequest): ChunkMetadata | null {
const fileId = request.headers["x-file-id"] as string;
const chunkIndex = request.headers["x-chunk-index"] as string;
const totalChunks = request.headers["x-total-chunks"] as string;
const chunkSize = request.headers["x-chunk-size"] as string;
const totalSize = request.headers["x-total-size"] as string;
const fileName = request.headers["x-file-name"] as string;
const isLastChunk = request.headers["x-is-last-chunk"] as string;
if (!fileId || !chunkIndex || !totalChunks || !chunkSize || !totalSize || !fileName) {
return null;
}
const metadata = {
fileId,
chunkIndex: parseInt(chunkIndex, 10),
totalChunks: parseInt(totalChunks, 10),
chunkSize: parseInt(chunkSize, 10),
totalSize: parseInt(totalSize, 10),
fileName,
isLastChunk: isLastChunk === "true",
};
return metadata;
}
/**
* Handle chunked upload with streaming
*/
private async handleChunkedUpload(request: FastifyRequest, metadata: ChunkMetadata, originalObjectName: string) {
const stream = request.raw;
stream.on("error", (error) => {
console.error("Request stream error:", error);
});
return await this.chunkManager.processChunk(metadata, stream, originalObjectName);
}
/**
* Get upload progress for chunked uploads
*/
async getUploadProgress(request: FastifyRequest, reply: FastifyReply) {
try {
const { fileId } = request.params as { fileId: string };
const progress = this.chunkManager.getUploadProgress(fileId);
if (!progress) {
return reply.status(404).send({ error: "Upload not found" });
}
reply.status(200).send(progress);
} catch (error) {
return reply.status(500).send({ error: "Internal server error" });
}
}
/**
* Cancel chunked upload
*/
async cancelUpload(request: FastifyRequest, reply: FastifyReply) {
try {
const { fileId } = request.params as { fileId: string };
await this.chunkManager.cancelUpload(fileId);
reply.status(200).send({ message: "Upload cancelled successfully" });
} catch (error) {
return reply.status(500).send({ error: "Internal server error" });
}
}
async download(request: FastifyRequest, reply: FastifyReply) {
try {
const { token } = request.params as { token: string };
@@ -128,14 +226,13 @@ 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);
}
}
provider.consumeDownloadToken(token);
} catch (error) {
console.error("Error in filesystem download:", error);
return reply.status(500).send({ error: "Internal server error" });
}
}
@@ -147,7 +244,6 @@ export class FilesystemController {
try {
await pipeline(readStream, decryptStream, reply.raw);
} catch (error) {
console.error("Error streaming large file:", error);
throw error;
}
}
@@ -159,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

@@ -67,4 +67,57 @@ export async function filesystemRoutes(app: FastifyInstance) {
},
filesystemController.download.bind(filesystemController)
);
app.get(
"/filesystem/upload-progress/:fileId",
{
schema: {
tags: ["Filesystem"],
operationId: "getUploadProgress",
summary: "Get chunked upload progress",
description: "Get the progress of a chunked upload",
params: z.object({
fileId: z.string().describe("File ID"),
}),
response: {
200: z.object({
uploaded: z.number(),
total: z.number(),
percentage: z.number(),
}),
404: z.object({
error: z.string(),
}),
500: z.object({
error: z.string(),
}),
},
},
},
filesystemController.getUploadProgress.bind(filesystemController)
);
app.delete(
"/filesystem/cancel-upload/:fileId",
{
schema: {
tags: ["Filesystem"],
operationId: "cancelUpload",
summary: "Cancel chunked upload",
description: "Cancel an ongoing chunked upload",
params: z.object({
fileId: z.string().describe("File ID"),
}),
response: {
200: z.object({
message: z.string(),
}),
500: z.object({
error: z.string(),
}),
},
},
},
filesystemController.cancelUpload.bind(filesystemController)
);
}

View File

@@ -2,7 +2,6 @@ import { FastifyReply, FastifyRequest } from "fastify";
import {
CreateReverseShareSchema,
GetPresignedUrlSchema,
ReverseSharePasswordSchema,
UpdateReverseSharePasswordSchema,
UpdateReverseShareSchema,
@@ -454,6 +453,7 @@ export class ReverseShareController {
async copyFileToUserFiles(request: FastifyRequest, reply: FastifyReply) {
try {
await request.jwtVerify();
const { fileId } = request.params as { fileId: string };
const userId = (request as any).user?.userId;
@@ -461,9 +461,16 @@ export class ReverseShareController {
return reply.status(401).send({ error: "Unauthorized" });
}
console.log(`Copy to my files: User ${userId} copying file ${fileId}`);
const file = await this.reverseShareService.copyReverseShareFileToUserFiles(fileId, userId);
console.log(`Copy to my files: Successfully copied file ${fileId}`);
return reply.send({ file, message: "File copied to your files successfully" });
} catch (error: any) {
console.error(`Copy to my files: Error:`, error.message);
if (error.message === "File not found") {
return reply.status(404).send({ error: "File not found" });
}

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,17 @@ 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 },
@@ -533,46 +563,72 @@ export class ReverseShareService {
const { FilesystemStorageProvider } = await import("../../providers/filesystem-storage.provider.js");
const provider = FilesystemStorageProvider.getInstance();
// Use streaming copy for filesystem mode
const sourcePath = provider.getFilePath(file.objectName);
const fs = await import("fs");
const { pipeline } = await import("stream/promises");
const sourceStream = fs.createReadStream(sourcePath);
const decryptStream = provider.createDecryptStream();
const targetPath = provider.getFilePath(newObjectName);
// Create a passthrough stream to get the decrypted content
const { PassThrough } = await import("stream");
const passThrough = new PassThrough();
const path = await import("path");
const targetDir = path.dirname(targetPath);
if (!fs.existsSync(targetDir)) {
fs.mkdirSync(targetDir, { recursive: true });
}
// First, decrypt the source file into the passthrough stream
await pipeline(sourceStream, decryptStream, passThrough);
// Then upload the decrypted content
await provider.uploadFileFromStream(newObjectName, passThrough);
const { copyFile } = await import("fs/promises");
await copyFile(sourcePath, targetPath);
} else {
const fileSizeMB = Number(file.size) / (1024 * 1024);
const needsStreaming = fileSizeMB > 100;
const downloadUrl = await this.fileService.getPresignedGetUrl(file.objectName, 300);
const uploadUrl = await this.fileService.getPresignedPutUrl(newObjectName, 300);
const response = await fetch(downloadUrl);
if (!response.ok) {
throw new Error(`Failed to download file: ${response.statusText}`);
}
let retries = 0;
const maxRetries = 3;
let success = false;
if (!response.body) {
throw new Error("No response body received");
}
while (retries < maxRetries && !success) {
try {
const response = await fetch(downloadUrl, {
signal: AbortSignal.timeout(600000), // 10 minutes timeout
});
const uploadResponse = await fetch(uploadUrl, {
method: "PUT",
body: response.body,
headers: {
"Content-Type": "application/octet-stream",
},
});
if (!response.ok) {
throw new Error(`Failed to download file: ${response.statusText}`);
}
if (!uploadResponse.ok) {
throw new Error(`Failed to upload file: ${uploadResponse.statusText}`);
if (!response.body) {
throw new Error("No response body received");
}
const uploadOptions: any = {
method: "PUT",
body: response.body,
headers: {
"Content-Type": "application/octet-stream",
"Content-Length": file.size.toString(),
},
signal: AbortSignal.timeout(600000), // 10 minutes timeout
};
const uploadResponse = await fetch(uploadUrl, uploadOptions);
if (!uploadResponse.ok) {
const errorText = await uploadResponse.text();
throw new Error(`Failed to upload file: ${uploadResponse.statusText} - ${errorText}`);
}
success = true;
} catch (error: any) {
retries++;
if (retries >= maxRetries) {
throw new Error(`Failed to copy file after ${maxRetries} attempts: ${error.message}`);
}
const delay = Math.min(1000 * Math.pow(2, retries - 1), 10000);
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
}
@@ -600,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

@@ -0,0 +1,159 @@
import { FastifyReply, FastifyRequest } from "fastify";
import { z } from "zod";
import { prisma } from "../../shared/prisma";
import { ConfigService } from "../config/service";
import { TwoFactorService } from "./service";
const SetupSchema = z
.object({
appName: z.string().optional(),
})
.optional()
.default({});
const VerifySetupSchema = z.object({
token: z.string().min(6, "Token must be at least 6 characters"),
secret: z.string().min(1, "Secret is required"),
});
const VerifyTokenSchema = z.object({
token: z.string().min(6, "Token must be at least 6 characters"),
});
const DisableSchema = z.object({
password: z.string().min(1, "Password is required"),
});
export class TwoFactorController {
private twoFactorService = new TwoFactorService();
private configService = new ConfigService();
/**
* Generate 2FA setup (QR code and secret)
*/
async generateSetup(request: FastifyRequest, reply: FastifyReply) {
try {
const userId = (request as any).user?.userId;
if (!userId) {
return reply.status(401).send({ error: "Unauthorized" });
}
const body = SetupSchema.parse(request.body || {});
const user = await prisma.user.findUnique({
where: { id: userId },
select: { email: true },
});
if (!user) {
return reply.status(404).send({ error: "User not found" });
}
const appName = body?.appName || (await this.configService.getValue("appName")) || "Palmr";
const setupData = await this.twoFactorService.generateSetup(userId, user.email, appName);
return reply.send(setupData);
} catch (error: any) {
return reply.status(400).send({ error: error.message });
}
}
/**
* Verify setup token and enable 2FA
*/
async verifySetup(request: FastifyRequest, reply: FastifyReply) {
try {
const userId = (request as any).user?.userId;
if (!userId) {
return reply.status(401).send({ error: "Unauthorized" });
}
const body = VerifySetupSchema.parse(request.body);
const result = await this.twoFactorService.verifySetup(userId, body.token, body.secret);
return reply.send(result);
} catch (error: any) {
return reply.status(400).send({ error: error.message });
}
}
/**
* Verify 2FA token during login
*/
async verifyToken(request: FastifyRequest, reply: FastifyReply) {
try {
const userId = (request as any).user?.userId;
if (!userId) {
return reply.status(401).send({ error: "Unauthorized" });
}
const body = VerifyTokenSchema.parse(request.body);
const result = await this.twoFactorService.verifyToken(userId, body.token);
return reply.send(result);
} catch (error: any) {
return reply.status(400).send({ error: error.message });
}
}
/**
* Disable 2FA
*/
async disable2FA(request: FastifyRequest, reply: FastifyReply) {
try {
const userId = (request as any).user?.userId;
if (!userId) {
return reply.status(401).send({ error: "Unauthorized" });
}
const body = DisableSchema.parse(request.body);
const result = await this.twoFactorService.disable2FA(userId, body.password);
return reply.send(result);
} catch (error: any) {
console.error("2FA Disable Error:", error.message);
return reply.status(400).send({ error: error.message });
}
}
/**
* Generate new backup codes
*/
async generateBackupCodes(request: FastifyRequest, reply: FastifyReply) {
try {
const userId = (request as any).user?.userId;
if (!userId) {
return reply.status(401).send({ error: "Unauthorized" });
}
const codes = await this.twoFactorService.generateNewBackupCodes(userId);
return reply.send({ backupCodes: codes });
} catch (error: any) {
return reply.status(400).send({ error: error.message });
}
}
/**
* Get 2FA status
*/
async getStatus(request: FastifyRequest, reply: FastifyReply) {
try {
const userId = (request as any).user?.userId;
if (!userId) {
return reply.status(401).send({ error: "Unauthorized" });
}
const status = await this.twoFactorService.getStatus(userId);
return reply.send(status);
} catch (error: any) {
return reply.status(400).send({ error: error.message });
}
}
}

View File

@@ -0,0 +1,170 @@
import { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { z } from "zod";
import { TwoFactorController } from "./controller";
export async function twoFactorRoutes(app: FastifyInstance) {
const twoFactorController = new TwoFactorController();
const preValidation = async (request: FastifyRequest, reply: FastifyReply) => {
try {
await request.jwtVerify();
} catch (err) {
console.error(err);
reply.status(401).send({ error: "Unauthorized: a valid token is required to access this resource." });
}
};
app.post(
"/2fa/setup",
{
preValidation,
schema: {
tags: ["Two-Factor Authentication"],
operationId: "generate2FASetup",
summary: "Generate 2FA Setup",
description: "Generate QR code and secret for 2FA setup",
body: z.object({
appName: z.string().optional().describe("Application name for QR code"),
}),
response: {
200: z.object({
secret: z.string().describe("Base32 encoded secret"),
qrCode: z.string().describe("QR code as data URL"),
manualEntryKey: z.string().describe("Manual entry key"),
backupCodes: z
.array(
z.object({
code: z.string().describe("Backup code"),
used: z.boolean().describe("Whether backup code is used"),
})
)
.describe("Backup codes"),
}),
400: z.object({ error: z.string().describe("Error message") }),
401: z.object({ error: z.string().describe("Error message") }),
},
},
},
twoFactorController.generateSetup.bind(twoFactorController)
);
app.post(
"/2fa/verify-setup",
{
preValidation,
schema: {
tags: ["Two-Factor Authentication"],
operationId: "verify2FASetup",
summary: "Verify 2FA Setup",
description: "Verify the setup token and enable 2FA",
body: z.object({
token: z.string().min(6).describe("TOTP token"),
secret: z.string().min(1).describe("Base32 encoded secret"),
}),
response: {
200: z.object({
success: z.boolean().describe("Setup success"),
backupCodes: z.array(z.string()).describe("Backup codes"),
}),
400: z.object({ error: z.string().describe("Error message") }),
401: z.object({ error: z.string().describe("Error message") }),
},
},
},
twoFactorController.verifySetup.bind(twoFactorController)
);
app.post(
"/2fa/verify",
{
preValidation,
schema: {
tags: ["Two-Factor Authentication"],
operationId: "verify2FAToken",
summary: "Verify 2FA Token",
description: "Verify a 2FA token during authentication",
body: z.object({
token: z.string().min(6).describe("TOTP token or backup code"),
}),
response: {
200: z.object({
success: z.boolean().describe("Verification success"),
method: z.enum(["totp", "backup"]).describe("Verification method used"),
}),
400: z.object({ error: z.string().describe("Error message") }),
401: z.object({ error: z.string().describe("Error message") }),
},
},
},
twoFactorController.verifyToken.bind(twoFactorController)
);
app.post(
"/2fa/disable",
{
preValidation,
schema: {
tags: ["Two-Factor Authentication"],
operationId: "disable2FA",
summary: "Disable 2FA",
description: "Disable two-factor authentication",
body: z.object({
password: z.string().min(1).describe("User password for confirmation"),
}),
response: {
200: z.object({
success: z.boolean().describe("Disable success"),
}),
400: z.object({ error: z.string().describe("Error message") }),
401: z.object({ error: z.string().describe("Error message") }),
},
},
},
twoFactorController.disable2FA.bind(twoFactorController)
);
app.post(
"/2fa/backup-codes",
{
preValidation,
schema: {
tags: ["Two-Factor Authentication"],
operationId: "generateBackupCodes",
summary: "Generate Backup Codes",
description: "Generate new backup codes for 2FA",
response: {
200: z.object({
backupCodes: z.array(z.string()).describe("New backup codes"),
}),
400: z.object({ error: z.string().describe("Error message") }),
401: z.object({ error: z.string().describe("Error message") }),
},
},
},
twoFactorController.generateBackupCodes.bind(twoFactorController)
);
app.get(
"/2fa/status",
{
preValidation,
schema: {
tags: ["Two-Factor Authentication"],
operationId: "get2FAStatus",
summary: "Get 2FA Status",
description: "Get current 2FA status for the user",
response: {
200: z.object({
enabled: z.boolean().describe("Whether 2FA is enabled"),
verified: z.boolean().describe("Whether 2FA is verified"),
availableBackupCodes: z.number().describe("Number of available backup codes"),
}),
400: z.object({ error: z.string().describe("Error message") }),
401: z.object({ error: z.string().describe("Error message") }),
},
},
},
twoFactorController.getStatus.bind(twoFactorController)
);
}

View File

@@ -0,0 +1,287 @@
import crypto from "node:crypto";
import bcrypt from "bcryptjs";
import QRCode from "qrcode";
import speakeasy from "speakeasy";
import { prisma } from "../../shared/prisma";
import { ConfigService } from "../config/service";
interface BackupCode {
code: string;
used: boolean;
}
export class TwoFactorService {
private configService = new ConfigService();
/**
* Generate a new 2FA secret and QR code for setup
*/
async generateSetup(userId: string, userEmail: string, appName?: string) {
const user = await prisma.user.findUnique({
where: { id: userId },
select: { id: true, email: true, twoFactorEnabled: true },
});
if (!user) {
throw new Error("User not found");
}
if (user.twoFactorEnabled) {
throw new Error("Two-factor authentication is already enabled");
}
const secret = speakeasy.generateSecret({
name: `${appName || "Palmr"}:${userEmail}`,
issuer: appName || "Palmr",
length: 32,
});
const qrCodeUrl = await QRCode.toDataURL(secret.otpauth_url || "");
return {
secret: secret.base32,
qrCode: qrCodeUrl,
manualEntryKey: secret.base32,
backupCodes: await this.generateBackupCodes(),
};
}
/**
* Verify setup token and enable 2FA
*/
async verifySetup(userId: string, token: string, secret: string) {
const user = await prisma.user.findUnique({
where: { id: userId },
select: { id: true, twoFactorEnabled: true },
});
if (!user) {
throw new Error("User not found");
}
if (user.twoFactorEnabled) {
throw new Error("Two-factor authentication is already enabled");
}
const verified = speakeasy.totp.verify({
secret: secret,
encoding: "base32",
token: token,
window: 1,
});
if (!verified) {
throw new Error("Invalid verification code");
}
const backupCodes = await this.generateBackupCodes();
await prisma.user.update({
where: { id: userId },
data: {
twoFactorEnabled: true,
twoFactorSecret: secret,
twoFactorBackupCodes: JSON.stringify(backupCodes),
twoFactorVerified: true,
},
});
return {
success: true,
backupCodes: backupCodes.map((bc) => bc.code),
};
}
/**
* Verify a 2FA token during login
*/
async verifyToken(userId: string, token: string) {
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
id: true,
twoFactorEnabled: true,
twoFactorSecret: true,
twoFactorBackupCodes: true,
},
});
if (!user) {
throw new Error("User not found");
}
if (!user.twoFactorEnabled || !user.twoFactorSecret) {
throw new Error("Two-factor authentication is not enabled");
}
const verified = speakeasy.totp.verify({
secret: user.twoFactorSecret,
encoding: "base32",
token: token,
window: 1,
});
if (verified) {
return { success: true, method: "totp" };
}
if (user.twoFactorBackupCodes) {
const backupCodes: BackupCode[] = JSON.parse(user.twoFactorBackupCodes);
const backupCodeIndex = backupCodes.findIndex((bc) => bc.code === token && !bc.used);
if (backupCodeIndex !== -1) {
backupCodes[backupCodeIndex].used = true;
await prisma.user.update({
where: { id: userId },
data: {
twoFactorBackupCodes: JSON.stringify(backupCodes),
},
});
return { success: true, method: "backup" };
}
}
throw new Error("Invalid verification code");
}
/**
* Disable 2FA for a user
*/
async disable2FA(userId: string, password: string) {
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
id: true,
password: true,
twoFactorEnabled: true,
},
});
if (!user) {
throw new Error("User not found");
}
if (!user.twoFactorEnabled) {
throw new Error("Two-factor authentication is not enabled");
}
if (!user.password) {
throw new Error("Password verification required");
}
let isValidPassword = false;
try {
isValidPassword = await bcrypt.compare(password, user.password);
} catch (error) {
console.error("bcrypt.compare error:", error);
throw new Error("Password verification failed");
}
if (!isValidPassword) {
throw new Error("Invalid password");
}
await prisma.user.update({
where: { id: userId },
data: {
twoFactorEnabled: false,
twoFactorSecret: null,
twoFactorBackupCodes: null,
twoFactorVerified: false,
},
});
return { success: true };
}
/**
* Generate new backup codes
*/
async generateNewBackupCodes(userId: string) {
const user = await prisma.user.findUnique({
where: { id: userId },
select: { id: true, twoFactorEnabled: true },
});
if (!user) {
throw new Error("User not found");
}
if (!user.twoFactorEnabled) {
throw new Error("Two-factor authentication is not enabled");
}
const backupCodes = await this.generateBackupCodes();
await prisma.user.update({
where: { id: userId },
data: {
twoFactorBackupCodes: JSON.stringify(backupCodes),
},
});
return backupCodes.map((bc) => bc.code);
}
/**
* Get 2FA status for a user
*/
async getStatus(userId: string) {
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
id: true,
twoFactorEnabled: true,
twoFactorVerified: true,
twoFactorBackupCodes: true,
},
});
if (!user) {
throw new Error("User not found");
}
let availableBackupCodes = 0;
if (user.twoFactorBackupCodes) {
const backupCodes: BackupCode[] = JSON.parse(user.twoFactorBackupCodes);
availableBackupCodes = backupCodes.filter((bc) => !bc.used).length;
}
return {
enabled: user.twoFactorEnabled,
verified: user.twoFactorVerified,
availableBackupCodes,
};
}
/**
* Generate backup codes
*/
private async generateBackupCodes(): Promise<BackupCode[]> {
const codes: BackupCode[] = [];
for (let i = 0; i < 10; i++) {
const code = crypto.randomBytes(4).toString("hex").toUpperCase();
codes.push({
code: code.match(/.{1,4}/g)?.join("-") || code,
used: false,
});
}
return codes;
}
/**
* Check if user has 2FA enabled
*/
async isEnabled(userId: string): Promise<boolean> {
const user = await prisma.user.findUnique({
where: { id: userId },
select: { twoFactorEnabled: true },
});
return user?.twoFactorEnabled ?? false;
}
}

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;
@@ -179,7 +205,6 @@ export class FilesystemStorageProvider implements StorageProvider {
}
async uploadFile(objectName: string, buffer: Buffer): Promise<void> {
// For backward compatibility, convert buffer to stream and use streaming upload
const filePath = this.getFilePath(objectName);
const dir = path.dirname(filePath);
@@ -197,7 +222,6 @@ export class FilesystemStorageProvider implements StorageProvider {
await fs.mkdir(dir, { recursive: true });
// Use the new temp file system for better organization
const tempPath = getTempFilePath(objectName);
const tempDir = path.dirname(tempPath);
@@ -215,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 {
@@ -308,10 +326,8 @@ export class FilesystemStorageProvider implements StorageProvider {
*/
private async cleanupTempFile(tempPath: string): Promise<void> {
try {
// Remove the temp file
await fs.unlink(tempPath);
// Try to remove the parent directory if it's empty
const tempDir = path.dirname(tempPath);
try {
const files = await fs.readdir(tempDir);
@@ -319,7 +335,6 @@ export class FilesystemStorageProvider implements StorageProvider {
await fs.rmdir(tempDir);
}
} catch (dirError: any) {
// Ignore errors when trying to remove directory (might not be empty or might not exist)
if (dirError.code !== "ENOTEMPTY" && dirError.code !== "ENOENT") {
console.warn("Warning: Could not remove temp directory:", dirError.message);
}
@@ -338,11 +353,10 @@ export class FilesystemStorageProvider implements StorageProvider {
try {
const tempUploadsDir = directoriesConfig.tempUploads;
// Check if temp-uploads directory exists
try {
await fs.access(tempUploadsDir);
} catch {
return; // Directory doesn't exist, nothing to clean
return;
}
const items = await fs.readdir(tempUploadsDir);
@@ -354,14 +368,12 @@ export class FilesystemStorageProvider implements StorageProvider {
const stat = await fs.stat(itemPath);
if (stat.isDirectory()) {
// Check if directory is empty
const dirContents = await fs.readdir(itemPath);
if (dirContents.length === 0) {
await fs.rmdir(itemPath);
console.log(`🧹 Cleaned up empty temp directory: ${itemPath}`);
}
} else if (stat.isFile()) {
// Check if file is older than 1 hour (stale temp files)
const oneHourAgo = Date.now() - 60 * 60 * 1000;
if (stat.mtime.getTime() < oneHourAgo) {
await fs.unlink(itemPath);
@@ -369,7 +381,6 @@ export class FilesystemStorageProvider implements StorageProvider {
}
}
} catch (error: any) {
// Ignore errors for individual items
if (error.code !== "ENOENT") {
console.warn(`Warning: Could not process temp item ${itemPath}:`, error.message);
}

View File

@@ -11,11 +11,13 @@ import { appRoutes } from "./modules/app/routes";
import { authProvidersRoutes } from "./modules/auth-providers/routes";
import { authRoutes } from "./modules/auth/routes";
import { fileRoutes } from "./modules/file/routes";
import { ChunkManager } from "./modules/filesystem/chunk-manager";
import { filesystemRoutes } from "./modules/filesystem/routes";
import { healthRoutes } from "./modules/health/routes";
import { reverseShareRoutes } from "./modules/reverse-share/routes";
import { shareRoutes } from "./modules/share/routes";
import { storageRoutes } from "./modules/storage/routes";
import { twoFactorRoutes } from "./modules/two-factor/routes";
import { userRoutes } from "./modules/user/routes";
import { IS_RUNNING_IN_CONTAINER } from "./utils/container-detection";
@@ -69,6 +71,7 @@ async function startServer() {
app.register(authRoutes);
app.register(authProvidersRoutes, { prefix: "/auth" });
app.register(twoFactorRoutes, { prefix: "/auth" });
app.register(userRoutes);
app.register(fileRoutes);
@@ -98,11 +101,25 @@ 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:");
console.log(` - API Reference: http://localhost:3333/docs\n`);
process.on("SIGINT", async () => {
const chunkManager = ChunkManager.getInstance();
chunkManager.destroy();
process.exit(0);
});
process.on("SIGTERM", async () => {
const chunkManager = ChunkManager.getInstance();
chunkManager.destroy();
process.exit(0);
});
}
startServer().catch((err) => {

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

@@ -64,8 +64,7 @@ export default [
"@typescript-eslint/no-var-requires": "off",
},
},
// Ignore ESLint errors in @/ui directory
{
ignores: ["src/components/ui/**/*"],
ignores: ["src/components/ui/**/*", ".next/**/*"],
},
];

View File

@@ -143,7 +143,8 @@
"saving": "جاري الحفظ...",
"update": "تحديث",
"click": "انقر على",
"creating": "جاري الإنشاء..."
"creating": "جاري الإنشاء...",
"loadingSimple": "جاري التحميل..."
},
"createShare": {
"title": "إنشاء مشاركة",
@@ -182,7 +183,15 @@
"invalidCredentials": "بريد إلكتروني أو كلمة مرور غير صحيحة",
"userNotFound": "المستخدم غير موجود",
"accountLocked": "تم قفل الحساب. يرجى المحاولة لاحقًا",
"unexpectedError": "حدث خطأ غير متوقع. يرجى المحاولة مرة أخرى"
"unexpectedError": "حدث خطأ غير متوقع. يرجى المحاولة مرة أخرى",
"Invalid password": "كلمة المرور غير صحيحة",
"Invalid two-factor authentication code": "رمز المصادقة الثنائية غير صحيح",
"Invalid verification code": "رمز التحقق غير صحيح",
"Password verification required": "مطلوب التحقق من كلمة المرور",
"Two-factor authentication is already enabled": "المصادقة الثنائية مفعلة بالفعل",
"Two-factor authentication is not enabled": "المصادقة الثنائية غير مفعلة",
"Two-factor authentication required": "المصادقة الثنائية مطلوبة",
"noUserData": "لا يوجد بيانات المستخدم"
},
"fileActions": {
"editFile": "تعديل الملف",
@@ -304,7 +313,8 @@
"title": "نسيت كلمة المرور",
"description": "أدخل بريدك الإلكتروني وسنرسل لك تعليمات إعادة تعيين كلمة المرور.",
"resetInstructions": "تم إرسال تعليمات إعادة التعيين إلى بريدك الإلكتروني",
"pageTitle": "نسيت كلمة المرور"
"pageTitle": "نسيت كلمة المرور",
"passwordAuthDisabled": "تم تعطيل المصادقة بكلمة المرور. يرجى الاتصال بالمسؤول أو استخدام مزود مصادقة خارجي."
},
"generateShareLink": {
"generateTitle": "إنشاء رابط المشاركة",
@@ -318,7 +328,12 @@
"copyButton": "نسخ الرابط",
"success": "تم إنشاء الرابط بنجاح",
"error": "فشل في إنشاء الرابط",
"copied": "تم نسخ الرابط إلى الحافظة"
"copied": "تم نسخ الرابط إلى الحافظة",
"readyDescription": "رابط المشاركة الخاص بك جاهز. يمكنك مسح رمز QR مباشرة، أو تنزيله للاستخدام لاحقًا، أو نسخ الرابط أدناه.",
"tabs": {
"link": "الرابط",
"qrcode": "رمز QR"
}
},
"home": {
"description": "البديل مفتوح المصدر لـ WeTransfer. شارك ملفاتك بأمان، دون تتبع أو قيود.",
@@ -331,6 +346,27 @@
},
"pageTitle": "الصفحة الرئيسية"
},
"iconPicker": {
"title": "اختر أيقونة",
"placeholder": "اختر أيقونة",
"searchPlaceholder": "البحث عن الأيقونات...",
"loadingMore": "جاري تحميل المزيد من الأيقونات...",
"allIconsLoaded": "تم تحميل جميع الأيقونات {count}",
"noIconsFound": "لم يتم العثور على أيقونات لـ \"{search}\"",
"tabs": {
"all": "جميع الأيقونات",
"popular": "الشائعة",
"auth": "مزودي المصادقة"
},
"stats": "{iconCount} أيقونة من {libraryCount} مكتبة",
"categoryBadge": "{category} ({count} أيقونات)"
},
"imageEdit": {
"title": "تعديل الصورة",
"rotate": "تدوير",
"zoom": "تكبير/تصغير",
"cropInstructions": "اسحب لإعادة تحديد الموضع، غير حجم الزوايا لضبط منطقة القص"
},
"login": {
"welcome": "مرحبا بك",
"signInToContinue": "قم بتسجيل الدخول للمتابعة",
@@ -589,7 +625,8 @@
"createLink": "إنشاء رابط",
"delete": "حذف",
"copyLinkTitle": "نسخ الرابط",
"createLinkCTA": "إنشاء رابط استلام"
"createLinkCTA": "إنشاء رابط استلام",
"viewQrCode": "عرض رمز QR"
},
"status": {
"active": "نشط",
@@ -605,7 +642,8 @@
"viewDetails": "عرض التفاصيل",
"edit": "تحرير",
"delete": "حذف",
"viewFiles": "الملفات المستلمة"
"viewFiles": "الملفات المستلمة",
"viewQrCode": "عرض رمز QR"
},
"empty": {
"title": "لم يتم إنشاء روابط استلام",
@@ -746,7 +784,12 @@
"selectAll": "تحديد الكل",
"selectFile": "تحديد الملف {fileName}",
"deleteError": "خطأ في حذف الملف",
"deleteSuccess": "تم حذف الملف بنجاح"
"deleteSuccess": "تم حذف الملف بنجاح",
"copyErrors": {
"timeout": "انتهت مهلة عملية النسخ. يرجى المحاولة مرة أخرى باستخدام ملف أصغر أو التحقق من اتصالك.",
"failed": "فشلت عملية النسخ. يرجى المحاولة مرة أخرى.",
"aborted": "تم إلغاء عملية النسخ بسبب انتهاء المهلة."
}
}
},
"form": {
@@ -897,7 +940,8 @@
"fileList": {
"title": "الملفات المختارة:",
"statusUploaded": "تم الرفع",
"statusError": "خطأ"
"statusError": "خطأ",
"retry": "إعادة المحاولة"
},
"form": {
"nameLabel": "الاسم",
@@ -1087,6 +1131,10 @@
"smtpTrustSelfSigned": {
"title": "الوثوق بالشهادات الموقعة ذاتياً",
"description": "قم بتمكين هذا للوثوق بشهادات SSL/TLS الموقعة ذاتياً (مفيد لبيئات التطوير)"
},
"passwordAuthEnabled": {
"title": "المصادقة بالكلمة السرية",
"description": "تمكين أو تعطيل المصادقة بالكلمة السرية"
}
},
"buttons": {
@@ -1096,7 +1144,8 @@
},
"errors": {
"loadFailed": "فشل في تحميل الإعدادات",
"updateFailed": "فشل في تحديث الإعدادات"
"updateFailed": "فشل في تحديث الإعدادات",
"passwordAuthRequiresProvider": "لا يمكن تعطيل المصادقة بالكلمة السرية دون وجود على الأقل موفرين مصادقة مفعلين"
},
"messages": {
"noChanges": "لا توجد تغييرات للحفظ",
@@ -1202,7 +1251,10 @@
"invalidDate": "تاريخ غير صحيح",
"loadError": "فشل في تحميل تفاصيل المشاركة",
"editSecurity": "تحرير الأمان",
"editExpiration": "تحرير انتهاء الصلاحية"
"editExpiration": "تحرير انتهاء الصلاحية",
"clickToEnlargeQrCode": "انقر لتكبير رمز QR",
"downloadQrCode": "تحميل رمز QR",
"qrCode": "رمز QR"
},
"shareExpiration": {
"neverExpires": "لا تنتهي صلاحيته أبداً",
@@ -1389,7 +1441,8 @@
"copyLink": "نسخ الرابط",
"notifyRecipients": "إشعار المستقبلين",
"delete": "حذف",
"downloadShareFiles": "قم بتنزيل جميع الملفات"
"downloadShareFiles": "قم بتنزيل جميع الملفات",
"viewQrCode": "عرض رمز QR"
},
"bulkActions": {
"delete": "حذف",
@@ -1421,6 +1474,160 @@
"dark": "داكن",
"system": "النظام"
},
"twoFactor": {
"title": "المصادقة الثنائية",
"description": "أضف طبقة إضافية من الأمان إلى حسابك",
"enabled": "حسابك محمي بالمصادقة الثنائية",
"disabled": "المصادقة الثنائية غير مفعلة",
"setup": {
"title": "تفعيل المصادقة الثنائية",
"description": "امسح رمز QR باستخدام تطبيق المصادقة، ثم أدخل رمز التحقق.",
"qrCode": "رمز QR",
"manualEntryKey": "مفتاح الإدخال اليدوي",
"verificationCode": "رمز التحقق",
"verificationCodePlaceholder": "أدخل الرمز المكون من 6 أرقام",
"verificationCodeDescription": "أدخل الرمز المكون من 6 أرقام من تطبيق المصادقة",
"verifyAndEnable": "تحقق وتفعيل",
"cancel": "إلغاء"
},
"disable": {
"title": "تعطيل المصادقة الثنائية",
"description": "أدخل كلمة المرور للتأكيد على تعطيل المصادقة الثنائية.",
"password": "كلمة المرور",
"passwordPlaceholder": "أدخل كلمة المرور",
"confirm": "تأكيد التعطيل",
"cancel": "إلغاء"
},
"backupCodes": {
"title": "رموز النسخ الاحتياطي",
"description": "احفظ رموز النسخ الاحتياطي هذه في مكان آمن. يمكنك استخدامها للوصول إلى حسابك في حالة فقدان جهاز المصادقة.",
"warning": "هام:",
"warningText": "يمكن استخدام كل رمز نسخ احتياطي مرة واحدة فقط. احتفظ بها بشكل آمن ولا تشاركها مع أي شخص.",
"generateNew": "إنشاء رموز نسخ احتياطي جديدة",
"download": "تحميل رموز النسخ الاحتياطي",
"copyToClipboard": "نسخ إلى الحافظة",
"savedMessage": "لقد حفظت رموز النسخ الاحتياطي",
"available": "{count} رموز نسخ احتياطي متاحة",
"instructions": [
"• احفظ هذه الرموز في مكان آمن",
"• يمكن استخدام كل رمز نسخ احتياطي مرة واحدة فقط",
"• يمكنك إنشاء رموز جديدة في أي وقت"
]
},
"verification": {
"title": "المصادقة الثنائية",
"description": "أدخل الرمز المكون من 6 أرقام من تطبيق المصادقة",
"backupDescription": "أدخل أحد رموز النسخ الاحتياطي للمتابعة",
"verificationCode": "رمز التحقق",
"backupCode": "رمز النسخ الاحتياطي",
"verificationCodePlaceholder": "000000",
"backupCodePlaceholder": "XXXX-XXXX",
"verify": "تحقق",
"verifying": "جاري التحقق...",
"useBackupCode": "استخدم رمز النسخ الاحتياطي بدلاً من ذلك",
"useAuthenticatorCode": "استخدم رمز المصادقة بدلاً من ذلك",
"rememberDevice": "تذكر هذا الجهاز لمدة 30 يومًا",
"rememberDeviceDescription": "لن تحتاج إلى إدخال رموز المصادقة الثنائية على هذا الجهاز لمدة 30 يومًا"
},
"messages": {
"enabledSuccess": "تم تفعيل المصادقة الثنائية بنجاح!",
"disabledSuccess": "تم تعطيل المصادقة الثنائية بنجاح",
"backupCodesGenerated": "تم إنشاء رموز النسخ الاحتياطي الجديدة بنجاح",
"backupCodesCopied": "تم نسخ رموز النسخ الاحتياطي إلى الحافظة",
"setupFailed": "فشل في إنشاء إعداد المصادقة الثنائية",
"verificationFailed": "رمز التحقق غير صالح",
"disableFailed": "فشل في تعطيل المصادقة الثنائية. يرجى التحقق من كلمة المرور.",
"backupCodesFailed": "فشل في إنشاء رموز النسخ الاحتياطي",
"backupCodesCopyFailed": "فشل في نسخ رموز النسخ الاحتياطي",
"statusLoadFailed": "فشل في تحميل حالة المصادقة الثنائية",
"enterVerificationCode": "يرجى إدخال رمز التحقق",
"enterPassword": "يرجى إدخال كلمة المرور",
"deviceTrusted": "تم تحديد هذا الجهاز كجهاز موثوق به لمدة 30 يومًا"
},
"errors": {
"invalidVerificationCode": "رمز التحقق غير صالح",
"invalidTwoFactorCode": "رمز المصادقة الثنائية غير صالح",
"twoFactorRequired": "المصادقة الثنائية مطلوبة",
"twoFactorAlreadyEnabled": "المصادقة الثنائية مفعلة بالفعل",
"twoFactorNotEnabled": "المصادقة الثنائية غير مفعلة",
"passwordVerificationRequired": "التحقق من كلمة المرور مطلوب",
"invalidPassword": "كلمة المرور غير صالحة",
"userNotFound": "المستخدم غير موجود"
},
"buttons": {
"enable2FA": "تفعيل المصادقة الثنائية",
"disable2FA": "تعطيل المصادقة الثنائية"
},
"deviceNames": {
"unknownDevice": "جهاز غير معروف",
"browsers": {
"chrome": "كروم",
"firefox": "فايرفوكس",
"safari": "سفاري",
"edge": "إيدج"
},
"platforms": {
"windows": " على ويندوز",
"macos": " على ماك",
"linux": " على لينكس",
"iphone": " على آيفون",
"android": " على أندرويد"
}
},
"status": {
"label": "الحالة:",
"enabled": "مفعل",
"disabled": "معطل"
},
"trustedDevices": {
"title": "الأجهزة الموثوقة - المصادقة الثنائية",
"description": "الأجهزة التي لا تتطلب التحقق من المصادقة الثنائية",
"noDevices": "لا توجد أجهزة موثوقة",
"deviceName": "الجهاز",
"addedOn": "تمت الإضافة في",
"expiresOn": "تنتهي في",
"remove": "إزالة",
"removeAll": "إزالة الكل",
"confirmRemove": "هل أنت متأكد أنك تريد إزالة هذا الجهاز الموثوق به؟",
"confirmRemoveAll": "هل أنت متأكد أنك تريد إزالة جميع الأجهزة الموثوقة؟",
"deviceRemoved": "تمت إزالة الجهاز الموثوق به بنجاح",
"allDevicesRemoved": "تمت إزالة جميع الأجهزة الموثوقة بنجاح",
"loadFailed": "فشل في تحميل الأجهزة الموثوقة",
"removeFailed": "فشل في إزالة الجهاز الموثوق به",
"removeAllFailed": "فشل في إزالة جميع الأجهزة الموثوقة",
"loading": "جاري تحميل الأجهزة الموثوقة...",
"noDevicesDescription": "ستظهر الأجهزة هنا عندما تختار الوثوق بها أثناء التحقق من المصادقة الثنائية",
"tableHeaders": {
"device": "الجهاز",
"added": "تمت الإضافة",
"expires": "تنتهي",
"lastUsed": "آخر استخدام",
"ipAddress": "عنوان IP",
"actions": "الإجراءات"
},
"status": {
"never": "أبداً",
"expired": "منتهي الصلاحية"
},
"modals": {
"removeDevice": {
"title": "إزالة الجهاز الموثوق به",
"added": "تمت الإضافة:",
"ip": "عنوان IP:"
},
"removeAllDevices": {
"title": "إزالة جميع الأجهزة الموثوقة",
"description": "سيؤدي هذا إلى إزالة {count} جهاز موثوق به{count, plural, =1 {} other {}}. ستحتاج إلى التحقق من المصادقة الثنائية على جميع الأجهزة مرة أخرى."
},
"buttons": {
"cancel": "إلغاء",
"removing": "جاري الإزالة...",
"removeDevice": "إزالة الجهاز",
"removeAllDevices": "إزالة جميع الأجهزة"
}
}
}
},
"uploadFile": {
"title": "رفع ملف",
"multipleTitle": "رفع ملفات متعددة",
@@ -1538,19 +1745,9 @@
"nameRequired": "الاسم مطلوب",
"required": "هذا الحقل مطلوب"
},
"iconPicker": {
"title": "اختر أيقونة",
"placeholder": "اختر أيقونة",
"searchPlaceholder": "البحث عن الأيقونات...",
"loadingMore": "جاري تحميل المزيد من الأيقونات...",
"allIconsLoaded": "تم تحميل جميع الأيقونات {count}",
"noIconsFound": "لم يتم العثور على أيقونات لـ \"{search}\"",
"tabs": {
"all": "جميع الأيقونات",
"popular": "الشائعة",
"auth": "مزودي المصادقة"
},
"stats": "{iconCount} أيقونة من {libraryCount} مكتبة",
"categoryBadge": "{category} ({count} أيقونات)"
"qrCodeModal": {
"title": "مشاركة رمز QR",
"description": "امسح رمز QR هذا للوصول إلى الرابط.",
"download": "تحميل رمز QR"
}
}

View File

@@ -143,7 +143,8 @@
"saving": "Speichere...",
"update": "Aktualisieren",
"click": "Klicken Sie auf",
"creating": "Erstellen..."
"creating": "Erstellen...",
"loadingSimple": "Lade..."
},
"createShare": {
"title": "Freigabe Erstellen",
@@ -182,7 +183,15 @@
"invalidCredentials": "Ungültige E-Mail oder Passwort",
"userNotFound": "Benutzer nicht gefunden",
"accountLocked": "Konto gesperrt. Bitte versuchen Sie es später erneut",
"unexpectedError": "Ein unerwarteter Fehler ist aufgetreten. Bitte versuchen Sie es erneut"
"unexpectedError": "Ein unerwarteter Fehler ist aufgetreten. Bitte versuchen Sie es erneut",
"Invalid password": "Ungültiges Passwort",
"Invalid two-factor authentication code": "Ungültiger Zwei-Faktor-Authentifizierungscode",
"Invalid verification code": "Ungültiger Verifizierungscode",
"Password verification required": "Passwortüberprüfung erforderlich",
"Two-factor authentication is already enabled": "Zwei-Faktor-Authentifizierung ist bereits aktiviert",
"Two-factor authentication is not enabled": "Zwei-Faktor-Authentifizierung ist nicht aktiviert",
"Two-factor authentication required": "Zwei-Faktor-Authentifizierung erforderlich",
"noUserData": "Keine Benutzerdaten"
},
"fileActions": {
"editFile": "Datei bearbeiten",
@@ -304,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",
@@ -318,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.",
@@ -331,6 +346,27 @@
},
"pageTitle": "Startseite"
},
"iconPicker": {
"title": "Symbol auswählen",
"placeholder": "Wählen Sie ein Symbol",
"searchPlaceholder": "Symbole suchen...",
"loadingMore": "Weitere Symbole werden geladen...",
"allIconsLoaded": "Alle {count} Symbole geladen",
"noIconsFound": "Keine Symbole für \"{search}\" gefunden",
"tabs": {
"all": "Alle Symbole",
"popular": "Beliebt",
"auth": "Authentifizierungsanbieter"
},
"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",
@@ -589,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",
@@ -605,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",
@@ -746,7 +784,12 @@
"selectAll": "Alle auswählen",
"selectFile": "Datei {fileName} auswählen",
"deleteError": "Fehler beim Löschen der Datei",
"deleteSuccess": "Datei erfolgreich gelöscht"
"deleteSuccess": "Datei erfolgreich gelöscht",
"copyErrors": {
"timeout": "Zeitüberschreitung beim Kopiervorgang. Bitte versuchen Sie es erneut mit einer kleineren Datei oder überprüfen Sie Ihre Verbindung.",
"failed": "Kopiervorgang fehlgeschlagen. Bitte versuchen Sie es erneut.",
"aborted": "Kopiervorgang wurde wegen Zeitüberschreitung abgebrochen."
}
}
},
"form": {
@@ -897,7 +940,8 @@
"fileList": {
"title": "Ausgewählte Dateien:",
"statusUploaded": "Hochgeladen",
"statusError": "Fehler"
"statusError": "Fehler",
"retry": "Wiederholen"
},
"form": {
"nameLabel": "Name",
@@ -1085,6 +1129,10 @@
"tls": "STARTTLS (Port 587)",
"none": "Keine (Unsicher)"
}
},
"passwordAuthEnabled": {
"title": "Passwort-Authentifizierung",
"description": "Passwort-basierte Authentifizierung aktivieren oder deaktivieren"
}
},
"buttons": {
@@ -1094,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",
@@ -1200,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",
@@ -1387,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",
@@ -1419,6 +1472,160 @@
"dark": "Dunkel",
"system": "System"
},
"twoFactor": {
"title": "Zwei-Faktor-Authentifizierung",
"description": "Fügen Sie Ihrem Konto eine zusätzliche Sicherheitsebene hinzu",
"enabled": "Ihr Konto ist mit Zwei-Faktor-Authentifizierung geschützt",
"disabled": "Zwei-Faktor-Authentifizierung ist nicht aktiviert",
"setup": {
"title": "Zwei-Faktor-Authentifizierung aktivieren",
"description": "Scannen Sie den QR-Code mit Ihrer Authenticator-App und geben Sie dann den Bestätigungscode ein.",
"qrCode": "QR-Code",
"manualEntryKey": "Manueller Eingabeschlüssel",
"verificationCode": "Bestätigungscode",
"verificationCodePlaceholder": "6-stelligen Code eingeben",
"verificationCodeDescription": "Geben Sie den 6-stelligen Code aus Ihrer Authenticator-App ein",
"verifyAndEnable": "Überprüfen & Aktivieren",
"cancel": "Abbrechen"
},
"disable": {
"title": "Zwei-Faktor-Authentifizierung deaktivieren",
"description": "Geben Sie Ihr Passwort ein, um die Deaktivierung der Zwei-Faktor-Authentifizierung zu bestätigen.",
"password": "Passwort",
"passwordPlaceholder": "Geben Sie Ihr Passwort ein",
"confirm": "Deaktivierung bestätigen",
"cancel": "Abbrechen"
},
"backupCodes": {
"title": "Backup-Codes",
"description": "Speichern Sie diese Backup-Codes an einem sicheren Ort. Sie können sie verwenden, um auf Ihr Konto zuzugreifen, wenn Sie Ihr Authentifizierungsgerät verlieren.",
"warning": "Wichtig:",
"warningText": "Jeder Backup-Code kann nur einmal verwendet werden. Bewahren Sie sie sicher auf und teilen Sie sie mit niemandem.",
"generateNew": "Neue Backup-Codes generieren",
"download": "Backup-Codes herunterladen",
"copyToClipboard": "In die Zwischenablage kopieren",
"savedMessage": "Ich habe meine Backup-Codes gespeichert",
"available": "{count} Backup-Codes verfügbar",
"instructions": [
"• Speichern Sie diese Codes an einem sicheren Ort",
"• Jeder Backup-Code kann nur einmal verwendet werden",
"• Sie können jederzeit neue Codes generieren"
]
},
"verification": {
"title": "Zwei-Faktor-Authentifizierung",
"description": "Geben Sie den 6-stelligen Code aus Ihrer Authenticator-App ein",
"backupDescription": "Geben Sie einen Ihrer Backup-Codes ein, um fortzufahren",
"verificationCode": "Bestätigungscode",
"backupCode": "Backup-Code",
"verificationCodePlaceholder": "000000",
"backupCodePlaceholder": "XXXX-XXXX",
"verify": "Überprüfen",
"verifying": "Überprüfung läuft...",
"useBackupCode": "Stattdessen Backup-Code verwenden",
"useAuthenticatorCode": "Stattdessen Authenticator-Code verwenden",
"rememberDevice": "Dieses Gerät für 30 Tage merken",
"rememberDeviceDescription": "Sie müssen auf diesem Gerät 30 Tage lang keine 2FA-Codes eingeben"
},
"messages": {
"enabledSuccess": "Zwei-Faktor-Authentifizierung erfolgreich aktiviert!",
"disabledSuccess": "Zwei-Faktor-Authentifizierung erfolgreich deaktiviert",
"backupCodesGenerated": "Neue Backup-Codes erfolgreich generiert",
"backupCodesCopied": "Backup-Codes in die Zwischenablage kopiert",
"setupFailed": "2FA-Setup konnte nicht generiert werden",
"verificationFailed": "Ungültiger Bestätigungscode",
"disableFailed": "2FA konnte nicht deaktiviert werden. Bitte überprüfen Sie Ihr Passwort.",
"backupCodesFailed": "Backup-Codes konnten nicht generiert werden",
"backupCodesCopyFailed": "Backup-Codes konnten nicht kopiert werden",
"statusLoadFailed": "2FA-Status konnte nicht geladen werden",
"enterVerificationCode": "Bitte geben Sie den Bestätigungscode ein",
"enterPassword": "Bitte geben Sie Ihr Passwort ein",
"deviceTrusted": "Dieses Gerät wurde für 30 Tage als vertrauenswürdig markiert"
},
"errors": {
"invalidVerificationCode": "Ungültiger Bestätigungscode",
"invalidTwoFactorCode": "Ungültiger Zwei-Faktor-Authentifizierungscode",
"twoFactorRequired": "Zwei-Faktor-Authentifizierung erforderlich",
"twoFactorAlreadyEnabled": "Zwei-Faktor-Authentifizierung ist bereits aktiviert",
"twoFactorNotEnabled": "Zwei-Faktor-Authentifizierung ist nicht aktiviert",
"passwordVerificationRequired": "Passwortüberprüfung erforderlich",
"invalidPassword": "Ungültiges Passwort",
"userNotFound": "Benutzer nicht gefunden"
},
"buttons": {
"enable2FA": "2FA aktivieren",
"disable2FA": "2FA deaktivieren"
},
"deviceNames": {
"unknownDevice": "Unbekanntes Gerät",
"browsers": {
"chrome": "Chrome",
"firefox": "Firefox",
"safari": "Safari",
"edge": "Edge"
},
"platforms": {
"windows": " auf Windows",
"macos": " auf macOS",
"linux": " auf Linux",
"iphone": " auf iPhone",
"android": " auf Android"
}
},
"status": {
"label": "Status:",
"enabled": "Aktiviert",
"disabled": "Deaktiviert"
},
"trustedDevices": {
"title": "Vertrauenswürdige Geräte - 2FA",
"description": "Geräte, die keine 2FA-Verifizierung benötigen",
"noDevices": "Keine vertrauenswürdigen Geräte",
"deviceName": "Gerät",
"addedOn": "Hinzugefügt am",
"expiresOn": "Läuft ab am",
"remove": "Entfernen",
"removeAll": "Alle entfernen",
"confirmRemove": "Möchten Sie dieses vertrauenswürdige Gerät wirklich entfernen?",
"confirmRemoveAll": "Möchten Sie wirklich alle vertrauenswürdigen Geräte entfernen?",
"deviceRemoved": "Vertrauenswürdiges Gerät erfolgreich entfernt",
"allDevicesRemoved": "Alle vertrauenswürdigen Geräte erfolgreich entfernt",
"loadFailed": "Fehler beim Laden der vertrauenswürdigen Geräte",
"removeFailed": "Fehler beim Entfernen des vertrauenswürdigen Geräts",
"removeAllFailed": "Fehler beim Entfernen aller vertrauenswürdigen Geräte",
"loading": "Lade vertrauenswürdige Geräte...",
"noDevicesDescription": "Geräte erscheinen hier, wenn Sie sie während der 2FA-Verifizierung als vertrauenswürdig markieren",
"tableHeaders": {
"device": "Gerät",
"added": "Hinzugefügt",
"expires": "Läuft ab",
"lastUsed": "Zuletzt verwendet",
"ipAddress": "IP-Adresse",
"actions": "Aktionen"
},
"status": {
"never": "Nie",
"expired": "Abgelaufen"
},
"modals": {
"removeDevice": {
"title": "Vertrauenswürdiges Gerät entfernen",
"added": "Hinzugefügt:",
"ip": "IP:"
},
"removeAllDevices": {
"title": "Alle vertrauenswürdigen Geräte entfernen",
"description": "Dies wird {count} vertrauenswürdige{count, plural, =1 {s Gerät} other { Geräte}} entfernen. Sie müssen die 2FA auf allen Geräten erneut verifizieren."
},
"buttons": {
"cancel": "Abbrechen",
"removing": "Wird entfernt...",
"removeDevice": "Gerät entfernen",
"removeAllDevices": "Alle Geräte entfernen"
}
}
}
},
"uploadFile": {
"title": "Datei hochladen",
"multipleTitle": "Mehrere Dateien Hochladen",
@@ -1536,19 +1743,9 @@
"nameRequired": "Name ist erforderlich",
"required": "Dieses Feld ist erforderlich"
},
"iconPicker": {
"title": "Symbol auswählen",
"placeholder": "Wählen Sie ein Symbol",
"searchPlaceholder": "Symbole suchen...",
"loadingMore": "Weitere Symbole werden geladen...",
"allIconsLoaded": "Alle {count} Symbole geladen",
"noIconsFound": "Keine Symbole für \"{search}\" gefunden",
"tabs": {
"all": "Alle Symbole",
"popular": "Beliebt",
"auth": "Authentifizierungsanbieter"
},
"stats": "{iconCount} Symbole aus {libraryCount} Bibliotheken",
"categoryBadge": "{category} ({count} Symbole)"
"qrCodeModal": {
"title": "QR-Code teilen",
"description": "Scannen Sie diesen QR-Code, um auf den Link zuzugreifen.",
"download": "QR-Code herunterladen"
}
}

View File

@@ -129,6 +129,7 @@
},
"common": {
"loading": "Loading, please wait...",
"loadingSimple": "Loading...",
"cancel": "Cancel",
"save": "Save",
"saving": "Saving...",
@@ -182,7 +183,15 @@
"invalidCredentials": "Invalid email or password",
"userNotFound": "User not found",
"accountLocked": "Account locked. Please try again later",
"unexpectedError": "An unexpected error occurred. Please try again"
"unexpectedError": "An unexpected error occurred. Please try again",
"Invalid verification code": "Invalid verification code",
"Two-factor authentication is already enabled": "Two-factor authentication is already enabled",
"Two-factor authentication is not enabled": "Two-factor authentication is not enabled",
"Invalid password": "Invalid password",
"Password verification required": "Password verification required",
"Invalid two-factor authentication code": "Invalid two-factor authentication code",
"Two-factor authentication required": "Two-factor authentication required",
"noUserData": "No user data"
},
"fileActions": {
"editFile": "Edit File",
@@ -304,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.",
@@ -331,6 +346,27 @@
},
"pageTitle": "Home"
},
"iconPicker": {
"title": "Select Icon",
"placeholder": "Select an icon",
"searchPlaceholder": "Search icons...",
"loadingMore": "Loading more icons...",
"allIconsLoaded": "All {count} icons loaded",
"noIconsFound": "No icons found for \"{search}\"",
"tabs": {
"all": "All Icons",
"popular": "Popular",
"auth": "Auth Providers"
},
"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",
@@ -418,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",
@@ -583,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",
@@ -605,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",
@@ -746,7 +789,12 @@
"delete": "Delete Selected"
},
"selectAll": "Select all",
"selectFile": "Select file {fileName}"
"selectFile": "Select file {fileName}",
"copyErrors": {
"timeout": "Copy operation timed out. Please try again with a smaller file or check your connection.",
"failed": "Copy operation failed. Please try again.",
"aborted": "Copy operation was cancelled due to timeout."
}
}
},
"form": {
@@ -898,7 +946,8 @@
"fileList": {
"title": "Selected files:",
"statusUploaded": "Uploaded",
"statusError": "Error"
"statusError": "Error",
"retry": "Retry"
},
"form": {
"nameLabel": "Name",
@@ -944,8 +993,6 @@
},
"fileActions": {
"edit": "Edit",
"save": "Save",
"cancel": "Cancel",
"preview": "Preview",
"download": "Download",
"delete": "Delete",
@@ -1085,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": {
@@ -1094,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",
@@ -1172,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": {
@@ -1385,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"
@@ -1419,6 +1474,160 @@
"dark": "Dark",
"system": "System"
},
"twoFactor": {
"title": "Two-Factor Authentication",
"description": "Add an extra layer of security to your account",
"enabled": "Your account is protected with two-factor authentication",
"disabled": "Two-factor authentication is not enabled",
"status": {
"label": "Status:",
"enabled": "Enabled",
"disabled": "Disabled"
},
"buttons": {
"enable2FA": "Enable 2FA",
"disable2FA": "Disable 2FA"
},
"setup": {
"title": "Enable Two-Factor Authentication",
"description": "Scan the QR code with your authenticator app, then enter the verification code.",
"qrCode": "QR Code",
"manualEntryKey": "Manual Entry Key",
"verificationCode": "Verification Code",
"verificationCodePlaceholder": "Enter 6-digit code",
"verificationCodeDescription": "Enter the 6-digit code from your authenticator app",
"verifyAndEnable": "Verify & Enable",
"cancel": "Cancel"
},
"disable": {
"title": "Disable Two-Factor Authentication",
"description": "Enter your password to confirm disabling two-factor authentication.",
"password": "Password",
"passwordPlaceholder": "Enter your password",
"confirm": "Confirm Disable",
"cancel": "Cancel"
},
"backupCodes": {
"title": "Backup Codes",
"description": "Save these backup codes in a safe place. You can use them to access your account if you lose your authenticator device.",
"warning": "Important:",
"warningText": "Each backup code can only be used once. Keep them secure and don't share them with anyone.",
"generateNew": "Generate New Backup Codes",
"download": "Download Backup Codes",
"copyToClipboard": "Copy to Clipboard",
"savedMessage": "I've Saved My Backup Codes",
"available": "{count} backup codes available",
"instructions": [
"• Save these codes in a secure location",
"• Each backup code can only be used once",
"• You can generate new codes anytime"
]
},
"verification": {
"title": "Two-Factor Authentication",
"description": "Enter the 6-digit code from your authenticator app",
"backupDescription": "Enter one of your backup codes to continue",
"verificationCode": "Verification Code",
"backupCode": "Backup Code",
"verificationCodePlaceholder": "000000",
"backupCodePlaceholder": "XXXX-XXXX",
"verify": "Verify",
"verifying": "Verifying...",
"useBackupCode": "Use backup code instead",
"useAuthenticatorCode": "Use authenticator code instead",
"rememberDevice": "Remember this device for 30 days",
"rememberDeviceDescription": "You won't need to enter 2FA codes on this device for 30 days"
},
"trustedDevices": {
"title": "Trusted Devices - 2FA",
"description": "Devices that don't require 2FA verification",
"noDevices": "No trusted devices",
"deviceName": "Device",
"addedOn": "Added on",
"expiresOn": "Expires on",
"remove": "Remove",
"removeAll": "Remove All",
"confirmRemove": "Are you sure you want to remove this trusted device?",
"confirmRemoveAll": "Are you sure you want to remove all trusted devices?",
"deviceRemoved": "Trusted device removed successfully",
"allDevicesRemoved": "All trusted devices removed successfully",
"loadFailed": "Failed to load trusted devices",
"removeFailed": "Failed to remove trusted device",
"removeAllFailed": "Failed to remove all trusted devices",
"loading": "Loading trusted devices...",
"noDevicesDescription": "Devices will appear here when you choose to trust them during 2FA verification",
"tableHeaders": {
"device": "Device",
"added": "Added",
"expires": "Expires",
"lastUsed": "Last Used",
"ipAddress": "IP Address",
"actions": "Actions"
},
"status": {
"never": "Never",
"expired": "Expired"
},
"modals": {
"removeDevice": {
"title": "Remove Trusted Device",
"added": "Added:",
"ip": "IP:"
},
"removeAllDevices": {
"title": "Remove All Trusted Devices",
"description": "This will remove {count} trusted device{count, plural, =1 {} other {s}}. You will need to verify 2FA on all devices again."
},
"buttons": {
"cancel": "Cancel",
"removing": "Removing...",
"removeDevice": "Remove Device",
"removeAllDevices": "Remove All Devices"
}
}
},
"messages": {
"enabledSuccess": "Two-factor authentication enabled successfully!",
"disabledSuccess": "Two-factor authentication disabled successfully",
"backupCodesGenerated": "New backup codes generated successfully",
"backupCodesCopied": "Backup codes copied to clipboard",
"setupFailed": "Failed to generate 2FA setup",
"verificationFailed": "Invalid verification code",
"disableFailed": "Failed to disable 2FA. Please check your password.",
"backupCodesFailed": "Failed to generate backup codes",
"backupCodesCopyFailed": "Failed to copy backup codes",
"statusLoadFailed": "Failed to load 2FA status",
"enterVerificationCode": "Please enter the verification code",
"enterPassword": "Please enter your password",
"deviceTrusted": "This device has been marked as trusted for 30 days"
},
"errors": {
"invalidVerificationCode": "Invalid verification code",
"invalidTwoFactorCode": "Invalid two-factor authentication code",
"twoFactorRequired": "Two-factor authentication required",
"twoFactorAlreadyEnabled": "Two-factor authentication is already enabled",
"twoFactorNotEnabled": "Two-factor authentication is not enabled",
"passwordVerificationRequired": "Password verification required",
"invalidPassword": "Invalid password",
"userNotFound": "User not found"
},
"deviceNames": {
"unknownDevice": "Unknown Device",
"browsers": {
"chrome": "Chrome",
"firefox": "Firefox",
"safari": "Safari",
"edge": "Edge"
},
"platforms": {
"windows": " on Windows",
"macos": " on macOS",
"linux": " on Linux",
"iphone": " on iPhone",
"android": " on Android"
}
}
},
"uploadFile": {
"title": "Upload File",
"multipleTitle": "Upload Files",
@@ -1500,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": {
@@ -1535,20 +1744,5 @@
"passwordMinLength": "Password must be at least 6 characters",
"nameRequired": "Name is required",
"required": "This field is required"
},
"iconPicker": {
"title": "Select Icon",
"placeholder": "Select an icon",
"searchPlaceholder": "Search icons...",
"loadingMore": "Loading more icons...",
"allIconsLoaded": "All {count} icons loaded",
"noIconsFound": "No icons found for \"{search}\"",
"tabs": {
"all": "All Icons",
"popular": "Popular",
"auth": "Auth Providers"
},
"stats": "{iconCount} icons from {libraryCount} libraries",
"categoryBadge": "{category} ({count} icons)"
}
}

View File

@@ -143,7 +143,8 @@
"saving": "Guardando...",
"update": "Actualizar",
"click": "Haga clic para",
"creating": "Creando..."
"creating": "Creando...",
"loadingSimple": "Cargando..."
},
"createShare": {
"title": "Crear Compartir",
@@ -182,7 +183,15 @@
"invalidCredentials": "Correo electrónico o contraseña inválidos",
"userNotFound": "Usuario no encontrado",
"accountLocked": "Cuenta bloqueada. Por favor, inténtalo de nuevo más tarde",
"unexpectedError": "Ocurrió un error inesperado. Por favor, inténtalo de nuevo"
"unexpectedError": "Ocurrió un error inesperado. Por favor, inténtalo de nuevo",
"Invalid password": "Contraseña inválida",
"Invalid two-factor authentication code": "Código de autenticación de dos factores inválido",
"Invalid verification code": "Código de verificación inválido",
"Password verification required": "Se requiere verificación de contraseña",
"Two-factor authentication is already enabled": "La autenticación de dos factores ya está habilitada",
"Two-factor authentication is not enabled": "La autenticación de dos factores no está habilitada",
"Two-factor authentication required": "Se requiere autenticación de dos factores",
"noUserData": "No hay datos del usuario"
},
"fileActions": {
"editFile": "Editar archivo",
@@ -304,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",
@@ -318,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.",
@@ -331,6 +346,27 @@
},
"pageTitle": "Inicio"
},
"iconPicker": {
"title": "Seleccionar Icono",
"placeholder": "Seleccionar un icono",
"searchPlaceholder": "Buscar iconos...",
"loadingMore": "Cargando más iconos...",
"allIconsLoaded": "Todos los {count} iconos cargados",
"noIconsFound": "No se encontraron iconos para \"{search}\"",
"tabs": {
"all": "Todos los Iconos",
"popular": "Populares",
"auth": "Proveedores de Autenticación"
},
"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",
@@ -589,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",
@@ -605,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",
@@ -746,7 +784,12 @@
"selectAll": "Seleccionar todo",
"selectFile": "Seleccionar archivo {fileName}",
"deleteError": "Error al eliminar el archivo",
"deleteSuccess": "Archivo eliminado correctamente"
"deleteSuccess": "Archivo eliminado correctamente",
"copyErrors": {
"timeout": "La operación de copia expiró. Por favor, inténtalo de nuevo con un archivo más pequeño o verifica tu conexión.",
"failed": "La operación de copia falló. Por favor, inténtalo de nuevo.",
"aborted": "La operación de copia fue cancelada debido al tiempo de espera."
}
}
},
"form": {
@@ -897,7 +940,8 @@
"fileList": {
"title": "Archivos seleccionados:",
"statusUploaded": "Enviado",
"statusError": "Error"
"statusError": "Error",
"retry": "Reintentar"
},
"form": {
"nameLabel": "Nombre",
@@ -1085,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": {
@@ -1094,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",
@@ -1200,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",
@@ -1387,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",
@@ -1419,6 +1472,160 @@
"dark": "Oscuro",
"system": "Sistema"
},
"twoFactor": {
"title": "Autenticación de dos factores",
"description": "Añade una capa extra de seguridad a tu cuenta",
"enabled": "Tu cuenta está protegida con autenticación de dos factores",
"disabled": "La autenticación de dos factores no está habilitada",
"setup": {
"title": "Habilitar autenticación de dos factores",
"description": "Escanea el código QR con tu aplicación de autenticación y luego ingresa el código de verificación.",
"qrCode": "Código QR",
"manualEntryKey": "Clave de entrada manual",
"verificationCode": "Código de verificación",
"verificationCodePlaceholder": "Ingresa el código de 6 dígitos",
"verificationCodeDescription": "Ingresa el código de 6 dígitos de tu aplicación de autenticación",
"verifyAndEnable": "Verificar y habilitar",
"cancel": "Cancelar"
},
"disable": {
"title": "Deshabilitar autenticación de dos factores",
"description": "Ingresa tu contraseña para confirmar la desactivación de la autenticación de dos factores.",
"password": "Contraseña",
"passwordPlaceholder": "Ingresa tu contraseña",
"confirm": "Confirmar desactivación",
"cancel": "Cancelar"
},
"backupCodes": {
"title": "Códigos de respaldo",
"description": "Guarda estos códigos de respaldo en un lugar seguro. Puedes usarlos para acceder a tu cuenta si pierdes tu dispositivo de autenticación.",
"warning": "Importante:",
"warningText": "Cada código de respaldo solo se puede usar una vez. Mantenlos seguros y no los compartas con nadie.",
"generateNew": "Generar nuevos códigos de respaldo",
"download": "Descargar códigos de respaldo",
"copyToClipboard": "Copiar al portapapeles",
"savedMessage": "He guardado mis códigos de respaldo",
"available": "{count} códigos de respaldo disponibles",
"instructions": [
"• Guarda estos códigos en un lugar seguro",
"• Cada código de respaldo solo se puede usar una vez",
"• Puedes generar nuevos códigos en cualquier momento"
]
},
"verification": {
"title": "Autenticación de dos factores",
"description": "Ingresa el código de 6 dígitos de tu aplicación de autenticación",
"backupDescription": "Ingresa uno de tus códigos de respaldo para continuar",
"verificationCode": "Código de verificación",
"backupCode": "Código de respaldo",
"verificationCodePlaceholder": "000000",
"backupCodePlaceholder": "XXXX-XXXX",
"verify": "Verificar",
"verifying": "Verificando...",
"useBackupCode": "Usar código de respaldo en su lugar",
"useAuthenticatorCode": "Usar código de autenticación en su lugar",
"rememberDevice": "Recordar este dispositivo durante 30 días",
"rememberDeviceDescription": "No necesitarás ingresar códigos 2FA en este dispositivo durante 30 días"
},
"messages": {
"enabledSuccess": "¡Autenticación de dos factores habilitada exitosamente!",
"disabledSuccess": "Autenticación de dos factores deshabilitada exitosamente",
"backupCodesGenerated": "Nuevos códigos de respaldo generados exitosamente",
"backupCodesCopied": "Códigos de respaldo copiados al portapapeles",
"setupFailed": "Error al generar la configuración de 2FA",
"verificationFailed": "Código de verificación inválido",
"disableFailed": "Error al deshabilitar 2FA. Por favor verifica tu contraseña.",
"backupCodesFailed": "Error al generar códigos de respaldo",
"backupCodesCopyFailed": "Error al copiar códigos de respaldo",
"statusLoadFailed": "Error al cargar el estado de 2FA",
"enterVerificationCode": "Por favor ingresa el código de verificación",
"enterPassword": "Por favor ingresa tu contraseña",
"deviceTrusted": "Este dispositivo ha sido marcado como confiable durante 30 días"
},
"errors": {
"invalidVerificationCode": "Código de verificación inválido",
"invalidTwoFactorCode": "Código de autenticación de dos factores inválido",
"twoFactorRequired": "Se requiere autenticación de dos factores",
"twoFactorAlreadyEnabled": "La autenticación de dos factores ya está habilitada",
"twoFactorNotEnabled": "La autenticación de dos factores no está habilitada",
"passwordVerificationRequired": "Se requiere verificación de contraseña",
"invalidPassword": "Contraseña inválida",
"userNotFound": "Usuario no encontrado"
},
"buttons": {
"enable2FA": "Habilitar 2FA",
"disable2FA": "Deshabilitar 2FA"
},
"deviceNames": {
"unknownDevice": "Dispositivo Desconocido",
"browsers": {
"chrome": "Chrome",
"firefox": "Firefox",
"safari": "Safari",
"edge": "Edge"
},
"platforms": {
"windows": " en Windows",
"macos": " en macOS",
"linux": " en Linux",
"iphone": " en iPhone",
"android": " en Android"
}
},
"status": {
"label": "Estado:",
"enabled": "Habilitado",
"disabled": "Deshabilitado"
},
"trustedDevices": {
"title": "Dispositivos Confiables - 2FA",
"description": "Dispositivos que no requieren verificación 2FA",
"noDevices": "No hay dispositivos confiables",
"deviceName": "Dispositivo",
"addedOn": "Agregado el",
"expiresOn": "Expira el",
"remove": "Eliminar",
"removeAll": "Eliminar Todos",
"confirmRemove": "¿Estás seguro de que deseas eliminar este dispositivo confiable?",
"confirmRemoveAll": "¿Estás seguro de que deseas eliminar todos los dispositivos confiables?",
"deviceRemoved": "Dispositivo confiable eliminado exitosamente",
"allDevicesRemoved": "Todos los dispositivos confiables fueron eliminados exitosamente",
"loadFailed": "Error al cargar los dispositivos confiables",
"removeFailed": "Error al eliminar el dispositivo confiable",
"removeAllFailed": "Error al eliminar todos los dispositivos confiables",
"loading": "Cargando dispositivos confiables...",
"noDevicesDescription": "Los dispositivos aparecerán aquí cuando elijas confiar en ellos durante la verificación 2FA",
"tableHeaders": {
"device": "Dispositivo",
"added": "Agregado",
"expires": "Expira",
"lastUsed": "Último uso",
"ipAddress": "Dirección IP",
"actions": "Acciones"
},
"status": {
"never": "Nunca",
"expired": "Expirado"
},
"modals": {
"removeDevice": {
"title": "Eliminar Dispositivo Confiable",
"added": "Agregado:",
"ip": "IP:"
},
"removeAllDevices": {
"title": "Eliminar Todos los Dispositivos Confiables",
"description": "Esto eliminará {count} dispositivo{count, plural, =1 {} other {s}} confiable{count, plural, =1 {} other {s}}. Necesitarás verificar 2FA en todos los dispositivos nuevamente."
},
"buttons": {
"cancel": "Cancelar",
"removing": "Eliminando...",
"removeDevice": "Eliminar Dispositivo",
"removeAllDevices": "Eliminar Todos los Dispositivos"
}
}
}
},
"uploadFile": {
"title": "Subir archivo",
"multipleTitle": "Subir Múltiples Archivos",
@@ -1536,19 +1743,9 @@
"nameRequired": "El nombre es obligatorio",
"required": "Este campo es obligatorio"
},
"iconPicker": {
"title": "Seleccionar Icono",
"placeholder": "Seleccionar un icono",
"searchPlaceholder": "Buscar iconos...",
"loadingMore": "Cargando más iconos...",
"allIconsLoaded": "Todos los {count} iconos cargados",
"noIconsFound": "No se encontraron iconos para \"{search}\"",
"tabs": {
"all": "Todos los Iconos",
"popular": "Populares",
"auth": "Proveedores de Autenticación"
},
"stats": "{iconCount} iconos de {libraryCount} bibliotecas",
"categoryBadge": "{category} ({count} iconos)"
"qrCodeModal": {
"title": "Compartir Código QR",
"description": "Escanea este código QR para acceder al enlace.",
"download": "Descargar Código QR"
}
}

View File

@@ -143,7 +143,8 @@
"saving": "Enregistrement...",
"update": "Mettre à jour",
"click": "Clique para",
"creating": "Criando..."
"creating": "Criando...",
"loadingSimple": "Chargement..."
},
"createShare": {
"title": "Créer un Partage",
@@ -182,7 +183,15 @@
"invalidCredentials": "E-mail ou mot de passe invalide",
"userNotFound": "Utilisateur non trouvé",
"accountLocked": "Compte bloqué. Veuillez réessayer plus tard",
"unexpectedError": "Une erreur inattendue s'est produite. Veuillez réessayer"
"unexpectedError": "Une erreur inattendue s'est produite. Veuillez réessayer",
"Invalid password": "Mot de passe invalide",
"Invalid two-factor authentication code": "Code d'authentification à deux facteurs invalide",
"Invalid verification code": "Code de vérification invalide",
"Password verification required": "Vérification du mot de passe requise",
"Two-factor authentication is already enabled": "L'authentification à deux facteurs est déjà activée",
"Two-factor authentication is not enabled": "L'authentification à deux facteurs n'est pas activée",
"Two-factor authentication required": "L'authentification à deux facteurs est requise",
"noUserData": "Aucune donnée utilisateur"
},
"fileActions": {
"editFile": "Modifier le Fichier",
@@ -304,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",
@@ -318,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.",
@@ -331,6 +346,27 @@
},
"pageTitle": "Accueil"
},
"iconPicker": {
"title": "Sélectionner une Icône",
"placeholder": "Sélectionner une icône",
"searchPlaceholder": "Rechercher des icônes...",
"loadingMore": "Chargement d'autres icônes...",
"allIconsLoaded": "Toutes les {count} icônes sont chargées",
"noIconsFound": "Aucune icône trouvée pour \"{search}\"",
"tabs": {
"all": "Toutes les Icônes",
"popular": "Populaires",
"auth": "Fournisseurs d'Authentification"
},
"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",
@@ -428,8 +464,8 @@
"description": "Visualisez et gérez vos fichiers partagés"
},
"reverseShares": {
"title": "Receber Arquivos",
"description": "Crie links para outros enviarem arquivos para você"
"title": "Recevoir des Fichiers",
"description": "Créez des liens pour que d'autres puissent vous envoyer des fichiers"
}
},
"recentFiles": {
@@ -589,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",
@@ -605,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éé",
@@ -746,7 +784,12 @@
"selectAll": "Tout sélectionner",
"selectFile": "Sélectionner le fichier {fileName}",
"deleteError": "Erreur lors de la suppression du fichier",
"deleteSuccess": "Fichier supprimé avec succès"
"deleteSuccess": "Fichier supprimé avec succès",
"copyErrors": {
"timeout": "L'opération de copie a expiré. Veuillez réessayer avec un fichier plus petit ou vérifier votre connexion.",
"failed": "L'opération de copie a échoué. Veuillez réessayer.",
"aborted": "L'opération de copie a été annulée en raison d'un délai d'attente dépassé."
}
}
},
"form": {
@@ -897,7 +940,8 @@
"fileList": {
"title": "Fichiers sélectionnés :",
"statusUploaded": "Envoyé",
"statusError": "Erreur"
"statusError": "Erreur",
"retry": "Réessayer"
},
"form": {
"nameLabel": "Nom",
@@ -1068,8 +1112,8 @@
"description": "URL de base du serveur Palmr (ex: https://palmr.exemple.com)"
},
"testSmtp": {
"title": "[TO_TRANSLATE] Test SMTP Connection",
"description": "[TO_TRANSLATE] Test if the SMTP configuration is valid"
"title": "Test de la Connexion SMTP",
"description": "Tester si la configuration SMTP est valide"
},
"smtpNoAuth": {
"title": "Pas d'Authentification",
@@ -1088,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": {
@@ -1097,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",
@@ -1200,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",
@@ -1387,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",
@@ -1419,6 +1472,160 @@
"dark": "Sombre",
"system": "Système"
},
"twoFactor": {
"title": "Authentification à Deux Facteurs",
"description": "Ajoutez une couche de sécurité supplémentaire à votre compte",
"enabled": "Votre compte est protégé par l'authentification à deux facteurs",
"disabled": "L'authentification à deux facteurs n'est pas activée",
"setup": {
"title": "Activer l'Authentification à Deux Facteurs",
"description": "Scannez le code QR avec votre application d'authentification, puis saisissez le code de vérification.",
"qrCode": "Code QR",
"manualEntryKey": "Clé de Saisie Manuelle",
"verificationCode": "Code de Vérification",
"verificationCodePlaceholder": "Entrez le code à 6 chiffres",
"verificationCodeDescription": "Saisissez le code à 6 chiffres de votre application d'authentification",
"verifyAndEnable": "Vérifier et Activer",
"cancel": "Annuler"
},
"disable": {
"title": "Désactiver l'Authentification à Deux Facteurs",
"description": "Saisissez votre mot de passe pour confirmer la désactivation de l'authentification à deux facteurs.",
"password": "Mot de passe",
"passwordPlaceholder": "Entrez votre mot de passe",
"confirm": "Confirmer la Désactivation",
"cancel": "Annuler"
},
"backupCodes": {
"title": "Codes de Secours",
"description": "Conservez ces codes de secours dans un endroit sûr. Vous pouvez les utiliser pour accéder à votre compte si vous perdez votre appareil d'authentification.",
"warning": "Important :",
"warningText": "Chaque code de secours ne peut être utilisé qu'une seule fois. Gardez-les en sécurité et ne les partagez avec personne.",
"generateNew": "Générer de Nouveaux Codes de Secours",
"download": "Télécharger les Codes de Secours",
"copyToClipboard": "Copier dans le Presse-papiers",
"savedMessage": "J'ai Sauvegardé Mes Codes de Secours",
"available": "{count} codes de secours disponibles",
"instructions": [
"• Sauvegardez ces codes dans un endroit sécurisé",
"• Chaque code de secours ne peut être utilisé qu'une seule fois",
"• Vous pouvez générer de nouveaux codes à tout moment"
]
},
"verification": {
"title": "Authentification à Deux Facteurs",
"description": "Saisissez le code à 6 chiffres de votre application d'authentification",
"backupDescription": "Saisissez l'un de vos codes de secours pour continuer",
"verificationCode": "Code de Vérification",
"backupCode": "Code de Secours",
"verificationCodePlaceholder": "000000",
"backupCodePlaceholder": "XXXX-XXXX",
"verify": "Vérifier",
"verifying": "Vérification en cours...",
"useBackupCode": "Utiliser un code de secours à la place",
"useAuthenticatorCode": "Utiliser le code d'authentification à la place",
"rememberDevice": "Se souvenir de cet appareil pendant 30 jours",
"rememberDeviceDescription": "Vous n'aurez pas besoin de saisir de codes 2FA sur cet appareil pendant 30 jours"
},
"messages": {
"enabledSuccess": "L'authentification à deux facteurs a été activée avec succès !",
"disabledSuccess": "L'authentification à deux facteurs a été désactivée avec succès",
"backupCodesGenerated": "Nouveaux codes de secours générés avec succès",
"backupCodesCopied": "Codes de secours copiés dans le presse-papiers",
"setupFailed": "Échec de la génération de la configuration 2FA",
"verificationFailed": "Code de vérification invalide",
"disableFailed": "Échec de la désactivation de la 2FA. Veuillez vérifier votre mot de passe.",
"backupCodesFailed": "Échec de la génération des codes de secours",
"backupCodesCopyFailed": "Échec de la copie des codes de secours",
"statusLoadFailed": "Échec du chargement du statut 2FA",
"enterVerificationCode": "Veuillez saisir le code de vérification",
"enterPassword": "Veuillez saisir votre mot de passe",
"deviceTrusted": "Cet appareil a été marqué comme fiable pendant 30 jours"
},
"errors": {
"invalidVerificationCode": "Code de vérification invalide",
"invalidTwoFactorCode": "Code d'authentification à deux facteurs invalide",
"twoFactorRequired": "L'authentification à deux facteurs est requise",
"twoFactorAlreadyEnabled": "L'authentification à deux facteurs est déjà activée",
"twoFactorNotEnabled": "L'authentification à deux facteurs n'est pas activée",
"passwordVerificationRequired": "Vérification du mot de passe requise",
"invalidPassword": "Mot de passe invalide",
"userNotFound": "Utilisateur non trouvé"
},
"buttons": {
"enable2FA": "Activer 2FA",
"disable2FA": "Désactiver 2FA"
},
"deviceNames": {
"unknownDevice": "Appareil Inconnu",
"browsers": {
"chrome": "Chrome",
"firefox": "Firefox",
"safari": "Safari",
"edge": "Edge"
},
"platforms": {
"windows": " sur Windows",
"macos": " sur macOS",
"linux": " sur Linux",
"iphone": " sur iPhone",
"android": " sur Android"
}
},
"status": {
"label": "Statut :",
"enabled": "Activé",
"disabled": "Désactivé"
},
"trustedDevices": {
"title": "Appareils de Confiance - 2FA",
"description": "Appareils qui ne nécessitent pas de vérification 2FA",
"noDevices": "Aucun appareil de confiance",
"deviceName": "Appareil",
"addedOn": "Ajouté le",
"expiresOn": "Expire le",
"remove": "Supprimer",
"removeAll": "Tout Supprimer",
"confirmRemove": "Êtes-vous sûr de vouloir supprimer cet appareil de confiance ?",
"confirmRemoveAll": "Êtes-vous sûr de vouloir supprimer tous les appareils de confiance ?",
"deviceRemoved": "Appareil de confiance supprimé avec succès",
"allDevicesRemoved": "Tous les appareils de confiance ont été supprimés avec succès",
"loadFailed": "Échec du chargement des appareils de confiance",
"removeFailed": "Échec de la suppression de l'appareil de confiance",
"removeAllFailed": "Échec de la suppression de tous les appareils de confiance",
"loading": "Chargement des appareils de confiance...",
"noDevicesDescription": "Les appareils apparaîtront ici lorsque vous choisirez de leur faire confiance lors de la vérification 2FA",
"tableHeaders": {
"device": "Appareil",
"added": "Ajouté",
"expires": "Expire",
"lastUsed": "Dernière Utilisation",
"ipAddress": "Adresse IP",
"actions": "Actions"
},
"status": {
"never": "Jamais",
"expired": "Expiré"
},
"modals": {
"removeDevice": {
"title": "Supprimer l'Appareil de Confiance",
"added": "Ajouté :",
"ip": "IP :"
},
"removeAllDevices": {
"title": "Supprimer Tous les Appareils de Confiance",
"description": "Cela supprimera {count} appareil{count, plural, =1 {} other {s}} de confiance. Vous devrez vérifier la 2FA sur tous les appareils à nouveau."
},
"buttons": {
"cancel": "Annuler",
"removing": "Suppression...",
"removeDevice": "Supprimer l'Appareil",
"removeAllDevices": "Supprimer Tous les Appareils"
}
}
}
},
"uploadFile": {
"title": "Télécharger fichier",
"multipleTitle": "Télécharger Plusieurs Fichiers",
@@ -1536,19 +1743,9 @@
"nameRequired": "Nome é obrigatório",
"required": "Este campo é obrigatório"
},
"iconPicker": {
"title": "Sélectionner une Icône",
"placeholder": "Sélectionner une icône",
"searchPlaceholder": "Rechercher des icônes...",
"loadingMore": "Chargement d'autres icônes...",
"allIconsLoaded": "Toutes les {count} icônes sont chargées",
"noIconsFound": "Aucune icône trouvée pour \"{search}\"",
"tabs": {
"all": "Toutes les Icônes",
"popular": "Populaires",
"auth": "Fournisseurs d'Authentification"
},
"stats": "{iconCount} icônes de {libraryCount} bibliothèques",
"categoryBadge": "{category} ({count} icônes)"
"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

@@ -143,7 +143,8 @@
"saving": "सहेज रहा है...",
"update": "अपडेट करें",
"click": "क्लिक करें",
"creating": "बना रहा है..."
"creating": "बना रहा है...",
"loadingSimple": "लोड हो रहा है..."
},
"createShare": {
"title": "साझाकरण बनाएं",
@@ -182,7 +183,15 @@
"invalidCredentials": "गलत ईमेल या पासवर्ड",
"userNotFound": "उपयोगकर्ता नहीं मिला",
"accountLocked": "खाता लॉक है। कृपया बाद में प्रयास करें",
"unexpectedError": "एक अप्रत्याशित त्रुटि हुई। कृपया पुनः प्रयास करें"
"unexpectedError": "एक अप्रत्याशित त्रुटि हुई। कृपया पुनः प्रयास करें",
"Invalid password": "अमान्य पासवर्ड",
"Invalid two-factor authentication code": "अमान्य दो-कारक प्रमाणीकरण कोड",
"Invalid verification code": "अमान्य सत्यापन कोड",
"Password verification required": "पासवर्ड सत्यापन आवश्यक है",
"Two-factor authentication is already enabled": "दो-कारक प्रमाणीकरण पहले से सक्षम है",
"Two-factor authentication is not enabled": "दो-कारक प्रमाणीकरण सक्षम नहीं है",
"Two-factor authentication required": "दो-कारक प्रमाणीकरण आवश्यक है",
"noUserData": "उपयोगकर्ता डेटा नहीं मिला"
},
"fileActions": {
"editFile": "फाइल संपादित करें",
@@ -304,7 +313,8 @@
"title": "पासवर्ड भूल गए",
"description": "अपना ईमेल पता दर्ज करें और हम आपको पासवर्ड रीसेट करने के निर्देश भेजेंगे।",
"resetInstructions": "रीसेट निर्देश आपके ईमेल पर भेज दिए गए हैं",
"pageTitle": "पासवर्ड भूल गए"
"pageTitle": "पासवर्ड भूल गए",
"passwordAuthDisabled": "पासवर्ड ऑथेंटिकेशन अक्टिवेटेड है। कृपया अपने एडमिन से संपर्क करें या एक बाहरी ऑथेंटिकेशन प्रोवाइडर का उपयोग करें।"
},
"generateShareLink": {
"generateTitle": "साझाकरण लिंक उत्पन्न करें",
@@ -318,7 +328,12 @@
"copyButton": "लिंक कॉपी करें",
"success": "लिंक सफलतापूर्वक उत्पन्न हुआ",
"error": "लिंक उत्पन्न करने में विफल",
"copied": "लिंक क्लिपबोर्ड में कॉपी किया गया"
"copied": "लिंक क्लिपबोर्ड में कॉपी किया गया",
"readyDescription": "आपका साझाकरण लिंक तैयार है। आप डायरेक्ट रूप से QR कोड स्कैन कर सकते हैं, इसे बाद में उपयोग के लिए डाउनलोड कर सकते हैं, या नीचे लिंक कॉपी कर सकते हैं।",
"tabs": {
"link": "लिंक",
"qrcode": "QR कोड"
}
},
"home": {
"description": "WeTransfer का ओपन-सोर्स विकल्प। फाइलें सुरक्षित रूप से साझा करें, बिना ट्रैकिंग या सीमाओं के।",
@@ -331,6 +346,27 @@
},
"pageTitle": "होम"
},
"iconPicker": {
"title": "आइकन चुनें",
"placeholder": "एक आइकन चुनें",
"searchPlaceholder": "आइकन खोजें...",
"loadingMore": "अधिक आइकन लोड हो रहे हैं...",
"allIconsLoaded": "सभी {count} आइकन लोड हो गए",
"noIconsFound": "\"{search}\" के लिए कोई आइकन नहीं मिला",
"tabs": {
"all": "सभी आइकन",
"popular": "लोकप्रिय",
"auth": "प्रमाणीकरण प्रदाता"
},
"stats": "{libraryCount} लाइब्रेरी से {iconCount} आइकन",
"categoryBadge": "{category} ({count} आइकन)"
},
"imageEdit": {
"title": "छवि संपादित करें",
"rotate": "घुमाएं",
"zoom": "ज़ूम",
"cropInstructions": "छवि को पुनः स्थानांतरित करने के लिए खींचें, कोणों को समायोजित करने के लिए आकार बदलें"
},
"login": {
"welcome": "स्वागत है में",
"signInToContinue": "जारी रखने के लिए साइन इन करें",
@@ -589,7 +625,8 @@
"createLink": "लिंक बनाएं",
"delete": "हटाएं",
"copyLinkTitle": "लिंक कॉपी करें",
"createLinkCTA": "प्राप्ति लिंक बनाएं"
"createLinkCTA": "प्राप्ति लिंक बनाएं",
"viewQrCode": "QR कोड देखें"
},
"status": {
"active": "सक्रिय",
@@ -605,7 +642,8 @@
"viewDetails": "विवरण देखें",
"edit": "संपादित करें",
"delete": "हटाएं",
"viewFiles": "प्राप्त फ़ाइलें"
"viewFiles": "प्राप्त फ़ाइलें",
"viewQrCode": "QR कोड देखें"
},
"empty": {
"title": "कोई प्राप्ति लिंक नहीं बनाया गया",
@@ -746,7 +784,12 @@
"selectAll": "सभी चुनें",
"selectFile": "फ़ाइल {fileName} चुनें",
"deleteError": "फ़ाइल हटाने में त्रुटि",
"deleteSuccess": "फ़ाइल सफलतापूर्वक हटा दी गई"
"deleteSuccess": "फ़ाइल सफलतापूर्वक हटा दी गई",
"copyErrors": {
"timeout": "कॉपी ऑपरेशन का समय समाप्त हो गया। कृपया छोटी फ़ाइल के साथ पुनः प्रयास करें या अपना कनेक्शन जांचें।",
"failed": "कॉपी ऑपरेशन विफल हो गया। कृपया पुनः प्रयास करें।",
"aborted": "टाइमआउट के कारण कॉपी ऑपरेशन रद्द कर दिया गया।"
}
}
},
"form": {
@@ -897,7 +940,8 @@
"fileList": {
"title": "चयनित फ़ाइलें:",
"statusUploaded": "अपलोड की गई",
"statusError": "त्रुटि"
"statusError": "त्रुटि",
"retry": "पुनः प्रयास करें"
},
"form": {
"nameLabel": "नाम",
@@ -1085,6 +1129,10 @@
"smtpTrustSelfSigned": {
"title": "स्व-हस्ताक्षरित प्रमाणपत्रों पर विश्वास करें",
"description": "स्व-हस्ताक्षरित SSL/TLS प्रमाणपत्रों पर विश्वास करने के लिए इसे सक्षम करें (विकास वातावरण के लिए उपयोगी)"
},
"passwordAuthEnabled": {
"title": "पासवर्ड प्रमाणीकरण",
"description": "पासवर्ड आधारित प्रमाणीकरण सक्षम या अक्षम करें"
}
},
"buttons": {
@@ -1094,7 +1142,8 @@
},
"errors": {
"loadFailed": "सेटिंग्स लोड करने में विफल",
"updateFailed": "सेटिंग्स अपडेट करने में विफल"
"updateFailed": "सेटिंग्स अपडेट करने में विफल",
"passwordAuthRequiresProvider": "कम से कम एक सक्रिय प्रमाणीकरण प्रदाता के बिना पासवर्ड प्रमाणीकरण अक्षम नहीं किया जा सकता"
},
"messages": {
"noChanges": "सहेजने के लिए कोई परिवर्तन नहीं",
@@ -1200,7 +1249,10 @@
"invalidDate": "अमान्य तिथि",
"loadError": "साझाकरण विवरण लोड करने में विफल",
"editSecurity": "सुरक्षा संपादित करें",
"editExpiration": "समाप्ति संपादित करें"
"editExpiration": "समाप्ति संपादित करें",
"clickToEnlargeQrCode": "QR कोड को बड़ा करने के लिए क्लिक करें",
"downloadQrCode": "QR कोड डाउनलोड करें",
"qrCode": "QR कोड"
},
"shareExpiration": {
"neverExpires": "कभी समाप्त नहीं होता",
@@ -1387,7 +1439,8 @@
"copyLink": "लिंक कॉपी करें",
"notifyRecipients": "प्राप्तकर्ताओं को सूचित करें",
"delete": "हटाएं",
"downloadShareFiles": "सभी फ़ाइलें डाउनलोड करें"
"downloadShareFiles": "सभी फ़ाइलें डाउनलोड करें",
"viewQrCode": "QR कोड देखें"
},
"bulkActions": {
"delete": "हटाएं",
@@ -1419,6 +1472,160 @@
"dark": "डार्क",
"system": "सिस्टम"
},
"twoFactor": {
"title": "दो-कारक प्रमाणीकरण",
"description": "अपने खाते में अतिरिक्त सुरक्षा स्तर जोड़ें",
"enabled": "आपका खाता दो-कारक प्रमाणीकरण से सुरक्षित है",
"disabled": "दो-कारक प्रमाणीकरण सक्षम नहीं है",
"setup": {
"title": "दो-कारक प्रमाणीकरण सक्षम करें",
"description": "अपने प्रमाणीकरण ऐप से QR कोड स्कैन करें, फिर सत्यापन कोड दर्ज करें।",
"qrCode": "QR कोड",
"manualEntryKey": "मैनुअल एंट्री कुंजी",
"verificationCode": "सत्यापन कोड",
"verificationCodePlaceholder": "6-अंकों का कोड दर्ज करें",
"verificationCodeDescription": "अपने प्रमाणीकरण ऐप से 6-अंकों का कोड दर्ज करें",
"verifyAndEnable": "सत्यापित करें और सक्षम करें",
"cancel": "रद्द करें"
},
"disable": {
"title": "दो-कारक प्रमाणीकरण अक्षम करें",
"description": "दो-कारक प्रमाणीकरण को अक्षम करने की पुष्टि के लिए अपना पासवर्ड दर्ज करें।",
"password": "पासवर्ड",
"passwordPlaceholder": "अपना पासवर्ड दर्ज करें",
"confirm": "अक्षम करने की पुष्टि करें",
"cancel": "रद्द करें"
},
"backupCodes": {
"title": "बैकअप कोड",
"description": "इन बैकअप कोड को सुरक्षित स्थान पर सहेजें। यदि आप अपना प्रमाणीकरण डिवाइस खो देते हैं तो आप इनका उपयोग अपने खाते तक पहुंचने के लिए कर सकते हैं।",
"warning": "महत्वपूर्ण:",
"warningText": "प्रत्येक बैकअप कोड का उपयोग केवल एक बार किया जा सकता है। उन्हें सुरक्षित रखें और किसी के साथ साझा न करें।",
"generateNew": "नए बैकअप कोड जनरेट करें",
"download": "बैकअप कोड डाउनलोड करें",
"copyToClipboard": "क्लिपबोर्ड पर कॉपी करें",
"savedMessage": "मैंने अपने बैकअप कोड सहेज लिए हैं",
"available": "{count} बैकअप कोड उपलब्ध हैं",
"instructions": [
"• इन कोड को सुरक्षित स्थान पर सहेजें",
"• प्रत्येक बैकअप कोड का उपयोग केवल एक बार किया जा सकता है",
"• आप कभी भी नए कोड जनरेट कर सकते हैं"
]
},
"verification": {
"title": "दो-कारक प्रमाणीकरण",
"description": "अपने प्रमाणीकरण ऐप से 6-अंकों का कोड दर्ज करें",
"backupDescription": "जारी रखने के लिए अपने बैकअप कोड में से एक दर्ज करें",
"verificationCode": "सत्यापन कोड",
"backupCode": "बैकअप कोड",
"verificationCodePlaceholder": "000000",
"backupCodePlaceholder": "XXXX-XXXX",
"verify": "सत्यापित करें",
"verifying": "सत्यापन हो रहा है...",
"useBackupCode": "इसके बजाय बैकअप कोड का उपयोग करें",
"useAuthenticatorCode": "इसके बजाय प्रमाणीकरण कोड का उपयोग करें",
"rememberDevice": "इस डिवाइस को 30 दिनों के लिए याद रखें",
"rememberDeviceDescription": "आपको इस डिवाइस पर 30 दिनों तक 2FA कोड दर्ज करने की आवश्यकता नहीं होगी"
},
"messages": {
"enabledSuccess": "दो-कारक प्रमाणीकरण सफलतापूर्वक सक्षम किया गया!",
"disabledSuccess": "दो-कारक प्रमाणीकरण सफलतापूर्वक अक्षम किया गया",
"backupCodesGenerated": "नए बैकअप कोड सफलतापूर्वक जनरेट किए गए",
"backupCodesCopied": "बैकअप कोड क्लिपबोर्ड पर कॉपी किए गए",
"setupFailed": "2FA सेटअप जनरेट करने में विफल",
"verificationFailed": "अमान्य सत्यापन कोड",
"disableFailed": "2FA अक्षम करने में विफल। कृपया अपना पासवर्ड जांचें।",
"backupCodesFailed": "बैकअप कोड जनरेट करने में विफल",
"backupCodesCopyFailed": "बैकअप कोड कॉपी करने में विफल",
"statusLoadFailed": "2FA स्थिति लोड करने में विफल",
"enterVerificationCode": "कृपया सत्यापन कोड दर्ज करें",
"enterPassword": "कृपया अपना पासवर्ड दर्ज करें",
"deviceTrusted": "यह डिवाइस 30 दिनों के लिए विश्वसनीय के रूप में चिह्नित किया गया है"
},
"errors": {
"invalidVerificationCode": "अमान्य सत्यापन कोड",
"invalidTwoFactorCode": "अमान्य दो-कारक प्रमाणीकरण कोड",
"twoFactorRequired": "दो-कारक प्रमाणीकरण आवश्यक है",
"twoFactorAlreadyEnabled": "दो-कारक प्रमाणीकरण पहले से सक्षम है",
"twoFactorNotEnabled": "दो-कारक प्रमाणीकरण सक्षम नहीं है",
"passwordVerificationRequired": "पासवर्ड सत्यापन आवश्यक है",
"invalidPassword": "अमान्य पासवर्ड",
"userNotFound": "उपयोगकर्ता नहीं मिला"
},
"buttons": {
"enable2FA": "2FA सक्षम करें",
"disable2FA": "2FA अक्षम करें"
},
"deviceNames": {
"unknownDevice": "अज्ञात डिवाइस",
"browsers": {
"chrome": "Chrome",
"firefox": "Firefox",
"safari": "Safari",
"edge": "Edge"
},
"platforms": {
"windows": " Windows पर",
"macos": " macOS पर",
"linux": " Linux पर",
"iphone": " iPhone पर",
"android": " Android पर"
}
},
"status": {
"label": "स्थिति:",
"enabled": "सक्षम",
"disabled": "अक्षम"
},
"trustedDevices": {
"title": "विश्वसनीय डिवाइस - 2FA",
"description": "डिवाइस जिन्हें 2FA सत्यापन की आवश्यकता नहीं है",
"noDevices": "कोई विश्वसनीय डिवाइस नहीं",
"deviceName": "डिवाइस",
"addedOn": "जोड़ा गया",
"expiresOn": "समाप्ति तिथि",
"remove": "हटाएं",
"removeAll": "सभी हटाएं",
"confirmRemove": "क्या आप वाकई इस विश्वसनीय डिवाइस को हटाना चाहते हैं?",
"confirmRemoveAll": "क्या आप वाकई सभी विश्वसनीय डिवाइस हटाना चाहते हैं?",
"deviceRemoved": "विश्वसनीय डिवाइस सफलतापूर्वक हटाया गया",
"allDevicesRemoved": "सभी विश्वसनीय डिवाइस सफलतापूर्वक हटाए गए",
"loadFailed": "विश्वसनीय डिवाइस लोड करने में विफल",
"removeFailed": "विश्वसनीय डिवाइस हटाने में विफल",
"removeAllFailed": "सभी विश्वसनीय डिवाइस हटाने में विफल",
"loading": "विश्वसनीय डिवाइस लोड हो रहे हैं...",
"noDevicesDescription": "जब आप 2FA सत्यापन के दौरान उन्हें विश्वसनीय मानने का चयन करेंगे तो डिवाइस यहां दिखाई देंगे",
"tableHeaders": {
"device": "डिवाइस",
"added": "जोड़ा गया",
"expires": "समाप्ति",
"lastUsed": "अंतिम उपयोग",
"ipAddress": "IP पता",
"actions": "कार्रवाई"
},
"status": {
"never": "कभी नहीं",
"expired": "समाप्त"
},
"modals": {
"removeDevice": {
"title": "विश्वसनीय डिवाइस हटाएं",
"added": "जोड़ा गया:",
"ip": "IP:"
},
"removeAllDevices": {
"title": "सभी विश्वसनीय डिवाइस हटाएं",
"description": "यह {count} विश्वसनीय डिवाइस{count, plural, =1 {} other {s}} को हटा देगा। आपको सभी डिवाइस पर फिर से 2FA सत्यापित करना होगा।"
},
"buttons": {
"cancel": "रद्द करें",
"removing": "हटाया जा रहा है...",
"removeDevice": "डिवाइस हटाएं",
"removeAllDevices": "सभी डिवाइस हटाएं"
}
}
}
},
"uploadFile": {
"title": "फ़ाइल अपलोड करें",
"multipleTitle": "कई फ़ाइलें अपलोड करें",
@@ -1536,19 +1743,9 @@
"nameRequired": "नाम आवश्यक है",
"required": "यह फ़ील्ड आवश्यक है"
},
"iconPicker": {
"title": "आइकन चुनें",
"placeholder": "एक आइकन चुनें",
"searchPlaceholder": "आइकन खोजें...",
"loadingMore": "अधिक आइकन लोड हो रहे हैं...",
"allIconsLoaded": "सभी {count} आइकन लोड हो गए",
"noIconsFound": "\"{search}\" के लिए कोई आइकन नहीं मिला",
"tabs": {
"all": "सभी आइकन",
"popular": "लोकप्रिय",
"auth": "प्रमाणीकरण प्रदाता"
},
"stats": "{libraryCount} लाइब्रेरी से {iconCount} आइकन",
"categoryBadge": "{category} ({count} आइकन)"
"qrCodeModal": {
"title": "QR कोड साझा करें",
"description": "इस QR कोड को स्कैन करके लिंक तक पहुंच सकते हैं।",
"download": "QR कोड डाउनलोड करें"
}
}

View File

@@ -143,7 +143,8 @@
"saving": "Salvataggio...",
"update": "Aggiorna",
"click": "Clicca per",
"creating": "Creazione in corso..."
"creating": "Creazione in corso...",
"loadingSimple": "Caricamento..."
},
"createShare": {
"title": "Crea Condivisione",
@@ -182,7 +183,15 @@
"invalidCredentials": "Email o password non validi",
"userNotFound": "Utente non trovato",
"accountLocked": "Account bloccato. Riprova più tardi",
"unexpectedError": "Si è verificato un errore imprevisto. Riprova"
"unexpectedError": "Si è verificato un errore imprevisto. Riprova",
"Invalid password": "Password non valida",
"Invalid two-factor authentication code": "Codice di autenticazione a due fattori non valido",
"Invalid verification code": "Codice di verifica non valido",
"Password verification required": "Verifica della password richiesta",
"Two-factor authentication is already enabled": "L'autenticazione a due fattori è già abilitata",
"Two-factor authentication is not enabled": "L'autenticazione a due fattori non è abilitata",
"Two-factor authentication required": "Autenticazione a due fattori richiesta",
"noUserData": "Nessun dato utente"
},
"fileActions": {
"editFile": "Modifica File",
@@ -304,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",
@@ -318,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.",
@@ -331,6 +346,27 @@
},
"pageTitle": "Pagina iniziale"
},
"iconPicker": {
"title": "Seleziona Icona",
"placeholder": "Seleziona un'icona",
"searchPlaceholder": "Cerca icone...",
"loadingMore": "Caricamento altre icone...",
"allIconsLoaded": "Tutte le {count} icone caricate",
"noIconsFound": "Nessuna icona trovata per \"{search}\"",
"tabs": {
"all": "Tutte le Icone",
"popular": "Popolari",
"auth": "Provider di Autenticazione"
},
"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",
@@ -589,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",
@@ -605,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",
@@ -746,7 +784,12 @@
"selectAll": "Seleziona tutto",
"selectFile": "Seleziona file {fileName}",
"deleteError": "Errore durante l'eliminazione del file",
"deleteSuccess": "File eliminato con successo"
"deleteSuccess": "File eliminato con successo",
"copyErrors": {
"timeout": "Operazione di copia scaduta. Riprova con un file più piccolo o controlla la tua connessione.",
"failed": "Operazione di copia fallita. Per favore riprova.",
"aborted": "L'operazione di copia è stata annullata per timeout."
}
}
},
"form": {
@@ -897,7 +940,8 @@
"fileList": {
"title": "File selezionati:",
"statusUploaded": "Inviato",
"statusError": "Errore"
"statusError": "Errore",
"retry": "Riprova"
},
"form": {
"nameLabel": "Nome",
@@ -1085,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": {
@@ -1094,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",
@@ -1200,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",
@@ -1387,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",
@@ -1419,6 +1472,160 @@
"dark": "Scuro",
"system": "Sistema"
},
"twoFactor": {
"title": "Autenticazione a Due Fattori",
"description": "Aggiungi un ulteriore livello di sicurezza al tuo account",
"enabled": "Il tuo account è protetto con l'autenticazione a due fattori",
"disabled": "L'autenticazione a due fattori non è abilitata",
"setup": {
"title": "Abilita Autenticazione a Due Fattori",
"description": "Scansiona il codice QR con la tua app di autenticazione, quindi inserisci il codice di verifica.",
"qrCode": "Codice QR",
"manualEntryKey": "Chiave per Inserimento Manuale",
"verificationCode": "Codice di Verifica",
"verificationCodePlaceholder": "Inserisci il codice a 6 cifre",
"verificationCodeDescription": "Inserisci il codice a 6 cifre dalla tua app di autenticazione",
"verifyAndEnable": "Verifica e Abilita",
"cancel": "Annulla"
},
"disable": {
"title": "Disabilita Autenticazione a Due Fattori",
"description": "Inserisci la tua password per confermare la disabilitazione dell'autenticazione a due fattori.",
"password": "Password",
"passwordPlaceholder": "Inserisci la tua password",
"confirm": "Conferma Disabilitazione",
"cancel": "Annulla"
},
"backupCodes": {
"title": "Codici di Backup",
"description": "Salva questi codici di backup in un luogo sicuro. Puoi usarli per accedere al tuo account se perdi il tuo dispositivo di autenticazione.",
"warning": "Importante:",
"warningText": "Ogni codice di backup può essere utilizzato una sola volta. Conservali in modo sicuro e non condividerli con nessuno.",
"generateNew": "Genera Nuovi Codici di Backup",
"download": "Scarica Codici di Backup",
"copyToClipboard": "Copia negli Appunti",
"savedMessage": "Ho Salvato i Miei Codici di Backup",
"available": "{count} codici di backup disponibili",
"instructions": [
"• Salva questi codici in un luogo sicuro",
"• Ogni codice di backup può essere utilizzato una sola volta",
"• Puoi generare nuovi codici in qualsiasi momento"
]
},
"verification": {
"title": "Autenticazione a Due Fattori",
"description": "Inserisci il codice a 6 cifre dalla tua app di autenticazione",
"backupDescription": "Inserisci uno dei tuoi codici di backup per continuare",
"verificationCode": "Codice di Verifica",
"backupCode": "Codice di Backup",
"verificationCodePlaceholder": "000000",
"backupCodePlaceholder": "XXXX-XXXX",
"verify": "Verifica",
"verifying": "Verifica in corso...",
"useBackupCode": "Usa invece un codice di backup",
"useAuthenticatorCode": "Usa invece il codice dell'autenticatore",
"rememberDevice": "Ricorda questo dispositivo per 30 giorni",
"rememberDeviceDescription": "Non dovrai inserire codici 2FA su questo dispositivo per 30 giorni"
},
"messages": {
"enabledSuccess": "Autenticazione a due fattori abilitata con successo!",
"disabledSuccess": "Autenticazione a due fattori disabilitata con successo",
"backupCodesGenerated": "Nuovi codici di backup generati con successo",
"backupCodesCopied": "Codici di backup copiati negli appunti",
"setupFailed": "Impossibile generare la configurazione 2FA",
"verificationFailed": "Codice di verifica non valido",
"disableFailed": "Impossibile disabilitare 2FA. Verifica la tua password.",
"backupCodesFailed": "Impossibile generare i codici di backup",
"backupCodesCopyFailed": "Impossibile copiare i codici di backup",
"statusLoadFailed": "Impossibile caricare lo stato 2FA",
"enterVerificationCode": "Inserisci il codice di verifica",
"enterPassword": "Inserisci la tua password",
"deviceTrusted": "Questo dispositivo è stato contrassegnato come affidabile per 30 giorni"
},
"errors": {
"invalidVerificationCode": "Codice di verifica non valido",
"invalidTwoFactorCode": "Codice di autenticazione a due fattori non valido",
"twoFactorRequired": "Autenticazione a due fattori richiesta",
"twoFactorAlreadyEnabled": "L'autenticazione a due fattori è già abilitata",
"twoFactorNotEnabled": "L'autenticazione a due fattori non è abilitata",
"passwordVerificationRequired": "Verifica password richiesta",
"invalidPassword": "Password non valida",
"userNotFound": "Utente non trovato"
},
"buttons": {
"enable2FA": "Abilita 2FA",
"disable2FA": "Disabilita 2FA"
},
"deviceNames": {
"unknownDevice": "Dispositivo Sconosciuto",
"browsers": {
"chrome": "Chrome",
"firefox": "Firefox",
"safari": "Safari",
"edge": "Edge"
},
"platforms": {
"windows": " su Windows",
"macos": " su macOS",
"linux": " su Linux",
"iphone": " su iPhone",
"android": " su Android"
}
},
"status": {
"label": "Stato:",
"enabled": "Abilitato",
"disabled": "Disabilitato"
},
"trustedDevices": {
"title": "Dispositivi Affidabili - 2FA",
"description": "Dispositivi che non richiedono la verifica 2FA",
"noDevices": "Nessun dispositivo affidabile",
"deviceName": "Dispositivo",
"addedOn": "Aggiunto il",
"expiresOn": "Scade il",
"remove": "Rimuovi",
"removeAll": "Rimuovi Tutti",
"confirmRemove": "Sei sicuro di voler rimuovere questo dispositivo affidabile?",
"confirmRemoveAll": "Sei sicuro di voler rimuovere tutti i dispositivi affidabili?",
"deviceRemoved": "Dispositivo affidabile rimosso con successo",
"allDevicesRemoved": "Tutti i dispositivi affidabili rimossi con successo",
"loadFailed": "Impossibile caricare i dispositivi affidabili",
"removeFailed": "Impossibile rimuovere il dispositivo affidabile",
"removeAllFailed": "Impossibile rimuovere tutti i dispositivi affidabili",
"loading": "Caricamento dispositivi affidabili...",
"noDevicesDescription": "I dispositivi appariranno qui quando scegli di fidarti di loro durante la verifica 2FA",
"tableHeaders": {
"device": "Dispositivo",
"added": "Aggiunto",
"expires": "Scade",
"lastUsed": "Ultimo Utilizzo",
"ipAddress": "Indirizzo IP",
"actions": "Azioni"
},
"status": {
"never": "Mai",
"expired": "Scaduto"
},
"modals": {
"removeDevice": {
"title": "Rimuovi Dispositivo Affidabile",
"added": "Aggiunto:",
"ip": "IP:"
},
"removeAllDevices": {
"title": "Rimuovi Tutti i Dispositivi Affidabili",
"description": "Questo rimuoverà {count} dispositivo{count, plural, =1 {} other {i}} affidabile{count, plural, =1 {} other {i}}. Dovrai verificare 2FA su tutti i dispositivi nuovamente."
},
"buttons": {
"cancel": "Annulla",
"removing": "Rimozione in corso...",
"removeDevice": "Rimuovi Dispositivo",
"removeAllDevices": "Rimuovi Tutti i Dispositivi"
}
}
}
},
"uploadFile": {
"title": "Carica file",
"multipleTitle": "Carica Più File",
@@ -1536,19 +1743,9 @@
"nameRequired": "Il nome è obbligatorio",
"required": "Questo campo è obbligatorio"
},
"iconPicker": {
"title": "Seleziona Icona",
"placeholder": "Seleziona un'icona",
"searchPlaceholder": "Cerca icone...",
"loadingMore": "Caricamento altre icone...",
"allIconsLoaded": "Tutte le {count} icone caricate",
"noIconsFound": "Nessuna icona trovata per \"{search}\"",
"tabs": {
"all": "Tutte le Icone",
"popular": "Popolari",
"auth": "Provider di Autenticazione"
},
"stats": "{iconCount} icone da {libraryCount} librerie",
"categoryBadge": "{category} ({count} icone)"
"qrCodeModal": {
"title": "Condividi QR Code",
"description": "Scansiona questo codice QR per accedere al link.",
"download": "Scarica QR Code"
}
}

View File

@@ -143,7 +143,8 @@
"saving": "保存中...",
"update": "更新",
"click": "クリックして",
"creating": "作成中..."
"creating": "作成中...",
"loadingSimple": "読み込み中..."
},
"createShare": {
"title": "共有を作成",
@@ -182,7 +183,15 @@
"invalidCredentials": "無効なメールアドレスまたはパスワードです",
"userNotFound": "ユーザーが見つかりません",
"accountLocked": "アカウントがロックされています。後でもう一度お試しください",
"unexpectedError": "予期しないエラーが発生しました。もう一度お試しください"
"unexpectedError": "予期しないエラーが発生しました。もう一度お試しください",
"Invalid password": "パスワードが無効です",
"Invalid two-factor authentication code": "二要素認証コードが無効です",
"Invalid verification code": "認証コードが無効です",
"Password verification required": "パスワードの確認が必要です",
"Two-factor authentication is already enabled": "二要素認証は既に有効になっています",
"Two-factor authentication is not enabled": "二要素認証が有効になっていません",
"Two-factor authentication required": "二要素認証が必要です",
"noUserData": "ユーザーデータがありません"
},
"fileActions": {
"editFile": "ファイルを編集",
@@ -304,7 +313,8 @@
"title": "パスワードをお忘れですか?",
"description": "メールアドレスを入力すると、パスワードリセットの指示を送信します。",
"resetInstructions": "パスワードリセットの指示がメールに送信されました",
"pageTitle": "パスワードをお忘れですか?"
"pageTitle": "パスワードをお忘れですか?",
"passwordAuthDisabled": "パスワード認証が無効になっています。管理者に連絡するか、外部認証プロバイダーを使用してください。"
},
"generateShareLink": {
"generateTitle": "共有リンクを生成",
@@ -318,7 +328,12 @@
"copyButton": "リンクをコピー",
"success": "リンクが正常に生成されました",
"error": "リンクの生成に失敗しました",
"copied": "リンクがクリップボードにコピーされました"
"copied": "リンクがクリップボードにコピーされました",
"readyDescription": "共有リンクが準備できました。QRコードを直接スキャンして、後で使用するためにダウンロードするか、リンクをコピーしてください。",
"tabs": {
"link": "リンク",
"qrcode": "QRコード"
}
},
"home": {
"description": "WeTransferのオープンソース代替です。トラッキングや制限なしに安全にファイルを共有します。",
@@ -331,6 +346,27 @@
},
"pageTitle": "ホーム"
},
"iconPicker": {
"title": "アイコンを選択",
"placeholder": "アイコンを選択してください",
"searchPlaceholder": "アイコンを検索...",
"loadingMore": "アイコンを読み込み中...",
"allIconsLoaded": "全{count}個のアイコンを読み込みました",
"noIconsFound": "\"{search}\"に一致するアイコンが見つかりませんでした",
"tabs": {
"all": "すべてのアイコン",
"popular": "人気",
"auth": "認証プロバイダー"
},
"stats": "{libraryCount}ライブラリから{iconCount}個のアイコン",
"categoryBadge": "{category}{count}個のアイコン)"
},
"imageEdit": {
"title": "画像を編集",
"rotate": "回転",
"zoom": "ズーム",
"cropInstructions": "位置を変更するにはドラッグし、カット領域を調整するには角をリサイズしてください"
},
"login": {
"welcome": "ようこそへ",
"signInToContinue": "続行するにはサインインしてください",
@@ -589,7 +625,8 @@
"createLink": "リンクを作成",
"delete": "削除",
"copyLinkTitle": "リンクをコピー",
"createLinkCTA": "受信リンクを作成"
"createLinkCTA": "受信リンクを作成",
"viewQrCode": "QRコードを表示"
},
"status": {
"active": "有効",
@@ -605,7 +642,8 @@
"viewDetails": "詳細を表示",
"edit": "編集",
"delete": "削除",
"viewFiles": "受信済みファイル"
"viewFiles": "受信済みファイル",
"viewQrCode": "QRコードを表示"
},
"empty": {
"title": "受信リンクが作成されていません",
@@ -746,7 +784,12 @@
"selectAll": "すべて選択",
"selectFile": "ファイル{fileName}を選択",
"deleteError": "ファイルの削除に失敗しました",
"deleteSuccess": "ファイルを正常に削除しました"
"deleteSuccess": "ファイルを正常に削除しました",
"copyErrors": {
"timeout": "コピー操作がタイムアウトしました。より小さいファイルで再試行するか、接続を確認してください。",
"failed": "コピー操作に失敗しました。もう一度お試しください。",
"aborted": "タイムアウトによりコピー操作がキャンセルされました。"
}
}
},
"form": {
@@ -897,7 +940,8 @@
"fileList": {
"title": "選択されたファイル:",
"statusUploaded": "アップロード済み",
"statusError": "エラー"
"statusError": "エラー",
"retry": "再試行"
},
"form": {
"nameLabel": "名前",
@@ -1085,6 +1129,10 @@
"smtpTrustSelfSigned": {
"title": "自己署名証明書を信頼",
"description": "自己署名SSL/TLS証明書を信頼するように設定します開発環境で便利"
},
"passwordAuthEnabled": {
"title": "パスワード認証",
"description": "パスワード認証を有効または無効にする"
}
},
"buttons": {
@@ -1094,7 +1142,8 @@
},
"errors": {
"loadFailed": "設定の読み込みに失敗しました",
"updateFailed": "設定の更新に失敗しました"
"updateFailed": "設定の更新に失敗しました",
"passwordAuthRequiresProvider": "少なくとも1つのアクティブな認証プロバイダーがない場合、パスワード認証を無効にできません"
},
"messages": {
"noChanges": "保存する変更はありません",
@@ -1200,7 +1249,10 @@
"invalidDate": "無効な日付",
"loadError": "共有詳細の読み込みに失敗しました",
"editSecurity": "セキュリティを編集",
"editExpiration": "期限を編集"
"editExpiration": "期限を編集",
"clickToEnlargeQrCode": "QRコードを拡大",
"downloadQrCode": "QRコードをダウンロード",
"qrCode": "QRコード"
},
"shareExpiration": {
"neverExpires": "期限なし",
@@ -1387,7 +1439,8 @@
"copyLink": "リンクコピー",
"notifyRecipients": "受信者に通知",
"delete": "削除",
"downloadShareFiles": "すべてのファイルをダウンロードします"
"downloadShareFiles": "すべてのファイルをダウンロードします",
"viewQrCode": "QRコードを表示"
},
"bulkActions": {
"delete": "削除",
@@ -1419,6 +1472,160 @@
"dark": "ダーク",
"system": "システム"
},
"twoFactor": {
"title": "二要素認証",
"description": "アカウントにセキュリティ層を追加",
"enabled": "アカウントは二要素認証で保護されています",
"disabled": "二要素認証は有効になっていません",
"setup": {
"title": "二要素認証を有効にする",
"description": "認証アプリでQRコードをスキャンし、確認コードを入力してください。",
"qrCode": "QRコード",
"manualEntryKey": "手動入力キー",
"verificationCode": "確認コード",
"verificationCodePlaceholder": "6桁のコードを入力",
"verificationCodeDescription": "認証アプリから6桁のコードを入力してください",
"verifyAndEnable": "確認して有効化",
"cancel": "キャンセル"
},
"disable": {
"title": "二要素認証を無効にする",
"description": "二要素認証を無効にするには、パスワードを入力して確認してください。",
"password": "パスワード",
"passwordPlaceholder": "パスワードを入力",
"confirm": "無効化を確認",
"cancel": "キャンセル"
},
"backupCodes": {
"title": "バックアップコード",
"description": "これらのバックアップコードを安全な場所に保管してください。認証デバイスを紛失した場合、アカウントへのアクセスに使用できます。",
"warning": "重要:",
"warningText": "各バックアップコードは1回のみ使用できます。安全に保管し、誰とも共有しないでください。",
"generateNew": "新しいバックアップコードを生成",
"download": "バックアップコードをダウンロード",
"copyToClipboard": "クリップボードにコピー",
"savedMessage": "バックアップコードを保存しました",
"available": "利用可能なバックアップコード:{count}個",
"instructions": [
"• これらのコードを安全な場所に保管してください",
"• 各バックアップコードは1回のみ使用できます",
"• いつでも新しいコードを生成できます"
]
},
"verification": {
"title": "二要素認証",
"description": "認証アプリから6桁のコードを入力してください",
"backupDescription": "続行するにはバックアップコードを入力してください",
"verificationCode": "確認コード",
"backupCode": "バックアップコード",
"verificationCodePlaceholder": "000000",
"backupCodePlaceholder": "XXXX-XXXX",
"verify": "確認",
"verifying": "確認中...",
"useBackupCode": "代わりにバックアップコードを使用",
"useAuthenticatorCode": "代わりに認証アプリのコードを使用",
"rememberDevice": "このデバイスを30日間記憶する",
"rememberDeviceDescription": "このデバイスでは30日間2FAコードを入力する必要はありません"
},
"messages": {
"enabledSuccess": "二要素認証が正常に有効化されました!",
"disabledSuccess": "二要素認証が正常に無効化されました",
"backupCodesGenerated": "新しいバックアップコードが正常に生成されました",
"backupCodesCopied": "バックアップコードがクリップボードにコピーされました",
"setupFailed": "2FA設定の生成に失敗しました",
"verificationFailed": "確認コードが無効です",
"disableFailed": "2FAの無効化に失敗しました。パスワードを確認してください。",
"backupCodesFailed": "バックアップコードの生成に失敗しました",
"backupCodesCopyFailed": "バックアップコードのコピーに失敗しました",
"statusLoadFailed": "2FAステータスの読み込みに失敗しました",
"enterVerificationCode": "確認コードを入力してください",
"enterPassword": "パスワードを入力してください",
"deviceTrusted": "このデバイスは30日間信頼できるデバイスとしてマークされました"
},
"errors": {
"invalidVerificationCode": "確認コードが無効です",
"invalidTwoFactorCode": "二要素認証コードが無効です",
"twoFactorRequired": "二要素認証が必要です",
"twoFactorAlreadyEnabled": "二要素認証はすでに有効になっています",
"twoFactorNotEnabled": "二要素認証が有効になっていません",
"passwordVerificationRequired": "パスワードの確認が必要です",
"invalidPassword": "パスワードが無効です",
"userNotFound": "ユーザーが見つかりません"
},
"buttons": {
"enable2FA": "2FAを有効にする",
"disable2FA": "2FAを無効にする"
},
"deviceNames": {
"unknownDevice": "不明なデバイス",
"browsers": {
"chrome": "Chrome",
"firefox": "Firefox",
"safari": "Safari",
"edge": "Edge"
},
"platforms": {
"windows": " on Windows",
"macos": " on macOS",
"linux": " on Linux",
"iphone": " on iPhone",
"android": " on Android"
}
},
"status": {
"label": "状態:",
"enabled": "有効",
"disabled": "無効"
},
"trustedDevices": {
"title": "信頼済みデバイス - 2FA",
"description": "2FA認証が不要なデバイス",
"noDevices": "信頼済みデバイスはありません",
"deviceName": "デバイス",
"addedOn": "追加日",
"expiresOn": "有効期限",
"remove": "削除",
"removeAll": "すべて削除",
"confirmRemove": "この信頼済みデバイスを削除してもよろしいですか?",
"confirmRemoveAll": "すべての信頼済みデバイスを削除してもよろしいですか?",
"deviceRemoved": "信頼済みデバイスが正常に削除されました",
"allDevicesRemoved": "すべての信頼済みデバイスが正常に削除されました",
"loadFailed": "信頼済みデバイスの読み込みに失敗しました",
"removeFailed": "信頼済みデバイスの削除に失敗しました",
"removeAllFailed": "すべての信頼済みデバイスの削除に失敗しました",
"loading": "信頼済みデバイスを読み込み中...",
"noDevicesDescription": "2FA認証時にデバイスを信頼するように選択すると、ここに表示されます",
"tableHeaders": {
"device": "デバイス",
"added": "追加日",
"expires": "有効期限",
"lastUsed": "最終使用日",
"ipAddress": "IPアドレス",
"actions": "アクション"
},
"status": {
"never": "なし",
"expired": "期限切れ"
},
"modals": {
"removeDevice": {
"title": "信頼済みデバイスを削除",
"added": "追加日:",
"ip": "IP"
},
"removeAllDevices": {
"title": "すべての信頼済みデバイスを削除",
"description": "{count}個の信頼済みデバイスを削除します。すべてのデバイスで再度2FA認証が必要になります。"
},
"buttons": {
"cancel": "キャンセル",
"removing": "削除中...",
"removeDevice": "デバイスを削除",
"removeAllDevices": "すべてのデバイスを削除"
}
}
}
},
"uploadFile": {
"title": "ファイルをアップロード",
"selectFile": "ファイルを選択するにはクリックしてください",
@@ -1536,19 +1743,9 @@
"nameRequired": "名前は必須です",
"required": "このフィールドは必須です"
},
"iconPicker": {
"title": "アイコンを選択",
"placeholder": "アイコンを選択してください",
"searchPlaceholder": "アイコンを検索...",
"loadingMore": "アイコンを読み込み中...",
"allIconsLoaded": "全{count}個のアイコンを読み込みました",
"noIconsFound": "\"{search}\"に一致するアイコンが見つかりませんでした",
"tabs": {
"all": "すべてのアイコン",
"popular": "人気",
"auth": "認証プロバイダー"
},
"stats": "{libraryCount}ライブラリから{iconCount}個のアイコン",
"categoryBadge": "{category}{count}個のアイコン)"
"qrCodeModal": {
"title": "QRコードを共有",
"description": "このQRコードをスキャンしてリンクにアクセスしてください",
"download": "QRコードをダウンロード"
}
}

View File

@@ -143,7 +143,8 @@
"saving": "저장 중...",
"update": "업데이트",
"click": "클릭하여",
"creating": "생성 중..."
"creating": "생성 중...",
"loadingSimple": "로딩 중..."
},
"createShare": {
"title": "공유 생성",
@@ -182,7 +183,15 @@
"invalidCredentials": "잘못된 이메일 또는 비밀번호입니다",
"userNotFound": "사용자를 찾을 수 없습니다",
"accountLocked": "계정이 잠겼습니다. 나중에 다시 시도하세요",
"unexpectedError": "예기치 않은 오류가 발생했습니다. 다시 시도해주세요"
"unexpectedError": "예기치 않은 오류가 발생했습니다. 다시 시도해주세요",
"Invalid password": "잘못된 비밀번호입니다",
"Invalid two-factor authentication code": "잘못된 2단계 인증 코드입니다",
"Invalid verification code": "잘못된 인증 코드입니다",
"Password verification required": "비밀번호 확인이 필요합니다",
"Two-factor authentication is already enabled": "2단계 인증이 이미 활성화되어 있습니다",
"Two-factor authentication is not enabled": "2단계 인증이 활성화되어 있지 않습니다",
"Two-factor authentication required": "2단계 인증이 필요합니다",
"noUserData": "사용자 데이터가 없습니다"
},
"fileActions": {
"editFile": "파일 편집",
@@ -304,7 +313,8 @@
"title": "비밀번호를 잊으셨나요?",
"description": "이메일 주소를 입력하면 비밀번호 재설정 지침을 보내드립니다.",
"resetInstructions": "비밀번호 재설정 지침이 이메일로 전송되었습니다",
"pageTitle": "비밀번호를 잊으셨나요?"
"pageTitle": "비밀번호를 잊으셨나요?",
"passwordAuthDisabled": "비밀번호 인증이 비활성화되어 있습니다. 관리자에게 문의하거나 외부 인증 공급자를 사용하세요."
},
"generateShareLink": {
"generateTitle": "공유 링크 생성",
@@ -318,7 +328,12 @@
"copyButton": "링크 복사",
"success": "링크가 성공적으로 생성되었습니다",
"error": "링크 생성에 실패했습니다",
"copied": "링크가 클립보드에 복사되었습니다"
"copied": "링크가 클립보드에 복사되었습니다",
"readyDescription": "공유 링크가 준비되었습니다. QR 코드를 직접 스캔하여 링크에 접근하거나, 나중에 사용하기 위해 다운로드하거나, 아래 링크를 복사할 수 있습니다.",
"tabs": {
"link": "링크",
"qrcode": "QR 코드"
}
},
"home": {
"description": "WeTransfer의 오픈소스 대안입니다. 추적이나 제한 없이 파일을 안전하게 공유하세요.",
@@ -331,6 +346,27 @@
},
"pageTitle": "홈"
},
"iconPicker": {
"title": "아이콘 선택",
"placeholder": "아이콘 선택",
"searchPlaceholder": "아이콘 검색...",
"loadingMore": "아이콘 더 불러오는 중...",
"allIconsLoaded": "모든 {count}개의 아이콘이 로드됨",
"noIconsFound": "\"{search}\"에 대한 아이콘을 찾을 수 없습니다",
"tabs": {
"all": "모든 아이콘",
"popular": "인기",
"auth": "인증 제공자"
},
"stats": "{libraryCount}개의 라이브러리에서 {iconCount}개의 아이콘",
"categoryBadge": "{category} ({count}개의 아이콘)"
},
"imageEdit": {
"title": "이미지 편집",
"rotate": "회전",
"zoom": "확대/축소",
"cropInstructions": "위치를 변경하려면 드래그하고, 자르기 영역을 조정하려면 모서리를 확대/축소하세요"
},
"login": {
"welcome": "에 오신 것을 환영합니다",
"signInToContinue": "계속하려면 로그인하세요",
@@ -589,7 +625,8 @@
"createLink": "링크 생성",
"delete": "삭제",
"copyLinkTitle": "링크 복사",
"createLinkCTA": "수신 링크 생성"
"createLinkCTA": "수신 링크 생성",
"viewQrCode": "QR 코드 보기"
},
"status": {
"active": "활성",
@@ -605,7 +642,8 @@
"viewDetails": "상세 보기",
"edit": "편집",
"delete": "삭제",
"viewFiles": "받은 파일"
"viewFiles": "받은 파일",
"viewQrCode": "QR 코드 보기"
},
"empty": {
"title": "생성된 수신 링크 없음",
@@ -746,7 +784,12 @@
"selectAll": "모두 선택",
"selectFile": "{fileName} 파일 선택",
"deleteError": "파일 삭제 오류",
"deleteSuccess": "파일이 성공적으로 삭제됨"
"deleteSuccess": "파일이 성공적으로 삭제됨",
"copyErrors": {
"timeout": "복사 작업 시간이 초과되었습니다. 더 작은 파일로 다시 시도하거나 연결을 확인하십시오.",
"failed": "복사 작업이 실패했습니다. 다시 시도해 주세요.",
"aborted": "시간 초과로 인해 복사 작업이 취소되었습니다."
}
}
},
"form": {
@@ -897,7 +940,8 @@
"fileList": {
"title": "선택된 파일:",
"statusUploaded": "업로드됨",
"statusError": "오류"
"statusError": "오류",
"retry": "다시 시도"
},
"form": {
"nameLabel": "이름",
@@ -1085,6 +1129,10 @@
"smtpTrustSelfSigned": {
"title": "자체 서명된 인증서 신뢰",
"description": "자체 서명된 SSL/TLS 인증서를 신뢰하려면 활성화하세요 (개발 환경에서 유용)"
},
"passwordAuthEnabled": {
"title": "비밀번호 인증",
"description": "비밀번호 기반 인증 활성화 또는 비활성화"
}
},
"buttons": {
@@ -1094,7 +1142,8 @@
},
"errors": {
"loadFailed": "설정을 불러오는데 실패했습니다",
"updateFailed": "설정 업데이트에 실패했습니다"
"updateFailed": "설정 업데이트에 실패했습니다",
"passwordAuthRequiresProvider": "최소 하나의 활성 인증 제공자가 없으면 비밀번호 인증을 비활성화할 수 없습니다"
},
"messages": {
"noChanges": "저장할 변경 사항이 없습니다",
@@ -1200,7 +1249,10 @@
"invalidDate": "잘못된 날짜",
"loadError": "공유 세부 정보 로드에 실패했습니다",
"editSecurity": "보안 편집",
"editExpiration": "만료 편집"
"editExpiration": "만료 편집",
"clickToEnlargeQrCode": "QR 코드 확대",
"downloadQrCode": "QR 코드 다운로드",
"qrCode": "QR 코드"
},
"shareExpiration": {
"neverExpires": "만료되지 않음",
@@ -1387,7 +1439,8 @@
"copyLink": "링크 복사",
"notifyRecipients": "받는 사람에게 알림",
"delete": "삭제",
"downloadShareFiles": "모든 파일을 다운로드하십시오"
"downloadShareFiles": "모든 파일을 다운로드하십시오",
"viewQrCode": "QR 코드 보기"
},
"bulkActions": {
"delete": "삭제",
@@ -1419,6 +1472,160 @@
"dark": "다크",
"system": "시스템"
},
"twoFactor": {
"title": "2단계 인증",
"description": "계정에 추가 보안 계층 추가",
"enabled": "귀하의 계정은 2단계 인증으로 보호되고 있습니다",
"disabled": "2단계 인증이 활성화되지 않았습니다",
"setup": {
"title": "2단계 인증 활성화",
"description": "인증 앱으로 QR 코드를 스캔한 다음 인증 코드를 입력하세요.",
"qrCode": "QR 코드",
"manualEntryKey": "수동 입력 키",
"verificationCode": "인증 코드",
"verificationCodePlaceholder": "6자리 코드 입력",
"verificationCodeDescription": "인증 앱에서 6자리 코드를 입력하세요",
"verifyAndEnable": "확인 및 활성화",
"cancel": "취소"
},
"disable": {
"title": "2단계 인증 비활성화",
"description": "2단계 인증 비활성화를 확인하려면 비밀번호를 입력하세요.",
"password": "비밀번호",
"passwordPlaceholder": "비밀번호 입력",
"confirm": "비활성화 확인",
"cancel": "취소"
},
"backupCodes": {
"title": "백업 코드",
"description": "이 백업 코드를 안전한 곳에 보관하세요. 인증 기기를 분실한 경우 계정에 액세스하는 데 사용할 수 있습니다.",
"warning": "중요:",
"warningText": "각 백업 코드는 한 번만 사용할 수 있습니다. 안전하게 보관하고 다른 사람과 공유하지 마세요.",
"generateNew": "새 백업 코드 생성",
"download": "백업 코드 다운로드",
"copyToClipboard": "클립보드에 복사",
"savedMessage": "백업 코드를 저장했습니다",
"available": "사용 가능한 백업 코드 {count}개",
"instructions": [
"• 이 코드를 안전한 곳에 보관하세요",
"• 각 백업 코드는 한 번만 사용할 수 있습니다",
"• 언제든지 새 코드를 생성할 수 있습니다"
]
},
"verification": {
"title": "2단계 인증",
"description": "인증 앱에서 6자리 코드를 입력하세요",
"backupDescription": "계속하려면 백업 코드 중 하나를 입력하세요",
"verificationCode": "인증 코드",
"backupCode": "백업 코드",
"verificationCodePlaceholder": "000000",
"backupCodePlaceholder": "XXXX-XXXX",
"verify": "확인",
"verifying": "확인 중...",
"useBackupCode": "대신 백업 코드 사용",
"useAuthenticatorCode": "대신 인증 앱 코드 사용",
"rememberDevice": "이 기기를 30일 동안 기억하기",
"rememberDeviceDescription": "30일 동안 이 기기에서 2단계 인증 코드를 입력할 필요가 없습니다"
},
"messages": {
"enabledSuccess": "2단계 인증이 성공적으로 활성화되었습니다!",
"disabledSuccess": "2단계 인증이 성공적으로 비활성화되었습니다",
"backupCodesGenerated": "새 백업 코드가 성공적으로 생성되었습니다",
"backupCodesCopied": "백업 코드가 클립보드에 복사되었습니다",
"setupFailed": "2단계 인증 설정 생성 실패",
"verificationFailed": "잘못된 인증 코드",
"disableFailed": "2단계 인증 비활성화 실패. 비밀번호를 확인하세요.",
"backupCodesFailed": "백업 코드 생성 실패",
"backupCodesCopyFailed": "백업 코드 복사 실패",
"statusLoadFailed": "2단계 인증 상태 로드 실패",
"enterVerificationCode": "인증 코드를 입력하세요",
"enterPassword": "비밀번호를 입력하세요",
"deviceTrusted": "이 기기는 30일 동안 신뢰할 수 있는 기기로 표시되었습니다"
},
"errors": {
"invalidVerificationCode": "잘못된 인증 코드",
"invalidTwoFactorCode": "잘못된 2단계 인증 코드",
"twoFactorRequired": "2단계 인증이 필요합니다",
"twoFactorAlreadyEnabled": "2단계 인증이 이미 활성화되어 있습니다",
"twoFactorNotEnabled": "2단계 인증이 활성화되어 있지 않습니다",
"passwordVerificationRequired": "비밀번호 확인이 필요합니다",
"invalidPassword": "잘못된 비밀번호",
"userNotFound": "사용자를 찾을 수 없습니다"
},
"buttons": {
"enable2FA": "2단계 인증 활성화",
"disable2FA": "2단계 인증 비활성화"
},
"deviceNames": {
"unknownDevice": "알 수 없는 기기",
"browsers": {
"chrome": "Chrome",
"firefox": "Firefox",
"safari": "Safari",
"edge": "Edge"
},
"platforms": {
"windows": " Windows에서",
"macos": " macOS에서",
"linux": " Linux에서",
"iphone": " iPhone에서",
"android": " Android에서"
}
},
"status": {
"label": "상태:",
"enabled": "활성화됨",
"disabled": "비활성화됨"
},
"trustedDevices": {
"title": "신뢰할 수 있는 기기 - 2단계 인증",
"description": "2단계 인증 확인이 필요하지 않은 기기",
"noDevices": "신뢰할 수 있는 기기 없음",
"deviceName": "기기",
"addedOn": "추가된 날짜",
"expiresOn": "만료 날짜",
"remove": "제거",
"removeAll": "모두 제거",
"confirmRemove": "이 신뢰할 수 있는 기기를 제거하시겠습니까?",
"confirmRemoveAll": "모든 신뢰할 수 있는 기기를 제거하시겠습니까?",
"deviceRemoved": "신뢰할 수 있는 기기가 성공적으로 제거되었습니다",
"allDevicesRemoved": "모든 신뢰할 수 있는 기기가 성공적으로 제거되었습니다",
"loadFailed": "신뢰할 수 있는 기기 로드 실패",
"removeFailed": "신뢰할 수 있는 기기 제거 실패",
"removeAllFailed": "모든 신뢰할 수 있는 기기 제거 실패",
"loading": "신뢰할 수 있는 기기 로드 중...",
"noDevicesDescription": "2단계 인증 확인 중에 기기를 신뢰하도록 선택하면 여기에 표시됩니다",
"tableHeaders": {
"device": "기기",
"added": "추가됨",
"expires": "만료",
"lastUsed": "마지막 사용",
"ipAddress": "IP 주소",
"actions": "작업"
},
"status": {
"never": "없음",
"expired": "만료됨"
},
"modals": {
"removeDevice": {
"title": "신뢰할 수 있는 기기 제거",
"added": "추가됨:",
"ip": "IP:"
},
"removeAllDevices": {
"title": "모든 신뢰할 수 있는 기기 제거",
"description": "{count}개의 신뢰할 수 있는 기기를 제거합니다. 모든 기기에서 다시 2단계 인증을 확인해야 합니다."
},
"buttons": {
"cancel": "취소",
"removing": "제거 중...",
"removeDevice": "기기 제거",
"removeAllDevices": "모든 기기 제거"
}
}
}
},
"uploadFile": {
"title": "파일 업로드",
"multipleTitle": "여러 파일 업로드",
@@ -1536,19 +1743,9 @@
"nameRequired": "이름은 필수입니다",
"required": "이 필드는 필수입니다"
},
"iconPicker": {
"title": "아이콘 선택",
"placeholder": "아이콘 선택",
"searchPlaceholder": "아이콘 검색...",
"loadingMore": "아이콘 더 불러오는 중...",
"allIconsLoaded": "모든 {count}개의 아이콘이 로드됨",
"noIconsFound": "\"{search}\"에 대한 아이콘을 찾을 수 없습니다",
"tabs": {
"all": "모든 아이콘",
"popular": "인기",
"auth": "인증 제공자"
},
"stats": "{libraryCount}개의 라이브러리에서 {iconCount}개의 아이콘",
"categoryBadge": "{category} ({count}개의 아이콘)"
"qrCodeModal": {
"title": "QR 코드 공유",
"description": "이 QR 코드를 스캔하여 링크에 접근할 수 있습니다.",
"download": "QR 코드 다운로드"
}
}

View File

@@ -143,7 +143,8 @@
"saving": "Opslaan...",
"update": "Bijwerken",
"click": "Klik om",
"creating": "Maken..."
"creating": "Maken...",
"loadingSimple": "Laden..."
},
"createShare": {
"title": "Delen Maken",
@@ -182,7 +183,15 @@
"invalidCredentials": "Ongeldig e-mail of wachtwoord",
"userNotFound": "Gebruiker niet gevonden",
"accountLocked": "Account vergrendeld. Probeer het later opnieuw",
"unexpectedError": "Er is een onverwachte fout opgetreden. Probeer het opnieuw"
"unexpectedError": "Er is een onverwachte fout opgetreden. Probeer het opnieuw",
"Invalid password": "Ongeldig wachtwoord",
"Invalid two-factor authentication code": "Ongeldige tweefactorauthenticatiecode",
"Invalid verification code": "Ongeldige verificatiecode",
"Password verification required": "Wachtwoordverificatie vereist",
"Two-factor authentication is already enabled": "Tweefactorauthenticatie is al ingeschakeld",
"Two-factor authentication is not enabled": "Tweefactorauthenticatie is niet ingeschakeld",
"Two-factor authentication required": "Tweefactorauthenticatie vereist",
"noUserData": "Geen gebruikersgegevens"
},
"fileActions": {
"editFile": "Bestand Bewerken",
@@ -304,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",
@@ -318,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.",
@@ -331,6 +346,27 @@
},
"pageTitle": "Startpagina"
},
"iconPicker": {
"title": "Selecteer Pictogram",
"placeholder": "Selecteer een pictogram",
"searchPlaceholder": "Zoek pictogrammen...",
"loadingMore": "Meer pictogrammen laden...",
"allIconsLoaded": "Alle {count} pictogrammen geladen",
"noIconsFound": "Geen pictogrammen gevonden voor \"{search}\"",
"tabs": {
"all": "Alle Pictogrammen",
"popular": "Populair",
"auth": "Authenticatie Providers"
},
"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",
@@ -589,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",
@@ -605,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",
@@ -746,7 +784,12 @@
"selectAll": "Alles selecteren",
"selectFile": "Selecteer bestand {fileName}",
"deleteError": "Fout bij verwijderen bestand",
"deleteSuccess": "Bestand succesvol verwijderd"
"deleteSuccess": "Bestand succesvol verwijderd",
"copyErrors": {
"timeout": "Kopieeroperatie verlopen. Probeer het opnieuw met een kleiner bestand of controleer uw verbinding.",
"failed": "Kopieeroperatie mislukt. Probeer het opnieuw.",
"aborted": "Kopieeroperatie is geannuleerd vanwege een time-out."
}
}
},
"form": {
@@ -897,7 +940,8 @@
"fileList": {
"title": "Geselecteerde bestanden:",
"statusUploaded": "Geüpload",
"statusError": "Fout"
"statusError": "Fout",
"retry": "Opnieuw proberen"
},
"form": {
"nameLabel": "Naam",
@@ -1085,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": {
@@ -1094,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",
@@ -1200,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",
@@ -1387,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",
@@ -1419,6 +1472,160 @@
"dark": "Donker",
"system": "Systeem"
},
"twoFactor": {
"title": "Twee-Factor Authenticatie",
"description": "Voeg een extra beveiligingslaag toe aan uw account",
"enabled": "Uw account is beveiligd met twee-factor authenticatie",
"disabled": "Twee-factor authenticatie is niet ingeschakeld",
"setup": {
"title": "Twee-Factor Authenticatie Inschakelen",
"description": "Scan de QR-code met uw authenticator-app en voer vervolgens de verificatiecode in.",
"qrCode": "QR-Code",
"manualEntryKey": "Handmatige Invoersleutel",
"verificationCode": "Verificatiecode",
"verificationCodePlaceholder": "Voer 6-cijferige code in",
"verificationCodeDescription": "Voer de 6-cijferige code van uw authenticator-app in",
"verifyAndEnable": "Verifiëren & Inschakelen",
"cancel": "Annuleren"
},
"disable": {
"title": "Twee-Factor Authenticatie Uitschakelen",
"description": "Voer uw wachtwoord in om het uitschakelen van twee-factor authenticatie te bevestigen.",
"password": "Wachtwoord",
"passwordPlaceholder": "Voer uw wachtwoord in",
"confirm": "Bevestig Uitschakelen",
"cancel": "Annuleren"
},
"backupCodes": {
"title": "Back-upcodes",
"description": "Bewaar deze back-upcodes op een veilige plaats. U kunt ze gebruiken om toegang te krijgen tot uw account als u uw authenticator-apparaat verliest.",
"warning": "Belangrijk:",
"warningText": "Elke back-upcode kan slechts één keer worden gebruikt. Bewaar ze veilig en deel ze met niemand.",
"generateNew": "Genereer Nieuwe Back-upcodes",
"download": "Download Back-upcodes",
"copyToClipboard": "Kopiëren naar Klembord",
"savedMessage": "Ik heb mijn back-upcodes opgeslagen",
"available": "{count} back-upcodes beschikbaar",
"instructions": [
"• Bewaar deze codes op een veilige plaats",
"• Elke back-upcode kan slechts één keer worden gebruikt",
"• U kunt op elk moment nieuwe codes genereren"
]
},
"verification": {
"title": "Twee-Factor Authenticatie",
"description": "Voer de 6-cijferige code van uw authenticator-app in",
"backupDescription": "Voer een van uw back-upcodes in om door te gaan",
"verificationCode": "Verificatiecode",
"backupCode": "Back-upcode",
"verificationCodePlaceholder": "000000",
"backupCodePlaceholder": "XXXX-XXXX",
"verify": "Verifiëren",
"verifying": "Verifiëren...",
"useBackupCode": "Gebruik in plaats daarvan een back-upcode",
"useAuthenticatorCode": "Gebruik in plaats daarvan authenticator-code",
"rememberDevice": "Onthoud dit apparaat voor 30 dagen",
"rememberDeviceDescription": "U hoeft gedurende 30 dagen geen 2FA-codes in te voeren op dit apparaat"
},
"messages": {
"enabledSuccess": "Twee-factor authenticatie succesvol ingeschakeld!",
"disabledSuccess": "Twee-factor authenticatie succesvol uitgeschakeld",
"backupCodesGenerated": "Nieuwe back-upcodes succesvol gegenereerd",
"backupCodesCopied": "Back-upcodes gekopieerd naar klembord",
"setupFailed": "Genereren van 2FA-configuratie mislukt",
"verificationFailed": "Ongeldige verificatiecode",
"disableFailed": "Uitschakelen van 2FA mislukt. Controleer uw wachtwoord.",
"backupCodesFailed": "Genereren van back-upcodes mislukt",
"backupCodesCopyFailed": "Kopiëren van back-upcodes mislukt",
"statusLoadFailed": "Laden van 2FA-status mislukt",
"enterVerificationCode": "Voer de verificatiecode in",
"enterPassword": "Voer uw wachtwoord in",
"deviceTrusted": "Dit apparaat is als vertrouwd gemarkeerd voor 30 dagen"
},
"errors": {
"invalidVerificationCode": "Ongeldige verificatiecode",
"invalidTwoFactorCode": "Ongeldige twee-factor authenticatiecode",
"twoFactorRequired": "Twee-factor authenticatie vereist",
"twoFactorAlreadyEnabled": "Twee-factor authenticatie is al ingeschakeld",
"twoFactorNotEnabled": "Twee-factor authenticatie is niet ingeschakeld",
"passwordVerificationRequired": "Wachtwoordverificatie vereist",
"invalidPassword": "Ongeldig wachtwoord",
"userNotFound": "Gebruiker niet gevonden"
},
"buttons": {
"enable2FA": "2FA Inschakelen",
"disable2FA": "2FA Uitschakelen"
},
"deviceNames": {
"unknownDevice": "Onbekend Apparaat",
"browsers": {
"chrome": "Chrome",
"firefox": "Firefox",
"safari": "Safari",
"edge": "Edge"
},
"platforms": {
"windows": " op Windows",
"macos": " op macOS",
"linux": " op Linux",
"iphone": " op iPhone",
"android": " op Android"
}
},
"status": {
"label": "Status:",
"enabled": "Ingeschakeld",
"disabled": "Uitgeschakeld"
},
"trustedDevices": {
"title": "Vertrouwde Apparaten - 2FA",
"description": "Apparaten die geen 2FA-verificatie vereisen",
"noDevices": "Geen vertrouwde apparaten",
"deviceName": "Apparaat",
"addedOn": "Toegevoegd op",
"expiresOn": "Verloopt op",
"remove": "Verwijderen",
"removeAll": "Alles Verwijderen",
"confirmRemove": "Weet u zeker dat u dit vertrouwde apparaat wilt verwijderen?",
"confirmRemoveAll": "Weet u zeker dat u alle vertrouwde apparaten wilt verwijderen?",
"deviceRemoved": "Vertrouwd apparaat succesvol verwijderd",
"allDevicesRemoved": "Alle vertrouwde apparaten succesvol verwijderd",
"loadFailed": "Laden van vertrouwde apparaten mislukt",
"removeFailed": "Verwijderen van vertrouwd apparaat mislukt",
"removeAllFailed": "Verwijderen van alle vertrouwde apparaten mislukt",
"loading": "Vertrouwde apparaten laden...",
"noDevicesDescription": "Apparaten verschijnen hier wanneer u ervoor kiest om ze te vertrouwen tijdens 2FA-verificatie",
"tableHeaders": {
"device": "Apparaat",
"added": "Toegevoegd",
"expires": "Verloopt",
"lastUsed": "Laatst Gebruikt",
"ipAddress": "IP-Adres",
"actions": "Acties"
},
"status": {
"never": "Nooit",
"expired": "Verlopen"
},
"modals": {
"removeDevice": {
"title": "Vertrouwd Apparaat Verwijderen",
"added": "Toegevoegd:",
"ip": "IP:"
},
"removeAllDevices": {
"title": "Alle Vertrouwde Apparaten Verwijderen",
"description": "Dit zal {count} vertrouwd{count, plural, =1 { apparaat} other {e apparaten}} verwijderen. U moet 2FA opnieuw verifiëren op alle apparaten."
},
"buttons": {
"cancel": "Annuleren",
"removing": "Verwijderen...",
"removeDevice": "Apparaat Verwijderen",
"removeAllDevices": "Alle Apparaten Verwijderen"
}
}
}
},
"uploadFile": {
"title": "Bestand uploaden",
"multipleTitle": "Meerdere Bestanden Uploaden",
@@ -1536,19 +1743,9 @@
"nameRequired": "Naam is verplicht",
"required": "Dit veld is verplicht"
},
"iconPicker": {
"title": "Selecteer Pictogram",
"placeholder": "Selecteer een pictogram",
"searchPlaceholder": "Zoek pictogrammen...",
"loadingMore": "Meer pictogrammen laden...",
"allIconsLoaded": "Alle {count} pictogrammen geladen",
"noIconsFound": "Geen pictogrammen gevonden voor \"{search}\"",
"tabs": {
"all": "Alle Pictogrammen",
"popular": "Populair",
"auth": "Authenticatie Providers"
},
"stats": "{iconCount} pictogrammen van {libraryCount} bibliotheken",
"categoryBadge": "{category} ({count} pictogrammen)"
"qrCodeModal": {
"title": "QR Code Delen",
"description": "Scan deze QR-code om toegang te krijgen tot de link.",
"download": "QR Code Downloaden"
}
}

View File

@@ -143,7 +143,8 @@
"dashboard": "Panel główny",
"back": "Wróć",
"click": "Kliknij, aby",
"creating": "Tworzenie..."
"creating": "Tworzenie...",
"loadingSimple": "Ładowanie..."
},
"createShare": {
"title": "Utwórz Udostępnienie",
@@ -182,7 +183,15 @@
"invalidCredentials": "Nieprawidłowy adres e-mail lub hasło",
"userNotFound": "Użytkownik nie znaleziony",
"accountLocked": "Konto zablokowane. Spróbuj ponownie później",
"unexpectedError": "Wystąpił nieoczekiwany błąd. Spróbuj ponownie."
"unexpectedError": "Wystąpił nieoczekiwany błąd. Spróbuj ponownie.",
"Invalid password": "Nieprawidłowe hasło",
"Invalid two-factor authentication code": "Nieprawidłowy kod uwierzytelniania dwuskładnikowego",
"Invalid verification code": "Nieprawidłowy kod weryfikacyjny",
"Password verification required": "Wymagana weryfikacja hasła",
"Two-factor authentication is already enabled": "Uwierzytelnianie dwuskładnikowe jest już włączone",
"Two-factor authentication is not enabled": "Uwierzytelnianie dwuskładnikowe nie jest włączone",
"Two-factor authentication required": "Wymagane uwierzytelnianie dwuskładnikowe",
"noUserData": "Brak danych użytkownika"
},
"fileActions": {
"editFile": "Edytuj plik",
@@ -304,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",
@@ -318,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ń.",
@@ -331,6 +346,27 @@
},
"pageTitle": "Strona główna"
},
"iconPicker": {
"title": "Wybierz ikonę",
"placeholder": "Wybierz ikonę",
"searchPlaceholder": "Szukaj ikon...",
"loadingMore": "Ładowanie kolejnych ikon...",
"allIconsLoaded": "Załadowano wszystkie {count} ikon",
"noIconsFound": "Nie znaleziono ikon dla \"{search}\"",
"tabs": {
"all": "Wszystkie ikony",
"popular": "Popularne",
"auth": "Dostawcy uwierzytelniania"
},
"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ć",
@@ -589,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",
@@ -605,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",
@@ -746,7 +784,12 @@
"selectAll": "Zaznacz wszystko",
"selectFile": "Wybierz plik {fileName}",
"deleteError": "Błąd usuwania pliku",
"deleteSuccess": "Plik usunięty pomyślnie"
"deleteSuccess": "Plik usunięty pomyślnie",
"copyErrors": {
"timeout": "Operacja kopiowania przekroczyła limit czasu. Spróbuj ponownie z mniejszym plikiem lub sprawdź swoje połączenie.",
"failed": "Operacja kopiowania nie powiodła się. Spróbuj ponownie.",
"aborted": "Operacja kopiowania została anulowana z powodu przekroczenia limitu czasu."
}
}
},
"form": {
@@ -897,7 +940,8 @@
"fileList": {
"title": "Wybrane pliki:",
"statusUploaded": "Przesłano",
"statusError": "Błąd"
"statusError": "Błąd",
"retry": "Spróbuj Ponownie"
},
"form": {
"nameLabel": "Imię",
@@ -1085,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": {
@@ -1094,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",
@@ -1145,7 +1194,7 @@
"description": "To udostępnienie mogło zostać usunięte lub wygasło."
},
"pageTitle": "Udostępnij",
"downloadAll": "[TO_TRANSLATE] Download All"
"downloadAll": "Pobierz wszystkie"
},
"shareActions": {
"deleteTitle": "Usuń udostępnienie",
@@ -1200,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",
@@ -1387,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ń",
@@ -1419,6 +1472,160 @@
"dark": "Ciemny",
"system": "Systemowy"
},
"twoFactor": {
"title": "Uwierzytelnianie dwuskładnikowe",
"description": "Dodaj dodatkową warstwę zabezpieczeń do swojego konta",
"enabled": "Twoje konto jest chronione uwierzytelnianiem dwuskładnikowym",
"disabled": "Uwierzytelnianie dwuskładnikowe nie jest włączone",
"setup": {
"title": "Włącz uwierzytelnianie dwuskładnikowe",
"description": "Zeskanuj kod QR za pomocą aplikacji uwierzytelniającej, a następnie wprowadź kod weryfikacyjny.",
"qrCode": "Kod QR",
"manualEntryKey": "Klucz do ręcznego wprowadzenia",
"verificationCode": "Kod weryfikacyjny",
"verificationCodePlaceholder": "Wprowadź 6-cyfrowy kod",
"verificationCodeDescription": "Wprowadź 6-cyfrowy kod z aplikacji uwierzytelniającej",
"verifyAndEnable": "Zweryfikuj i włącz",
"cancel": "Anuluj"
},
"disable": {
"title": "Wyłącz uwierzytelnianie dwuskładnikowe",
"description": "Wprowadź hasło, aby potwierdzić wyłączenie uwierzytelniania dwuskładnikowego.",
"password": "Hasło",
"passwordPlaceholder": "Wprowadź swoje hasło",
"confirm": "Potwierdź wyłączenie",
"cancel": "Anuluj"
},
"backupCodes": {
"title": "Kody zapasowe",
"description": "Zapisz te kody zapasowe w bezpiecznym miejscu. Możesz ich użyć, aby uzyskać dostęp do swojego konta w przypadku utraty urządzenia uwierzytelniającego.",
"warning": "Ważne:",
"warningText": "Każdy kod zapasowy może być użyty tylko raz. Przechowuj je bezpiecznie i nie udostępniaj nikomu.",
"generateNew": "Wygeneruj nowe kody zapasowe",
"download": "Pobierz kody zapasowe",
"copyToClipboard": "Kopiuj do schowka",
"savedMessage": "Zapisałem moje kody zapasowe",
"available": "{count} dostępnych kodów zapasowych",
"instructions": [
"• Zapisz te kody w bezpiecznym miejscu",
"• Każdy kod zapasowy może być użyty tylko raz",
"• Możesz wygenerować nowe kody w dowolnym momencie"
]
},
"verification": {
"title": "Uwierzytelnianie dwuskładnikowe",
"description": "Wprowadź 6-cyfrowy kod z aplikacji uwierzytelniającej",
"backupDescription": "Wprowadź jeden z kodów zapasowych, aby kontynuować",
"verificationCode": "Kod weryfikacyjny",
"backupCode": "Kod zapasowy",
"verificationCodePlaceholder": "000000",
"backupCodePlaceholder": "XXXX-XXXX",
"verify": "Zweryfikuj",
"verifying": "Weryfikacja...",
"useBackupCode": "Użyj kodu zapasowego",
"useAuthenticatorCode": "Użyj kodu z aplikacji uwierzytelniającej",
"rememberDevice": "Zapamiętaj to urządzenie na 30 dni",
"rememberDeviceDescription": "Nie będziesz musiał wprowadzać kodów 2FA na tym urządzeniu przez 30 dni"
},
"messages": {
"enabledSuccess": "Uwierzytelnianie dwuskładnikowe zostało pomyślnie włączone!",
"disabledSuccess": "Uwierzytelnianie dwuskładnikowe zostało pomyślnie wyłączone",
"backupCodesGenerated": "Nowe kody zapasowe zostały pomyślnie wygenerowane",
"backupCodesCopied": "Kody zapasowe skopiowane do schowka",
"setupFailed": "Nie udało się wygenerować konfiguracji 2FA",
"verificationFailed": "Nieprawidłowy kod weryfikacyjny",
"disableFailed": "Nie udało się wyłączyć 2FA. Sprawdź swoje hasło.",
"backupCodesFailed": "Nie udało się wygenerować kodów zapasowych",
"backupCodesCopyFailed": "Nie udało się skopiować kodów zapasowych",
"statusLoadFailed": "Nie udało się załadować statusu 2FA",
"enterVerificationCode": "Wprowadź kod weryfikacyjny",
"enterPassword": "Wprowadź swoje hasło",
"deviceTrusted": "To urządzenie zostało oznaczone jako zaufane na 30 dni"
},
"errors": {
"invalidVerificationCode": "Nieprawidłowy kod weryfikacyjny",
"invalidTwoFactorCode": "Nieprawidłowy kod uwierzytelniania dwuskładnikowego",
"twoFactorRequired": "Wymagane uwierzytelnianie dwuskładnikowe",
"twoFactorAlreadyEnabled": "Uwierzytelnianie dwuskładnikowe jest już włączone",
"twoFactorNotEnabled": "Uwierzytelnianie dwuskładnikowe nie jest włączone",
"passwordVerificationRequired": "Wymagana weryfikacja hasła",
"invalidPassword": "Nieprawidłowe hasło",
"userNotFound": "Nie znaleziono użytkownika"
},
"buttons": {
"enable2FA": "Włącz 2FA",
"disable2FA": "Wyłącz 2FA"
},
"deviceNames": {
"unknownDevice": "Nieznane urządzenie",
"browsers": {
"chrome": "Chrome",
"firefox": "Firefox",
"safari": "Safari",
"edge": "Edge"
},
"platforms": {
"windows": " na Windows",
"macos": " na macOS",
"linux": " na Linux",
"iphone": " na iPhone",
"android": " na Android"
}
},
"status": {
"label": "Status:",
"enabled": "Włączone",
"disabled": "Wyłączone"
},
"trustedDevices": {
"title": "Zaufane urządzenia - 2FA",
"description": "Urządzenia, które nie wymagają weryfikacji 2FA",
"noDevices": "Brak zaufanych urządzeń",
"deviceName": "Urządzenie",
"addedOn": "Dodano",
"expiresOn": "Wygasa",
"remove": "Usuń",
"removeAll": "Usuń wszystkie",
"confirmRemove": "Czy na pewno chcesz usunąć to zaufane urządzenie?",
"confirmRemoveAll": "Czy na pewno chcesz usunąć wszystkie zaufane urządzenia?",
"deviceRemoved": "Zaufane urządzenie zostało pomyślnie usunięte",
"allDevicesRemoved": "Wszystkie zaufane urządzenia zostały pomyślnie usunięte",
"loadFailed": "Nie udało się załadować zaufanych urządzeń",
"removeFailed": "Nie udało się usunąć zaufanego urządzenia",
"removeAllFailed": "Nie udało się usunąć wszystkich zaufanych urządzeń",
"loading": "Ładowanie zaufanych urządzeń...",
"noDevicesDescription": "Urządzenia pojawią się tutaj, gdy zdecydujesz się im zaufać podczas weryfikacji 2FA",
"tableHeaders": {
"device": "Urządzenie",
"added": "Dodano",
"expires": "Wygasa",
"lastUsed": "Ostatnio użyte",
"ipAddress": "Adres IP",
"actions": "Akcje"
},
"status": {
"never": "Nigdy",
"expired": "Wygasło"
},
"modals": {
"removeDevice": {
"title": "Usuń zaufane urządzenie",
"added": "Dodano:",
"ip": "IP:"
},
"removeAllDevices": {
"title": "Usuń wszystkie zaufane urządzenia",
"description": "Spowoduje to usunięcie {count} zaufanych urządzeń. Będziesz musiał zweryfikować 2FA na wszystkich urządzeniach ponownie."
},
"buttons": {
"cancel": "Anuluj",
"removing": "Usuwanie...",
"removeDevice": "Usuń urządzenie",
"removeAllDevices": "Usuń wszystkie urządzenia"
}
}
}
},
"uploadFile": {
"title": "Prześlij plik",
"multipleTitle": "Prześlij pliki",
@@ -1536,19 +1743,9 @@
"nameRequired": "Nazwa jest wymagana",
"required": "To pole jest wymagane"
},
"iconPicker": {
"title": "Wybierz ikonę",
"placeholder": "Wybierz ikonę",
"searchPlaceholder": "Szukaj ikon...",
"loadingMore": "Ładowanie kolejnych ikon...",
"allIconsLoaded": "Załadowano wszystkie {count} ikon",
"noIconsFound": "Nie znaleziono ikon dla \"{search}\"",
"tabs": {
"all": "Wszystkie ikony",
"popular": "Popularne",
"auth": "Dostawcy uwierzytelniania"
},
"stats": "{iconCount} ikon z {libraryCount} bibliotek",
"categoryBadge": "{category} ({count} ikon)"
"qrCodeModal": {
"title": "Udostępnij kod QR",
"description": "Skanuj ten kod QR, aby uzyskać dostęp do linku.",
"download": "Pobierz kod QR"
}
}

View File

@@ -143,7 +143,8 @@
"saving": "Salvando...",
"update": "Atualizar",
"creating": "Criando...",
"click": "Clique para"
"click": "Clique para",
"loadingSimple": "Carregando..."
},
"createShare": {
"title": "Criar compartilhamento",
@@ -182,7 +183,15 @@
"invalidCredentials": "E-mail ou senha inválidos",
"userNotFound": "Usuário não encontrado",
"accountLocked": "Conta bloqueada. Tente novamente mais tarde",
"unexpectedError": "Ocorreu um erro inesperado. Por favor, tente novamente"
"unexpectedError": "Ocorreu um erro inesperado. Por favor, tente novamente",
"Invalid password": "Senha inválida",
"Invalid two-factor authentication code": "Código de autenticação de dois fatores inválido",
"Invalid verification code": "Código de verificação inválido",
"Password verification required": "Verificação de senha necessária",
"Two-factor authentication is already enabled": "A autenticação de dois fatores já está ativada",
"Two-factor authentication is not enabled": "A autenticação de dois fatores não está ativada",
"Two-factor authentication required": "Autenticação de dois fatores necessária",
"noUserData": "Nenhum dado do usuário"
},
"fileActions": {
"editFile": "Editar arquivo",
@@ -304,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",
@@ -318,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.",
@@ -331,6 +346,27 @@
},
"pageTitle": "Início"
},
"iconPicker": {
"title": "Selecionar ícone",
"placeholder": "Selecione um ícone",
"searchPlaceholder": "Pesquisar ícones...",
"loadingMore": "Carregando mais ícones...",
"allIconsLoaded": "Todos os {count} ícones carregados",
"noIconsFound": "Nenhum ícone encontrado para \"{search}\"",
"tabs": {
"all": "Todos os ícones",
"popular": "Populares",
"auth": "Provedores de autenticação"
},
"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",
@@ -589,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",
@@ -605,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",
@@ -746,7 +784,12 @@
"selectAll": "Selecionar todos",
"selectFile": "Selecionar arquivo {fileName}",
"deleteError": "Erro ao excluir arquivo",
"deleteSuccess": "Arquivo excluído com sucesso"
"deleteSuccess": "Arquivo excluído com sucesso",
"copyErrors": {
"timeout": "A operação de cópia expirou. Por favor, tente novamente com um arquivo menor ou verifique sua conexão.",
"failed": "A operação de cópia falhou. Por favor, tente novamente.",
"aborted": "A operação de cópia foi cancelada devido ao tempo limite."
}
}
},
"form": {
@@ -897,7 +940,8 @@
"fileList": {
"title": "Arquivos selecionados:",
"statusUploaded": "Enviado",
"statusError": "Erro"
"statusError": "Erro",
"retry": "Tentar Novamente"
},
"form": {
"nameLabel": "Nome",
@@ -1093,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": {
@@ -1102,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",
@@ -1200,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",
@@ -1395,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": {
@@ -1419,6 +1472,160 @@
"dark": "Escuro",
"system": "Sistema"
},
"twoFactor": {
"title": "Autenticação de dois fatores",
"description": "Adicione uma camada extra de segurança à sua conta",
"enabled": "Sua conta está protegida com autenticação de dois fatores",
"disabled": "A autenticação de dois fatores não está ativada",
"setup": {
"title": "Ativar autenticação de dois fatores",
"description": "Escaneie o código QR com seu aplicativo autenticador e depois insira o código de verificação.",
"qrCode": "Código QR",
"manualEntryKey": "Chave de Entrada Manual",
"verificationCode": "Código de Verificação",
"verificationCodePlaceholder": "Digite o código de 6 dígitos",
"verificationCodeDescription": "Digite o código de 6 dígitos do seu aplicativo autenticador",
"verifyAndEnable": "Verificar e ativar",
"cancel": "Cancelar"
},
"disable": {
"title": "Desativar autenticação de dois fatores",
"description": "Digite sua senha para confirmar a desativação da autenticação de dois fatores.",
"password": "Senha",
"passwordPlaceholder": "Digite sua senha",
"confirm": "Confirmar Desativação",
"cancel": "Cancelar"
},
"backupCodes": {
"title": "Códigos de backup",
"description": "Salve estes códigos de backup em um local seguro. Você pode usá-los para acessar sua conta se perder seu dispositivo autenticador.",
"warning": "Importante:",
"warningText": "Cada código de backup só pode ser usado uma vez. Mantenha-os seguros e não os compartilhe com ninguém.",
"generateNew": "Gerar novos códigos de backup",
"download": "Baixar códigos de backup",
"copyToClipboard": "Copiar para área de transferência",
"savedMessage": "Salvei meus códigos de backup",
"available": "{count} códigos de backup disponíveis",
"instructions": [
"• Salve estes códigos em um local seguro",
"• Cada código de backup só pode ser usado uma vez",
"• Você pode gerar novos códigos a qualquer momento"
]
},
"verification": {
"title": "Autenticação de dois fatores",
"description": "Digite o código de 6 dígitos do seu aplicativo autenticador",
"backupDescription": "Digite um dos seus códigos de backup para continuar",
"verificationCode": "Código de verificação",
"backupCode": "Código de backup",
"verificationCodePlaceholder": "000000",
"backupCodePlaceholder": "XXXX-XXXX",
"verify": "Verificar",
"verifying": "Verificando...",
"useBackupCode": "Usar código de backup",
"useAuthenticatorCode": "Usar código do autenticador",
"rememberDevice": "Lembrar este dispositivo por 30 dias",
"rememberDeviceDescription": "Você não precisará inserir códigos 2FA neste dispositivo por 30 dias"
},
"messages": {
"enabledSuccess": "Autenticação de dois fatores ativada com sucesso!",
"disabledSuccess": "Autenticação de dois fatores desativada com sucesso",
"backupCodesGenerated": "Novos códigos de backup gerados com sucesso",
"backupCodesCopied": "Códigos de backup copiados para a área de transferência",
"setupFailed": "Falha ao gerar configuração 2FA",
"verificationFailed": "Código de verificação inválido",
"disableFailed": "Falha ao desativar 2FA. Por favor, verifique sua senha.",
"backupCodesFailed": "Falha ao gerar códigos de backup",
"backupCodesCopyFailed": "Falha ao copiar códigos de backup",
"statusLoadFailed": "Falha ao carregar status do 2FA",
"enterVerificationCode": "Por favor, digite o código de verificação",
"enterPassword": "Por favor, digite sua senha",
"deviceTrusted": "Este dispositivo foi marcado como confiável por 30 dias"
},
"errors": {
"invalidVerificationCode": "Código de verificação inválido",
"invalidTwoFactorCode": "Código de autenticação de dois fatores inválido",
"twoFactorRequired": "Autenticação de dois fatores necessária",
"twoFactorAlreadyEnabled": "A autenticação de dois fatores já está ativada",
"twoFactorNotEnabled": "A autenticação de dois fatores não está ativada",
"passwordVerificationRequired": "Verificação de senha necessária",
"invalidPassword": "Senha inválida",
"userNotFound": "Usuário não encontrado"
},
"buttons": {
"enable2FA": "Ativar 2FA",
"disable2FA": "Desativar 2FA"
},
"deviceNames": {
"unknownDevice": "Dispositivo Desconhecido",
"browsers": {
"chrome": "Chrome",
"firefox": "Firefox",
"safari": "Safari",
"edge": "Edge"
},
"platforms": {
"windows": " no Windows",
"macos": " no macOS",
"linux": " no Linux",
"iphone": " no iPhone",
"android": " no Android"
}
},
"status": {
"label": "Status:",
"enabled": "Ativado",
"disabled": "Desativado"
},
"trustedDevices": {
"title": "Dispositivos Confiáveis - 2FA",
"description": "Dispositivos que não requerem verificação 2FA",
"noDevices": "Nenhum dispositivo confiável",
"deviceName": "Dispositivo",
"addedOn": "Adicionado em",
"expiresOn": "Expira em",
"remove": "Remover",
"removeAll": "Remover Todos",
"confirmRemove": "Tem certeza que deseja remover este dispositivo confiável?",
"confirmRemoveAll": "Tem certeza que deseja remover todos os dispositivos confiáveis?",
"deviceRemoved": "Dispositivo confiável removido com sucesso",
"allDevicesRemoved": "Todos os dispositivos confiáveis foram removidos com sucesso",
"loadFailed": "Falha ao carregar dispositivos confiáveis",
"removeFailed": "Falha ao remover dispositivo confiável",
"removeAllFailed": "Falha ao remover todos os dispositivos confiáveis",
"loading": "Carregando dispositivos confiáveis...",
"noDevicesDescription": "Os dispositivos aparecerão aqui quando você optar por confiar neles durante a verificação 2FA",
"tableHeaders": {
"device": "Dispositivo",
"added": "Adicionado",
"expires": "Expira",
"lastUsed": "Último Uso",
"ipAddress": "Endereço IP",
"actions": "Ações"
},
"status": {
"never": "Nunca",
"expired": "Expirado"
},
"modals": {
"removeDevice": {
"title": "Remover Dispositivo Confiável",
"added": "Adicionado:",
"ip": "IP:"
},
"removeAllDevices": {
"title": "Remover Todos os Dispositivos Confiáveis",
"description": "Isso removerá {count} dispositivo{count, plural, =1 {} other {s}} confiável{count, plural, =1 {} other {is}}. Você precisará verificar o 2FA em todos os dispositivos novamente."
},
"buttons": {
"cancel": "Cancelar",
"removing": "Removendo...",
"removeDevice": "Remover Dispositivo",
"removeAllDevices": "Remover Todos os Dispositivos"
}
}
}
},
"uploadFile": {
"title": "Enviar Arquivo",
"multipleTitle": "Enviar Múltiplos Arquivos",
@@ -1536,19 +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"
},
"iconPicker": {
"title": "Selecionar ícone",
"placeholder": "Selecione um ícone",
"searchPlaceholder": "Pesquisar ícones...",
"loadingMore": "Carregando mais ícones...",
"allIconsLoaded": "Todos os {count} ícones carregados",
"noIconsFound": "Nenhum ícone encontrado para \"{search}\"",
"tabs": {
"all": "Todos os ícones",
"popular": "Populares",
"auth": "Provedores de autenticação"
},
"stats": "{iconCount} ícones de {libraryCount} bibliotecas",
"categoryBadge": "{category} ({count} ícones)"
"qrCodeModal": {
"title": "Compartilhar QR Code",
"description": "Escaneie este código QR para acessar o link.",
"download": "Baixar QR Code"
}
}

View File

@@ -143,7 +143,8 @@
"saving": "Сохранение...",
"update": "Обновить",
"click": "Нажмите для",
"creating": "Создание..."
"creating": "Создание...",
"loadingSimple": "Загрузка..."
},
"createShare": {
"title": "Создать общий доступ",
@@ -182,7 +183,15 @@
"invalidCredentials": "Неверный адрес электронной почты или пароль",
"userNotFound": "Пользователь не найден",
"accountLocked": "Аккаунт заблокирован. Пожалуйста, попробуйте позже",
"unexpectedError": "Произошла непредвиденная ошибка. Пожалуйста, попробуйте еще раз"
"unexpectedError": "Произошла непредвиденная ошибка. Пожалуйста, попробуйте еще раз",
"Invalid password": "Неверный пароль",
"Invalid two-factor authentication code": "Неверный код двухфакторной аутентификации",
"Invalid verification code": "Неверный код подтверждения",
"Password verification required": "Требуется подтверждение пароля",
"Two-factor authentication is already enabled": "Двухфакторная аутентификация уже включена",
"Two-factor authentication is not enabled": "Двухфакторная аутентификация не включена",
"Two-factor authentication required": "Требуется двухфакторная аутентификация",
"noUserData": "Нет данных пользователя"
},
"fileActions": {
"editFile": "Редактировать файл",
@@ -304,7 +313,8 @@
"title": "Забыли пароль",
"description": "Введите адрес электронной почты, и мы отправим вам инструкции по сбросу пароля.",
"resetInstructions": "Инструкции по сбросу отправлены на вашу электронную почту",
"pageTitle": "Забыли пароль"
"pageTitle": "Забыли пароль",
"passwordAuthDisabled": "Парольная аутентификация отключена. Пожалуйста, свяжитесь с администратором или используйте внешний провайдер аутентификации."
},
"generateShareLink": {
"generateTitle": "Создать ссылку для обмена",
@@ -318,7 +328,12 @@
"copyButton": "Копировать ссылку",
"success": "Ссылка успешно создана",
"error": "Ошибка при создании ссылки",
"copied": "Ссылка скопирована в буфер обмена"
"copied": "Ссылка скопирована в буфер обмена",
"readyDescription": "Ваша ссылка для обмена готова. Вы можете сканировать QR-код напрямую, скачать его для последующего использования или скопировать ссылку ниже.",
"tabs": {
"link": "Ссылка",
"qrcode": "QR-код"
}
},
"home": {
"description": "Открытая альтернатива WeTransfer. Делитесь файлами безопасно, без отслеживания и ограничений.",
@@ -331,6 +346,27 @@
},
"pageTitle": "Главная"
},
"iconPicker": {
"title": "Выбрать иконку",
"placeholder": "Выберите иконку",
"searchPlaceholder": "Поиск иконок...",
"loadingMore": "Загрузка дополнительных иконок...",
"allIconsLoaded": "Загружено все {count} иконок",
"noIconsFound": "Не найдено иконок для \"{search}\"",
"tabs": {
"all": "Все иконки",
"popular": "Популярные",
"auth": "Провайдеры аутентификации"
},
"stats": "{iconCount} иконок из {libraryCount} библиотек",
"categoryBadge": "{category} ({count} иконок)"
},
"imageEdit": {
"title": "Редактировать изображение",
"rotate": "Повернуть",
"zoom": "Увеличить",
"cropInstructions": "Перетащите, чтобы переместить, измените размер углов, чтобы отрегулировать область обрезки"
},
"login": {
"welcome": "Добро пожаловать в",
"signInToContinue": "Войдите, чтобы продолжить",
@@ -589,7 +625,8 @@
"createLink": "Создать ссылку",
"delete": "Удалить",
"copyLinkTitle": "Копировать ссылку",
"createLinkCTA": "Создать ссылку для получения"
"createLinkCTA": "Создать ссылку для получения",
"viewQrCode": "Просмотр QR-кода"
},
"status": {
"active": "Активно",
@@ -605,7 +642,8 @@
"viewDetails": "Просмотр деталей",
"edit": "Редактировать",
"delete": "Удалить",
"viewFiles": "Полученные файлы"
"viewFiles": "Полученные файлы",
"viewQrCode": "Просмотр QR-кода"
},
"empty": {
"title": "Нет созданных ссылок для получения",
@@ -746,7 +784,12 @@
"selectAll": "Выбрать все",
"selectFile": "Выбрать файл {fileName}",
"deleteError": "Ошибка при удалении файла",
"deleteSuccess": "Файл успешно удален"
"deleteSuccess": "Файл успешно удален",
"copyErrors": {
"timeout": "Время операции копирования истекло. Пожалуйста, попробуйте еще раз с файлом меньшего размера или проверьте подключение.",
"failed": "Ошибка операции копирования. Пожалуйста, попробуйте еще раз.",
"aborted": "Операция копирования была отменена из-за истечения времени ожидания."
}
}
},
"form": {
@@ -897,7 +940,8 @@
"fileList": {
"title": "Выбранные файлы:",
"statusUploaded": "Загружено",
"statusError": "Ошибка"
"statusError": "Ошибка",
"retry": "Повторить"
},
"form": {
"nameLabel": "Имя",
@@ -1085,6 +1129,10 @@
"smtpTrustSelfSigned": {
"title": "Доверять самоподписанным сертификатам",
"description": "Включите это для доверия самоподписанным SSL/TLS сертификатам (полезно для сред разработки)"
},
"passwordAuthEnabled": {
"title": "Парольная аутентификация",
"description": "Включить или отключить парольную аутентификацию"
}
},
"buttons": {
@@ -1094,7 +1142,8 @@
},
"errors": {
"loadFailed": "Ошибка загрузки настроек",
"updateFailed": "Ошибка обновления настроек"
"updateFailed": "Ошибка обновления настроек",
"passwordAuthRequiresProvider": "Парольную аутентификацию нельзя отключить, если нет хотя бы одного активного поставщика аутентификации"
},
"messages": {
"noChanges": "Изменений для сохранения нет",
@@ -1200,7 +1249,10 @@
"invalidDate": "Неверная дата",
"loadError": "Ошибка загрузки деталей общего доступа",
"editSecurity": "Изменить безопасность",
"editExpiration": "Изменить срок действия"
"editExpiration": "Изменить срок действия",
"clickToEnlargeQrCode": "Нажмите, чтобы увеличить QR-код",
"downloadQrCode": "Скачать QR-код",
"qrCode": "QR-код"
},
"shareExpiration": {
"neverExpires": "Никогда не истекает",
@@ -1387,7 +1439,8 @@
"copyLink": "Скопировать Ссылку",
"notifyRecipients": "Уведомить Получателей",
"delete": "Удалить",
"downloadShareFiles": "Загрузите все файлы"
"downloadShareFiles": "Загрузите все файлы",
"viewQrCode": "Просмотр QR-кода"
},
"bulkActions": {
"delete": "Удалить",
@@ -1419,6 +1472,160 @@
"dark": "Тёмная",
"system": "Системная"
},
"twoFactor": {
"title": "Двухфакторная аутентификация",
"description": "Добавьте дополнительный уровень безопасности для вашей учетной записи",
"enabled": "Ваша учетная запись защищена двухфакторной аутентификацией",
"disabled": "Двухфакторная аутентификация не включена",
"setup": {
"title": "Включить двухфакторную аутентификацию",
"description": "Отсканируйте QR-код с помощью приложения-аутентификатора, затем введите код подтверждения.",
"qrCode": "QR-код",
"manualEntryKey": "Ключ для ручного ввода",
"verificationCode": "Код подтверждения",
"verificationCodePlaceholder": "Введите 6-значный код",
"verificationCodeDescription": "Введите 6-значный код из вашего приложения-аутентификатора",
"verifyAndEnable": "Проверить и включить",
"cancel": "Отмена"
},
"disable": {
"title": "Отключить двухфакторную аутентификацию",
"description": "Введите ваш пароль для подтверждения отключения двухфакторной аутентификации.",
"password": "Пароль",
"passwordPlaceholder": "Введите ваш пароль",
"confirm": "Подтвердить отключение",
"cancel": "Отмена"
},
"backupCodes": {
"title": "Резервные коды",
"description": "Сохраните эти резервные коды в безопасном месте. Вы можете использовать их для доступа к своей учетной записи, если потеряете устройство аутентификации.",
"warning": "Важно:",
"warningText": "Каждый резервный код можно использовать только один раз. Храните их в безопасности и не делитесь ими ни с кем.",
"generateNew": "Сгенерировать новые резервные коды",
"download": "Скачать резервные коды",
"copyToClipboard": "Копировать в буфер обмена",
"savedMessage": "Я сохранил мои резервные коды",
"available": "{count} резервных кодов доступно",
"instructions": [
"• Сохраните эти коды в безопасном месте",
"• Каждый резервный код можно использовать только один раз",
"• Вы можете сгенерировать новые коды в любое время"
]
},
"verification": {
"title": "Двухфакторная аутентификация",
"description": "Введите 6-значный код из вашего приложения-аутентификатора",
"backupDescription": "Введите один из ваших резервных кодов для продолжения",
"verificationCode": "Код подтверждения",
"backupCode": "Резервный код",
"verificationCodePlaceholder": "000000",
"backupCodePlaceholder": "XXXX-XXXX",
"verify": "Проверить",
"verifying": "Проверка...",
"useBackupCode": "Использовать резервный код",
"useAuthenticatorCode": "Использовать код аутентификатора",
"rememberDevice": "Запомнить это устройство на 30 дней",
"rememberDeviceDescription": "Вам не нужно будет вводить коды 2FA на этом устройстве в течение 30 дней"
},
"messages": {
"enabledSuccess": "Двухфакторная аутентификация успешно включена!",
"disabledSuccess": "Двухфакторная аутентификация успешно отключена",
"backupCodesGenerated": "Новые резервные коды успешно сгенерированы",
"backupCodesCopied": "Резервные коды скопированы в буфер обмена",
"setupFailed": "Не удалось сгенерировать настройку 2FA",
"verificationFailed": "Неверный код подтверждения",
"disableFailed": "Не удалось отключить 2FA. Пожалуйста, проверьте ваш пароль.",
"backupCodesFailed": "Не удалось сгенерировать резервные коды",
"backupCodesCopyFailed": "Не удалось скопировать резервные коды",
"statusLoadFailed": "Не удалось загрузить статус 2FA",
"enterVerificationCode": "Пожалуйста, введите код подтверждения",
"enterPassword": "Пожалуйста, введите ваш пароль",
"deviceTrusted": "Это устройство отмечено как доверенное на 30 дней"
},
"errors": {
"invalidVerificationCode": "Неверный код подтверждения",
"invalidTwoFactorCode": "Неверный код двухфакторной аутентификации",
"twoFactorRequired": "Требуется двухфакторная аутентификация",
"twoFactorAlreadyEnabled": "Двухфакторная аутентификация уже включена",
"twoFactorNotEnabled": "Двухфакторная аутентификация не включена",
"passwordVerificationRequired": "Требуется подтверждение пароля",
"invalidPassword": "Неверный пароль",
"userNotFound": "Пользователь не найден"
},
"buttons": {
"enable2FA": "Включить 2FA",
"disable2FA": "Отключить 2FA"
},
"deviceNames": {
"unknownDevice": "Неизвестное устройство",
"browsers": {
"chrome": "Chrome",
"firefox": "Firefox",
"safari": "Safari",
"edge": "Edge"
},
"platforms": {
"windows": " на Windows",
"macos": " на macOS",
"linux": " на Linux",
"iphone": " на iPhone",
"android": " на Android"
}
},
"status": {
"label": "Статус:",
"enabled": "Включено",
"disabled": "Отключено"
},
"trustedDevices": {
"title": "Доверенные устройства - 2FA",
"description": "Устройства, не требующие проверки 2FA",
"noDevices": "Нет доверенных устройств",
"deviceName": "Устройство",
"addedOn": "Добавлено",
"expiresOn": "Истекает",
"remove": "Удалить",
"removeAll": "Удалить все",
"confirmRemove": "Вы уверены, что хотите удалить это доверенное устройство?",
"confirmRemoveAll": "Вы уверены, что хотите удалить все доверенные устройства?",
"deviceRemoved": "Доверенное устройство успешно удалено",
"allDevicesRemoved": "Все доверенные устройства успешно удалены",
"loadFailed": "Не удалось загрузить доверенные устройства",
"removeFailed": "Не удалось удалить доверенное устройство",
"removeAllFailed": "Не удалось удалить все доверенные устройства",
"loading": "Загрузка доверенных устройств...",
"noDevicesDescription": "Устройства появятся здесь, когда вы решите доверять им во время проверки 2FA",
"tableHeaders": {
"device": "Устройство",
"added": "Добавлено",
"expires": "Истекает",
"lastUsed": "Последнее использование",
"ipAddress": "IP-адрес",
"actions": "Действия"
},
"status": {
"never": "Никогда",
"expired": "Истекло"
},
"modals": {
"removeDevice": {
"title": "Удалить доверенное устройство",
"added": "Добавлено:",
"ip": "IP:"
},
"removeAllDevices": {
"title": "Удалить все доверенные устройства",
"description": "Это удалит {count} доверенное устройство{count, plural, =1 {} other {s}}. Вам потребуется повторно проверять 2FA на всех устройствах."
},
"buttons": {
"cancel": "Отмена",
"removing": "Удаление...",
"removeDevice": "Удалить устройство",
"removeAllDevices": "Удалить все устройства"
}
}
}
},
"uploadFile": {
"title": "Загрузить файл",
"multipleTitle": "Загрузить Несколько Файлов",
@@ -1536,19 +1743,9 @@
"nameRequired": "Требуется имя",
"required": "Это поле обязательно"
},
"iconPicker": {
"title": "Выбрать иконку",
"placeholder": "Выберите иконку",
"searchPlaceholder": "Поиск иконок...",
"loadingMore": "Загрузка дополнительных иконок...",
"allIconsLoaded": "Загружено все {count} иконок",
"noIconsFound": "Не найдено иконок для \"{search}\"",
"tabs": {
"all": "Все иконки",
"popular": "Популярные",
"auth": "Провайдеры аутентификации"
},
"stats": "{iconCount} иконок из {libraryCount} библиотек",
"categoryBadge": "{category} ({count} иконок)"
"qrCodeModal": {
"title": "Поделиться QR-кодом",
"description": "Отсканируйте этот QR-код, чтобы получить доступ к ссылке.",
"download": "Скачать QR-код"
}
}

View File

@@ -143,7 +143,8 @@
"saving": "Kaydediliyor...",
"update": "Güncelle",
"click": "Tıklayın",
"creating": "Oluşturuluyor..."
"creating": "Oluşturuluyor...",
"loadingSimple": "Yükleniyor..."
},
"createShare": {
"title": "Paylaşım Oluştur",
@@ -182,7 +183,15 @@
"invalidCredentials": "Geçersiz e-posta veya şifre",
"userNotFound": "Kullanıcı bulunamadı",
"accountLocked": "Hesap kilitlendi. Lütfen daha sonra tekrar deneyin",
"unexpectedError": "Beklenmeyen bir hata oluştu. Lütfen tekrar deneyin"
"unexpectedError": "Beklenmeyen bir hata oluştu. Lütfen tekrar deneyin",
"Invalid password": "Geçersiz şifre",
"Invalid two-factor authentication code": "Geçersiz iki faktörlü kimlik doğrulama kodu",
"Invalid verification code": "Geçersiz doğrulama kodu",
"Password verification required": "Şifre doğrulaması gerekli",
"Two-factor authentication is already enabled": "İki faktörlü kimlik doğrulama zaten etkin",
"Two-factor authentication is not enabled": "İki faktörlü kimlik doğrulama etkin değil",
"Two-factor authentication required": "İki faktörlü kimlik doğrulama gerekli",
"noUserData": "Kullanıcı verisi yok"
},
"fileActions": {
"editFile": "Dosyayı Düzenle",
@@ -304,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",
@@ -318,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.",
@@ -331,6 +346,27 @@
},
"pageTitle": "Ana Sayfa"
},
"iconPicker": {
"title": "Simge Seç",
"placeholder": "Bir simge seç",
"searchPlaceholder": "Simgeleri ara...",
"loadingMore": "Daha fazla simge yükleniyor...",
"allIconsLoaded": "Tüm {count} simge yüklendi",
"noIconsFound": "\"{search}\" için simge bulunamadı",
"tabs": {
"all": "Tüm Simgeler",
"popular": "Popüler",
"auth": "Kimlik Doğrulama Sağlayıcıları"
},
"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",
@@ -589,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",
@@ -605,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ı",
@@ -746,7 +784,12 @@
"selectAll": "Tümünü seç",
"selectFile": "{fileName} dosyasını seç",
"deleteError": "Dosya silinirken hata oluştu",
"deleteSuccess": "Dosya başarıyla silindi"
"deleteSuccess": "Dosya başarıyla silindi",
"copyErrors": {
"timeout": "Kopyalama işlemi zaman aşımına uğradı. Lütfen daha küçük bir dosya ile tekrar deneyin veya bağlantınızı kontrol edin.",
"failed": "Kopyalama işlemi başarısız oldu. Lütfen tekrar deneyin.",
"aborted": "Kopyalama işlemi zaman aşımı nedeniyle iptal edildi."
}
}
},
"form": {
@@ -897,7 +940,8 @@
"fileList": {
"title": "Seçilen dosyalar:",
"statusUploaded": "Yüklendi",
"statusError": "Hata"
"statusError": "Hata",
"retry": "Tekrar Dene"
},
"form": {
"nameLabel": "Ad",
@@ -1085,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": {
@@ -1094,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",
@@ -1200,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",
@@ -1387,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",
@@ -1419,6 +1472,160 @@
"dark": "Koyu",
"system": "Sistem"
},
"twoFactor": {
"title": "İki Faktörlü Kimlik Doğrulama",
"description": "Hesabınıza ekstra bir güvenlik katmanı ekleyin",
"enabled": "Hesabınız iki faktörlü kimlik doğrulama ile korunuyor",
"disabled": "İki faktörlü kimlik doğrulama etkin değil",
"setup": {
"title": "İki Faktörlü Kimlik Doğrulamayı Etkinleştir",
"description": "QR kodunu kimlik doğrulayıcı uygulamanızla tarayın, ardından doğrulama kodunu girin.",
"qrCode": "QR Kodu",
"manualEntryKey": "Manuel Giriş Anahtarı",
"verificationCode": "Doğrulama Kodu",
"verificationCodePlaceholder": "6 haneli kodu girin",
"verificationCodeDescription": "Kimlik doğrulayıcı uygulamanızdan 6 haneli kodu girin",
"verifyAndEnable": "Doğrula ve Etkinleştir",
"cancel": "İptal"
},
"disable": {
"title": "İki Faktörlü Kimlik Doğrulamayı Devre Dışı Bırak",
"description": "İki faktörlü kimlik doğrulamayı devre dışı bırakmayı onaylamak için şifrenizi girin.",
"password": "Şifre",
"passwordPlaceholder": "Şifrenizi girin",
"confirm": "Devre Dışı Bırakmayı Onayla",
"cancel": "İptal"
},
"backupCodes": {
"title": "Yedek Kodlar",
"description": "Bu yedek kodları güvenli bir yerde saklayın. Kimlik doğrulayıcı cihazınızı kaybederseniz hesabınıza erişmek için bunları kullanabilirsiniz.",
"warning": "Önemli:",
"warningText": "Her yedek kod yalnızca bir kez kullanılabilir. Güvenli tutun ve kimseyle paylaşmayın.",
"generateNew": "Yeni Yedek Kodlar Oluştur",
"download": "Yedek Kodları İndir",
"copyToClipboard": "Panoya Kopyala",
"savedMessage": "Yedek Kodlarımı Kaydettim",
"available": "{count} yedek kod mevcut",
"instructions": [
"• Bu kodları güvenli bir yerde saklayın",
"• Her yedek kod yalnızca bir kez kullanılabilir",
"• İstediğiniz zaman yeni kodlar oluşturabilirsiniz"
]
},
"verification": {
"title": "İki Faktörlü Kimlik Doğrulama",
"description": "Kimlik doğrulayıcı uygulamanızdan 6 haneli kodu girin",
"backupDescription": "Devam etmek için yedek kodlarınızdan birini girin",
"verificationCode": "Doğrulama Kodu",
"backupCode": "Yedek Kod",
"verificationCodePlaceholder": "000000",
"backupCodePlaceholder": "XXXX-XXXX",
"verify": "Doğrula",
"verifying": "Doğrulanıyor...",
"useBackupCode": "Bunun yerine yedek kod kullan",
"useAuthenticatorCode": "Bunun yerine kimlik doğrulayıcı kodu kullan",
"rememberDevice": "Bu cihazı 30 gün boyunca hatırla",
"rememberDeviceDescription": "Bu cihazda 30 gün boyunca 2FA kodları girmeniz gerekmeyecek"
},
"messages": {
"enabledSuccess": "İki faktörlü kimlik doğrulama başarıyla etkinleştirildi!",
"disabledSuccess": "İki faktörlü kimlik doğrulama başarıyla devre dışı bırakıldı",
"backupCodesGenerated": "Yeni yedek kodlar başarıyla oluşturuldu",
"backupCodesCopied": "Yedek kodlar panoya kopyalandı",
"setupFailed": "2FA kurulumu oluşturulamadı",
"verificationFailed": "Geçersiz doğrulama kodu",
"disableFailed": "2FA devre dışı bırakılamadı. Lütfen şifrenizi kontrol edin.",
"backupCodesFailed": "Yedek kodlar oluşturulamadı",
"backupCodesCopyFailed": "Yedek kodlar kopyalanamadı",
"statusLoadFailed": "2FA durumu yüklenemedi",
"enterVerificationCode": "Lütfen doğrulama kodunu girin",
"enterPassword": "Lütfen şifrenizi girin",
"deviceTrusted": "Bu cihaz 30 gün boyunca güvenilir olarak işaretlendi"
},
"errors": {
"invalidVerificationCode": "Geçersiz doğrulama kodu",
"invalidTwoFactorCode": "Geçersiz iki faktörlü kimlik doğrulama kodu",
"twoFactorRequired": "İki faktörlü kimlik doğrulama gerekli",
"twoFactorAlreadyEnabled": "İki faktörlü kimlik doğrulama zaten etkin",
"twoFactorNotEnabled": "İki faktörlü kimlik doğrulama etkin değil",
"passwordVerificationRequired": "Şifre doğrulaması gerekli",
"invalidPassword": "Geçersiz şifre",
"userNotFound": "Kullanıcı bulunamadı"
},
"buttons": {
"enable2FA": "2FA'yı Etkinleştir",
"disable2FA": "2FA'yı Devre Dışı Bırak"
},
"deviceNames": {
"unknownDevice": "Bilinmeyen Cihaz",
"browsers": {
"chrome": "Chrome",
"firefox": "Firefox",
"safari": "Safari",
"edge": "Edge"
},
"platforms": {
"windows": " Windows'ta",
"macos": " macOS'ta",
"linux": " Linux'ta",
"iphone": " iPhone'da",
"android": " Android'de"
}
},
"status": {
"label": "Durum:",
"enabled": "Etkin",
"disabled": "Devre Dışı"
},
"trustedDevices": {
"title": "Güvenilir Cihazlar - 2FA",
"description": "2FA doğrulaması gerektirmeyen cihazlar",
"noDevices": "Güvenilir cihaz yok",
"deviceName": "Cihaz",
"addedOn": "Eklenme tarihi",
"expiresOn": "Bitiş tarihi",
"remove": "Kaldır",
"removeAll": "Tümünü Kaldır",
"confirmRemove": "Bu güvenilir cihazı kaldırmak istediğinizden emin misiniz?",
"confirmRemoveAll": "Tüm güvenilir cihazları kaldırmak istediğinizden emin misiniz?",
"deviceRemoved": "Güvenilir cihaz başarıyla kaldırıldı",
"allDevicesRemoved": "Tüm güvenilir cihazlar başarıyla kaldırıldı",
"loadFailed": "Güvenilir cihazlar yüklenemedi",
"removeFailed": "Güvenilir cihaz kaldırılamadı",
"removeAllFailed": "Tüm güvenilir cihazlar kaldırılamadı",
"loading": "Güvenilir cihazlar yükleniyor...",
"noDevicesDescription": "2FA doğrulaması sırasında cihazları güvenilir olarak işaretlediğinizde burada görünecekler",
"tableHeaders": {
"device": "Cihaz",
"added": "Eklenme",
"expires": "Bitiş",
"lastUsed": "Son Kullanım",
"ipAddress": "IP Adresi",
"actions": "İşlemler"
},
"status": {
"never": "Hiç",
"expired": "Süresi Doldu"
},
"modals": {
"removeDevice": {
"title": "Güvenilir Cihazı Kaldır",
"added": "Eklenme:",
"ip": "IP:"
},
"removeAllDevices": {
"title": "Tüm Güvenilir Cihazları Kaldır",
"description": "Bu işlem {count} güvenilir cihaz{count, plural, =1 {ı} other {ı}} kaldıracak. Tüm cihazlarda 2FA doğrulaması yapmanız gerekecek."
},
"buttons": {
"cancel": "İptal",
"removing": "Kaldırılıyor...",
"removeDevice": "Cihazı Kaldır",
"removeAllDevices": "Tüm Cihazları Kaldır"
}
}
}
},
"uploadFile": {
"title": "Dosya Yükle",
"multipleTitle": "Çoklu Dosya Yükle",
@@ -1536,19 +1743,9 @@
"nameRequired": "İsim gereklidir",
"required": "Bu alan zorunludur"
},
"iconPicker": {
"title": "Simge Seç",
"placeholder": "Bir simge seç",
"searchPlaceholder": "Simgeleri ara...",
"loadingMore": "Daha fazla simge yükleniyor...",
"allIconsLoaded": "Tüm {count} simge yüklendi",
"noIconsFound": "\"{search}\" için simge bulunamadı",
"tabs": {
"all": "Tüm Simgeler",
"popular": "Popüler",
"auth": "Kimlik Doğrulama Sağlayıcıları"
},
"stats": "{libraryCount} kütüphaneden {iconCount} simge",
"categoryBadge": "{category} ({count} simge)"
"qrCodeModal": {
"title": "QR Kodu Paylaş",
"description": "Bu QR kodu tarayarak bağlantıya erişebilirsiniz.",
"download": "QR Kodu İndir"
}
}

View File

@@ -143,7 +143,8 @@
"saving": "保存中...",
"update": "更新",
"click": "点击",
"creating": "创建中..."
"creating": "创建中...",
"loadingSimple": "加载中..."
},
"createShare": {
"title": "创建分享",
@@ -182,7 +183,15 @@
"invalidCredentials": "电子邮件或密码错误",
"userNotFound": "未找到用户",
"accountLocked": "账户已锁定。请稍后再试",
"unexpectedError": "发生意外错误。请重试"
"unexpectedError": "发生意外错误。请重试",
"Invalid password": "密码无效",
"Invalid two-factor authentication code": "双重认证码无效",
"Invalid verification code": "验证码无效",
"Password verification required": "需要密码验证",
"Two-factor authentication is already enabled": "双重认证已启用",
"Two-factor authentication is not enabled": "双重认证未启用",
"Two-factor authentication required": "需要双重认证",
"noUserData": "没有用户数据"
},
"fileActions": {
"editFile": "编辑文件",
@@ -304,7 +313,8 @@
"title": "忘记密码?",
"description": "请输入您的电子邮件,我们将发送密码重置指令给您。",
"resetInstructions": "密码重置指令已发送到您的电子邮件",
"pageTitle": "忘记密码?"
"pageTitle": "忘记密码?",
"passwordAuthDisabled": "密码认证已禁用。请联系您的管理员或使用外部认证提供商。"
},
"generateShareLink": {
"generateTitle": "生成分享链接",
@@ -318,7 +328,12 @@
"copyButton": "复制链接",
"success": "链接生成成功",
"error": "链接生成失败",
"copied": "链接已复制到剪贴板"
"copied": "链接已复制到剪贴板",
"readyDescription": "您的分享链接已准备就绪:",
"tabs": {
"link": "链接",
"qrcode": "QR Code"
}
},
"home": {
"description": "WeTransfer的开源替代方案。安全分享文件无需跟踪或限制。",
@@ -331,6 +346,27 @@
},
"pageTitle": "首页"
},
"iconPicker": {
"title": "选择图标",
"placeholder": "选择一个图标",
"searchPlaceholder": "搜索图标...",
"loadingMore": "正在加载更多图标...",
"allIconsLoaded": "已加载全部 {count} 个图标",
"noIconsFound": "未找到与\"{search}\"相关的图标",
"tabs": {
"all": "所有图标",
"popular": "常用",
"auth": "认证提供商"
},
"stats": "来自 {libraryCount} 个库的 {iconCount} 个图标",
"categoryBadge": "{category}{count} 个图标)"
},
"imageEdit": {
"title": "编辑图片",
"rotate": "旋转",
"zoom": "缩放",
"cropInstructions": "拖动以重新定位,调整角落大小以调整裁剪区域"
},
"login": {
"welcome": "欢迎您",
"signInToContinue": "请登录以继续",
@@ -589,7 +625,8 @@
"createLink": "创建链接",
"delete": "删除",
"copyLinkTitle": "复制链接",
"createLinkCTA": "创建接收链接"
"createLinkCTA": "创建接收链接",
"viewQrCode": "查看QR Code"
},
"status": {
"active": "活动",
@@ -605,7 +642,8 @@
"viewDetails": "查看详情",
"edit": "编辑",
"delete": "删除",
"viewFiles": "已接收文件"
"viewFiles": "已接收文件",
"viewQrCode": "查看QR Code"
},
"empty": {
"title": "未创建接收链接",
@@ -746,7 +784,12 @@
"selectAll": "全选",
"selectFile": "选择文件 {fileName}",
"deleteError": "删除文件时出错",
"deleteSuccess": "文件已成功删除"
"deleteSuccess": "文件已成功删除",
"copyErrors": {
"timeout": "复制操作超时。请尝试使用较小的文件或检查您的连接。",
"failed": "复制操作失败。请重试。",
"aborted": "由于超时,复制操作已取消。"
}
}
},
"form": {
@@ -897,7 +940,8 @@
"fileList": {
"title": "已选择的文件:",
"statusUploaded": "已上传",
"statusError": "错误"
"statusError": "错误",
"retry": "重试"
},
"form": {
"nameLabel": "姓名",
@@ -1085,6 +1129,10 @@
"smtpTrustSelfSigned": {
"title": "信任自签名证书",
"description": "启用此选项以信任自签名SSL/TLS证书对开发环境有用"
},
"passwordAuthEnabled": {
"title": "密码认证",
"description": "启用或禁用基于密码的认证"
}
},
"buttons": {
@@ -1094,7 +1142,8 @@
},
"errors": {
"loadFailed": "加载设置失败",
"updateFailed": "更新设置失败"
"updateFailed": "更新设置失败",
"passwordAuthRequiresProvider": "没有至少一个活动认证提供者时,无法禁用密码认证"
},
"messages": {
"noChanges": "没有需要保存的更改",
@@ -1200,7 +1249,10 @@
"description": "描述",
"linkCopied": "链接已复制到剪贴板",
"editSecurity": "编辑安全",
"editExpiration": "编辑过期"
"editExpiration": "编辑过期",
"clickToEnlargeQrCode": "点击放大QR Code",
"downloadQrCode": "下载QR Code",
"qrCode": "QR Code"
},
"shareExpiration": {
"neverExpires": "永不过期",
@@ -1387,7 +1439,8 @@
"copyLink": "复制链接",
"notifyRecipients": "通知收件人",
"delete": "删除",
"downloadShareFiles": "下载所有文件"
"downloadShareFiles": "下载所有文件",
"viewQrCode": "查看QR Code"
},
"bulkActions": {
"delete": "删除",
@@ -1419,6 +1472,160 @@
"dark": "暗黑",
"system": "系统"
},
"twoFactor": {
"title": "双重认证",
"description": "为您的账户添加额外的安全保护",
"enabled": "您的账户已启用双重认证保护",
"disabled": "未启用双重认证",
"setup": {
"title": "启用双重认证",
"description": "使用您的认证器应用扫描二维码,然后输入验证码。",
"qrCode": "二维码",
"manualEntryKey": "手动输入密钥",
"verificationCode": "验证码",
"verificationCodePlaceholder": "输入6位数验证码",
"verificationCodeDescription": "输入认证器应用生成的6位数验证码",
"verifyAndEnable": "验证并启用",
"cancel": "取消"
},
"disable": {
"title": "禁用双重认证",
"description": "请输入您的密码以确认禁用双重认证。",
"password": "密码",
"passwordPlaceholder": "输入您的密码",
"confirm": "确认禁用",
"cancel": "取消"
},
"backupCodes": {
"title": "备用码",
"description": "请将这些备用码保存在安全的地方。如果您丢失了认证器设备,可以使用它们访问您的账户。",
"warning": "重要提示:",
"warningText": "每个备用码只能使用一次。请妥善保管,不要与任何人分享。",
"generateNew": "生成新的备用码",
"download": "下载备用码",
"copyToClipboard": "复制到剪贴板",
"savedMessage": "我已保存备用码",
"available": "可用备用码:{count}个",
"instructions": [
"• 将这些代码保存在安全的位置",
"• 每个备用码只能使用一次",
"• 您可以随时生成新的备用码"
]
},
"verification": {
"title": "双重认证",
"description": "请输入认证器应用生成的6位数验证码",
"backupDescription": "请输入一个备用码以继续",
"verificationCode": "验证码",
"backupCode": "备用码",
"verificationCodePlaceholder": "000000",
"backupCodePlaceholder": "XXXX-XXXX",
"verify": "验证",
"verifying": "验证中...",
"useBackupCode": "使用备用码",
"useAuthenticatorCode": "使用认证器验证码",
"rememberDevice": "在30天内记住此设备",
"rememberDeviceDescription": "在30天内您无需在此设备上输入双重认证码"
},
"messages": {
"enabledSuccess": "双重认证启用成功!",
"disabledSuccess": "双重认证已成功禁用",
"backupCodesGenerated": "新的备用码生成成功",
"backupCodesCopied": "备用码已复制到剪贴板",
"setupFailed": "生成双重认证设置失败",
"verificationFailed": "验证码无效",
"disableFailed": "禁用双重认证失败。请检查您的密码。",
"backupCodesFailed": "生成备用码失败",
"backupCodesCopyFailed": "复制备用码失败",
"statusLoadFailed": "加载双重认证状态失败",
"enterVerificationCode": "请输入验证码",
"enterPassword": "请输入您的密码",
"deviceTrusted": "此设备已被标记为30天内受信任"
},
"errors": {
"invalidVerificationCode": "验证码无效",
"invalidTwoFactorCode": "双重认证码无效",
"twoFactorRequired": "需要双重认证",
"twoFactorAlreadyEnabled": "双重认证已启用",
"twoFactorNotEnabled": "未启用双重认证",
"passwordVerificationRequired": "需要密码验证",
"invalidPassword": "密码无效",
"userNotFound": "未找到用户"
},
"buttons": {
"enable2FA": "启用双重认证",
"disable2FA": "禁用双重认证"
},
"deviceNames": {
"unknownDevice": "未知设备",
"browsers": {
"chrome": "Chrome",
"firefox": "Firefox",
"safari": "Safari",
"edge": "Edge"
},
"platforms": {
"windows": " 在 Windows 上",
"macos": " 在 macOS 上",
"linux": " 在 Linux 上",
"iphone": " 在 iPhone 上",
"android": " 在 Android 上"
}
},
"status": {
"label": "状态:",
"enabled": "已启用",
"disabled": "已禁用"
},
"trustedDevices": {
"title": "受信任设备 - 双重认证",
"description": "无需双重认证验证的设备",
"noDevices": "没有受信任的设备",
"deviceName": "设备",
"addedOn": "添加于",
"expiresOn": "过期于",
"remove": "移除",
"removeAll": "移除全部",
"confirmRemove": "您确定要移除此受信任设备吗?",
"confirmRemoveAll": "您确定要移除所有受信任设备吗?",
"deviceRemoved": "受信任设备已成功移除",
"allDevicesRemoved": "所有受信任设备已成功移除",
"loadFailed": "加载受信任设备失败",
"removeFailed": "移除受信任设备失败",
"removeAllFailed": "移除所有受信任设备失败",
"loading": "正在加载受信任设备...",
"noDevicesDescription": "当您在双重认证验证时选择信任设备时,设备将显示在此处",
"tableHeaders": {
"device": "设备",
"added": "添加时间",
"expires": "过期时间",
"lastUsed": "最后使用",
"ipAddress": "IP地址",
"actions": "操作"
},
"status": {
"never": "从未",
"expired": "已过期"
},
"modals": {
"removeDevice": {
"title": "移除受信任设备",
"added": "添加时间:",
"ip": "IP地址"
},
"removeAllDevices": {
"title": "移除所有受信任设备",
"description": "这将移除 {count} 个受信任设备。您需要在所有设备上重新进行双重认证验证。"
},
"buttons": {
"cancel": "取消",
"removing": "正在移除...",
"removeDevice": "移除设备",
"removeAllDevices": "移除所有设备"
}
}
}
},
"uploadFile": {
"title": "上传文件",
"multipleTitle": "上传多个文件",
@@ -1536,19 +1743,9 @@
"nameRequired": "名称为必填项",
"required": "此字段为必填项"
},
"iconPicker": {
"title": "选择图标",
"placeholder": "选择一个图标",
"searchPlaceholder": "搜索图标...",
"loadingMore": "正在加载更多图标...",
"allIconsLoaded": "已加载全部 {count} 个图标",
"noIconsFound": "未找到与\"{search}\"相关的图标",
"tabs": {
"all": "所有图标",
"popular": "常用",
"auth": "认证提供商"
},
"stats": "来自 {libraryCount} 个库的 {iconCount} 个图标",
"categoryBadge": "{category}{count} 个图标)"
"qrCodeModal": {
"title": "分享QR Code",
"description": "扫描此QR Code以访问链接。",
"download": "下载QR Code"
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "palmr-web",
"version": "3.1-beta",
"version": "3.1.4-beta",
"description": "Frontend for Palmr",
"private": true,
"author": "Daniel Luiz Alves <daniel@kyantech.com.br>",
@@ -21,7 +21,7 @@
"lint:fix": "eslint \"src/**/*.+(ts|tsx)\" --fix",
"format": "prettier . --write",
"format:check": "prettier . --check",
"type-check": "npx tsc --noEmit",
"type-check": "npx tsc --noEmit --skipLibCheck",
"validate": "pnpm lint && pnpm type-check",
"translations": "python3 scripts/run_translations.py all",
"translations:check": "python3 scripts/run_translations.py check",
@@ -53,6 +53,7 @@
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"framer-motion": "^12.20.1",
"input-otp": "^1.4.2",
"js-cookie": "^3.0.5",
"jszip": "^3.10.1",
"lucide-react": "^0.525.0",
@@ -61,12 +62,16 @@
"next-intl": "^4.3.1",
"next-themes": "^0.4.6",
"nookies": "^2.5.2",
"qrcode": "^1.5.4",
"react": "^19.1.0",
"react-country-flag": "^3.1.0",
"react-dom": "^19.1.0",
"react-dropzone": "^14.3.8",
"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",
"tw-animate-css": "^1.3.4",
@@ -80,6 +85,7 @@
"@tailwindcss/postcss": "4.1.11",
"@types/js-cookie": "^3.0.6",
"@types/node": "22.14.0",
"@types/qrcode": "^1.5.5",
"@types/react": "19.1.8",
"@types/react-dom": "19.1.6",
"@typescript-eslint/eslint-plugin": "8.35.1",
@@ -93,4 +99,4 @@
"tailwindcss": "4.1.11",
"typescript": "5.8.3"
}
}
}

311
apps/web/pnpm-lock.yaml generated
View File

@@ -77,6 +77,9 @@ importers:
framer-motion:
specifier: ^12.20.1
version: 12.23.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
input-otp:
specifier: ^1.4.2
version: 1.4.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
js-cookie:
specifier: ^3.0.5
version: 3.0.5
@@ -101,6 +104,9 @@ importers:
nookies:
specifier: ^2.5.2
version: 2.5.2
qrcode:
specifier: ^1.5.4
version: 1.5.4
react:
specifier: ^19.1.0
version: 19.1.0
@@ -119,6 +125,15 @@ importers:
react-icons:
specifier: ^5.5.0
version: 5.5.0(react@19.1.0)
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)
sonner:
specifier: ^2.0.5
version: 2.0.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
@@ -153,6 +168,9 @@ importers:
'@types/node':
specifier: 22.14.0
version: 22.14.0
'@types/qrcode':
specifier: ^1.5.5
version: 1.5.5
'@types/react':
specifier: 19.1.8
version: 19.1.8
@@ -1163,6 +1181,9 @@ packages:
'@types/node@22.14.0':
resolution: {integrity: sha512-Kmpl+z84ILoG+3T/zQFyAJsU6EPTmOCj8/2+83fSN6djd6I4o7uOuGIH6vq3PrjY5BGitSbFuMN18j3iknubbA==}
'@types/qrcode@1.5.5':
resolution: {integrity: sha512-CdfBi/e3Qk+3Z/fXYShipBT13OJ2fDO2Q2w5CIP5anLTLIndQG9z6P1cnm+8zCWSpm5dnxMFd/uREtb0EXuQzg==}
'@types/react-dom@19.1.6':
resolution: {integrity: sha512-4hOiT/dwO8Ko0gV1m/TJZYk3y0KBnY9vzDh7W+DH17b2HFSOGgdj33dhihPeuy3l0q23+4e+hoXHV6hCC4dCXw==}
peerDependencies:
@@ -1332,6 +1353,18 @@ packages:
cpu: [x64]
os: [win32]
'@zxing/browser@0.0.7':
resolution: {integrity: sha512-AepzMgDnD6EjxewqmXpHJsi4S3Gw9ilZJLIbTf6fWuWySEcHBodnGu3p7FWlgq1Sd5QyfPhTum5z3CBkkhMVng==}
peerDependencies:
'@zxing/library': ^0.18.3
'@zxing/library@0.18.6':
resolution: {integrity: sha512-bulZ9JHoLFd9W36pi+7e7DnEYNJhljYjZ1UTsKPOoLMU3qtC+REHITeCRNx40zTRJZx18W5TBRXt5pq2Uopjsw==}
engines: {node: '>= 10.4.0'}
'@zxing/text-encoding@0.9.0':
resolution: {integrity: sha512-U/4aVJ2mxI0aDNI8Uq0wEhMgY+u4CNtEb0om3+y3+niDAsoTCOB33UF0sxpzqzdqXLqmvc+vZyAt4O8pPdfkwA==}
acorn-jsx@5.3.2:
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
peerDependencies:
@@ -1345,6 +1378,10 @@ packages:
ajv@6.12.6:
resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==}
ansi-regex@5.0.1:
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
engines: {node: '>=8'}
ansi-styles@4.3.0:
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
engines: {node: '>=8'}
@@ -1454,6 +1491,10 @@ packages:
resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
engines: {node: '>=6'}
camelcase@5.3.1:
resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==}
engines: {node: '>=6'}
caniuse-lite@1.0.30001727:
resolution: {integrity: sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q==}
@@ -1471,6 +1512,9 @@ packages:
client-only@0.0.1:
resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
cliui@6.0.0:
resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==}
clsx@2.1.1:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'}
@@ -1548,6 +1592,10 @@ packages:
supports-color:
optional: true
decamelize@1.2.0:
resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
engines: {node: '>=0.10.0'}
decimal.js@10.6.0:
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
@@ -1573,6 +1621,9 @@ packages:
detect-node-es@1.1.0:
resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==}
dijkstrajs@1.0.3:
resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==}
doctrine@2.1.0:
resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==}
engines: {node: '>=0.10.0'}
@@ -1581,6 +1632,9 @@ packages:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
emoji-regex@8.0.0:
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
emoji-regex@9.2.2:
resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==}
@@ -1803,6 +1857,10 @@ packages:
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
engines: {node: '>=8'}
find-up@4.1.0:
resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==}
engines: {node: '>=8'}
find-up@5.0.0:
resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
engines: {node: '>=10'}
@@ -1845,6 +1903,11 @@ packages:
react-dom:
optional: true
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
function-bind@1.1.2:
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
@@ -1855,6 +1918,10 @@ packages:
functions-have-names@1.2.3:
resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==}
get-caller-file@2.0.5:
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
engines: {node: 6.* || 8.* || >= 10.*}
get-intrinsic@1.3.0:
resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
engines: {node: '>= 0.4'}
@@ -1949,6 +2016,12 @@ packages:
inherits@2.0.4:
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
input-otp@1.4.2:
resolution: {integrity: sha512-l3jWwYNvrEa6NTCt7BECfCm48GvwuZzkoeG3gBL2w4CHeOXW3eKFmf9UNYkNfYc3mxMrthMnxjIE07MT0zLBQA==}
peerDependencies:
react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc
internal-slot@1.1.0:
resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
engines: {node: '>= 0.4'}
@@ -2002,6 +2075,10 @@ packages:
resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==}
engines: {node: '>= 0.4'}
is-fullwidth-code-point@3.0.0:
resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==}
engines: {node: '>=8'}
is-generator-function@1.1.0:
resolution: {integrity: sha512-nPUB5km40q9e8UfN/Zc24eLlzdSf9OfKByBw9CIdw4H1giPMeA0OIJvbchsCu4npfI2QcMVBsGEBHKZ7wLTWmQ==}
engines: {node: '>= 0.4'}
@@ -2196,6 +2273,10 @@ packages:
resolution: {integrity: sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==}
engines: {node: '>= 12.0.0'}
locate-path@5.0.0:
resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==}
engines: {node: '>=8'}
locate-path@6.0.0:
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
engines: {node: '>=10'}
@@ -2369,14 +2450,26 @@ packages:
resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==}
engines: {node: '>= 0.4'}
p-limit@2.3.0:
resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==}
engines: {node: '>=6'}
p-limit@3.1.0:
resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
engines: {node: '>=10'}
p-locate@4.1.0:
resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==}
engines: {node: '>=8'}
p-locate@5.0.0:
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
engines: {node: '>=10'}
p-try@2.2.0:
resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==}
engines: {node: '>=6'}
pako@1.0.11:
resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
@@ -2406,6 +2499,10 @@ packages:
resolution: {integrity: sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==}
engines: {node: '>=12'}
pngjs@5.0.0:
resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==}
engines: {node: '>=10.13.0'}
possible-typed-array-names@1.1.0:
resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
engines: {node: '>= 0.4'}
@@ -2450,6 +2547,14 @@ 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'}
hasBin: true
queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
@@ -2484,9 +2589,25 @@ packages:
peerDependencies:
react: '*'
react-image-crop@11.0.10:
resolution: {integrity: sha512-+5FfDXUgYLLqBh1Y/uQhIycpHCbXkI50a+nbfkB1C0xXXUTwkisHDo2QCB1SQJyHCqIuia4FeyReqXuMDKWQTQ==}
peerDependencies:
react: '>=16.13.1'
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:
react: ^16.8.0 || ^17.0.0
react-dom: ^16.8.0 || ^17.0.0
react-redux@9.2.0:
resolution: {integrity: sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==}
peerDependencies:
@@ -2547,6 +2668,13 @@ packages:
resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==}
engines: {node: '>= 0.4'}
require-directory@2.1.1:
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
engines: {node: '>=0.10.0'}
require-main-filename@2.0.0:
resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==}
resolve-from@4.0.0:
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
engines: {node: '>=4'}
@@ -2567,6 +2695,11 @@ packages:
resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
rollup@2.79.2:
resolution: {integrity: sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==}
engines: {node: '>=10.0.0'}
hasBin: true
run-parallel@1.2.0:
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
@@ -2597,6 +2730,9 @@ packages:
engines: {node: '>=10'}
hasBin: true
set-blocking@2.0.0:
resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==}
set-cookie-parser@2.7.1:
resolution: {integrity: sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==}
@@ -2667,6 +2803,10 @@ packages:
resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==}
engines: {node: '>=10.0.0'}
string-width@4.2.3:
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
engines: {node: '>=8'}
string.prototype.includes@2.0.1:
resolution: {integrity: sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==}
engines: {node: '>= 0.4'}
@@ -2693,6 +2833,10 @@ packages:
string_decoder@1.1.1:
resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==}
strip-ansi@6.0.1:
resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
engines: {node: '>=8'}
strip-bom@3.0.0:
resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==}
engines: {node: '>=4'}
@@ -2757,6 +2901,10 @@ packages:
peerDependencies:
typescript: '>=4.8.4'
ts-custom-error@3.3.1:
resolution: {integrity: sha512-5OX1tzOjxWEgsr/YEUWSuPrQ00deKLh6D7OTWcvNHm12/7QPyRh8SYpyWvA4IZv8H/+GQWQEh/kwo95Q9OVW1A==}
engines: {node: '>=14.0.0'}
tsconfig-paths@3.15.0:
resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==}
@@ -2849,6 +2997,9 @@ packages:
resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==}
engines: {node: '>= 0.4'}
which-module@2.0.1:
resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==}
which-typed-array@1.1.19:
resolution: {integrity: sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==}
engines: {node: '>= 0.4'}
@@ -2862,10 +3013,25 @@ packages:
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
engines: {node: '>=0.10.0'}
wrap-ansi@6.2.0:
resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
engines: {node: '>=8'}
y18n@4.0.3:
resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==}
yallist@5.0.0:
resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==}
engines: {node: '>=18'}
yargs-parser@18.1.3:
resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==}
engines: {node: '>=6'}
yargs@15.4.1:
resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==}
engines: {node: '>=8'}
yocto-queue@0.1.0:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'}
@@ -3811,6 +3977,10 @@ snapshots:
dependencies:
undici-types: 6.21.0
'@types/qrcode@1.5.5':
dependencies:
'@types/node': 22.14.0
'@types/react-dom@19.1.6(@types/react@19.1.8)':
dependencies:
'@types/react': 19.1.8
@@ -3978,6 +4148,21 @@ snapshots:
'@unrs/resolver-binding-win32-x64-msvc@1.11.0':
optional: true
'@zxing/browser@0.0.7(@zxing/library@0.18.6)':
dependencies:
'@zxing/library': 0.18.6
optionalDependencies:
'@zxing/text-encoding': 0.9.0
'@zxing/library@0.18.6':
dependencies:
ts-custom-error: 3.3.1
optionalDependencies:
'@zxing/text-encoding': 0.9.0
'@zxing/text-encoding@0.9.0':
optional: true
acorn-jsx@5.3.2(acorn@8.15.0):
dependencies:
acorn: 8.15.0
@@ -3991,6 +4176,8 @@ snapshots:
json-schema-traverse: 0.4.1
uri-js: 4.4.1
ansi-regex@5.0.1: {}
ansi-styles@4.3.0:
dependencies:
color-convert: 2.0.1
@@ -4132,6 +4319,8 @@ snapshots:
callsites@3.1.0: {}
camelcase@5.3.1: {}
caniuse-lite@1.0.30001727: {}
chalk@4.1.2:
@@ -4147,6 +4336,12 @@ snapshots:
client-only@0.0.1: {}
cliui@6.0.0:
dependencies:
string-width: 4.2.3
strip-ansi: 6.0.1
wrap-ansi: 6.2.0
clsx@2.1.1: {}
color-convert@2.0.1:
@@ -4219,6 +4414,8 @@ snapshots:
dependencies:
ms: 2.1.3
decamelize@1.2.0: {}
decimal.js@10.6.0: {}
deep-is@0.1.4: {}
@@ -4241,6 +4438,8 @@ snapshots:
detect-node-es@1.1.0: {}
dijkstrajs@1.0.3: {}
doctrine@2.1.0:
dependencies:
esutils: 2.0.3
@@ -4251,6 +4450,8 @@ snapshots:
es-errors: 1.3.0
gopd: 1.2.0
emoji-regex@8.0.0: {}
emoji-regex@9.2.2: {}
enhanced-resolve@5.18.2:
@@ -4615,6 +4816,11 @@ snapshots:
dependencies:
to-regex-range: 5.0.1
find-up@4.1.0:
dependencies:
locate-path: 5.0.0
path-exists: 4.0.0
find-up@5.0.0:
dependencies:
locate-path: 6.0.0
@@ -4650,6 +4856,9 @@ snapshots:
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
fsevents@2.3.3:
optional: true
function-bind@1.1.2: {}
function.prototype.name@1.1.8:
@@ -4663,6 +4872,8 @@ snapshots:
functions-have-names@1.2.3: {}
get-caller-file@2.0.5: {}
get-intrinsic@1.3.0:
dependencies:
call-bind-apply-helpers: 1.0.2
@@ -4751,6 +4962,11 @@ snapshots:
inherits@2.0.4: {}
input-otp@1.4.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
dependencies:
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
internal-slot@1.1.0:
dependencies:
es-errors: 1.3.0
@@ -4817,6 +5033,8 @@ snapshots:
dependencies:
call-bound: 1.0.4
is-fullwidth-code-point@3.0.0: {}
is-generator-function@1.1.0:
dependencies:
call-bound: 1.0.4
@@ -4993,6 +5211,10 @@ snapshots:
lightningcss-win32-arm64-msvc: 1.30.1
lightningcss-win32-x64-msvc: 1.30.1
locate-path@5.0.0:
dependencies:
p-locate: 4.1.0
locate-path@6.0.0:
dependencies:
p-locate: 5.0.0
@@ -5164,14 +5386,24 @@ snapshots:
object-keys: 1.1.1
safe-push-apply: 1.0.0
p-limit@2.3.0:
dependencies:
p-try: 2.2.0
p-limit@3.1.0:
dependencies:
yocto-queue: 0.1.0
p-locate@4.1.0:
dependencies:
p-limit: 2.3.0
p-locate@5.0.0:
dependencies:
p-limit: 3.1.0
p-try@2.2.0: {}
pako@1.0.11: {}
parent-module@1.0.1:
@@ -5190,6 +5422,8 @@ snapshots:
picomatch@4.0.2: {}
pngjs@5.0.0: {}
possible-typed-array-names@1.1.0: {}
postcss@8.4.31:
@@ -5228,6 +5462,14 @@ snapshots:
punycode@2.3.1: {}
qr.js@0.0.0: {}
qrcode@1.5.4:
dependencies:
dijkstrajs: 1.0.3
pngjs: 5.0.0
yargs: 15.4.1
queue-microtask@1.2.3: {}
raf-schd@4.0.3: {}
@@ -5256,8 +5498,26 @@ snapshots:
dependencies:
react: 19.1.0
react-image-crop@11.0.10(react@19.1.0):
dependencies:
react: 19.1.0
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)
'@zxing/library': 0.18.6
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
rollup: 2.79.2
react-redux@9.2.0(@types/react@19.1.8)(react@19.1.0)(redux@5.0.1):
dependencies:
'@types/use-sync-external-store': 0.0.6
@@ -5328,6 +5588,10 @@ snapshots:
gopd: 1.2.0
set-function-name: 2.0.2
require-directory@2.1.1: {}
require-main-filename@2.0.0: {}
resolve-from@4.0.0: {}
resolve-pkg-maps@1.0.0: {}
@@ -5346,6 +5610,10 @@ snapshots:
reusify@1.1.0: {}
rollup@2.79.2:
optionalDependencies:
fsevents: 2.3.3
run-parallel@1.2.0:
dependencies:
queue-microtask: 1.2.3
@@ -5377,6 +5645,8 @@ snapshots:
semver@7.7.2: {}
set-blocking@2.0.0: {}
set-cookie-parser@2.7.1: {}
set-function-length@1.2.2:
@@ -5487,6 +5757,12 @@ snapshots:
streamsearch@1.1.0: {}
string-width@4.2.3:
dependencies:
emoji-regex: 8.0.0
is-fullwidth-code-point: 3.0.0
strip-ansi: 6.0.1
string.prototype.includes@2.0.1:
dependencies:
call-bind: 1.0.8
@@ -5541,6 +5817,10 @@ snapshots:
dependencies:
safe-buffer: 5.1.2
strip-ansi@6.0.1:
dependencies:
ansi-regex: 5.0.1
strip-bom@3.0.0: {}
strip-json-comments@3.1.1: {}
@@ -5590,6 +5870,8 @@ snapshots:
dependencies:
typescript: 5.8.3
ts-custom-error@3.3.1: {}
tsconfig-paths@3.15.0:
dependencies:
'@types/json5': 0.0.29
@@ -5736,6 +6018,8 @@ snapshots:
is-weakmap: 2.0.2
is-weakset: 2.0.4
which-module@2.0.1: {}
which-typed-array@1.1.19:
dependencies:
available-typed-arrays: 1.0.7
@@ -5752,8 +6036,35 @@ snapshots:
word-wrap@1.2.5: {}
wrap-ansi@6.2.0:
dependencies:
ansi-styles: 4.3.0
string-width: 4.2.3
strip-ansi: 6.0.1
y18n@4.0.3: {}
yallist@5.0.0: {}
yargs-parser@18.1.3:
dependencies:
camelcase: 5.3.1
decamelize: 1.2.0
yargs@15.4.1:
dependencies:
cliui: 6.0.0
decamelize: 1.2.0
find-up: 4.1.0
get-caller-file: 2.0.5
require-directory: 2.1.1
require-main-filename: 2.0.0
set-blocking: 2.0.0
string-width: 4.2.3
which-module: 2.0.1
y18n: 4.0.3
yargs-parser: 18.1.3
yocto-queue@0.1.0: {}
zod@3.25.74: {}

View File

@@ -14,6 +14,8 @@ 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";
import { FileUploadSectionProps, FileWithProgress } from "../types";
@@ -24,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;
@@ -138,17 +155,35 @@ export function FileUploadSection({ reverseShare, password, alias, onUploadSucce
presignedUrl: string,
onProgress?: (progress: number) => void
): Promise<void> => {
await axios.put(presignedUrl, file, {
headers: {
"Content-Type": file.type,
},
onUploadProgress: (progressEvent) => {
if (onProgress && progressEvent.total) {
const progress = (progressEvent.loaded / progressEvent.total) * 100;
onProgress(Math.round(progress));
}
},
});
const shouldUseChunked = ChunkedUploader.shouldUseChunkedUpload(file.size, isS3Enabled ?? undefined);
if (shouldUseChunked) {
const chunkSize = ChunkedUploader.calculateOptimalChunkSize(file.size);
const result = await ChunkedUploader.uploadFile({
file,
url: presignedUrl,
chunkSize,
isS3Enabled: isS3Enabled ?? undefined,
onProgress,
});
if (!result.success) {
throw new Error(result.error || "Chunked upload failed");
}
} else {
await axios.put(presignedUrl, file, {
headers: {
"Content-Type": file.type,
},
onUploadProgress: (progressEvent) => {
if (onProgress && progressEvent.total) {
const progress = (progressEvent.loaded / progressEvent.total) * 100;
onProgress(Math.round(progress));
}
},
});
}
};
const registerUploadedFile = async (file: File, objectName: string): Promise<void> => {
@@ -195,7 +230,6 @@ export function FileUploadSection({ reverseShare, password, alias, onUploadSucce
updateFileStatus(index, { status: FILE_STATUS.SUCCESS });
} catch (error: any) {
console.error("Upload error:", error);
const errorMessage = error.response?.data?.error || t("reverseShares.upload.errors.uploadFailed");
updateFileStatus(index, {
@@ -213,7 +247,6 @@ export function FileUploadSection({ reverseShare, password, alias, onUploadSucce
return false;
}
// Check if either name or email is required
const nameRequired = reverseShare.nameFieldRequired === "REQUIRED";
const emailRequired = reverseShare.emailFieldRequired === "REQUIRED";
@@ -227,9 +260,6 @@ export function FileUploadSection({ reverseShare, password, alias, onUploadSucce
return false;
}
// Remove the validation that requires at least one field when both are optional
// When both fields are OPTIONAL, they should be truly optional (can be empty)
return true;
};
@@ -275,8 +305,6 @@ export function FileUploadSection({ reverseShare, password, alias, onUploadSucce
if (emailRequired && !uploaderEmail.trim()) return false;
// When both fields are OPTIONAL, they should be truly optional (can be empty)
// Remove the check that requires at least one field to be filled
return true;
};

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

@@ -460,7 +460,6 @@ export function ReceivedFilesModal({
const { editingFile, editValue, setEditValue, inputRef, startEdit, cancelEdit } = useFileEdit();
// Clear selections when files change
useEffect(() => {
setSelectedFiles(new Set());
}, [reverseShare?.files]);
@@ -558,16 +557,21 @@ export function ReceivedFilesModal({
} catch (error: any) {
console.error("Error copying file:", error);
if (error.response?.data?.error) {
const errorMessage = error.response.data.error;
if (errorMessage.includes("File size exceeds") || errorMessage.includes("Insufficient storage")) {
toast.error(errorMessage);
} else {
toast.error(t("reverseShares.modals.receivedFiles.copyError"));
let errorMessage = t("reverseShares.modals.receivedFiles.copyError");
if (error.message?.includes("timeout") || error.code === "UND_ERR_SOCKET") {
errorMessage = t("reverseShares.modals.receivedFiles.copyErrors.timeout");
} else if (error.response?.data?.error) {
const serverError = error.response.data.error;
if (serverError.includes("File size exceeds") || serverError.includes("Insufficient storage")) {
errorMessage = serverError;
} else if (serverError.includes("Copy operation failed")) {
errorMessage = t("reverseShares.modals.receivedFiles.copyErrors.failed");
}
} else {
toast.error(t("reverseShares.modals.receivedFiles.copyError"));
} else if (error.name === "AbortError") {
errorMessage = t("reverseShares.modals.receivedFiles.copyErrors.aborted");
}
toast.error(errorMessage);
} finally {
setCopyingFile(null);
}
@@ -651,7 +655,6 @@ export function ReceivedFilesModal({
document.body.removeChild(a);
URL.revokeObjectURL(url);
// Clear selections after successful download
setSelectedFiles(new Set());
})(),
{
@@ -684,7 +687,6 @@ export function ReceivedFilesModal({
await Promise.all(copyPromises);
// Clear selections after successful copy
setSelectedFiles(new Set());
} finally {
setBulkCopying(false);
@@ -732,7 +734,6 @@ export function ReceivedFilesModal({
await Promise.all(deletePromises);
// Clear selections and refresh data
setSelectedFiles(new Set());
setFilesToDeleteBulk([]);
if (onRefresh) {

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,9 +110,7 @@ export function ReverseSharesModals({
onCopyLink={onCopyLink}
onUpdatePassword={onUpdatePassword}
onToggleActive={onToggleActive}
onSuccess={() => {
console.log("Operation completed successfully - data updated");
}}
onViewQrCode={onViewQrCode}
/>
<ReceivedFilesModal
@@ -115,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,37 @@
import { NextRequest, NextResponse } from "next/server";
const API_BASE_URL = process.env.API_BASE_URL || "http://localhost:3333";
export async function POST(req: NextRequest) {
try {
const cookieHeader = req.headers.get("cookie");
const url = `${API_BASE_URL}/auth/2fa/backup-codes`;
const apiRes = await fetch(url, {
method: "POST",
headers: {
cookie: cookieHeader || "",
...Object.fromEntries(Array.from(req.headers.entries()).filter(([key]) => key.startsWith("authorization"))),
},
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;
} catch (error) {
console.error("Error proxying 2FA backup codes request:", error);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}

View File

@@ -0,0 +1,40 @@
import { NextRequest, NextResponse } from "next/server";
const API_BASE_URL = process.env.API_BASE_URL || "http://localhost:3333";
export async function POST(req: NextRequest) {
try {
const body = await req.text();
const cookieHeader = req.headers.get("cookie");
const url = `${API_BASE_URL}/auth/2fa/disable`;
const apiRes = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
cookie: cookieHeader || "",
...Object.fromEntries(Array.from(req.headers.entries()).filter(([key]) => key.startsWith("authorization"))),
},
body,
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;
} catch (error) {
console.error("Error proxying 2FA disable request:", error);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}

View File

@@ -0,0 +1,44 @@
import { NextRequest, NextResponse } from "next/server";
import { getClientHeaders } from "@/lib/proxy-utils";
const API_BASE_URL = process.env.API_BASE_URL || "http://localhost:3333";
export async function POST(req: NextRequest) {
try {
const body = await req.text();
const cookieHeader = req.headers.get("cookie");
const url = `${API_BASE_URL}/auth/2fa/login`;
const clientHeaders = getClientHeaders(req);
const apiRes = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
cookie: cookieHeader || "",
...clientHeaders,
...Object.fromEntries(Array.from(req.headers.entries()).filter(([key]) => key.startsWith("authorization"))),
},
body,
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;
} catch (error) {
console.error("Error proxying 2FA login request:", error);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}

View File

@@ -0,0 +1,40 @@
import { NextRequest, NextResponse } from "next/server";
const API_BASE_URL = process.env.API_BASE_URL || "http://localhost:3333";
export async function POST(req: NextRequest) {
try {
const body = await req.text();
const cookieHeader = req.headers.get("cookie");
const url = `${API_BASE_URL}/auth/2fa/setup`;
const apiRes = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
cookie: cookieHeader || "",
...Object.fromEntries(Array.from(req.headers.entries()).filter(([key]) => key.startsWith("authorization"))),
},
body: body || "{}",
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;
} catch (error) {
console.error("Error proxying 2FA setup request:", error);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}

View File

@@ -0,0 +1,37 @@
import { NextRequest, NextResponse } from "next/server";
const API_BASE_URL = process.env.API_BASE_URL || "http://localhost:3333";
export async function GET(req: NextRequest) {
try {
const cookieHeader = req.headers.get("cookie");
const url = `${API_BASE_URL}/auth/2fa/status`;
const apiRes = await fetch(url, {
method: "GET",
headers: {
cookie: cookieHeader || "",
...Object.fromEntries(Array.from(req.headers.entries()).filter(([key]) => key.startsWith("authorization"))),
},
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;
} catch (error) {
console.error("Error proxying 2FA status request:", error);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}

View File

@@ -0,0 +1,40 @@
import { NextRequest, NextResponse } from "next/server";
const API_BASE_URL = process.env.API_BASE_URL || "http://localhost:3333";
export async function POST(req: NextRequest) {
try {
const body = await req.text();
const cookieHeader = req.headers.get("cookie");
const url = `${API_BASE_URL}/auth/2fa/verify-setup`;
const apiRes = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
cookie: cookieHeader || "",
...Object.fromEntries(Array.from(req.headers.entries()).filter(([key]) => key.startsWith("authorization"))),
},
body,
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;
} catch (error) {
console.error("Error proxying 2FA verify setup request:", error);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}

View File

@@ -0,0 +1,40 @@
import { NextRequest, NextResponse } from "next/server";
const API_BASE_URL = process.env.API_BASE_URL || "http://localhost:3333";
export async function POST(req: NextRequest) {
try {
const body = await req.text();
const cookieHeader = req.headers.get("cookie");
const url = `${API_BASE_URL}/auth/2fa/verify`;
const apiRes = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
cookie: cookieHeader || "",
...Object.fromEntries(Array.from(req.headers.entries()).filter(([key]) => key.startsWith("authorization"))),
},
body,
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;
} catch (error) {
console.error("Error proxying 2FA verify request:", error);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}

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

@@ -1,15 +1,21 @@
import { NextRequest, NextResponse } from "next/server";
import { getClientHeaders } from "@/lib/proxy-utils";
const API_BASE_URL = process.env.API_BASE_URL || "http://localhost:3333";
export async function POST(req: NextRequest) {
const body = await req.text();
const url = `${API_BASE_URL}/auth/login`;
// Get real client IP and user agent headers
const clientHeaders = getClientHeaders(req);
const apiRes = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
...clientHeaders,
},
body,
redirect: "manual",

View File

@@ -0,0 +1,38 @@
import { NextRequest, NextResponse } from "next/server";
import { getClientHeaders } from "@/lib/proxy-utils";
const API_BASE_URL = process.env.API_BASE_URL || "http://localhost:3333";
export async function DELETE(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const { id } = await params;
const deleteUrl = `${API_BASE_URL}/auth/trusted-devices/${id}`;
const clientHeaders = getClientHeaders(request);
const apiRes = await fetch(deleteUrl, {
method: "DELETE",
headers: {
...clientHeaders,
...Object.fromEntries(
Array.from(request.headers.entries()).filter(
([key]) => key.startsWith("authorization") || key.startsWith("cookie")
)
),
},
});
const data = await apiRes.json();
return NextResponse.json(data, {
status: apiRes.status,
headers: {
"Content-Type": "application/json",
},
});
} catch (error) {
console.error("Error proxying trusted device delete:", error);
return NextResponse.json({ success: false, error: "Internal server error" }, { status: 500 });
}
}

Some files were not shown because too many files have changed in this diff Show More