Compare commits
48 Commits
v3.1.0-bet
...
v3.1.3-bet
Author | SHA1 | Date | |
---|---|---|---|
|
25fed8db61 | ||
|
de42e1ca47 | ||
|
138e20d36d | ||
|
433610286c | ||
|
236f94247a | ||
|
1a5c1de510 | ||
|
6fb55005d4 | ||
|
4779671323 | ||
|
e7876739e7 | ||
|
e699e30af3 | ||
|
7541a2b085 | ||
|
24aa605973 | ||
|
fd28445680 | ||
|
19b7448c3a | ||
|
53c39135af | ||
|
b9147038e6 | ||
|
9a0b7f5c55 | ||
|
2a5f9f03ae | ||
|
78f6e36fc9 | ||
|
8e7aadd183 | ||
|
794a2782ac | ||
|
383f26e777 | ||
|
2db88d3902 | ||
|
5e96633a1e | ||
|
6c80ad8b2a | ||
|
96bd39eb25 | ||
|
b4bf227603 | ||
|
90c0300d77 | ||
|
a5a22ca5c4 | ||
|
f1ef32b5d4 | ||
|
a4bc5ec015 | ||
|
2e56b7e59f | ||
|
5672d25bce | ||
|
edf20e6190 | ||
|
dc3da45c2d | ||
|
f3f792e053 | ||
|
ad689bd6d9 | ||
|
ffd5005c8b | ||
|
e9ae414a6e | ||
|
a3389b8b0d | ||
|
199dd9ffd4 | ||
|
233ea0da41 | ||
|
1134beb6a6 | ||
|
b26450d277 | ||
|
61255b5e19 | ||
|
e4bdfb8432 | ||
|
7f76d48314 | ||
|
4d101fbdeb |
@@ -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
|
||||
|
@@ -1,5 +0,0 @@
|
||||
|
||||
|
||||
> palmr-docs@3.1-beta lint /Users/daniel/clones/Palmr/apps/docs
|
||||
> eslint "src/**/*.+(ts|tsx)"
|
||||
|
@@ -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.
|
||||
|
@@ -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"
|
||||
}
|
||||
}
|
@@ -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)
|
@@ -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 \
|
||||
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "palmr-docs",
|
||||
"version": "3.1-beta",
|
||||
"version": "3.1.3-beta",
|
||||
"description": "Docs for Palmr",
|
||||
"private": true,
|
||||
"author": "Daniel Luiz Alves <daniel@kyantech.com.br>",
|
||||
|
BIN
apps/docs/public/assets/v3/oidc/pocket-id/config-urls.png
Normal file
After Width: | Height: | Size: 152 KiB |
After Width: | Height: | Size: 89 KiB |
BIN
apps/docs/public/assets/v3/oidc/pocket-id/create-oidc-client.png
Normal file
After Width: | Height: | Size: 140 KiB |
BIN
apps/docs/public/assets/v3/oidc/pocket-id/credentials.png
Normal file
After Width: | Height: | Size: 188 KiB |
BIN
apps/docs/public/assets/v3/oidc/pocket-id/edit-pocket-id.png
Normal file
After Width: | Height: | Size: 161 KiB |
BIN
apps/docs/public/assets/v3/oidc/pocket-id/enabled-pocket-id.png
Normal file
After Width: | Height: | Size: 149 KiB |
BIN
apps/docs/public/assets/v3/oidc/pocket-id/oidc-clients.png
Normal file
After Width: | Height: | Size: 89 KiB |
BIN
apps/docs/public/assets/v3/oidc/pocket-id/pocket-id-console.png
Normal file
After Width: | Height: | Size: 172 KiB |
BIN
apps/docs/public/assets/v3/oidc/pocket-id/pocket-id-icon.png
Normal file
After Width: | Height: | Size: 142 KiB |
After Width: | Height: | Size: 843 KiB |
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 130 KiB |
@@ -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"
|
||||
|
225
apps/docs/src/app/demo/components/demo-client.tsx
Normal 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 />;
|
||||
}
|
13
apps/docs/src/app/demo/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
@@ -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 (
|
||||
|
@@ -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",
|
||||
|
33
apps/docs/src/components/ui/background-lights.tsx
Normal 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>
|
||||
);
|
||||
}
|
@@ -1,5 +0,0 @@
|
||||
|
||||
|
||||
> palmr-api@3.1-beta lint /Users/daniel/clones/Palmr/apps/server
|
||||
> eslint "src/**/*.+(ts|tsx)"
|
||||
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "palmr-api",
|
||||
"version": "3.1-beta",
|
||||
"version": "3.1.3-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",
|
||||
|
4568
apps/server/pnpm-lock.yaml
generated
@@ -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")
|
||||
}
|
||||
|
@@ -303,6 +303,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() {
|
||||
|
@@ -24,7 +24,7 @@ export async function buildApp() {
|
||||
},
|
||||
},
|
||||
logger: {
|
||||
level: "info",
|
||||
level: "warn",
|
||||
},
|
||||
bodyLimit: 1024 * 1024 * 1024 * 1024 * 1024,
|
||||
connectionTimeout: 0,
|
||||
|
@@ -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);
|
||||
|
@@ -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,
|
||||
};
|
||||
|
@@ -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,
|
||||
|
@@ -1,16 +1,68 @@
|
||||
import { FastifyReply, FastifyRequest } from "fastify";
|
||||
|
||||
import { env } from "../../env";
|
||||
import { createResetPasswordSchema, LoginSchema, RequestPasswordResetSchema } from "./dto";
|
||||
import {
|
||||
CompleteTwoFactorLoginSchema,
|
||||
createResetPasswordSchema,
|
||||
LoginSchema,
|
||||
RequestPasswordResetSchema,
|
||||
} from "./dto";
|
||||
import { AuthService } from "./service";
|
||||
|
||||
export class AuthController {
|
||||
private authService = new AuthService();
|
||||
|
||||
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 +126,47 @@ 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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -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>;
|
||||
|
@@ -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,101 @@ 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)
|
||||
);
|
||||
}
|
||||
|
@@ -4,16 +4,20 @@ 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) {
|
||||
async login(data: LoginInput, userAgent?: string, ipAddress?: string) {
|
||||
const user = await this.userRepository.findUserByEmailOrUsername(data.emailOrUsername);
|
||||
if (!user) {
|
||||
throw new Error("Invalid credentials");
|
||||
@@ -77,6 +81,67 @@ 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);
|
||||
}
|
||||
|
||||
@@ -146,4 +211,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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
109
apps/server/src/modules/auth/trusted-device.service.ts
Normal 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(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
@@ -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"),
|
||||
|
@@ -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 },
|
||||
|
345
apps/server/src/modules/filesystem/chunk-manager.ts
Normal 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();
|
||||
}
|
||||
}
|
@@ -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 };
|
||||
@@ -135,7 +233,6 @@ export class FilesystemController {
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
@@ -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)
|
||||
);
|
||||
}
|
||||
|
@@ -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" });
|
||||
}
|
||||
|
@@ -1,5 +1,6 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
import { env } from "../../env";
|
||||
import { FileService } from "../file/service";
|
||||
import {
|
||||
CreateReverseShareInput,
|
||||
@@ -513,7 +514,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 +544,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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -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)}`
|
||||
);
|
||||
|
159
apps/server/src/modules/two-factor/controller.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
}
|
170
apps/server/src/modules/two-factor/routes.ts
Normal 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)
|
||||
);
|
||||
}
|
287
apps/server/src/modules/two-factor/service.ts
Normal 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;
|
||||
}
|
||||
}
|
@@ -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 }>();
|
||||
|
||||
@@ -66,6 +66,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 +110,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 +197,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 +214,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 +231,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 +318,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 +327,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 +345,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 +360,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 +373,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);
|
||||
}
|
||||
|
@@ -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) => {
|
||||
|
@@ -1 +1,2 @@
|
||||
API_BASE_URL=http:localhost:3333
|
||||
API_BASE_URL=http:localhost:3333
|
||||
NEXT_PUBLIC_DEFAULT_LANGUAGE=en-US
|
@@ -1,5 +0,0 @@
|
||||
|
||||
|
||||
> palmr-web@3.1-beta lint /Users/daniel/clones/Palmr/apps/web
|
||||
> eslint "src/**/*.+(ts|tsx)"
|
||||
|
@@ -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/**/*"],
|
||||
},
|
||||
];
|
||||
|
@@ -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": "تعديل الملف",
|
||||
@@ -318,7 +327,12 @@
|
||||
"copyButton": "نسخ الرابط",
|
||||
"success": "تم إنشاء الرابط بنجاح",
|
||||
"error": "فشل في إنشاء الرابط",
|
||||
"copied": "تم نسخ الرابط إلى الحافظة"
|
||||
"copied": "تم نسخ الرابط إلى الحافظة",
|
||||
"readyDescription": "رابط المشاركة الخاص بك جاهز. يمكنك مسح رمز QR مباشرة، أو تنزيله للاستخدام لاحقًا، أو نسخ الرابط أدناه.",
|
||||
"tabs": {
|
||||
"link": "الرابط",
|
||||
"qrcode": "رمز QR"
|
||||
}
|
||||
},
|
||||
"home": {
|
||||
"description": "البديل مفتوح المصدر لـ WeTransfer. شارك ملفاتك بأمان، دون تتبع أو قيود.",
|
||||
@@ -331,6 +345,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,11 +624,12 @@
|
||||
"createLink": "إنشاء رابط",
|
||||
"delete": "حذف",
|
||||
"copyLinkTitle": "نسخ الرابط",
|
||||
"createLinkCTA": "إنشاء رابط استلام"
|
||||
"createLinkCTA": "إنشاء رابط استلام",
|
||||
"viewQrCode": "عرض رمز QR"
|
||||
},
|
||||
"status": {
|
||||
"active": "نشط",
|
||||
"inactive": "غير نشط",
|
||||
"inactive": "غير نشط",
|
||||
"expired": "منتهي الصلاحية",
|
||||
"protected": "محمي",
|
||||
"public": "عام"
|
||||
@@ -605,7 +641,8 @@
|
||||
"viewDetails": "عرض التفاصيل",
|
||||
"edit": "تحرير",
|
||||
"delete": "حذف",
|
||||
"viewFiles": "الملفات المستلمة"
|
||||
"viewFiles": "الملفات المستلمة",
|
||||
"viewQrCode": "عرض رمز QR"
|
||||
},
|
||||
"empty": {
|
||||
"title": "لم يتم إنشاء روابط استلام",
|
||||
@@ -746,7 +783,12 @@
|
||||
"selectAll": "تحديد الكل",
|
||||
"selectFile": "تحديد الملف {fileName}",
|
||||
"deleteError": "خطأ في حذف الملف",
|
||||
"deleteSuccess": "تم حذف الملف بنجاح"
|
||||
"deleteSuccess": "تم حذف الملف بنجاح",
|
||||
"copyErrors": {
|
||||
"timeout": "انتهت مهلة عملية النسخ. يرجى المحاولة مرة أخرى باستخدام ملف أصغر أو التحقق من اتصالك.",
|
||||
"failed": "فشلت عملية النسخ. يرجى المحاولة مرة أخرى.",
|
||||
"aborted": "تم إلغاء عملية النسخ بسبب انتهاء المهلة."
|
||||
}
|
||||
}
|
||||
},
|
||||
"form": {
|
||||
@@ -897,7 +939,8 @@
|
||||
"fileList": {
|
||||
"title": "الملفات المختارة:",
|
||||
"statusUploaded": "تم الرفع",
|
||||
"statusError": "خطأ"
|
||||
"statusError": "خطأ",
|
||||
"retry": "إعادة المحاولة"
|
||||
},
|
||||
"form": {
|
||||
"nameLabel": "الاسم",
|
||||
@@ -1202,7 +1245,10 @@
|
||||
"invalidDate": "تاريخ غير صحيح",
|
||||
"loadError": "فشل في تحميل تفاصيل المشاركة",
|
||||
"editSecurity": "تحرير الأمان",
|
||||
"editExpiration": "تحرير انتهاء الصلاحية"
|
||||
"editExpiration": "تحرير انتهاء الصلاحية",
|
||||
"clickToEnlargeQrCode": "انقر لتكبير رمز QR",
|
||||
"downloadQrCode": "تحميل رمز QR",
|
||||
"qrCode": "رمز QR"
|
||||
},
|
||||
"shareExpiration": {
|
||||
"neverExpires": "لا تنتهي صلاحيته أبداً",
|
||||
@@ -1389,7 +1435,8 @@
|
||||
"copyLink": "نسخ الرابط",
|
||||
"notifyRecipients": "إشعار المستقبلين",
|
||||
"delete": "حذف",
|
||||
"downloadShareFiles": "قم بتنزيل جميع الملفات"
|
||||
"downloadShareFiles": "قم بتنزيل جميع الملفات",
|
||||
"viewQrCode": "عرض رمز QR"
|
||||
},
|
||||
"bulkActions": {
|
||||
"delete": "حذف",
|
||||
@@ -1421,6 +1468,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 +1739,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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -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",
|
||||
@@ -318,7 +327,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 +345,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,10 +624,11 @@
|
||||
"createLink": "Link erstellen",
|
||||
"delete": "Löschen",
|
||||
"copyLinkTitle": "Link kopieren",
|
||||
"createLinkCTA": "Empfangslink erstellen"
|
||||
"createLinkCTA": "Empfangslink erstellen",
|
||||
"viewQrCode": "QR-Code anzeigen"
|
||||
},
|
||||
"status": {
|
||||
"active": "Aktiv",
|
||||
"active": "Aktiv",
|
||||
"inactive": "Inaktiv",
|
||||
"expired": "Abgelaufen",
|
||||
"protected": "Geschützt",
|
||||
@@ -600,12 +636,13 @@
|
||||
},
|
||||
"actions": {
|
||||
"copyLink": "Link kopieren",
|
||||
"editAlias": "Alias bearbeiten",
|
||||
"editAlias": "Alias bearbeiten",
|
||||
"createAlias": "Alias erstellen",
|
||||
"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 +783,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 +939,8 @@
|
||||
"fileList": {
|
||||
"title": "Ausgewählte Dateien:",
|
||||
"statusUploaded": "Hochgeladen",
|
||||
"statusError": "Fehler"
|
||||
"statusError": "Fehler",
|
||||
"retry": "Wiederholen"
|
||||
},
|
||||
"form": {
|
||||
"nameLabel": "Name",
|
||||
@@ -1200,7 +1243,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 +1433,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 +1466,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 +1737,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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -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",
|
||||
@@ -309,16 +318,21 @@
|
||||
"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 +345,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 +453,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 +623,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 +646,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 +788,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 +945,8 @@
|
||||
"fileList": {
|
||||
"title": "Selected files:",
|
||||
"statusUploaded": "Uploaded",
|
||||
"statusError": "Error"
|
||||
"statusError": "Error",
|
||||
"retry": "Retry"
|
||||
},
|
||||
"form": {
|
||||
"nameLabel": "Name",
|
||||
@@ -944,8 +992,6 @@
|
||||
},
|
||||
"fileActions": {
|
||||
"edit": "Edit",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"preview": "Preview",
|
||||
"download": "Download",
|
||||
"delete": "Delete",
|
||||
@@ -1172,34 +1218,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 +1433,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 +1468,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 +1703,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 +1738,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)"
|
||||
}
|
||||
}
|
@@ -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",
|
||||
@@ -318,7 +327,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 +345,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 +624,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 +641,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 +783,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 +939,8 @@
|
||||
"fileList": {
|
||||
"title": "Archivos seleccionados:",
|
||||
"statusUploaded": "Enviado",
|
||||
"statusError": "Error"
|
||||
"statusError": "Error",
|
||||
"retry": "Reintentar"
|
||||
},
|
||||
"form": {
|
||||
"nameLabel": "Nombre",
|
||||
@@ -1200,7 +1243,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 +1433,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 +1466,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 +1737,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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -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",
|
||||
@@ -318,7 +327,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 +345,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 +463,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 +624,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 +641,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 +783,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 +939,8 @@
|
||||
"fileList": {
|
||||
"title": "Fichiers sélectionnés :",
|
||||
"statusUploaded": "Envoyé",
|
||||
"statusError": "Erreur"
|
||||
"statusError": "Erreur",
|
||||
"retry": "Réessayer"
|
||||
},
|
||||
"form": {
|
||||
"nameLabel": "Nom",
|
||||
@@ -1068,8 +1111,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",
|
||||
@@ -1200,7 +1243,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 +1433,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 +1466,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 +1737,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"
|
||||
}
|
||||
}
|
@@ -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": "फाइल संपादित करें",
|
||||
@@ -318,7 +327,12 @@
|
||||
"copyButton": "लिंक कॉपी करें",
|
||||
"success": "लिंक सफलतापूर्वक उत्पन्न हुआ",
|
||||
"error": "लिंक उत्पन्न करने में विफल",
|
||||
"copied": "लिंक क्लिपबोर्ड में कॉपी किया गया"
|
||||
"copied": "लिंक क्लिपबोर्ड में कॉपी किया गया",
|
||||
"readyDescription": "आपका साझाकरण लिंक तैयार है। आप डायरेक्ट रूप से QR कोड स्कैन कर सकते हैं, इसे बाद में उपयोग के लिए डाउनलोड कर सकते हैं, या नीचे लिंक कॉपी कर सकते हैं।",
|
||||
"tabs": {
|
||||
"link": "लिंक",
|
||||
"qrcode": "QR कोड"
|
||||
}
|
||||
},
|
||||
"home": {
|
||||
"description": "WeTransfer का ओपन-सोर्स विकल्प। फाइलें सुरक्षित रूप से साझा करें, बिना ट्रैकिंग या सीमाओं के।",
|
||||
@@ -331,6 +345,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 +624,8 @@
|
||||
"createLink": "लिंक बनाएं",
|
||||
"delete": "हटाएं",
|
||||
"copyLinkTitle": "लिंक कॉपी करें",
|
||||
"createLinkCTA": "प्राप्ति लिंक बनाएं"
|
||||
"createLinkCTA": "प्राप्ति लिंक बनाएं",
|
||||
"viewQrCode": "QR कोड देखें"
|
||||
},
|
||||
"status": {
|
||||
"active": "सक्रिय",
|
||||
@@ -605,7 +641,8 @@
|
||||
"viewDetails": "विवरण देखें",
|
||||
"edit": "संपादित करें",
|
||||
"delete": "हटाएं",
|
||||
"viewFiles": "प्राप्त फ़ाइलें"
|
||||
"viewFiles": "प्राप्त फ़ाइलें",
|
||||
"viewQrCode": "QR कोड देखें"
|
||||
},
|
||||
"empty": {
|
||||
"title": "कोई प्राप्ति लिंक नहीं बनाया गया",
|
||||
@@ -746,7 +783,12 @@
|
||||
"selectAll": "सभी चुनें",
|
||||
"selectFile": "फ़ाइल {fileName} चुनें",
|
||||
"deleteError": "फ़ाइल हटाने में त्रुटि",
|
||||
"deleteSuccess": "फ़ाइल सफलतापूर्वक हटा दी गई"
|
||||
"deleteSuccess": "फ़ाइल सफलतापूर्वक हटा दी गई",
|
||||
"copyErrors": {
|
||||
"timeout": "कॉपी ऑपरेशन का समय समाप्त हो गया। कृपया छोटी फ़ाइल के साथ पुनः प्रयास करें या अपना कनेक्शन जांचें।",
|
||||
"failed": "कॉपी ऑपरेशन विफल हो गया। कृपया पुनः प्रयास करें।",
|
||||
"aborted": "टाइमआउट के कारण कॉपी ऑपरेशन रद्द कर दिया गया।"
|
||||
}
|
||||
}
|
||||
},
|
||||
"form": {
|
||||
@@ -897,7 +939,8 @@
|
||||
"fileList": {
|
||||
"title": "चयनित फ़ाइलें:",
|
||||
"statusUploaded": "अपलोड की गई",
|
||||
"statusError": "त्रुटि"
|
||||
"statusError": "त्रुटि",
|
||||
"retry": "पुनः प्रयास करें"
|
||||
},
|
||||
"form": {
|
||||
"nameLabel": "नाम",
|
||||
@@ -1200,7 +1243,10 @@
|
||||
"invalidDate": "अमान्य तिथि",
|
||||
"loadError": "साझाकरण विवरण लोड करने में विफल",
|
||||
"editSecurity": "सुरक्षा संपादित करें",
|
||||
"editExpiration": "समाप्ति संपादित करें"
|
||||
"editExpiration": "समाप्ति संपादित करें",
|
||||
"clickToEnlargeQrCode": "QR कोड को बड़ा करने के लिए क्लिक करें",
|
||||
"downloadQrCode": "QR कोड डाउनलोड करें",
|
||||
"qrCode": "QR कोड"
|
||||
},
|
||||
"shareExpiration": {
|
||||
"neverExpires": "कभी समाप्त नहीं होता",
|
||||
@@ -1387,7 +1433,8 @@
|
||||
"copyLink": "लिंक कॉपी करें",
|
||||
"notifyRecipients": "प्राप्तकर्ताओं को सूचित करें",
|
||||
"delete": "हटाएं",
|
||||
"downloadShareFiles": "सभी फ़ाइलें डाउनलोड करें"
|
||||
"downloadShareFiles": "सभी फ़ाइलें डाउनलोड करें",
|
||||
"viewQrCode": "QR कोड देखें"
|
||||
},
|
||||
"bulkActions": {
|
||||
"delete": "हटाएं",
|
||||
@@ -1419,6 +1466,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 +1737,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 कोड डाउनलोड करें"
|
||||
}
|
||||
}
|
@@ -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",
|
||||
@@ -318,7 +327,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 +345,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 +624,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 +641,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 +783,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 +939,8 @@
|
||||
"fileList": {
|
||||
"title": "File selezionati:",
|
||||
"statusUploaded": "Inviato",
|
||||
"statusError": "Errore"
|
||||
"statusError": "Errore",
|
||||
"retry": "Riprova"
|
||||
},
|
||||
"form": {
|
||||
"nameLabel": "Nome",
|
||||
@@ -1200,7 +1243,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 +1433,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 +1466,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 +1737,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"
|
||||
}
|
||||
}
|
@@ -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": "ファイルを編集",
|
||||
@@ -318,7 +327,12 @@
|
||||
"copyButton": "リンクをコピー",
|
||||
"success": "リンクが正常に生成されました",
|
||||
"error": "リンクの生成に失敗しました",
|
||||
"copied": "リンクがクリップボードにコピーされました"
|
||||
"copied": "リンクがクリップボードにコピーされました",
|
||||
"readyDescription": "共有リンクが準備できました。QRコードを直接スキャンして、後で使用するためにダウンロードするか、リンクをコピーしてください。",
|
||||
"tabs": {
|
||||
"link": "リンク",
|
||||
"qrcode": "QRコード"
|
||||
}
|
||||
},
|
||||
"home": {
|
||||
"description": "WeTransferのオープンソース代替です。トラッキングや制限なしに安全にファイルを共有します。",
|
||||
@@ -331,6 +345,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 +624,8 @@
|
||||
"createLink": "リンクを作成",
|
||||
"delete": "削除",
|
||||
"copyLinkTitle": "リンクをコピー",
|
||||
"createLinkCTA": "受信リンクを作成"
|
||||
"createLinkCTA": "受信リンクを作成",
|
||||
"viewQrCode": "QRコードを表示"
|
||||
},
|
||||
"status": {
|
||||
"active": "有効",
|
||||
@@ -605,7 +641,8 @@
|
||||
"viewDetails": "詳細を表示",
|
||||
"edit": "編集",
|
||||
"delete": "削除",
|
||||
"viewFiles": "受信済みファイル"
|
||||
"viewFiles": "受信済みファイル",
|
||||
"viewQrCode": "QRコードを表示"
|
||||
},
|
||||
"empty": {
|
||||
"title": "受信リンクが作成されていません",
|
||||
@@ -746,7 +783,12 @@
|
||||
"selectAll": "すべて選択",
|
||||
"selectFile": "ファイル{fileName}を選択",
|
||||
"deleteError": "ファイルの削除に失敗しました",
|
||||
"deleteSuccess": "ファイルを正常に削除しました"
|
||||
"deleteSuccess": "ファイルを正常に削除しました",
|
||||
"copyErrors": {
|
||||
"timeout": "コピー操作がタイムアウトしました。より小さいファイルで再試行するか、接続を確認してください。",
|
||||
"failed": "コピー操作に失敗しました。もう一度お試しください。",
|
||||
"aborted": "タイムアウトによりコピー操作がキャンセルされました。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"form": {
|
||||
@@ -897,7 +939,8 @@
|
||||
"fileList": {
|
||||
"title": "選択されたファイル:",
|
||||
"statusUploaded": "アップロード済み",
|
||||
"statusError": "エラー"
|
||||
"statusError": "エラー",
|
||||
"retry": "再試行"
|
||||
},
|
||||
"form": {
|
||||
"nameLabel": "名前",
|
||||
@@ -1200,7 +1243,10 @@
|
||||
"invalidDate": "無効な日付",
|
||||
"loadError": "共有詳細の読み込みに失敗しました",
|
||||
"editSecurity": "セキュリティを編集",
|
||||
"editExpiration": "期限を編集"
|
||||
"editExpiration": "期限を編集",
|
||||
"clickToEnlargeQrCode": "QRコードを拡大",
|
||||
"downloadQrCode": "QRコードをダウンロード",
|
||||
"qrCode": "QRコード"
|
||||
},
|
||||
"shareExpiration": {
|
||||
"neverExpires": "期限なし",
|
||||
@@ -1387,7 +1433,8 @@
|
||||
"copyLink": "リンクコピー",
|
||||
"notifyRecipients": "受信者に通知",
|
||||
"delete": "削除",
|
||||
"downloadShareFiles": "すべてのファイルをダウンロードします"
|
||||
"downloadShareFiles": "すべてのファイルをダウンロードします",
|
||||
"viewQrCode": "QRコードを表示"
|
||||
},
|
||||
"bulkActions": {
|
||||
"delete": "削除",
|
||||
@@ -1419,6 +1466,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 +1737,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コードをダウンロード"
|
||||
}
|
||||
}
|
@@ -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": "파일 편집",
|
||||
@@ -318,7 +327,12 @@
|
||||
"copyButton": "링크 복사",
|
||||
"success": "링크가 성공적으로 생성되었습니다",
|
||||
"error": "링크 생성에 실패했습니다",
|
||||
"copied": "링크가 클립보드에 복사되었습니다"
|
||||
"copied": "링크가 클립보드에 복사되었습니다",
|
||||
"readyDescription": "공유 링크가 준비되었습니다. QR 코드를 직접 스캔하여 링크에 접근하거나, 나중에 사용하기 위해 다운로드하거나, 아래 링크를 복사할 수 있습니다.",
|
||||
"tabs": {
|
||||
"link": "링크",
|
||||
"qrcode": "QR 코드"
|
||||
}
|
||||
},
|
||||
"home": {
|
||||
"description": "WeTransfer의 오픈소스 대안입니다. 추적이나 제한 없이 파일을 안전하게 공유하세요.",
|
||||
@@ -331,6 +345,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 +624,8 @@
|
||||
"createLink": "링크 생성",
|
||||
"delete": "삭제",
|
||||
"copyLinkTitle": "링크 복사",
|
||||
"createLinkCTA": "수신 링크 생성"
|
||||
"createLinkCTA": "수신 링크 생성",
|
||||
"viewQrCode": "QR 코드 보기"
|
||||
},
|
||||
"status": {
|
||||
"active": "활성",
|
||||
@@ -605,7 +641,8 @@
|
||||
"viewDetails": "상세 보기",
|
||||
"edit": "편집",
|
||||
"delete": "삭제",
|
||||
"viewFiles": "받은 파일"
|
||||
"viewFiles": "받은 파일",
|
||||
"viewQrCode": "QR 코드 보기"
|
||||
},
|
||||
"empty": {
|
||||
"title": "생성된 수신 링크 없음",
|
||||
@@ -746,7 +783,12 @@
|
||||
"selectAll": "모두 선택",
|
||||
"selectFile": "{fileName} 파일 선택",
|
||||
"deleteError": "파일 삭제 오류",
|
||||
"deleteSuccess": "파일이 성공적으로 삭제됨"
|
||||
"deleteSuccess": "파일이 성공적으로 삭제됨",
|
||||
"copyErrors": {
|
||||
"timeout": "복사 작업 시간이 초과되었습니다. 더 작은 파일로 다시 시도하거나 연결을 확인하십시오.",
|
||||
"failed": "복사 작업이 실패했습니다. 다시 시도해 주세요.",
|
||||
"aborted": "시간 초과로 인해 복사 작업이 취소되었습니다."
|
||||
}
|
||||
}
|
||||
},
|
||||
"form": {
|
||||
@@ -897,7 +939,8 @@
|
||||
"fileList": {
|
||||
"title": "선택된 파일:",
|
||||
"statusUploaded": "업로드됨",
|
||||
"statusError": "오류"
|
||||
"statusError": "오류",
|
||||
"retry": "다시 시도"
|
||||
},
|
||||
"form": {
|
||||
"nameLabel": "이름",
|
||||
@@ -1200,7 +1243,10 @@
|
||||
"invalidDate": "잘못된 날짜",
|
||||
"loadError": "공유 세부 정보 로드에 실패했습니다",
|
||||
"editSecurity": "보안 편집",
|
||||
"editExpiration": "만료 편집"
|
||||
"editExpiration": "만료 편집",
|
||||
"clickToEnlargeQrCode": "QR 코드 확대",
|
||||
"downloadQrCode": "QR 코드 다운로드",
|
||||
"qrCode": "QR 코드"
|
||||
},
|
||||
"shareExpiration": {
|
||||
"neverExpires": "만료되지 않음",
|
||||
@@ -1387,7 +1433,8 @@
|
||||
"copyLink": "링크 복사",
|
||||
"notifyRecipients": "받는 사람에게 알림",
|
||||
"delete": "삭제",
|
||||
"downloadShareFiles": "모든 파일을 다운로드하십시오"
|
||||
"downloadShareFiles": "모든 파일을 다운로드하십시오",
|
||||
"viewQrCode": "QR 코드 보기"
|
||||
},
|
||||
"bulkActions": {
|
||||
"delete": "삭제",
|
||||
@@ -1419,6 +1466,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 +1737,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 코드 다운로드"
|
||||
}
|
||||
}
|
@@ -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",
|
||||
@@ -318,7 +327,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 +345,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 +624,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 +641,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 +783,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 +939,8 @@
|
||||
"fileList": {
|
||||
"title": "Geselecteerde bestanden:",
|
||||
"statusUploaded": "Geüpload",
|
||||
"statusError": "Fout"
|
||||
"statusError": "Fout",
|
||||
"retry": "Opnieuw proberen"
|
||||
},
|
||||
"form": {
|
||||
"nameLabel": "Naam",
|
||||
@@ -1200,7 +1243,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 +1433,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 +1466,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 +1737,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"
|
||||
}
|
||||
}
|
@@ -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",
|
||||
@@ -318,7 +327,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 +345,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 +624,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 +641,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 +783,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 +939,8 @@
|
||||
"fileList": {
|
||||
"title": "Wybrane pliki:",
|
||||
"statusUploaded": "Przesłano",
|
||||
"statusError": "Błąd"
|
||||
"statusError": "Błąd",
|
||||
"retry": "Spróbuj Ponownie"
|
||||
},
|
||||
"form": {
|
||||
"nameLabel": "Imię",
|
||||
@@ -1145,7 +1188,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 +1243,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 +1433,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 +1466,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 +1737,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"
|
||||
}
|
||||
}
|
@@ -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",
|
||||
@@ -318,7 +327,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 +345,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 +624,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 +641,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 +783,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 +939,8 @@
|
||||
"fileList": {
|
||||
"title": "Arquivos selecionados:",
|
||||
"statusUploaded": "Enviado",
|
||||
"statusError": "Erro"
|
||||
"statusError": "Erro",
|
||||
"retry": "Tentar Novamente"
|
||||
},
|
||||
"form": {
|
||||
"nameLabel": "Nome",
|
||||
@@ -1200,7 +1243,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 +1441,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 +1466,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 +1737,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"
|
||||
}
|
||||
}
|
@@ -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": "Редактировать файл",
|
||||
@@ -318,7 +327,12 @@
|
||||
"copyButton": "Копировать ссылку",
|
||||
"success": "Ссылка успешно создана",
|
||||
"error": "Ошибка при создании ссылки",
|
||||
"copied": "Ссылка скопирована в буфер обмена"
|
||||
"copied": "Ссылка скопирована в буфер обмена",
|
||||
"readyDescription": "Ваша ссылка для обмена готова. Вы можете сканировать QR-код напрямую, скачать его для последующего использования или скопировать ссылку ниже.",
|
||||
"tabs": {
|
||||
"link": "Ссылка",
|
||||
"qrcode": "QR-код"
|
||||
}
|
||||
},
|
||||
"home": {
|
||||
"description": "Открытая альтернатива WeTransfer. Делитесь файлами безопасно, без отслеживания и ограничений.",
|
||||
@@ -331,6 +345,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 +624,8 @@
|
||||
"createLink": "Создать ссылку",
|
||||
"delete": "Удалить",
|
||||
"copyLinkTitle": "Копировать ссылку",
|
||||
"createLinkCTA": "Создать ссылку для получения"
|
||||
"createLinkCTA": "Создать ссылку для получения",
|
||||
"viewQrCode": "Просмотр QR-кода"
|
||||
},
|
||||
"status": {
|
||||
"active": "Активно",
|
||||
@@ -605,7 +641,8 @@
|
||||
"viewDetails": "Просмотр деталей",
|
||||
"edit": "Редактировать",
|
||||
"delete": "Удалить",
|
||||
"viewFiles": "Полученные файлы"
|
||||
"viewFiles": "Полученные файлы",
|
||||
"viewQrCode": "Просмотр QR-кода"
|
||||
},
|
||||
"empty": {
|
||||
"title": "Нет созданных ссылок для получения",
|
||||
@@ -746,7 +783,12 @@
|
||||
"selectAll": "Выбрать все",
|
||||
"selectFile": "Выбрать файл {fileName}",
|
||||
"deleteError": "Ошибка при удалении файла",
|
||||
"deleteSuccess": "Файл успешно удален"
|
||||
"deleteSuccess": "Файл успешно удален",
|
||||
"copyErrors": {
|
||||
"timeout": "Время операции копирования истекло. Пожалуйста, попробуйте еще раз с файлом меньшего размера или проверьте подключение.",
|
||||
"failed": "Ошибка операции копирования. Пожалуйста, попробуйте еще раз.",
|
||||
"aborted": "Операция копирования была отменена из-за истечения времени ожидания."
|
||||
}
|
||||
}
|
||||
},
|
||||
"form": {
|
||||
@@ -897,7 +939,8 @@
|
||||
"fileList": {
|
||||
"title": "Выбранные файлы:",
|
||||
"statusUploaded": "Загружено",
|
||||
"statusError": "Ошибка"
|
||||
"statusError": "Ошибка",
|
||||
"retry": "Повторить"
|
||||
},
|
||||
"form": {
|
||||
"nameLabel": "Имя",
|
||||
@@ -1200,7 +1243,10 @@
|
||||
"invalidDate": "Неверная дата",
|
||||
"loadError": "Ошибка загрузки деталей общего доступа",
|
||||
"editSecurity": "Изменить безопасность",
|
||||
"editExpiration": "Изменить срок действия"
|
||||
"editExpiration": "Изменить срок действия",
|
||||
"clickToEnlargeQrCode": "Нажмите, чтобы увеличить QR-код",
|
||||
"downloadQrCode": "Скачать QR-код",
|
||||
"qrCode": "QR-код"
|
||||
},
|
||||
"shareExpiration": {
|
||||
"neverExpires": "Никогда не истекает",
|
||||
@@ -1387,7 +1433,8 @@
|
||||
"copyLink": "Скопировать Ссылку",
|
||||
"notifyRecipients": "Уведомить Получателей",
|
||||
"delete": "Удалить",
|
||||
"downloadShareFiles": "Загрузите все файлы"
|
||||
"downloadShareFiles": "Загрузите все файлы",
|
||||
"viewQrCode": "Просмотр QR-кода"
|
||||
},
|
||||
"bulkActions": {
|
||||
"delete": "Удалить",
|
||||
@@ -1419,6 +1466,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 +1737,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-код"
|
||||
}
|
||||
}
|
@@ -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",
|
||||
@@ -318,7 +327,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 +345,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 +624,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 +641,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 +783,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 +939,8 @@
|
||||
"fileList": {
|
||||
"title": "Seçilen dosyalar:",
|
||||
"statusUploaded": "Yüklendi",
|
||||
"statusError": "Hata"
|
||||
"statusError": "Hata",
|
||||
"retry": "Tekrar Dene"
|
||||
},
|
||||
"form": {
|
||||
"nameLabel": "Ad",
|
||||
@@ -1200,7 +1243,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 +1433,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 +1466,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 +1737,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"
|
||||
}
|
||||
}
|
@@ -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": "编辑文件",
|
||||
@@ -318,7 +327,12 @@
|
||||
"copyButton": "复制链接",
|
||||
"success": "链接生成成功",
|
||||
"error": "链接生成失败",
|
||||
"copied": "链接已复制到剪贴板"
|
||||
"copied": "链接已复制到剪贴板",
|
||||
"readyDescription": "您的分享链接已准备就绪:",
|
||||
"tabs": {
|
||||
"link": "链接",
|
||||
"qrcode": "QR Code"
|
||||
}
|
||||
},
|
||||
"home": {
|
||||
"description": "WeTransfer的开源替代方案。安全分享文件,无需跟踪或限制。",
|
||||
@@ -331,6 +345,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 +624,8 @@
|
||||
"createLink": "创建链接",
|
||||
"delete": "删除",
|
||||
"copyLinkTitle": "复制链接",
|
||||
"createLinkCTA": "创建接收链接"
|
||||
"createLinkCTA": "创建接收链接",
|
||||
"viewQrCode": "查看QR Code"
|
||||
},
|
||||
"status": {
|
||||
"active": "活动",
|
||||
@@ -605,7 +641,8 @@
|
||||
"viewDetails": "查看详情",
|
||||
"edit": "编辑",
|
||||
"delete": "删除",
|
||||
"viewFiles": "已接收文件"
|
||||
"viewFiles": "已接收文件",
|
||||
"viewQrCode": "查看QR Code"
|
||||
},
|
||||
"empty": {
|
||||
"title": "未创建接收链接",
|
||||
@@ -746,7 +783,12 @@
|
||||
"selectAll": "全选",
|
||||
"selectFile": "选择文件 {fileName}",
|
||||
"deleteError": "删除文件时出错",
|
||||
"deleteSuccess": "文件已成功删除"
|
||||
"deleteSuccess": "文件已成功删除",
|
||||
"copyErrors": {
|
||||
"timeout": "复制操作超时。请尝试使用较小的文件或检查您的连接。",
|
||||
"failed": "复制操作失败。请重试。",
|
||||
"aborted": "由于超时,复制操作已取消。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"form": {
|
||||
@@ -897,7 +939,8 @@
|
||||
"fileList": {
|
||||
"title": "已选择的文件:",
|
||||
"statusUploaded": "已上传",
|
||||
"statusError": "错误"
|
||||
"statusError": "错误",
|
||||
"retry": "重试"
|
||||
},
|
||||
"form": {
|
||||
"nameLabel": "姓名",
|
||||
@@ -1200,7 +1243,10 @@
|
||||
"description": "描述",
|
||||
"linkCopied": "链接已复制到剪贴板",
|
||||
"editSecurity": "编辑安全",
|
||||
"editExpiration": "编辑过期"
|
||||
"editExpiration": "编辑过期",
|
||||
"clickToEnlargeQrCode": "点击放大QR Code",
|
||||
"downloadQrCode": "下载QR Code",
|
||||
"qrCode": "QR Code"
|
||||
},
|
||||
"shareExpiration": {
|
||||
"neverExpires": "永不过期",
|
||||
@@ -1387,7 +1433,8 @@
|
||||
"copyLink": "复制链接",
|
||||
"notifyRecipients": "通知收件人",
|
||||
"delete": "删除",
|
||||
"downloadShareFiles": "下载所有文件"
|
||||
"downloadShareFiles": "下载所有文件",
|
||||
"viewQrCode": "查看QR Code"
|
||||
},
|
||||
"bulkActions": {
|
||||
"delete": "删除",
|
||||
@@ -1419,6 +1466,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 +1737,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"
|
||||
}
|
||||
}
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "palmr-web",
|
||||
"version": "3.1-beta",
|
||||
"version": "3.1.3-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
@@ -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: {}
|
||||
|
@@ -14,6 +14,7 @@ import { Label } from "@/components/ui/label";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { getPresignedUrlForUploadByAlias, registerFileUploadByAlias } from "@/http/endpoints";
|
||||
import { 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";
|
||||
@@ -138,17 +139,34 @@ 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);
|
||||
|
||||
if (shouldUseChunked) {
|
||||
const chunkSize = ChunkedUploader.calculateOptimalChunkSize(file.size);
|
||||
|
||||
const result = await ChunkedUploader.uploadFile({
|
||||
file,
|
||||
url: presignedUrl,
|
||||
chunkSize,
|
||||
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 +213,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 +230,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 +243,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 +288,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;
|
||||
};
|
||||
|
||||
|
@@ -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();
|
||||
|
@@ -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) {
|
||||
|
@@ -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")}
|
||||
|
@@ -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 */}
|
||||
|
@@ -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}
|
||||
|
@@ -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"}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@@ -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,
|
||||
|
@@ -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}
|
||||
|
@@ -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)}
|
||||
|
@@ -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}
|
||||
/>
|
||||
|
37
apps/web/src/app/api/(proxy)/auth/2fa/backup-codes/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
40
apps/web/src/app/api/(proxy)/auth/2fa/disable/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
44
apps/web/src/app/api/(proxy)/auth/2fa/login/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
40
apps/web/src/app/api/(proxy)/auth/2fa/setup/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
37
apps/web/src/app/api/(proxy)/auth/2fa/status/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
40
apps/web/src/app/api/(proxy)/auth/2fa/verify-setup/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
40
apps/web/src/app/api/(proxy)/auth/2fa/verify/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
@@ -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",
|
||||
|
@@ -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 });
|
||||
}
|
||||
}
|
80
apps/web/src/app/api/(proxy)/auth/trusted-devices/route.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
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 GET(req: NextRequest) {
|
||||
try {
|
||||
const cookieHeader = req.headers.get("cookie");
|
||||
const url = `${API_BASE_URL}/auth/trusted-devices`;
|
||||
|
||||
const clientHeaders = getClientHeaders(req);
|
||||
|
||||
const apiRes = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
cookie: cookieHeader || "",
|
||||
...clientHeaders,
|
||||
...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 trusted devices request:", error);
|
||||
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(req: NextRequest) {
|
||||
try {
|
||||
const cookieHeader = req.headers.get("cookie");
|
||||
const url = `${API_BASE_URL}/auth/trusted-devices`;
|
||||
|
||||
// Get real client IP and user agent headers
|
||||
const clientHeaders = getClientHeaders(req);
|
||||
|
||||
const apiRes = await fetch(url, {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
cookie: cookieHeader || "",
|
||||
...clientHeaders,
|
||||
...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 remove all trusted devices request:", error);
|
||||
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
}
|
@@ -0,0 +1,33 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
const API_BASE_URL = process.env.API_BASE_URL || "http://localhost:3333";
|
||||
|
||||
export async function DELETE(req: NextRequest, { params }: { params: Promise<{ fileId: string }> }) {
|
||||
const { fileId } = await params;
|
||||
const cookieHeader = req.headers.get("cookie");
|
||||
const url = `${API_BASE_URL}/filesystem/cancel-upload/${fileId}`;
|
||||
|
||||
const apiRes = await fetch(url, {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
cookie: cookieHeader || "",
|
||||
},
|
||||
});
|
||||
|
||||
const contentType = apiRes.headers.get("Content-Type") || "application/json";
|
||||
const resBody = await apiRes.text();
|
||||
|
||||
const res = new NextResponse(resBody, {
|
||||
status: apiRes.status,
|
||||
headers: {
|
||||
"Content-Type": contentType,
|
||||
},
|
||||
});
|
||||
|
||||
const setCookie = apiRes.headers.getSetCookie?.() || [];
|
||||
if (setCookie.length > 0) {
|
||||
res.headers.set("Set-Cookie", setCookie.join(","));
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
@@ -0,0 +1,33 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
const API_BASE_URL = process.env.API_BASE_URL || "http://localhost:3333";
|
||||
|
||||
export async function GET(req: NextRequest, { params }: { params: Promise<{ fileId: string }> }) {
|
||||
const { fileId } = await params;
|
||||
const cookieHeader = req.headers.get("cookie");
|
||||
const url = `${API_BASE_URL}/filesystem/upload-progress/${fileId}`;
|
||||
|
||||
const apiRes = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
cookie: cookieHeader || "",
|
||||
},
|
||||
});
|
||||
|
||||
const contentType = apiRes.headers.get("Content-Type") || "application/json";
|
||||
const resBody = await apiRes.text();
|
||||
|
||||
const res = new NextResponse(resBody, {
|
||||
status: apiRes.status,
|
||||
headers: {
|
||||
"Content-Type": contentType,
|
||||
},
|
||||
});
|
||||
|
||||
const setCookie = apiRes.headers.getSetCookie?.() || [];
|
||||
if (setCookie.length > 0) {
|
||||
res.headers.set("Set-Cookie", setCookie.join(","));
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
@@ -1,6 +1,6 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
export const maxDuration = 30000;
|
||||
export const maxDuration = 120000; // 2 minutes to handle large files
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const API_BASE_URL = process.env.API_BASE_URL || "http://localhost:3333";
|
||||
@@ -10,37 +10,60 @@ export async function PUT(req: NextRequest, { params }: { params: Promise<{ toke
|
||||
const cookieHeader = req.headers.get("cookie");
|
||||
const url = `${API_BASE_URL}/filesystem/upload/${token}`;
|
||||
|
||||
const apiRes = await fetch(url, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
cookie: cookieHeader || "",
|
||||
"Content-Type": req.headers.get("Content-Type") || "application/octet-stream",
|
||||
"Content-Length": req.headers.get("Content-Length") || "0",
|
||||
},
|
||||
body: req.body,
|
||||
duplex: "half",
|
||||
} as RequestInit);
|
||||
const headers: Record<string, string> = {
|
||||
cookie: cookieHeader || "",
|
||||
"Content-Type": req.headers.get("Content-Type") || "application/octet-stream",
|
||||
"Content-Length": req.headers.get("Content-Length") || "0",
|
||||
};
|
||||
|
||||
const contentType = apiRes.headers.get("Content-Type") || "application/json";
|
||||
|
||||
let resBody;
|
||||
if (contentType.includes("application/json")) {
|
||||
resBody = await apiRes.text();
|
||||
} else {
|
||||
resBody = await apiRes.arrayBuffer();
|
||||
}
|
||||
|
||||
const res = new NextResponse(resBody, {
|
||||
status: apiRes.status,
|
||||
headers: {
|
||||
"Content-Type": contentType,
|
||||
},
|
||||
req.headers.forEach((value, key) => {
|
||||
if (key.startsWith("x-") || key.startsWith("X-")) {
|
||||
headers[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
const setCookie = apiRes.headers.getSetCookie?.() || [];
|
||||
if (setCookie.length > 0) {
|
||||
res.headers.set("Set-Cookie", setCookie.join(","));
|
||||
}
|
||||
try {
|
||||
const apiRes = await fetch(url, {
|
||||
method: "PUT",
|
||||
headers,
|
||||
body: req.body,
|
||||
duplex: "half",
|
||||
} as RequestInit);
|
||||
|
||||
return res;
|
||||
const contentType = apiRes.headers.get("Content-Type") || "application/json";
|
||||
|
||||
let resBody;
|
||||
if (contentType.includes("application/json")) {
|
||||
resBody = await apiRes.text();
|
||||
} else {
|
||||
resBody = await apiRes.arrayBuffer();
|
||||
}
|
||||
|
||||
const res = new NextResponse(resBody, {
|
||||
status: apiRes.status,
|
||||
headers: {
|
||||
"Content-Type": contentType,
|
||||
},
|
||||
});
|
||||
|
||||
const setCookie = apiRes.headers.getSetCookie?.() || [];
|
||||
if (setCookie.length > 0) {
|
||||
res.headers.set("Set-Cookie", setCookie.join(","));
|
||||
}
|
||||
|
||||
return res;
|
||||
} catch (error) {
|
||||
return new NextResponse(
|
||||
JSON.stringify({
|
||||
error: "Proxy request failed",
|
||||
details: error instanceof Error ? error.message : "Unknown error",
|
||||
}),
|
||||
{
|
||||
status: 500,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@@ -1,5 +1,8 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
export const maxDuration = 600000; // 10 minutes timeout for large file copies
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const API_BASE_URL = process.env.API_BASE_URL || "http://localhost:3333";
|
||||
|
||||
export async function POST(req: NextRequest, { params }: { params: Promise<{ fileId: string }> }) {
|
||||
@@ -7,27 +10,83 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ fil
|
||||
const cookieHeader = req.headers.get("cookie");
|
||||
const url = `${API_BASE_URL}/reverse-shares/files/${fileId}/copy`;
|
||||
|
||||
const apiRes = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
cookie: cookieHeader || "",
|
||||
},
|
||||
redirect: "manual",
|
||||
});
|
||||
try {
|
||||
const testResponse = await fetch(`${API_BASE_URL}/health`, {
|
||||
method: "GET",
|
||||
signal: AbortSignal.timeout(5000), // 5 seconds timeout
|
||||
});
|
||||
|
||||
const resBody = await apiRes.text();
|
||||
if (!testResponse.ok) {
|
||||
throw new Error(`Backend health check failed: ${testResponse.status}`);
|
||||
}
|
||||
|
||||
const res = new NextResponse(resBody, {
|
||||
status: apiRes.status,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => {
|
||||
controller.abort();
|
||||
}, 600000); // 10 minutes
|
||||
|
||||
const setCookie = apiRes.headers.getSetCookie?.() || [];
|
||||
if (setCookie.length > 0) {
|
||||
res.headers.set("Set-Cookie", setCookie.join(","));
|
||||
const apiRes = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
cookie: cookieHeader || "",
|
||||
},
|
||||
redirect: "manual",
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
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: any) {
|
||||
console.error(`Copy to my files proxy error details:`, {
|
||||
name: error.name,
|
||||
message: error.message,
|
||||
code: error.code,
|
||||
cause: error.cause,
|
||||
});
|
||||
|
||||
if (error.name === "AbortError") {
|
||||
return new NextResponse(
|
||||
JSON.stringify({
|
||||
error: "Copy operation timed out",
|
||||
details: "The operation took too long to complete",
|
||||
fileId,
|
||||
}),
|
||||
{
|
||||
status: 408,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return new NextResponse(
|
||||
JSON.stringify({
|
||||
error: "Copy operation failed",
|
||||
details: error.message || "Unknown error",
|
||||
fileId,
|
||||
}),
|
||||
{
|
||||
status: 500,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
@@ -4,9 +4,13 @@ import { useEffect } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { useAuth } from "@/contexts/auth-context";
|
||||
import { getCurrentUser } from "@/http/endpoints";
|
||||
|
||||
export default function AuthCallbackPage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { setUser, setIsAuthenticated, setIsAdmin } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
const token = searchParams.get("token");
|
||||
@@ -44,17 +48,35 @@ export default function AuthCallbackPage() {
|
||||
}
|
||||
|
||||
if (token) {
|
||||
// Set the token in a cookie and redirect to dashboard (using Palmr's standard cookie name)
|
||||
document.cookie = `token=${token}; path=/; max-age=${7 * 24 * 60 * 60}; samesite=lax`;
|
||||
|
||||
toast.success("Successfully authenticated!");
|
||||
router.push("/dashboard");
|
||||
// Buscar dados do usuário após definir o cookie
|
||||
const fetchUserData = async () => {
|
||||
try {
|
||||
const response = await getCurrentUser();
|
||||
if (response?.data?.user) {
|
||||
const { isAdmin, ...userData } = response.data.user;
|
||||
setUser(userData);
|
||||
setIsAdmin(isAdmin);
|
||||
setIsAuthenticated(true);
|
||||
toast.success("Successfully authenticated!");
|
||||
router.push("/dashboard");
|
||||
} else {
|
||||
throw new Error("No user data received");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching user data:", error);
|
||||
toast.error("Authentication failed");
|
||||
router.push("/login");
|
||||
}
|
||||
};
|
||||
|
||||
fetchUserData();
|
||||
return;
|
||||
}
|
||||
|
||||
// If no token or error, redirect to login
|
||||
router.push("/login");
|
||||
}, [router, searchParams]);
|
||||
}, [router, searchParams, setUser, setIsAuthenticated, setIsAdmin]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
|
@@ -62,6 +62,7 @@ export function RecentShares({ shares, shareManager, onOpenCreateModal, onCopyLi
|
||||
onManageFiles={shareManager.setShareToManageFiles}
|
||||
onManageRecipients={shareManager.setShareToManageRecipients}
|
||||
onNotifyRecipients={shareManager.handleNotifyRecipients}
|
||||
onViewQrCode={shareManager.setShareToViewQrCode}
|
||||
onViewDetails={shareManager.setShareToViewDetails}
|
||||
setClearSelectionCallback={shareManager.setClearSelectionCallback}
|
||||
/>
|
||||
|
@@ -7,6 +7,7 @@ import { DeleteConfirmationModal } from "@/components/modals/delete-confirmation
|
||||
import { FileActionsModals } from "@/components/modals/file-actions-modals";
|
||||
import { FilePreviewModal } from "@/components/modals/file-preview-modal";
|
||||
import { GenerateShareLinkModal } from "@/components/modals/generate-share-link-modal";
|
||||
import { QrCodeModal } from "@/components/modals/qr-code-modal";
|
||||
import { ShareActionsModals } from "@/components/modals/share-actions-modals";
|
||||
import { ShareDetailsModal } from "@/components/modals/share-details-modal";
|
||||
import { ShareExpirationModal } from "@/components/modals/share-expiration-modal";
|
||||
@@ -25,6 +26,11 @@ export function DashboardModals({ modals, fileManager, shareManager, onSuccess }
|
||||
onSuccess();
|
||||
};
|
||||
|
||||
const getShareLink = (share: any) => {
|
||||
if (!share?.alias?.alias) return "";
|
||||
return `${window.location.origin}/s/${share.alias.alias}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<UploadFileModal isOpen={modals.isUploadModalOpen} onClose={modals.onCloseUploadModal} onSuccess={onSuccess} />
|
||||
@@ -144,6 +150,13 @@ export function DashboardModals({ modals, fileManager, shareManager, onSuccess }
|
||||
onGenerate={shareManager.handleGenerateLink}
|
||||
onSuccess={onSuccess}
|
||||
/>
|
||||
|
||||
<QrCodeModal
|
||||
isOpen={!!shareManager.shareToViewQrCode}
|
||||
onClose={() => shareManager.setShareToViewQrCode(null)}
|
||||
shareLink={getShareLink(shareManager.shareToViewQrCode)}
|
||||
shareName={shareManager.shareToViewQrCode?.name || "Share"}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
137
apps/web/src/app/login/components/two-factor-verification.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { IconShield } from "@tabler/icons-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot } from "@/components/ui/input-otp";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
interface TwoFactorVerificationProps {
|
||||
twoFactorCode: string;
|
||||
setTwoFactorCode: (code: string) => void;
|
||||
onSubmit: (rememberDevice?: boolean) => void;
|
||||
error?: string;
|
||||
isSubmitting: boolean;
|
||||
}
|
||||
|
||||
export function TwoFactorVerification({
|
||||
twoFactorCode,
|
||||
setTwoFactorCode,
|
||||
onSubmit,
|
||||
error,
|
||||
isSubmitting,
|
||||
}: TwoFactorVerificationProps) {
|
||||
const t = useTranslations();
|
||||
const [showBackupCode, setShowBackupCode] = useState(false);
|
||||
const [rememberDevice, setRememberDevice] = useState(false);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
onSubmit(rememberDevice);
|
||||
};
|
||||
|
||||
const handleCodeChange = (value: string) => {
|
||||
setTwoFactorCode(value);
|
||||
};
|
||||
|
||||
const handleBackupCodeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value.toUpperCase();
|
||||
setTwoFactorCode(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="w-full max-w-md mx-auto">
|
||||
<CardHeader className="text-center">
|
||||
<div className="flex justify-center mb-4">
|
||||
<div className="p-3 rounded-full bg-primary/10">
|
||||
<IconShield className="h-8 w-8 text-primary" />
|
||||
</div>
|
||||
</div>
|
||||
<CardTitle>{t("twoFactor.verification.title")}</CardTitle>
|
||||
<CardDescription>
|
||||
{showBackupCode ? t("twoFactor.verification.backupDescription") : t("twoFactor.verification.description")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="twoFactorCode" className="mb-2">
|
||||
{showBackupCode ? t("twoFactor.verification.backupCode") : t("twoFactor.verification.verificationCode")}
|
||||
</Label>
|
||||
{showBackupCode ? (
|
||||
<Input
|
||||
id="twoFactorCode"
|
||||
type="text"
|
||||
placeholder={t("twoFactor.verification.backupCodePlaceholder")}
|
||||
value={twoFactorCode}
|
||||
onChange={handleBackupCodeChange}
|
||||
className="text-center tracking-widest font-mono"
|
||||
maxLength={9}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex justify-center">
|
||||
<InputOTP maxLength={6} value={twoFactorCode} onChange={handleCodeChange}>
|
||||
<InputOTPGroup>
|
||||
<InputOTPSlot index={0} />
|
||||
<InputOTPSlot index={1} />
|
||||
<InputOTPSlot index={2} />
|
||||
</InputOTPGroup>
|
||||
<InputOTPSeparator />
|
||||
<InputOTPGroup>
|
||||
<InputOTPSlot index={3} />
|
||||
<InputOTPSlot index={4} />
|
||||
<InputOTPSlot index={5} />
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="rememberDevice"
|
||||
checked={rememberDevice}
|
||||
onCheckedChange={(checked) => setRememberDevice(checked as boolean)}
|
||||
/>
|
||||
<Label htmlFor="rememberDevice" className="text-sm font-normal cursor-pointer">
|
||||
{t("twoFactor.verification.rememberDevice")}
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={isSubmitting || twoFactorCode.length < (showBackupCode ? 8 : 6)}
|
||||
>
|
||||
{isSubmitting ? t("twoFactor.verification.verifying") : t("twoFactor.verification.verify")}
|
||||
</Button>
|
||||
|
||||
{error && (
|
||||
<div className="text-sm text-destructive text-center bg-destructive/10 p-3 rounded-md">{error}</div>
|
||||
)}
|
||||
|
||||
<div className="text-center">
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
onClick={() => {
|
||||
setShowBackupCode(!showBackupCode);
|
||||
setTwoFactorCode("");
|
||||
}}
|
||||
className="text-sm text-muted-foreground hover:text-primary transition-colors"
|
||||
>
|
||||
{showBackupCode
|
||||
? t("twoFactor.verification.useAuthenticatorCode")
|
||||
: t("twoFactor.verification.useBackupCode")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|