mirror of
https://github.com/papra-hq/papra.git
synced 2026-01-03 17:10:17 -06:00
Compare commits
17 Commits
@papra/doc
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
68d848e622 | ||
|
|
d37025cb94 | ||
|
|
f3fb5ff46a | ||
|
|
8b52279807 | ||
|
|
16e33b827c | ||
|
|
8d70a7b3c3 | ||
|
|
7448a170af | ||
|
|
b8c14d0f44 | ||
|
|
4878a3f8dd | ||
|
|
a213f0683b | ||
|
|
6a5bcef5ad | ||
|
|
607ba9496c | ||
|
|
ec34cf1788 | ||
|
|
e52287d04f | ||
|
|
f903c33d26 | ||
|
|
4342b319ea | ||
|
|
815f6f94f8 |
5
.changeset/fine-aliens-jump.md
Normal file
5
.changeset/fine-aliens-jump.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@papra/docker": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Auto assign admin role to the first user registering
|
||||||
5
.changeset/khaki-glasses-draw.md
Normal file
5
.changeset/khaki-glasses-draw.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@papra/docker": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Added a dedicated increased timeout for the document upload route
|
||||||
5
.changeset/metal-buttons-mate.md
Normal file
5
.changeset/metal-buttons-mate.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@papra/docker": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Added a feedback message upon request timeout
|
||||||
5
.changeset/old-jars-fix.md
Normal file
5
.changeset/old-jars-fix.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@papra/docker": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Added support for two factor authentication
|
||||||
5
.changeset/proud-rivers-film.md
Normal file
5
.changeset/proud-rivers-film.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@papra/docker": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Organizations listing and details in the admin dashboard
|
||||||
5
.changeset/silent-gifts-enjoy.md
Normal file
5
.changeset/silent-gifts-enjoy.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@papra/docker": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Properly cleanup orphan file when the same document exists in trash
|
||||||
5
.changeset/ten-friends-shine.md
Normal file
5
.changeset/ten-friends-shine.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@papra/docker": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Changed config key `config.server.routeTimeoutMs` to `config.server.defaultRouteTimeoutMs` (env variable remains the same)
|
||||||
5
.changeset/true-olives-beam.md
Normal file
5
.changeset/true-olives-beam.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@papra/docker": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Added api endpoint to check current API key (GET /api/api-keys/current)
|
||||||
194
apps/docs/src/content/docs/02-self-hosting/04-from-source.mdx
Normal file
194
apps/docs/src/content/docs/02-self-hosting/04-from-source.mdx
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
---
|
||||||
|
title: Installing Papra from source
|
||||||
|
description: Self-host Papra by building from source.
|
||||||
|
slug: self-hosting/from-source
|
||||||
|
---
|
||||||
|
import { Steps } from '@astrojs/starlight/components';
|
||||||
|
import { Tabs, TabItem } from '@astrojs/starlight/components';
|
||||||
|
import { Aside } from '@astrojs/starlight/components';
|
||||||
|
import SecretKeyGenerator from '../../../components/encryption-key-generator.astro';
|
||||||
|
|
||||||
|
This guide covers building and running Papra directly from source code. This method is an alternative to Docker for users who prefer running applications natively on their system.
|
||||||
|
|
||||||
|
- **Full control**: Access to all source files for customization
|
||||||
|
- **Development ready**: Ideal for contributing or debugging
|
||||||
|
- **No containerization required**: Runs directly on your system
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
Ensure you have the following installed on your system:
|
||||||
|
|
||||||
|
- **Node.js**: Version 24 or higher
|
||||||
|
- **pnpm**: Version 10 or higher
|
||||||
|
|
||||||
|
Verify your installation with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node --version # Should show v24.x.x or higher
|
||||||
|
pnpm --version # Should show 10.x.x or higher
|
||||||
|
```
|
||||||
|
|
||||||
|
## Building from Source
|
||||||
|
|
||||||
|
<Steps>
|
||||||
|
|
||||||
|
1. Clone the Repository
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/papra-hq/papra.git
|
||||||
|
cd papra
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Install Dependencies
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm install
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Build All Packages
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm -r build
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Copy Client Files to Server
|
||||||
|
|
||||||
|
Create the public directory and copy the built client files:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p apps/papra-server/public
|
||||||
|
cp -r apps/papra-client/dist/* apps/papra-server/public/
|
||||||
|
```
|
||||||
|
|
||||||
|
5. Create Configuration File
|
||||||
|
|
||||||
|
Navigate to the server directory:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd apps/papra-server
|
||||||
|
```
|
||||||
|
|
||||||
|
First, generate a secure secret key for authentication:
|
||||||
|
|
||||||
|
<Tabs>
|
||||||
|
<TabItem label="Key generator">
|
||||||
|
<SecretKeyGenerator />
|
||||||
|
</TabItem>
|
||||||
|
<TabItem label="Command line">
|
||||||
|
```bash
|
||||||
|
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||||
|
```
|
||||||
|
</TabItem>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
Then create a `papra.config.yaml` file in this directory with the following content:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
server:
|
||||||
|
servePublicDir: true
|
||||||
|
baseUrl: http://localhost:1221
|
||||||
|
|
||||||
|
auth:
|
||||||
|
secret: <your-generated-secret-key>
|
||||||
|
```
|
||||||
|
|
||||||
|
Alternatively, you can use `papra.config.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"$schema": "https://docs.papra.app/papra-config-schema.json",
|
||||||
|
"server": {
|
||||||
|
"servePublicDir": true,
|
||||||
|
"baseUrl": "http://localhost:1221"
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"secret": "<your-generated-secret-key>"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
6. Start the Server
|
||||||
|
|
||||||
|
Run the server with database migrations:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm start:with-migrations
|
||||||
|
```
|
||||||
|
|
||||||
|
</Steps>
|
||||||
|
|
||||||
|
The application will be available at [http://localhost:1221](http://localhost:1221).
|
||||||
|
|
||||||
|
## Configuration Options
|
||||||
|
|
||||||
|
Papra uses configuration files (`papra.config.yaml` or `papra.config.json`) for settings.
|
||||||
|
|
||||||
|
<Aside type="note">
|
||||||
|
These three configuration options are the minimum required for a from-source installation.
|
||||||
|
</Aside>
|
||||||
|
|
||||||
|
| Option | Description | Default |
|
||||||
|
|--------|-------------|---------|
|
||||||
|
| `server.servePublicDir` | Serve the client files from the public directory | `false` |
|
||||||
|
| `server.baseUrl` | The base URL of your Papra instance | - |
|
||||||
|
| `auth.secret` | Secret key for authentication | - |
|
||||||
|
|
||||||
|
For a complete list of configuration options, refer to the [configuration reference](/self-hosting/configuration).
|
||||||
|
|
||||||
|
## Updating
|
||||||
|
|
||||||
|
To update your from-source installation:
|
||||||
|
|
||||||
|
<Steps>
|
||||||
|
|
||||||
|
1. Pull the Latest Changes
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git pull origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Reinstall Dependencies
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm install
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Rebuild All Packages
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm -r build
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Copy Updated Client Files
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p apps/papra-server/public
|
||||||
|
cp -r apps/papra-client/dist/* apps/papra-server/public/
|
||||||
|
```
|
||||||
|
|
||||||
|
5. Restart the Server
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd apps/papra-server
|
||||||
|
pnpm start:with-migrations
|
||||||
|
```
|
||||||
|
|
||||||
|
</Steps>
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Server returns "API route not found" for all pages
|
||||||
|
|
||||||
|
Ensure that:
|
||||||
|
- The `public` directory exists in `apps/papra-server/`
|
||||||
|
- The client files have been copied to `apps/papra-server/public/`
|
||||||
|
- The `server.servePublicDir` option is set to `true` in your configuration file
|
||||||
|
|
||||||
|
### Configuration file not loaded
|
||||||
|
|
||||||
|
Papra looks for configuration files in the following order:
|
||||||
|
1. `papra.config.yaml`
|
||||||
|
2. `papra.config.json`
|
||||||
|
3. `papra.config.ts`
|
||||||
|
|
||||||
|
Make sure your configuration file is in the `apps/papra-server/` directory and has the correct filename.
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
---
|
||||||
|
title: Roles and Administration
|
||||||
|
slug: guides/roles-administration
|
||||||
|
description: Understanding platform roles and admin access in Papra.
|
||||||
|
---
|
||||||
|
import { Aside } from '@astrojs/starlight/components';
|
||||||
|
|
||||||
|
This guide explains the roles and permissions system in Papra, focusing on platform-wide roles and how to manage admin access.
|
||||||
|
|
||||||
|
<Aside type="note">
|
||||||
|
The administration features are available starting from Papra version `26.0.0`.
|
||||||
|
</Aside>
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Papra has two separate role systems:
|
||||||
|
|
||||||
|
1. **Platform Roles** - System-wide roles like `admin` for managing the entire Papra instance
|
||||||
|
2. **Organization Roles** - Workspace-level roles like `owner` and `member` for managing organizations
|
||||||
|
|
||||||
|
This guide focuses on platform roles and admin access.
|
||||||
|
|
||||||
|
## Platform Roles
|
||||||
|
|
||||||
|
### Admin Role
|
||||||
|
|
||||||
|
The `admin` role provides system-wide administrative privileges. Admins can:
|
||||||
|
|
||||||
|
- **User Management**: View all users, their organizations, and activity
|
||||||
|
- **Analytics**: Access platform-wide usage statistics and metrics
|
||||||
|
- **Backoffice Access**: Full access to the admin panel at `/admin`
|
||||||
|
|
||||||
|
<Aside type="tip">
|
||||||
|
At the moment, the admin panel is focused on read-only operations for monitoring and analytics. Additional management capabilities (user moderation, deletion, system configuration, etc.) will be added in future releases.
|
||||||
|
</Aside>
|
||||||
|
|
||||||
|
## Accessing the Admin Panel
|
||||||
|
|
||||||
|
Users with the `admin` role can access the admin panel by navigating to:
|
||||||
|
|
||||||
|
```
|
||||||
|
https://your-papra-instance.com/admin
|
||||||
|
```
|
||||||
|
|
||||||
|
Or click on the `Admin` button in the navigation bar (visible only to admins).
|
||||||
|
|
||||||
|
## First User as Admin
|
||||||
|
|
||||||
|
### Overview
|
||||||
|
|
||||||
|
For self-hosted instances, Papra can automatically assign the `admin` role to the first user who registers. This simplifies initial setup by ensuring you have admin access from the start.
|
||||||
|
|
||||||
|
### How It Works
|
||||||
|
|
||||||
|
1. User registers (first person to create an account)
|
||||||
|
2. Account is created successfully
|
||||||
|
3. System checks if this is the first user (user count === 1)
|
||||||
|
4. The `admin` role is assigned to this user
|
||||||
|
5. User immediately has admin panel access
|
||||||
|
6. Subsequent users are normal users without admin privileges
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
The auto-assignment of the admin role to the first user is controlled by the `AUTH_FIRST_USER_AS_ADMIN` environment variable.
|
||||||
|
It is enabled by default for self-hosted instances, but can be disabled by setting it to `false`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
AUTH_FIRST_USER_AS_ADMIN=false
|
||||||
|
```
|
||||||
|
|
||||||
|
### Security Considerations
|
||||||
|
|
||||||
|
**Race Conditions:**
|
||||||
|
If multiple users register simultaneously, at most one will receive the admin role. The system checks `userCount === 1` and uses idempotent role assignment to prevent duplicate admin grants.
|
||||||
|
|
||||||
|
**Recommended Practice:**
|
||||||
|
1. Register your admin account first
|
||||||
|
2. Disable the feature after setup: `AUTH_FIRST_USER_AS_ADMIN=false`
|
||||||
|
3. Restart the service if you changed the config
|
||||||
|
|
||||||
|
## Manual Admin Assignment
|
||||||
|
|
||||||
|
For existing installations with already registered users, you can manually assign the `admin` role using the CLI script `script:make-user-admin`.
|
||||||
|
|
||||||
|
Run the following command, replacing `<user-email-or-id>` with the email or ID of the user you want to promote to admin:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm script:make-user-admin <user-email-or-id>
|
||||||
|
```
|
||||||
|
|
||||||
|
In docker, assuming your container is named `papra`, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec -it papra pnpm script:make-user-admin <user-email-or-id>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Admin Panel Features
|
||||||
|
|
||||||
|
### User Management
|
||||||
|
|
||||||
|
Navigate to `/admin/users` to:
|
||||||
|
- View all registered users
|
||||||
|
- Search users by email, name, or ID
|
||||||
|
- See user organization memberships
|
||||||
|
- View user roles and permissions
|
||||||
|
- Monitor user activity
|
||||||
|
|
||||||
|
### Analytics
|
||||||
|
|
||||||
|
Navigate to `/admin/analytics` to:
|
||||||
|
- View registration trends
|
||||||
|
- Monitor document processing stats
|
||||||
|
- Track system usage metrics
|
||||||
|
|
||||||
|
### Organizations
|
||||||
|
|
||||||
|
Navigate to `/admin/organizations` to:
|
||||||
|
- View all organizations
|
||||||
|
- Monitor organization activity
|
||||||
|
- See organization member counts
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### I registered but don't have admin access
|
||||||
|
|
||||||
|
You can manually assign admin using the script. See the [Manual Admin Assignment](#manual-admin-assignment) section.
|
||||||
|
|
||||||
|
### How do I check if a user has admin?
|
||||||
|
|
||||||
|
In the admin panel, navigate to the user management section and search for the user. Their roles will be listed in their profile.
|
||||||
|
|
||||||
|
### Can I have multiple admins?
|
||||||
|
|
||||||
|
Yes, you can assign the `admin` role to multiple users.
|
||||||
|
|
||||||
|
## Related Documentation
|
||||||
|
|
||||||
|
- [Configuration Guide](/self-hosting/configuration)
|
||||||
|
- [Docker Setup](/self-hosting/using-docker)
|
||||||
|
- [CLI Reference](/resources/cli)
|
||||||
@@ -66,6 +66,19 @@ When creating an API key, you can select from the following permissions:
|
|||||||
|
|
||||||
## Endpoints
|
## Endpoints
|
||||||
|
|
||||||
|
### Check current API key
|
||||||
|
|
||||||
|
**GET** `/api/api-keys/current`
|
||||||
|
|
||||||
|
Get information about the currently used API key.
|
||||||
|
|
||||||
|
- Required API key permissions: none
|
||||||
|
- Response (JSON)
|
||||||
|
- `apiKey`: The current API key information.
|
||||||
|
- `id`: The API key ID.
|
||||||
|
- `name`: The API key name.
|
||||||
|
- `permissions`: The list of permissions associated with the API key.
|
||||||
|
|
||||||
### List organizations
|
### List organizations
|
||||||
|
|
||||||
**GET** `/api/organizations`
|
**GET** `/api/organizations`
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export const sidebar = [
|
|||||||
{ label: 'Using Docker', slug: 'self-hosting/using-docker' },
|
{ label: 'Using Docker', slug: 'self-hosting/using-docker' },
|
||||||
{ label: 'Using Docker Compose', slug: 'self-hosting/using-docker-compose' },
|
{ label: 'Using Docker Compose', slug: 'self-hosting/using-docker-compose' },
|
||||||
{ label: 'Docker Compose Generator', link: '/docker-compose-generator', badge: { text: 'new', variant: 'note' } },
|
{ label: 'Docker Compose Generator', link: '/docker-compose-generator', badge: { text: 'new', variant: 'note' } },
|
||||||
|
{ label: 'From Source', slug: 'self-hosting/from-source' },
|
||||||
{ label: 'Configuration', slug: 'self-hosting/configuration' },
|
{ label: 'Configuration', slug: 'self-hosting/configuration' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -44,6 +45,11 @@ export const sidebar = [
|
|||||||
label: 'Tagging Rules',
|
label: 'Tagging Rules',
|
||||||
slug: 'guides/tagging-rules',
|
slug: 'guides/tagging-rules',
|
||||||
},
|
},
|
||||||
|
// Will be uncommented after release
|
||||||
|
// {
|
||||||
|
// label: 'Roles and Administration',
|
||||||
|
// slug: 'guides/roles-administration',
|
||||||
|
// },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -9,7 +9,8 @@
|
|||||||
"userInterfaceStyle": "automatic",
|
"userInterfaceStyle": "automatic",
|
||||||
"newArchEnabled": true,
|
"newArchEnabled": true,
|
||||||
"ios": {
|
"ios": {
|
||||||
"supportsTablet": true
|
"supportsTablet": true,
|
||||||
|
"bundleIdentifier": "app.papra.ios"
|
||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"adaptiveIcon": {
|
"adaptiveIcon": {
|
||||||
|
|||||||
5
apps/mobile/app/ReactotronConfig.ts
Normal file
5
apps/mobile/app/ReactotronConfig.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import Reactotron from 'reactotron-react-native';
|
||||||
|
|
||||||
|
Reactotron.configure({ name: 'Papra' }) // controls connection & communication settings
|
||||||
|
.useReactNative() // add all built-in react native plugins
|
||||||
|
.connect(); // let's connect!
|
||||||
@@ -3,6 +3,11 @@ import { Redirect } from 'expo-router';
|
|||||||
import { createAuthClient } from '@/modules/auth/auth.client';
|
import { createAuthClient } from '@/modules/auth/auth.client';
|
||||||
import { configLocalStorage } from '@/modules/config/config.local-storage';
|
import { configLocalStorage } from '@/modules/config/config.local-storage';
|
||||||
|
|
||||||
|
if (__DEV__) {
|
||||||
|
// eslint-disable-next-line ts/no-require-imports
|
||||||
|
require('./ReactotronConfig');
|
||||||
|
}
|
||||||
|
|
||||||
export default function Index() {
|
export default function Index() {
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey: ['api-server-url'],
|
queryKey: ['api-server-url'],
|
||||||
|
|||||||
@@ -29,12 +29,13 @@
|
|||||||
"better-auth": "catalog:",
|
"better-auth": "catalog:",
|
||||||
"expo": "~54.0.22",
|
"expo": "~54.0.22",
|
||||||
"expo-constants": "~18.0.10",
|
"expo-constants": "~18.0.10",
|
||||||
"expo-document-picker": "^14.0.7",
|
"expo-document-picker": "^14.0.8",
|
||||||
"expo-file-system": "^19.0.19",
|
"expo-file-system": "^19.0.19",
|
||||||
"expo-font": "~14.0.9",
|
"expo-font": "~14.0.9",
|
||||||
"expo-haptics": "~15.0.7",
|
"expo-haptics": "~15.0.7",
|
||||||
"expo-image": "~3.0.10",
|
"expo-image": "~3.0.10",
|
||||||
"expo-linking": "~8.0.8",
|
"expo-linking": "~8.0.8",
|
||||||
|
"expo-network": "^8.0.8",
|
||||||
"expo-router": "~6.0.14",
|
"expo-router": "~6.0.14",
|
||||||
"expo-secure-store": "^15.0.7",
|
"expo-secure-store": "^15.0.7",
|
||||||
"expo-sharing": "^14.0.7",
|
"expo-sharing": "^14.0.7",
|
||||||
@@ -62,6 +63,7 @@
|
|||||||
"eas-cli": "^16.27.0",
|
"eas-cli": "^16.27.0",
|
||||||
"eslint": "catalog:",
|
"eslint": "catalog:",
|
||||||
"eslint-config-expo": "~10.0.0",
|
"eslint-config-expo": "~10.0.0",
|
||||||
|
"reactotron-react-native": "^5.1.18",
|
||||||
"typescript": "catalog:",
|
"typescript": "catalog:",
|
||||||
"vitest": "catalog:"
|
"vitest": "catalog:"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,3 +41,9 @@ export function coerceDates<T extends Record<string, unknown>>(obj: T): CoerceDa
|
|||||||
...('scheduledPurgeAt' in obj ? { scheduledPurgeAt: coerceDateOrUndefined(obj.scheduledPurgeAt) } : {}),
|
...('scheduledPurgeAt' in obj ? { scheduledPurgeAt: coerceDateOrUndefined(obj.scheduledPurgeAt) } : {}),
|
||||||
} as CoerceDates<T>;
|
} as CoerceDates<T>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type LocalDocument = {
|
||||||
|
uri: string;
|
||||||
|
name: string;
|
||||||
|
type: string | undefined;
|
||||||
|
};
|
||||||
|
|||||||
@@ -198,7 +198,7 @@ function createStyles({ themeColors }: { themeColors: ThemeColors }) {
|
|||||||
borderRadius: 20,
|
borderRadius: 20,
|
||||||
backgroundColor: themeColors.secondaryBackground,
|
backgroundColor: themeColors.secondaryBackground,
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
alignItems: 'flex-start',
|
alignItems: 'center',
|
||||||
},
|
},
|
||||||
headerTitle: {
|
headerTitle: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
|
import type { LocalDocument } from '@/modules/api/api.models';
|
||||||
import type { ThemeColors } from '@/modules/ui/theme.constants';
|
import type { ThemeColors } from '@/modules/ui/theme.constants';
|
||||||
import * as DocumentPicker from 'expo-document-picker';
|
import * as DocumentPicker from 'expo-document-picker';
|
||||||
import { File } from 'expo-file-system';
|
|
||||||
import {
|
import {
|
||||||
Modal,
|
Modal,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
@@ -58,12 +58,16 @@ export function ImportDrawer({ visible, onClose }: ImportDrawerProps) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [pickerFile] = result.assets;
|
const pickerFile = result.assets[0];
|
||||||
if (!pickerFile) {
|
if (!pickerFile) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const file = new File(pickerFile.uri);
|
const file: LocalDocument = {
|
||||||
|
uri: pickerFile.uri,
|
||||||
|
name: pickerFile.name,
|
||||||
|
type: pickerFile.mimeType,
|
||||||
|
};
|
||||||
|
|
||||||
await uploadDocument({ file, apiClient, organizationId: currentOrganizationId });
|
await uploadDocument({ file, apiClient, organizationId: currentOrganizationId });
|
||||||
await queryClient.invalidateQueries({ queryKey: ['organizations', currentOrganizationId, 'documents'] });
|
await queryClient.invalidateQueries({ queryKey: ['organizations', currentOrganizationId, 'documents'] });
|
||||||
|
|||||||
@@ -1,31 +1,37 @@
|
|||||||
import type { ApiClient } from '../api/api.client';
|
import type { ApiClient } from '../api/api.client';
|
||||||
import type { CoerceDates } from '../api/api.models';
|
import type { CoerceDates, LocalDocument } from '../api/api.models';
|
||||||
import type { AuthClient } from '../auth/auth.client';
|
import type { AuthClient } from '../auth/auth.client';
|
||||||
import type { Document } from './documents.types';
|
import type { Document } from './documents.types';
|
||||||
import * as FileSystem from 'expo-file-system/legacy';
|
import * as FileSystem from 'expo-file-system/legacy';
|
||||||
import { coerceDates } from '../api/api.models';
|
import { coerceDates } from '../api/api.models';
|
||||||
|
|
||||||
export function getFormData(pojo: Record<string, string | Blob>): FormData {
|
export function getFormData(pojo: Record<string, string | FormDataValue | Blob>): FormData {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
Object.entries(pojo).forEach(([key, value]) => formData.append(key, value));
|
Object.entries(pojo).forEach(([key, value]) => formData.append(key, value));
|
||||||
|
|
||||||
return formData;
|
return formData;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function uploadDocument({
|
export async function uploadDocument({
|
||||||
file,
|
file,
|
||||||
organizationId,
|
organizationId,
|
||||||
|
|
||||||
apiClient,
|
apiClient,
|
||||||
}: {
|
}: {
|
||||||
file: Blob;
|
file: LocalDocument;
|
||||||
organizationId: string;
|
organizationId: string;
|
||||||
|
|
||||||
apiClient: ApiClient;
|
apiClient: ApiClient;
|
||||||
}) {
|
}) {
|
||||||
const { document } = await apiClient<{ document: Document }>({
|
const { document } = await apiClient<{ document: Document }>({
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: `/api/organizations/${organizationId}/documents`,
|
path: `/api/organizations/${organizationId}/documents`,
|
||||||
body: getFormData({ file }),
|
body: getFormData({
|
||||||
|
file: {
|
||||||
|
uri: file.uri,
|
||||||
|
// to avoid %20 in file name it is issue in react native that upload file name replaces spaces with %20
|
||||||
|
name: file.name.replace(/ /g, '_'),
|
||||||
|
type: file.type ?? 'application/json',
|
||||||
|
},
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
17
apps/mobile/src/types/formdata.d.ts
vendored
Normal file
17
apps/mobile/src/types/formdata.d.ts
vendored
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
/* eslint-disable ts/consistent-type-definitions */
|
||||||
|
/* eslint-disable ts/method-signature-style */
|
||||||
|
|
||||||
|
// Source - https://stackoverflow.com/a
|
||||||
|
// Posted by Patrick Roberts, modified by community. See post 'Timeline' for change history
|
||||||
|
// Retrieved 2025-12-19, License - CC BY-SA 4.0
|
||||||
|
|
||||||
|
interface FormDataValue {
|
||||||
|
uri: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FormData {
|
||||||
|
append(name: string, value: string | FormDataValue | Blob, fileName?: string): void;
|
||||||
|
set(name: string, value: string | FormDataValue | Blob, fileName?: string): void;
|
||||||
|
}
|
||||||
@@ -29,6 +29,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@branchlet/core": "^1.0.0",
|
"@branchlet/core": "^1.0.0",
|
||||||
"@corentinth/chisels": "catalog:",
|
"@corentinth/chisels": "catalog:",
|
||||||
|
"@corvu/otp-field": "^0.1.4",
|
||||||
"@kobalte/core": "^0.13.10",
|
"@kobalte/core": "^0.13.10",
|
||||||
"@kobalte/utils": "^0.9.1",
|
"@kobalte/utils": "^0.9.1",
|
||||||
"@modular-forms/solid": "^0.25.1",
|
"@modular-forms/solid": "^0.25.1",
|
||||||
@@ -50,6 +51,7 @@
|
|||||||
"tailwind-merge": "^2.6.0",
|
"tailwind-merge": "^2.6.0",
|
||||||
"unocss-preset-animations": "^1.3.0",
|
"unocss-preset-animations": "^1.3.0",
|
||||||
"unstorage": "^1.16.0",
|
"unstorage": "^1.16.0",
|
||||||
|
"uqr": "^0.1.2",
|
||||||
"valibot": "1.0.0-beta.10"
|
"valibot": "1.0.0-beta.10"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -42,6 +42,20 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||||||
'auth.login.form.forgot-password.label': 'Passwort vergessen?',
|
'auth.login.form.forgot-password.label': 'Passwort vergessen?',
|
||||||
'auth.login.form.submit': 'Anmelden',
|
'auth.login.form.submit': 'Anmelden',
|
||||||
|
|
||||||
|
'auth.login.two-factor.title': 'Zwei-Faktor-Verifizierung',
|
||||||
|
'auth.login.two-factor.description.totp': 'Geben Sie den 6-stelligen Verifizierungscode aus Ihrer Authentifizierungs-App ein.',
|
||||||
|
'auth.login.two-factor.description.backup-code': 'Geben Sie einen Ihrer Backup-Codes ein, um auf Ihr Konto zuzugreifen.',
|
||||||
|
'auth.login.two-factor.code.label.totp': 'Authentifizierungscode',
|
||||||
|
'auth.login.two-factor.code.label.backup-code': 'Backup-Code',
|
||||||
|
'auth.login.two-factor.code.placeholder.backup-code': 'Backup-Code eingeben',
|
||||||
|
'auth.login.two-factor.code.required': 'Bitte geben Sie den Verifizierungscode ein',
|
||||||
|
'auth.login.two-factor.trust-device.label': 'Diesem Gerät 30 Tage lang vertrauen',
|
||||||
|
'auth.login.two-factor.back': 'Zurück zur Anmeldung',
|
||||||
|
'auth.login.two-factor.submit': 'Verifizieren',
|
||||||
|
'auth.login.two-factor.verification-failed': 'Verifizierung fehlgeschlagen. Bitte überprüfen Sie Ihren Code und versuchen Sie es erneut.',
|
||||||
|
'auth.login.two-factor.use-backup-code': 'Stattdessen Backup-Code verwenden',
|
||||||
|
'auth.login.two-factor.use-totp': 'Stattdessen Authentifizierungs-App verwenden',
|
||||||
|
|
||||||
'auth.register.title': 'Bei Papra registrieren',
|
'auth.register.title': 'Bei Papra registrieren',
|
||||||
'auth.register.description': 'Erstellen Sie ein Konto, um Papra zu nutzen.',
|
'auth.register.description': 'Erstellen Sie ein Konto, um Papra zu nutzen.',
|
||||||
'auth.register.register-with-email': 'Mit E-Mail registrieren',
|
'auth.register.register-with-email': 'Mit E-Mail registrieren',
|
||||||
@@ -104,6 +118,66 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||||||
'user.settings.logout.description': 'Melden Sie sich von Ihrem Konto ab. Sie können sich später wieder anmelden.',
|
'user.settings.logout.description': 'Melden Sie sich von Ihrem Konto ab. Sie können sich später wieder anmelden.',
|
||||||
'user.settings.logout.button': 'Abmelden',
|
'user.settings.logout.button': 'Abmelden',
|
||||||
|
|
||||||
|
'user.settings.two-factor.title': 'Zwei-Faktor-Authentifizierung',
|
||||||
|
'user.settings.two-factor.description': 'Fügen Sie Ihrem Konto eine zusätzliche Sicherheitsebene hinzu.',
|
||||||
|
'user.settings.two-factor.status.enabled': 'Aktiviert',
|
||||||
|
'user.settings.two-factor.status.disabled': 'Deaktiviert',
|
||||||
|
'user.settings.two-factor.enable-button': '2FA aktivieren',
|
||||||
|
'user.settings.two-factor.disable-button': '2FA deaktivieren',
|
||||||
|
'user.settings.two-factor.regenerate-codes-button': 'Backup-Codes neu generieren',
|
||||||
|
|
||||||
|
'user.settings.two-factor.enable-dialog.title': 'Zwei-Faktor-Authentifizierung aktivieren',
|
||||||
|
'user.settings.two-factor.enable-dialog.description': 'Geben Sie Ihr Passwort ein, um 2FA zu aktivieren.',
|
||||||
|
'user.settings.two-factor.enable-dialog.password.label': 'Passwort',
|
||||||
|
'user.settings.two-factor.enable-dialog.password.placeholder': 'Geben Sie Ihr Passwort ein',
|
||||||
|
'user.settings.two-factor.enable-dialog.password.required': 'Bitte geben Sie Ihr Passwort ein',
|
||||||
|
'user.settings.two-factor.enable-dialog.cancel': 'Abbrechen',
|
||||||
|
'user.settings.two-factor.enable-dialog.submit': 'Fortfahren',
|
||||||
|
|
||||||
|
'user.settings.two-factor.setup-dialog.title': 'Zwei-Faktor-Authentifizierung einrichten',
|
||||||
|
'user.settings.two-factor.setup-dialog.description': 'Scannen Sie diesen QR-Code mit Ihrer Authentifizierungs-App und geben Sie dann den Verifizierungscode ein.',
|
||||||
|
'user.settings.two-factor.setup-dialog.qr-loading': 'QR-Code wird geladen...',
|
||||||
|
'user.settings.two-factor.setup-dialog.step1.title': 'Schritt 1: QR-Code scannen',
|
||||||
|
'user.settings.two-factor.setup-dialog.step1.description': 'Scannen Sie den untenstehenden QR-Code oder geben Sie den Einrichtungsschlüssel manuell in Ihre Authentifizierungs-App ein.',
|
||||||
|
'user.settings.two-factor.setup-dialog.copy-setup-key': 'Einrichtungsschlüssel kopieren',
|
||||||
|
'user.settings.two-factor.setup-dialog.step2.title': 'Schritt 2: Code verifizieren',
|
||||||
|
'user.settings.two-factor.setup-dialog.step2.description': 'Geben Sie den 6-stelligen Code ein, der von Ihrer Authentifizierungs-App generiert wurde, um die Zwei-Faktor-Authentifizierung zu verifizieren und zu aktivieren.',
|
||||||
|
'user.settings.two-factor.setup-dialog.code.label': 'Verifizierungscode',
|
||||||
|
'user.settings.two-factor.setup-dialog.code.placeholder': 'Geben Sie den 6-stelligen Code ein',
|
||||||
|
'user.settings.two-factor.setup-dialog.code.required': 'Bitte geben Sie den Verifizierungscode ein',
|
||||||
|
'user.settings.two-factor.setup-dialog.cancel': 'Abbrechen',
|
||||||
|
'user.settings.two-factor.setup-dialog.verify': '2FA verifizieren und aktivieren',
|
||||||
|
|
||||||
|
'user.settings.two-factor.backup-codes-dialog.title': 'Backup-Codes',
|
||||||
|
'user.settings.two-factor.backup-codes-dialog.description': 'Bewahren Sie diese Backup-Codes an einem sicheren Ort auf. Sie können sie verwenden, um auf Ihr Konto zuzugreifen, wenn Sie den Zugang zu Ihrer Authentifizierungs-App verlieren.',
|
||||||
|
'user.settings.two-factor.backup-codes-dialog.warning': 'Jeder Code kann nur einmal verwendet werden.',
|
||||||
|
'user.settings.two-factor.backup-codes-dialog.copy': 'Backup-Codes kopieren',
|
||||||
|
'user.settings.two-factor.backup-codes-dialog.download': 'Backup-Codes herunterladen',
|
||||||
|
'user.settings.two-factor.backup-codes-dialog.download-filename': 'papra-2fa-backup-codes.txt',
|
||||||
|
'user.settings.two-factor.backup-codes-dialog.copied': 'Codes in die Zwischenablage kopiert',
|
||||||
|
'user.settings.two-factor.backup-codes-dialog.close': 'Ich habe meine Codes gespeichert',
|
||||||
|
|
||||||
|
'user.settings.two-factor.disable-dialog.title': 'Zwei-Faktor-Authentifizierung deaktivieren',
|
||||||
|
'user.settings.two-factor.disable-dialog.description': 'Geben Sie Ihr Passwort ein, um 2FA zu deaktivieren. Dies macht Ihr Konto weniger sicher.',
|
||||||
|
'user.settings.two-factor.disable-dialog.password.label': 'Passwort',
|
||||||
|
'user.settings.two-factor.disable-dialog.password.placeholder': 'Geben Sie Ihr Passwort ein',
|
||||||
|
'user.settings.two-factor.disable-dialog.password.required': 'Bitte geben Sie Ihr Passwort ein',
|
||||||
|
'user.settings.two-factor.disable-dialog.cancel': 'Abbrechen',
|
||||||
|
'user.settings.two-factor.disable-dialog.submit': '2FA deaktivieren',
|
||||||
|
|
||||||
|
'user.settings.two-factor.regenerate-dialog.title': 'Backup-Codes neu generieren',
|
||||||
|
'user.settings.two-factor.regenerate-dialog.description': 'Dies macht alle bestehenden Backup-Codes ungültig und generiert neue. Geben Sie Ihr Passwort ein, um fortzufahren.',
|
||||||
|
'user.settings.two-factor.regenerate-dialog.password.label': 'Passwort',
|
||||||
|
'user.settings.two-factor.regenerate-dialog.password.placeholder': 'Geben Sie Ihr Passwort ein',
|
||||||
|
'user.settings.two-factor.regenerate-dialog.password.required': 'Bitte geben Sie Ihr Passwort ein',
|
||||||
|
'user.settings.two-factor.regenerate-dialog.cancel': 'Abbrechen',
|
||||||
|
'user.settings.two-factor.regenerate-dialog.submit': 'Codes neu generieren',
|
||||||
|
|
||||||
|
'user.settings.two-factor.enabled': 'Zwei-Faktor-Authentifizierung wurde aktiviert',
|
||||||
|
'user.settings.two-factor.disabled': 'Zwei-Faktor-Authentifizierung wurde deaktiviert',
|
||||||
|
'user.settings.two-factor.codes-regenerated': 'Backup-Codes wurden neu generiert',
|
||||||
|
'user.settings.two-factor.verification-failed': 'Verifizierung fehlgeschlagen. Bitte überprüfen Sie Ihren Code und versuchen Sie es erneut.',
|
||||||
|
|
||||||
// Organizations
|
// Organizations
|
||||||
|
|
||||||
'organizations.list.title': 'Ihre Organisationen',
|
'organizations.list.title': 'Ihre Organisationen',
|
||||||
@@ -573,6 +647,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||||||
'layout.menu.webhooks': 'Webhooks',
|
'layout.menu.webhooks': 'Webhooks',
|
||||||
'layout.menu.members': 'Mitglieder',
|
'layout.menu.members': 'Mitglieder',
|
||||||
'layout.menu.invitations': 'Einladungen',
|
'layout.menu.invitations': 'Einladungen',
|
||||||
|
'layout.menu.admin': 'Verwaltung',
|
||||||
|
|
||||||
'layout.upgrade-cta.title': 'Brauchen Sie mehr Platz?',
|
'layout.upgrade-cta.title': 'Brauchen Sie mehr Platz?',
|
||||||
'layout.upgrade-cta.description': '10x mehr Speicher + Team-Zusammenarbeit',
|
'layout.upgrade-cta.description': '10x mehr Speicher + Team-Zusammenarbeit',
|
||||||
@@ -600,6 +675,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||||||
|
|
||||||
// API errors
|
// API errors
|
||||||
|
|
||||||
|
'api-errors.api.timeout': 'Die Anfrage hat zu lange gedauert und ist abgelaufen. Bitte versuchen Sie es erneut.',
|
||||||
'api-errors.document.already_exists': 'Das Dokument existiert bereits',
|
'api-errors.document.already_exists': 'Das Dokument existiert bereits',
|
||||||
'api-errors.document.size_too_large': 'Die Datei ist zu groß',
|
'api-errors.document.size_too_large': 'Die Datei ist zu groß',
|
||||||
'api-errors.intake-emails.already_exists': 'Eine Eingang-Email mit dieser Adresse existiert bereits.',
|
'api-errors.intake-emails.already_exists': 'Eine Eingang-Email mit dieser Adresse existiert bereits.',
|
||||||
@@ -640,6 +716,15 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||||||
'api-errors.FAILED_TO_UNLINK_LAST_ACCOUNT': 'Fehler beim Trennen des letzten Kontos',
|
'api-errors.FAILED_TO_UNLINK_LAST_ACCOUNT': 'Fehler beim Trennen des letzten Kontos',
|
||||||
'api-errors.ACCOUNT_NOT_FOUND': 'Konto nicht gefunden',
|
'api-errors.ACCOUNT_NOT_FOUND': 'Konto nicht gefunden',
|
||||||
'api-errors.USER_ALREADY_HAS_PASSWORD': 'Benutzer hat bereits ein Passwort',
|
'api-errors.USER_ALREADY_HAS_PASSWORD': 'Benutzer hat bereits ein Passwort',
|
||||||
|
'api-errors.INVALID_CODE': 'Der angegebene Code ist ungültig oder abgelaufen',
|
||||||
|
'api-errors.OTP_NOT_ENABLED': 'Zwei-Faktor-Authentifizierung ist für dieses Konto nicht aktiviert',
|
||||||
|
'api-errors.OTP_HAS_EXPIRED': 'Der Zwei-Faktor-Authentifizierungscode ist abgelaufen',
|
||||||
|
'api-errors.TOTP_NOT_ENABLED': 'TOTP ist für dieses Konto nicht aktiviert',
|
||||||
|
'api-errors.TWO_FACTOR_NOT_ENABLED': 'Zwei-Faktor-Authentifizierung ist für dieses Konto nicht aktiviert',
|
||||||
|
'api-errors.BACKUP_CODES_NOT_ENABLED': 'Backup-Codes sind für dieses Konto nicht aktiviert',
|
||||||
|
'api-errors.INVALID_BACKUP_CODE': 'Der angegebene Backup-Code ist ungültig oder wurde bereits verwendet',
|
||||||
|
'api-errors.TOO_MANY_ATTEMPTS_REQUEST_NEW_CODE': 'Zu viele Versuche. Bitte fordern Sie einen neuen Code an.',
|
||||||
|
'api-errors.INVALID_TWO_FACTOR_COOKIE': 'Ungültiges Zwei-Faktor-Cookie',
|
||||||
|
|
||||||
// Not found
|
// Not found
|
||||||
|
|
||||||
@@ -712,6 +797,120 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||||||
'subscriptions.usage-warning.message': 'Sie haben {{ percent }}% Ihres Dokumentenspeichers verwendet. Erwägen Sie ein Upgrade Ihres Plans, um mehr Speicherplatz zu erhalten.',
|
'subscriptions.usage-warning.message': 'Sie haben {{ percent }}% Ihres Dokumentenspeichers verwendet. Erwägen Sie ein Upgrade Ihres Plans, um mehr Speicherplatz zu erhalten.',
|
||||||
'subscriptions.usage-warning.upgrade-button': 'Plan upgraden',
|
'subscriptions.usage-warning.upgrade-button': 'Plan upgraden',
|
||||||
|
|
||||||
|
// Admin
|
||||||
|
|
||||||
|
'admin.layout.header': 'Papra Verwaltung',
|
||||||
|
'admin.layout.back-to-app': 'Zurück zur App',
|
||||||
|
'admin.layout.menu.analytics': 'Statistiken',
|
||||||
|
'admin.layout.menu.users': 'Benutzer',
|
||||||
|
'admin.layout.menu.organizations': 'Organisationen',
|
||||||
|
|
||||||
|
'admin.analytics.title': 'Dashboard',
|
||||||
|
'admin.analytics.description': 'Einblicke und Statistiken zur Papra-Nutzung.',
|
||||||
|
'admin.analytics.user-count': 'Benutzeranzahl',
|
||||||
|
'admin.analytics.organization-count': 'Organisationsanzahl',
|
||||||
|
'admin.analytics.document-count': 'Dokumentenanzahl',
|
||||||
|
'admin.analytics.documents-storage': 'Dokumentenspeicher',
|
||||||
|
'admin.analytics.deleted-documents': 'Gelöschte Dokumente',
|
||||||
|
'admin.analytics.deleted-storage': 'Gelöschter Speicher',
|
||||||
|
|
||||||
|
'admin.organizations.title': 'Organisationsverwaltung',
|
||||||
|
'admin.organizations.description': 'Alle Organisationen im System verwalten und ansehen',
|
||||||
|
'admin.organizations.search-placeholder': 'Nach Name oder ID suchen...',
|
||||||
|
'admin.organizations.loading': 'Organisationen werden geladen...',
|
||||||
|
'admin.organizations.no-results': 'Keine Organisationen gefunden, die Ihrer Suche entsprechen.',
|
||||||
|
'admin.organizations.empty': 'Keine Organisationen gefunden.',
|
||||||
|
'admin.organizations.table.id': 'ID',
|
||||||
|
'admin.organizations.table.name': 'Name',
|
||||||
|
'admin.organizations.table.members': 'Mitglieder',
|
||||||
|
'admin.organizations.table.created': 'Erstellt',
|
||||||
|
'admin.organizations.table.updated': 'Aktualisiert',
|
||||||
|
'admin.organizations.pagination.info': 'Zeige {{ start }} bis {{ end }} von {{ total }} {{ total, =1:Organisation, Organisationen }}',
|
||||||
|
'admin.organizations.pagination.page-info': 'Seite {{ current }} von {{ total }}',
|
||||||
|
|
||||||
|
'admin.organization-detail.title': 'Organisationsdetails',
|
||||||
|
'admin.organization-detail.back': 'Zurück zu Organisationen',
|
||||||
|
'admin.organization-detail.loading.info': 'Organisationsinformationen werden geladen...',
|
||||||
|
'admin.organization-detail.loading.stats': 'Statistiken werden geladen...',
|
||||||
|
'admin.organization-detail.loading.intake-emails': 'Eingangs-E-Mails werden geladen...',
|
||||||
|
'admin.organization-detail.loading.webhooks': 'Webhooks werden geladen...',
|
||||||
|
'admin.organization-detail.loading.members': 'Mitglieder werden geladen...',
|
||||||
|
'admin.organization-detail.basic-info.title': 'Organisationsinformationen',
|
||||||
|
'admin.organization-detail.basic-info.description': 'Grundlegende Organisationsdetails',
|
||||||
|
'admin.organization-detail.basic-info.id': 'ID',
|
||||||
|
'admin.organization-detail.basic-info.name': 'Name',
|
||||||
|
'admin.organization-detail.basic-info.created': 'Erstellt',
|
||||||
|
'admin.organization-detail.basic-info.updated': 'Aktualisiert',
|
||||||
|
'admin.organization-detail.members.title': 'Mitglieder ({{ count }})',
|
||||||
|
'admin.organization-detail.members.description': 'Benutzer, die zu dieser Organisation gehören',
|
||||||
|
'admin.organization-detail.members.empty': 'Keine Mitglieder gefunden',
|
||||||
|
'admin.organization-detail.members.table.user': 'Benutzer',
|
||||||
|
'admin.organization-detail.members.table.id': 'ID',
|
||||||
|
'admin.organization-detail.members.table.role': 'Rolle',
|
||||||
|
'admin.organization-detail.members.table.joined': 'Beigetreten',
|
||||||
|
'admin.organization-detail.intake-emails.title': 'Eingangs-E-Mails ({{ count }})',
|
||||||
|
'admin.organization-detail.intake-emails.description': 'E-Mail-Adressen für Dokumentenaufnahme',
|
||||||
|
'admin.organization-detail.intake-emails.empty': 'Keine Eingangs-E-Mails konfiguriert',
|
||||||
|
'admin.organization-detail.intake-emails.status.enabled': 'Aktiviert',
|
||||||
|
'admin.organization-detail.intake-emails.status.disabled': 'Deaktiviert',
|
||||||
|
'admin.organization-detail.intake-emails.badge.active': 'Aktiv',
|
||||||
|
'admin.organization-detail.intake-emails.badge.inactive': 'Inaktiv',
|
||||||
|
'admin.organization-detail.webhooks.title': 'Webhooks ({{ count }})',
|
||||||
|
'admin.organization-detail.webhooks.description': 'Konfigurierte Webhook-Endpunkte',
|
||||||
|
'admin.organization-detail.webhooks.empty': 'Keine Webhooks konfiguriert',
|
||||||
|
'admin.organization-detail.webhooks.badge.active': 'Aktiv',
|
||||||
|
'admin.organization-detail.webhooks.badge.inactive': 'Inaktiv',
|
||||||
|
'admin.organization-detail.stats.title': 'Nutzungsstatistiken',
|
||||||
|
'admin.organization-detail.stats.description': 'Dokument- und Speicherstatistiken',
|
||||||
|
'admin.organization-detail.stats.active-documents': 'Aktive Dokumente',
|
||||||
|
'admin.organization-detail.stats.active-storage': 'Aktiver Speicher',
|
||||||
|
'admin.organization-detail.stats.deleted-documents': 'Gelöschte Dokumente',
|
||||||
|
'admin.organization-detail.stats.deleted-storage': 'Gelöschter Speicher',
|
||||||
|
'admin.organization-detail.stats.total-documents': 'Gesamte Dokumente',
|
||||||
|
'admin.organization-detail.stats.total-storage': 'Gesamter Speicher',
|
||||||
|
|
||||||
|
'admin.users.title': 'Benutzerverwaltung',
|
||||||
|
'admin.users.description': 'Alle Benutzer im System verwalten und ansehen',
|
||||||
|
'admin.users.search-placeholder': 'Nach Name, E-Mail oder ID suchen...',
|
||||||
|
'admin.users.loading': 'Benutzer werden geladen...',
|
||||||
|
'admin.users.no-results': 'Keine Benutzer gefunden, die Ihrer Suche entsprechen.',
|
||||||
|
'admin.users.empty': 'Keine Benutzer gefunden.',
|
||||||
|
'admin.users.table.user': 'Benutzer',
|
||||||
|
'admin.users.table.id': 'ID',
|
||||||
|
'admin.users.table.status': 'Status',
|
||||||
|
'admin.users.table.status.verified': 'Verifiziert',
|
||||||
|
'admin.users.table.status.unverified': 'Nicht verifiziert',
|
||||||
|
'admin.users.table.orgs': 'Orgs',
|
||||||
|
'admin.users.table.created': 'Erstellt',
|
||||||
|
'admin.users.pagination.info': 'Zeige {{ start }} bis {{ end }} von {{ total }} {{ total, =1:Benutzer, Benutzern }}',
|
||||||
|
'admin.users.pagination.page-info': 'Seite {{ current }} von {{ total }}',
|
||||||
|
|
||||||
|
'admin.user-detail.back': 'Zurück zu Benutzern',
|
||||||
|
'admin.user-detail.loading': 'Benutzerdetails werden geladen...',
|
||||||
|
'admin.user-detail.unnamed': 'Unbenannter Benutzer',
|
||||||
|
'admin.user-detail.basic-info.title': 'Benutzerinformationen',
|
||||||
|
'admin.user-detail.basic-info.description': 'Grundlegende Benutzerdetails und Kontoinformationen',
|
||||||
|
'admin.user-detail.basic-info.user-id': 'Benutzer-ID',
|
||||||
|
'admin.user-detail.basic-info.email': 'E-Mail',
|
||||||
|
'admin.user-detail.basic-info.name': 'Name',
|
||||||
|
'admin.user-detail.basic-info.name-empty': '-',
|
||||||
|
'admin.user-detail.basic-info.email-verified': 'E-Mail verifiziert',
|
||||||
|
'admin.user-detail.basic-info.email-verified.yes': 'Ja',
|
||||||
|
'admin.user-detail.basic-info.email-verified.no': 'Nein',
|
||||||
|
'admin.user-detail.basic-info.max-organizations': 'Max. Organisationen',
|
||||||
|
'admin.user-detail.basic-info.max-organizations.unlimited': 'Unbegrenzt',
|
||||||
|
'admin.user-detail.basic-info.created': 'Erstellt',
|
||||||
|
'admin.user-detail.basic-info.updated': 'Zuletzt aktualisiert',
|
||||||
|
'admin.user-detail.roles.title': 'Rollen & Berechtigungen',
|
||||||
|
'admin.user-detail.roles.description': 'Benutzerrollen und Zugriffsebenen',
|
||||||
|
'admin.user-detail.roles.empty': 'Keine Rollen zugewiesen',
|
||||||
|
'admin.user-detail.organizations.title': 'Organisationen ({{ count }})',
|
||||||
|
'admin.user-detail.organizations.description': 'Organisationen, zu denen dieser Benutzer gehört',
|
||||||
|
'admin.user-detail.organizations.empty': 'Mitglied in keinen Organisationen',
|
||||||
|
'admin.user-detail.organizations.table.id': 'ID',
|
||||||
|
'admin.user-detail.organizations.table.name': 'Name',
|
||||||
|
'admin.user-detail.organizations.table.created': 'Erstellt',
|
||||||
|
|
||||||
// Common / Shared
|
// Common / Shared
|
||||||
|
|
||||||
'common.confirm-modal.type-to-confirm': 'Geben Sie "{{ text }}" ein zur Bestätigung',
|
'common.confirm-modal.type-to-confirm': 'Geben Sie "{{ text }}" ein zur Bestätigung',
|
||||||
|
|||||||
@@ -40,6 +40,20 @@ export const translations = {
|
|||||||
'auth.login.form.forgot-password.label': 'Forgot password?',
|
'auth.login.form.forgot-password.label': 'Forgot password?',
|
||||||
'auth.login.form.submit': 'Login',
|
'auth.login.form.submit': 'Login',
|
||||||
|
|
||||||
|
'auth.login.two-factor.title': 'Two-Factor Verification',
|
||||||
|
'auth.login.two-factor.description.totp': 'Enter the 6-digit verification code from your authenticator app.',
|
||||||
|
'auth.login.two-factor.description.backup-code': 'Enter one of your backup codes to access your account.',
|
||||||
|
'auth.login.two-factor.code.label.totp': 'Authenticator code',
|
||||||
|
'auth.login.two-factor.code.label.backup-code': 'Backup code',
|
||||||
|
'auth.login.two-factor.code.placeholder.backup-code': 'Enter backup code',
|
||||||
|
'auth.login.two-factor.code.required': 'Please enter the verification code',
|
||||||
|
'auth.login.two-factor.trust-device.label': 'Trust this device for 30 days',
|
||||||
|
'auth.login.two-factor.back': 'Back to login',
|
||||||
|
'auth.login.two-factor.submit': 'Verify',
|
||||||
|
'auth.login.two-factor.verification-failed': 'Verification failed. Please check your code and try again.',
|
||||||
|
'auth.login.two-factor.use-backup-code': 'Use backup code instead',
|
||||||
|
'auth.login.two-factor.use-totp': 'Use authenticator app instead',
|
||||||
|
|
||||||
'auth.register.title': 'Register to Papra',
|
'auth.register.title': 'Register to Papra',
|
||||||
'auth.register.description': 'Create an account to start using Papra.',
|
'auth.register.description': 'Create an account to start using Papra.',
|
||||||
'auth.register.register-with-email': 'Register with email',
|
'auth.register.register-with-email': 'Register with email',
|
||||||
@@ -102,6 +116,66 @@ export const translations = {
|
|||||||
'user.settings.logout.description': 'Logout from your account. You can login again later.',
|
'user.settings.logout.description': 'Logout from your account. You can login again later.',
|
||||||
'user.settings.logout.button': 'Logout',
|
'user.settings.logout.button': 'Logout',
|
||||||
|
|
||||||
|
'user.settings.two-factor.title': 'Two-Factor Authentication',
|
||||||
|
'user.settings.two-factor.description': 'Add an extra layer of security to your account.',
|
||||||
|
'user.settings.two-factor.status.enabled': 'Enabled',
|
||||||
|
'user.settings.two-factor.status.disabled': 'Disabled',
|
||||||
|
'user.settings.two-factor.enable-button': 'Enable 2FA',
|
||||||
|
'user.settings.two-factor.disable-button': 'Disable 2FA',
|
||||||
|
'user.settings.two-factor.regenerate-codes-button': 'Regenerate backup codes',
|
||||||
|
|
||||||
|
'user.settings.two-factor.enable-dialog.title': 'Enable Two-Factor Authentication',
|
||||||
|
'user.settings.two-factor.enable-dialog.description': 'Enter your password to enable 2FA.',
|
||||||
|
'user.settings.two-factor.enable-dialog.password.label': 'Password',
|
||||||
|
'user.settings.two-factor.enable-dialog.password.placeholder': 'Enter your password',
|
||||||
|
'user.settings.two-factor.enable-dialog.password.required': 'Please enter your password',
|
||||||
|
'user.settings.two-factor.enable-dialog.cancel': 'Cancel',
|
||||||
|
'user.settings.two-factor.enable-dialog.submit': 'Continue',
|
||||||
|
|
||||||
|
'user.settings.two-factor.setup-dialog.title': 'Set Up Two-Factor Authentication',
|
||||||
|
'user.settings.two-factor.setup-dialog.description': 'Scan this QR code with your authenticator app, then enter the verification code.',
|
||||||
|
'user.settings.two-factor.setup-dialog.qr-loading': 'Loading QR code...',
|
||||||
|
'user.settings.two-factor.setup-dialog.step1.title': 'Step 1: Scan the QR code',
|
||||||
|
'user.settings.two-factor.setup-dialog.step1.description': 'Scan the QR code below or manually enter the setup key into your authenticator app.',
|
||||||
|
'user.settings.two-factor.setup-dialog.copy-setup-key': 'Copy setup key',
|
||||||
|
'user.settings.two-factor.setup-dialog.step2.title': 'Step 2: Verify the code',
|
||||||
|
'user.settings.two-factor.setup-dialog.step2.description': 'Enter the 6-digit code generated by your authenticator app to verify and enable two-factor authentication.',
|
||||||
|
'user.settings.two-factor.setup-dialog.code.label': 'Verification code',
|
||||||
|
'user.settings.two-factor.setup-dialog.code.placeholder': 'Enter 6-digit code',
|
||||||
|
'user.settings.two-factor.setup-dialog.code.required': 'Please enter the verification code',
|
||||||
|
'user.settings.two-factor.setup-dialog.cancel': 'Cancel',
|
||||||
|
'user.settings.two-factor.setup-dialog.verify': 'Verify and enable 2FA',
|
||||||
|
|
||||||
|
'user.settings.two-factor.backup-codes-dialog.title': 'Backup Codes',
|
||||||
|
'user.settings.two-factor.backup-codes-dialog.description': 'Save these backup codes in a safe place. You can use them to access your account if you lose access to your authenticator app.',
|
||||||
|
'user.settings.two-factor.backup-codes-dialog.warning': 'Each code can only be used once.',
|
||||||
|
'user.settings.two-factor.backup-codes-dialog.copy': 'Copy backup codes',
|
||||||
|
'user.settings.two-factor.backup-codes-dialog.download': 'Download backup codes',
|
||||||
|
'user.settings.two-factor.backup-codes-dialog.download-filename': 'papra-2fa-backup-codes.txt',
|
||||||
|
'user.settings.two-factor.backup-codes-dialog.copied': 'Codes copied to clipboard',
|
||||||
|
'user.settings.two-factor.backup-codes-dialog.close': 'I\'ve saved my codes',
|
||||||
|
|
||||||
|
'user.settings.two-factor.disable-dialog.title': 'Disable Two-Factor Authentication',
|
||||||
|
'user.settings.two-factor.disable-dialog.description': 'Enter your password to disable 2FA. This will make your account less secure.',
|
||||||
|
'user.settings.two-factor.disable-dialog.password.label': 'Password',
|
||||||
|
'user.settings.two-factor.disable-dialog.password.placeholder': 'Enter your password',
|
||||||
|
'user.settings.two-factor.disable-dialog.password.required': 'Please enter your password',
|
||||||
|
'user.settings.two-factor.disable-dialog.cancel': 'Cancel',
|
||||||
|
'user.settings.two-factor.disable-dialog.submit': 'Disable 2FA',
|
||||||
|
|
||||||
|
'user.settings.two-factor.regenerate-dialog.title': 'Regenerate Backup Codes',
|
||||||
|
'user.settings.two-factor.regenerate-dialog.description': 'This will invalidate all existing backup codes and generate new ones. Enter your password to continue.',
|
||||||
|
'user.settings.two-factor.regenerate-dialog.password.label': 'Password',
|
||||||
|
'user.settings.two-factor.regenerate-dialog.password.placeholder': 'Enter your password',
|
||||||
|
'user.settings.two-factor.regenerate-dialog.password.required': 'Please enter your password',
|
||||||
|
'user.settings.two-factor.regenerate-dialog.cancel': 'Cancel',
|
||||||
|
'user.settings.two-factor.regenerate-dialog.submit': 'Regenerate codes',
|
||||||
|
|
||||||
|
'user.settings.two-factor.enabled': 'Two-factor authentication has been enabled',
|
||||||
|
'user.settings.two-factor.disabled': 'Two-factor authentication has been disabled',
|
||||||
|
'user.settings.two-factor.codes-regenerated': 'Backup codes have been regenerated',
|
||||||
|
'user.settings.two-factor.verification-failed': 'Verification failed. Please check your code and try again.',
|
||||||
|
|
||||||
// Organizations
|
// Organizations
|
||||||
|
|
||||||
'organizations.list.title': 'Your organizations',
|
'organizations.list.title': 'Your organizations',
|
||||||
@@ -571,6 +645,7 @@ export const translations = {
|
|||||||
'layout.menu.webhooks': 'Webhooks',
|
'layout.menu.webhooks': 'Webhooks',
|
||||||
'layout.menu.members': 'Members',
|
'layout.menu.members': 'Members',
|
||||||
'layout.menu.invitations': 'Invitations',
|
'layout.menu.invitations': 'Invitations',
|
||||||
|
'layout.menu.admin': 'Admin',
|
||||||
|
|
||||||
'layout.upgrade-cta.title': 'Need more space?',
|
'layout.upgrade-cta.title': 'Need more space?',
|
||||||
'layout.upgrade-cta.description': 'Get 10x more storage + team collaboration',
|
'layout.upgrade-cta.description': 'Get 10x more storage + team collaboration',
|
||||||
@@ -598,6 +673,7 @@ export const translations = {
|
|||||||
|
|
||||||
// API errors
|
// API errors
|
||||||
|
|
||||||
|
'api-errors.api.timeout': 'The request took too long and timed out. Please try again.',
|
||||||
'api-errors.document.already_exists': 'The document already exists',
|
'api-errors.document.already_exists': 'The document already exists',
|
||||||
'api-errors.document.size_too_large': 'The file size is too large',
|
'api-errors.document.size_too_large': 'The file size is too large',
|
||||||
'api-errors.intake-emails.already_exists': 'An intake email with this address already exists.',
|
'api-errors.intake-emails.already_exists': 'An intake email with this address already exists.',
|
||||||
@@ -638,6 +714,15 @@ export const translations = {
|
|||||||
'api-errors.FAILED_TO_UNLINK_LAST_ACCOUNT': 'Failed to unlink last account',
|
'api-errors.FAILED_TO_UNLINK_LAST_ACCOUNT': 'Failed to unlink last account',
|
||||||
'api-errors.ACCOUNT_NOT_FOUND': 'Account not found',
|
'api-errors.ACCOUNT_NOT_FOUND': 'Account not found',
|
||||||
'api-errors.USER_ALREADY_HAS_PASSWORD': 'User already has password',
|
'api-errors.USER_ALREADY_HAS_PASSWORD': 'User already has password',
|
||||||
|
'api-errors.INVALID_CODE': 'The provided code is invalid or has expired',
|
||||||
|
'api-errors.OTP_NOT_ENABLED': 'Two-factor authentication is not enabled for this account',
|
||||||
|
'api-errors.OTP_HAS_EXPIRED': 'The two-factor authentication code has expired',
|
||||||
|
'api-errors.TOTP_NOT_ENABLED': 'TOTP is not enabled for this account',
|
||||||
|
'api-errors.TWO_FACTOR_NOT_ENABLED': 'Two-factor authentication is not enabled for this account',
|
||||||
|
'api-errors.BACKUP_CODES_NOT_ENABLED': 'Backup codes are not enabled for this account',
|
||||||
|
'api-errors.INVALID_BACKUP_CODE': 'The provided backup code is invalid or has already been used',
|
||||||
|
'api-errors.TOO_MANY_ATTEMPTS_REQUEST_NEW_CODE': 'Too many attempts. Please request a new code.',
|
||||||
|
'api-errors.INVALID_TWO_FACTOR_COOKIE': 'Invalid two factor cookie',
|
||||||
|
|
||||||
// Not found
|
// Not found
|
||||||
|
|
||||||
@@ -710,6 +795,120 @@ export const translations = {
|
|||||||
'subscriptions.usage-warning.message': 'You have used {{ percent }}% of your document storage. Consider upgrading your plan to get more space.',
|
'subscriptions.usage-warning.message': 'You have used {{ percent }}% of your document storage. Consider upgrading your plan to get more space.',
|
||||||
'subscriptions.usage-warning.upgrade-button': 'Upgrade Plan',
|
'subscriptions.usage-warning.upgrade-button': 'Upgrade Plan',
|
||||||
|
|
||||||
|
// Admin
|
||||||
|
|
||||||
|
'admin.layout.header': 'Papra admin',
|
||||||
|
'admin.layout.back-to-app': 'Back to App',
|
||||||
|
'admin.layout.menu.analytics': 'Analytics',
|
||||||
|
'admin.layout.menu.users': 'Users',
|
||||||
|
'admin.layout.menu.organizations': 'Organizations',
|
||||||
|
|
||||||
|
'admin.analytics.title': 'Dashboard',
|
||||||
|
'admin.analytics.description': 'Insights and analytics about Papra usage.',
|
||||||
|
'admin.analytics.user-count': 'User count',
|
||||||
|
'admin.analytics.organization-count': 'Organization count',
|
||||||
|
'admin.analytics.document-count': 'Document count',
|
||||||
|
'admin.analytics.documents-storage': 'Documents storage',
|
||||||
|
'admin.analytics.deleted-documents': 'Deleted documents',
|
||||||
|
'admin.analytics.deleted-storage': 'Deleted storage',
|
||||||
|
|
||||||
|
'admin.organizations.title': 'Organization Management',
|
||||||
|
'admin.organizations.description': 'Manage and view all organizations in the system',
|
||||||
|
'admin.organizations.search-placeholder': 'Search by name or ID...',
|
||||||
|
'admin.organizations.loading': 'Loading organizations...',
|
||||||
|
'admin.organizations.no-results': 'No organizations found matching your search.',
|
||||||
|
'admin.organizations.empty': 'No organizations found.',
|
||||||
|
'admin.organizations.table.id': 'ID',
|
||||||
|
'admin.organizations.table.name': 'Name',
|
||||||
|
'admin.organizations.table.members': 'Members',
|
||||||
|
'admin.organizations.table.created': 'Created',
|
||||||
|
'admin.organizations.table.updated': 'Updated',
|
||||||
|
'admin.organizations.pagination.info': 'Showing {{ start }} to {{ end }} of {{ total }} {{ total, =1:organization, organizations }}',
|
||||||
|
'admin.organizations.pagination.page-info': 'Page {{ current }} of {{ total }}',
|
||||||
|
|
||||||
|
'admin.organization-detail.title': 'Organization Details',
|
||||||
|
'admin.organization-detail.back': 'Back to Organizations',
|
||||||
|
'admin.organization-detail.loading.info': 'Loading organization info...',
|
||||||
|
'admin.organization-detail.loading.stats': 'Loading stats...',
|
||||||
|
'admin.organization-detail.loading.intake-emails': 'Loading intake emails...',
|
||||||
|
'admin.organization-detail.loading.webhooks': 'Loading webhooks...',
|
||||||
|
'admin.organization-detail.loading.members': 'Loading members...',
|
||||||
|
'admin.organization-detail.basic-info.title': 'Organization Information',
|
||||||
|
'admin.organization-detail.basic-info.description': 'Basic organization details',
|
||||||
|
'admin.organization-detail.basic-info.id': 'ID',
|
||||||
|
'admin.organization-detail.basic-info.name': 'Name',
|
||||||
|
'admin.organization-detail.basic-info.created': 'Created',
|
||||||
|
'admin.organization-detail.basic-info.updated': 'Updated',
|
||||||
|
'admin.organization-detail.members.title': 'Members ({{ count }})',
|
||||||
|
'admin.organization-detail.members.description': 'Users who belong to this organization',
|
||||||
|
'admin.organization-detail.members.empty': 'No members found',
|
||||||
|
'admin.organization-detail.members.table.user': 'User',
|
||||||
|
'admin.organization-detail.members.table.id': 'Id',
|
||||||
|
'admin.organization-detail.members.table.role': 'Role',
|
||||||
|
'admin.organization-detail.members.table.joined': 'Joined',
|
||||||
|
'admin.organization-detail.intake-emails.title': 'Intake Emails ({{ count }})',
|
||||||
|
'admin.organization-detail.intake-emails.description': 'Email addresses for document ingestion',
|
||||||
|
'admin.organization-detail.intake-emails.empty': 'No intake emails configured',
|
||||||
|
'admin.organization-detail.intake-emails.status.enabled': 'Enabled',
|
||||||
|
'admin.organization-detail.intake-emails.status.disabled': 'Disabled',
|
||||||
|
'admin.organization-detail.intake-emails.badge.active': 'Active',
|
||||||
|
'admin.organization-detail.intake-emails.badge.inactive': 'Inactive',
|
||||||
|
'admin.organization-detail.webhooks.title': 'Webhooks ({{ count }})',
|
||||||
|
'admin.organization-detail.webhooks.description': 'Configured webhook endpoints',
|
||||||
|
'admin.organization-detail.webhooks.empty': 'No webhooks configured',
|
||||||
|
'admin.organization-detail.webhooks.badge.active': 'Active',
|
||||||
|
'admin.organization-detail.webhooks.badge.inactive': 'Inactive',
|
||||||
|
'admin.organization-detail.stats.title': 'Usage Statistics',
|
||||||
|
'admin.organization-detail.stats.description': 'Document and storage statistics',
|
||||||
|
'admin.organization-detail.stats.active-documents': 'Active Documents',
|
||||||
|
'admin.organization-detail.stats.active-storage': 'Active Storage',
|
||||||
|
'admin.organization-detail.stats.deleted-documents': 'Deleted Documents',
|
||||||
|
'admin.organization-detail.stats.deleted-storage': 'Deleted Storage',
|
||||||
|
'admin.organization-detail.stats.total-documents': 'Total Documents',
|
||||||
|
'admin.organization-detail.stats.total-storage': 'Total Storage',
|
||||||
|
|
||||||
|
'admin.users.title': 'User Management',
|
||||||
|
'admin.users.description': 'Manage and view all users in the system',
|
||||||
|
'admin.users.search-placeholder': 'Search by name, email, or ID...',
|
||||||
|
'admin.users.loading': 'Loading users...',
|
||||||
|
'admin.users.no-results': 'No users found matching your search.',
|
||||||
|
'admin.users.empty': 'No users found.',
|
||||||
|
'admin.users.table.user': 'User',
|
||||||
|
'admin.users.table.id': 'ID',
|
||||||
|
'admin.users.table.status': 'Status',
|
||||||
|
'admin.users.table.status.verified': 'Verified',
|
||||||
|
'admin.users.table.status.unverified': 'Unverified',
|
||||||
|
'admin.users.table.orgs': 'Orgs',
|
||||||
|
'admin.users.table.created': 'Created',
|
||||||
|
'admin.users.pagination.info': 'Showing {{ start }} to {{ end }} of {{ total }} {{ total, =1:user, users }}',
|
||||||
|
'admin.users.pagination.page-info': 'Page {{ current }} of {{ total }}',
|
||||||
|
|
||||||
|
'admin.user-detail.back': 'Back to Users',
|
||||||
|
'admin.user-detail.loading': 'Loading user details...',
|
||||||
|
'admin.user-detail.unnamed': 'Unnamed User',
|
||||||
|
'admin.user-detail.basic-info.title': 'User Information',
|
||||||
|
'admin.user-detail.basic-info.description': 'Basic user details and account information',
|
||||||
|
'admin.user-detail.basic-info.user-id': 'User ID',
|
||||||
|
'admin.user-detail.basic-info.email': 'Email',
|
||||||
|
'admin.user-detail.basic-info.name': 'Name',
|
||||||
|
'admin.user-detail.basic-info.name-empty': '-',
|
||||||
|
'admin.user-detail.basic-info.email-verified': 'Email Verified',
|
||||||
|
'admin.user-detail.basic-info.email-verified.yes': 'Yes',
|
||||||
|
'admin.user-detail.basic-info.email-verified.no': 'No',
|
||||||
|
'admin.user-detail.basic-info.max-organizations': 'Max Organizations',
|
||||||
|
'admin.user-detail.basic-info.max-organizations.unlimited': 'Unlimited',
|
||||||
|
'admin.user-detail.basic-info.created': 'Created',
|
||||||
|
'admin.user-detail.basic-info.updated': 'Last Updated',
|
||||||
|
'admin.user-detail.roles.title': 'Roles & Permissions',
|
||||||
|
'admin.user-detail.roles.description': 'User roles and access levels',
|
||||||
|
'admin.user-detail.roles.empty': 'No roles assigned',
|
||||||
|
'admin.user-detail.organizations.title': 'Organizations ({{ count }})',
|
||||||
|
'admin.user-detail.organizations.description': 'Organizations this user belongs to',
|
||||||
|
'admin.user-detail.organizations.empty': 'Not a member of any organizations',
|
||||||
|
'admin.user-detail.organizations.table.id': 'ID',
|
||||||
|
'admin.user-detail.organizations.table.name': 'Name',
|
||||||
|
'admin.user-detail.organizations.table.created': 'Created',
|
||||||
|
|
||||||
// Common / Shared
|
// Common / Shared
|
||||||
|
|
||||||
'common.confirm-modal.type-to-confirm': 'Type "{{ text }}" to confirm',
|
'common.confirm-modal.type-to-confirm': 'Type "{{ text }}" to confirm',
|
||||||
|
|||||||
@@ -42,6 +42,20 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||||||
'auth.login.form.forgot-password.label': '¿Olvidaste tu contraseña?',
|
'auth.login.form.forgot-password.label': '¿Olvidaste tu contraseña?',
|
||||||
'auth.login.form.submit': 'Iniciar sesión',
|
'auth.login.form.submit': 'Iniciar sesión',
|
||||||
|
|
||||||
|
'auth.login.two-factor.title': 'Verificación en dos pasos',
|
||||||
|
'auth.login.two-factor.description.totp': 'Introduce el código de verificación de 6 dígitos de tu aplicación autenticadora.',
|
||||||
|
'auth.login.two-factor.description.backup-code': 'Introduce uno de tus códigos de respaldo para acceder a tu cuenta.',
|
||||||
|
'auth.login.two-factor.code.label.totp': 'Código de autenticación',
|
||||||
|
'auth.login.two-factor.code.label.backup-code': 'Código de respaldo',
|
||||||
|
'auth.login.two-factor.code.placeholder.backup-code': 'Introduce el código de respaldo',
|
||||||
|
'auth.login.two-factor.code.required': 'Por favor, introduce el código de verificación',
|
||||||
|
'auth.login.two-factor.trust-device.label': 'Confiar en este dispositivo durante 30 días',
|
||||||
|
'auth.login.two-factor.back': 'Volver al inicio de sesión',
|
||||||
|
'auth.login.two-factor.submit': 'Verificar',
|
||||||
|
'auth.login.two-factor.verification-failed': 'Verificación fallida. Por favor, verifica tu código e inténtalo de nuevo.',
|
||||||
|
'auth.login.two-factor.use-backup-code': 'Usar código de respaldo',
|
||||||
|
'auth.login.two-factor.use-totp': 'Usar aplicación autenticadora',
|
||||||
|
|
||||||
'auth.register.title': 'Regístrate en Papra',
|
'auth.register.title': 'Regístrate en Papra',
|
||||||
'auth.register.description': 'Crea una cuenta para comenzar a usar Papra.',
|
'auth.register.description': 'Crea una cuenta para comenzar a usar Papra.',
|
||||||
'auth.register.register-with-email': 'Registrarse con correo electrónico',
|
'auth.register.register-with-email': 'Registrarse con correo electrónico',
|
||||||
@@ -104,6 +118,66 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||||||
'user.settings.logout.description': 'Cierra la sesión de tu cuenta. Puedes iniciar sesión nuevamente más tarde.',
|
'user.settings.logout.description': 'Cierra la sesión de tu cuenta. Puedes iniciar sesión nuevamente más tarde.',
|
||||||
'user.settings.logout.button': 'Cerrar sesión',
|
'user.settings.logout.button': 'Cerrar sesión',
|
||||||
|
|
||||||
|
'user.settings.two-factor.title': 'Autenticación de dos factores',
|
||||||
|
'user.settings.two-factor.description': 'Añade una capa adicional de seguridad a tu cuenta.',
|
||||||
|
'user.settings.two-factor.status.enabled': 'Activada',
|
||||||
|
'user.settings.two-factor.status.disabled': 'Desactivada',
|
||||||
|
'user.settings.two-factor.enable-button': 'Activar A2F',
|
||||||
|
'user.settings.two-factor.disable-button': 'Desactivar A2F',
|
||||||
|
'user.settings.two-factor.regenerate-codes-button': 'Regenerar códigos de respaldo',
|
||||||
|
|
||||||
|
'user.settings.two-factor.enable-dialog.title': 'Activar autenticación de dos factores',
|
||||||
|
'user.settings.two-factor.enable-dialog.description': 'Introduce tu contraseña para activar A2F.',
|
||||||
|
'user.settings.two-factor.enable-dialog.password.label': 'Contraseña',
|
||||||
|
'user.settings.two-factor.enable-dialog.password.placeholder': 'Introduce tu contraseña',
|
||||||
|
'user.settings.two-factor.enable-dialog.password.required': 'Por favor, introduce tu contraseña',
|
||||||
|
'user.settings.two-factor.enable-dialog.cancel': 'Cancelar',
|
||||||
|
'user.settings.two-factor.enable-dialog.submit': 'Continuar',
|
||||||
|
|
||||||
|
'user.settings.two-factor.setup-dialog.title': 'Configurar autenticación de dos factores',
|
||||||
|
'user.settings.two-factor.setup-dialog.description': 'Escanea este código QR con tu aplicación autenticadora y luego introduce el código de verificación.',
|
||||||
|
'user.settings.two-factor.setup-dialog.qr-loading': 'Cargando código QR...',
|
||||||
|
'user.settings.two-factor.setup-dialog.step1.title': 'Paso 1: Escanear el código QR',
|
||||||
|
'user.settings.two-factor.setup-dialog.step1.description': 'Escanea el código QR a continuación o introduce manualmente la clave de configuración en tu aplicación autenticadora.',
|
||||||
|
'user.settings.two-factor.setup-dialog.copy-setup-key': 'Copiar clave de configuración',
|
||||||
|
'user.settings.two-factor.setup-dialog.step2.title': 'Paso 2: Verificar el código',
|
||||||
|
'user.settings.two-factor.setup-dialog.step2.description': 'Introduce el código de 6 dígitos generado por tu aplicación autenticadora para verificar y activar la autenticación de dos factores.',
|
||||||
|
'user.settings.two-factor.setup-dialog.code.label': 'Código de verificación',
|
||||||
|
'user.settings.two-factor.setup-dialog.code.placeholder': 'Introduce el código de 6 dígitos',
|
||||||
|
'user.settings.two-factor.setup-dialog.code.required': 'Por favor, introduce el código de verificación',
|
||||||
|
'user.settings.two-factor.setup-dialog.cancel': 'Cancelar',
|
||||||
|
'user.settings.two-factor.setup-dialog.verify': 'Verificar y activar A2F',
|
||||||
|
|
||||||
|
'user.settings.two-factor.backup-codes-dialog.title': 'Códigos de respaldo',
|
||||||
|
'user.settings.two-factor.backup-codes-dialog.description': 'Guarda estos códigos de respaldo en un lugar seguro. Puedes usarlos para acceder a tu cuenta si pierdes el acceso a tu aplicación autenticadora.',
|
||||||
|
'user.settings.two-factor.backup-codes-dialog.warning': 'Cada código solo se puede usar una vez.',
|
||||||
|
'user.settings.two-factor.backup-codes-dialog.copy': 'Copiar códigos de respaldo',
|
||||||
|
'user.settings.two-factor.backup-codes-dialog.download': 'Descargar códigos de respaldo',
|
||||||
|
'user.settings.two-factor.backup-codes-dialog.download-filename': 'papra-2fa-backup-codes.txt',
|
||||||
|
'user.settings.two-factor.backup-codes-dialog.copied': 'Códigos copiados al portapapeles',
|
||||||
|
'user.settings.two-factor.backup-codes-dialog.close': 'He guardado mis códigos',
|
||||||
|
|
||||||
|
'user.settings.two-factor.disable-dialog.title': 'Desactivar autenticación de dos factores',
|
||||||
|
'user.settings.two-factor.disable-dialog.description': 'Introduce tu contraseña para desactivar A2F. Esto hará que tu cuenta sea menos segura.',
|
||||||
|
'user.settings.two-factor.disable-dialog.password.label': 'Contraseña',
|
||||||
|
'user.settings.two-factor.disable-dialog.password.placeholder': 'Introduce tu contraseña',
|
||||||
|
'user.settings.two-factor.disable-dialog.password.required': 'Por favor, introduce tu contraseña',
|
||||||
|
'user.settings.two-factor.disable-dialog.cancel': 'Cancelar',
|
||||||
|
'user.settings.two-factor.disable-dialog.submit': 'Desactivar A2F',
|
||||||
|
|
||||||
|
'user.settings.two-factor.regenerate-dialog.title': 'Regenerar códigos de respaldo',
|
||||||
|
'user.settings.two-factor.regenerate-dialog.description': 'Esto invalidará todos los códigos de respaldo existentes y generará nuevos. Introduce tu contraseña para continuar.',
|
||||||
|
'user.settings.two-factor.regenerate-dialog.password.label': 'Contraseña',
|
||||||
|
'user.settings.two-factor.regenerate-dialog.password.placeholder': 'Introduce tu contraseña',
|
||||||
|
'user.settings.two-factor.regenerate-dialog.password.required': 'Por favor, introduce tu contraseña',
|
||||||
|
'user.settings.two-factor.regenerate-dialog.cancel': 'Cancelar',
|
||||||
|
'user.settings.two-factor.regenerate-dialog.submit': 'Regenerar códigos',
|
||||||
|
|
||||||
|
'user.settings.two-factor.enabled': 'La autenticación de dos factores ha sido activada',
|
||||||
|
'user.settings.two-factor.disabled': 'La autenticación de dos factores ha sido desactivada',
|
||||||
|
'user.settings.two-factor.codes-regenerated': 'Los códigos de respaldo han sido regenerados',
|
||||||
|
'user.settings.two-factor.verification-failed': 'Verificación fallida. Por favor, verifica tu código e inténtalo de nuevo.',
|
||||||
|
|
||||||
// Organizations
|
// Organizations
|
||||||
|
|
||||||
'organizations.list.title': 'Tus organizaciones',
|
'organizations.list.title': 'Tus organizaciones',
|
||||||
@@ -573,6 +647,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||||||
'layout.menu.webhooks': 'Webhooks',
|
'layout.menu.webhooks': 'Webhooks',
|
||||||
'layout.menu.members': 'Miembros',
|
'layout.menu.members': 'Miembros',
|
||||||
'layout.menu.invitations': 'Invitaciones',
|
'layout.menu.invitations': 'Invitaciones',
|
||||||
|
'layout.menu.admin': 'Administración',
|
||||||
|
|
||||||
'layout.upgrade-cta.title': '¿Necesitas más espacio?',
|
'layout.upgrade-cta.title': '¿Necesitas más espacio?',
|
||||||
'layout.upgrade-cta.description': 'Obtén 10x más almacenamiento + colaboración en equipo',
|
'layout.upgrade-cta.description': 'Obtén 10x más almacenamiento + colaboración en equipo',
|
||||||
@@ -600,6 +675,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||||||
|
|
||||||
// API errors
|
// API errors
|
||||||
|
|
||||||
|
'api-errors.api.timeout': 'La solicitud tardó demasiado y se agotó el tiempo. Por favor, inténtalo de nuevo.',
|
||||||
'api-errors.document.already_exists': 'El documento ya existe',
|
'api-errors.document.already_exists': 'El documento ya existe',
|
||||||
'api-errors.document.size_too_large': 'El archivo es demasiado grande',
|
'api-errors.document.size_too_large': 'El archivo es demasiado grande',
|
||||||
'api-errors.intake-emails.already_exists': 'Ya existe un correo de ingreso con esta dirección.',
|
'api-errors.intake-emails.already_exists': 'Ya existe un correo de ingreso con esta dirección.',
|
||||||
@@ -640,6 +716,15 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||||||
'api-errors.FAILED_TO_UNLINK_LAST_ACCOUNT': 'Error al desvincular la última cuenta',
|
'api-errors.FAILED_TO_UNLINK_LAST_ACCOUNT': 'Error al desvincular la última cuenta',
|
||||||
'api-errors.ACCOUNT_NOT_FOUND': 'Cuenta no encontrada',
|
'api-errors.ACCOUNT_NOT_FOUND': 'Cuenta no encontrada',
|
||||||
'api-errors.USER_ALREADY_HAS_PASSWORD': 'El usuario ya tiene contraseña',
|
'api-errors.USER_ALREADY_HAS_PASSWORD': 'El usuario ya tiene contraseña',
|
||||||
|
'api-errors.INVALID_CODE': 'El código proporcionado es inválido o ha expirado',
|
||||||
|
'api-errors.OTP_NOT_ENABLED': 'La autenticación de dos factores no está activada para esta cuenta',
|
||||||
|
'api-errors.OTP_HAS_EXPIRED': 'El código de autenticación de dos factores ha expirado',
|
||||||
|
'api-errors.TOTP_NOT_ENABLED': 'TOTP no está activado para esta cuenta',
|
||||||
|
'api-errors.TWO_FACTOR_NOT_ENABLED': 'La autenticación de dos factores no está activada para esta cuenta',
|
||||||
|
'api-errors.BACKUP_CODES_NOT_ENABLED': 'Los códigos de respaldo no están activados para esta cuenta',
|
||||||
|
'api-errors.INVALID_BACKUP_CODE': 'El código de respaldo proporcionado es inválido o ya ha sido usado',
|
||||||
|
'api-errors.TOO_MANY_ATTEMPTS_REQUEST_NEW_CODE': 'Demasiados intentos. Por favor, solicita un nuevo código.',
|
||||||
|
'api-errors.INVALID_TWO_FACTOR_COOKIE': 'Cookie de autenticación de dos factores inválida',
|
||||||
|
|
||||||
// Not found
|
// Not found
|
||||||
|
|
||||||
@@ -712,6 +797,120 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||||||
'subscriptions.usage-warning.message': 'Ha utilizado el {{ percent }}% de su almacenamiento de documentos. Considere actualizar su plan para obtener más espacio.',
|
'subscriptions.usage-warning.message': 'Ha utilizado el {{ percent }}% de su almacenamiento de documentos. Considere actualizar su plan para obtener más espacio.',
|
||||||
'subscriptions.usage-warning.upgrade-button': 'Actualizar plan',
|
'subscriptions.usage-warning.upgrade-button': 'Actualizar plan',
|
||||||
|
|
||||||
|
// Admin
|
||||||
|
|
||||||
|
'admin.layout.header': 'Administración de Papra',
|
||||||
|
'admin.layout.back-to-app': 'Volver a la aplicación',
|
||||||
|
'admin.layout.menu.analytics': 'Estadísticas',
|
||||||
|
'admin.layout.menu.users': 'Usuarios',
|
||||||
|
'admin.layout.menu.organizations': 'Organizaciones',
|
||||||
|
|
||||||
|
'admin.analytics.title': 'Panel de control',
|
||||||
|
'admin.analytics.description': 'Información y estadísticas sobre el uso de Papra.',
|
||||||
|
'admin.analytics.user-count': 'Cantidad de usuarios',
|
||||||
|
'admin.analytics.organization-count': 'Cantidad de organizaciones',
|
||||||
|
'admin.analytics.document-count': 'Cantidad de documentos',
|
||||||
|
'admin.analytics.documents-storage': 'Almacenamiento de documentos',
|
||||||
|
'admin.analytics.deleted-documents': 'Documentos eliminados',
|
||||||
|
'admin.analytics.deleted-storage': 'Almacenamiento eliminado',
|
||||||
|
|
||||||
|
'admin.organizations.title': 'Gestión de organizaciones',
|
||||||
|
'admin.organizations.description': 'Gestionar y ver todas las organizaciones del sistema',
|
||||||
|
'admin.organizations.search-placeholder': 'Buscar por nombre o ID...',
|
||||||
|
'admin.organizations.loading': 'Cargando organizaciones...',
|
||||||
|
'admin.organizations.no-results': 'No se encontraron organizaciones que coincidan con tu búsqueda.',
|
||||||
|
'admin.organizations.empty': 'No se encontraron organizaciones.',
|
||||||
|
'admin.organizations.table.id': 'ID',
|
||||||
|
'admin.organizations.table.name': 'Nombre',
|
||||||
|
'admin.organizations.table.members': 'Miembros',
|
||||||
|
'admin.organizations.table.created': 'Creada',
|
||||||
|
'admin.organizations.table.updated': 'Actualizada',
|
||||||
|
'admin.organizations.pagination.info': 'Mostrando {{ start }} a {{ end }} de {{ total }} {{ total, =1:organización, organizaciones }}',
|
||||||
|
'admin.organizations.pagination.page-info': 'Página {{ current }} de {{ total }}',
|
||||||
|
|
||||||
|
'admin.organization-detail.title': 'Detalles de la organización',
|
||||||
|
'admin.organization-detail.back': 'Volver a organizaciones',
|
||||||
|
'admin.organization-detail.loading.info': 'Cargando información de la organización...',
|
||||||
|
'admin.organization-detail.loading.stats': 'Cargando estadísticas...',
|
||||||
|
'admin.organization-detail.loading.intake-emails': 'Cargando correos de ingreso...',
|
||||||
|
'admin.organization-detail.loading.webhooks': 'Cargando webhooks...',
|
||||||
|
'admin.organization-detail.loading.members': 'Cargando miembros...',
|
||||||
|
'admin.organization-detail.basic-info.title': 'Información de la organización',
|
||||||
|
'admin.organization-detail.basic-info.description': 'Detalles básicos de la organización',
|
||||||
|
'admin.organization-detail.basic-info.id': 'ID',
|
||||||
|
'admin.organization-detail.basic-info.name': 'Nombre',
|
||||||
|
'admin.organization-detail.basic-info.created': 'Creada',
|
||||||
|
'admin.organization-detail.basic-info.updated': 'Actualizada',
|
||||||
|
'admin.organization-detail.members.title': 'Miembros ({{ count }})',
|
||||||
|
'admin.organization-detail.members.description': 'Usuarios que pertenecen a esta organización',
|
||||||
|
'admin.organization-detail.members.empty': 'No se encontraron miembros',
|
||||||
|
'admin.organization-detail.members.table.user': 'Usuario',
|
||||||
|
'admin.organization-detail.members.table.id': 'ID',
|
||||||
|
'admin.organization-detail.members.table.role': 'Rol',
|
||||||
|
'admin.organization-detail.members.table.joined': 'Se unió',
|
||||||
|
'admin.organization-detail.intake-emails.title': 'Correos de ingreso ({{ count }})',
|
||||||
|
'admin.organization-detail.intake-emails.description': 'Direcciones de correo para la ingesta de documentos',
|
||||||
|
'admin.organization-detail.intake-emails.empty': 'No hay correos de ingreso configurados',
|
||||||
|
'admin.organization-detail.intake-emails.status.enabled': 'Habilitado',
|
||||||
|
'admin.organization-detail.intake-emails.status.disabled': 'Deshabilitado',
|
||||||
|
'admin.organization-detail.intake-emails.badge.active': 'Activo',
|
||||||
|
'admin.organization-detail.intake-emails.badge.inactive': 'Inactivo',
|
||||||
|
'admin.organization-detail.webhooks.title': 'Webhooks ({{ count }})',
|
||||||
|
'admin.organization-detail.webhooks.description': 'Endpoints de webhook configurados',
|
||||||
|
'admin.organization-detail.webhooks.empty': 'No hay webhooks configurados',
|
||||||
|
'admin.organization-detail.webhooks.badge.active': 'Activo',
|
||||||
|
'admin.organization-detail.webhooks.badge.inactive': 'Inactivo',
|
||||||
|
'admin.organization-detail.stats.title': 'Estadísticas de uso',
|
||||||
|
'admin.organization-detail.stats.description': 'Estadísticas de documentos y almacenamiento',
|
||||||
|
'admin.organization-detail.stats.active-documents': 'Documentos activos',
|
||||||
|
'admin.organization-detail.stats.active-storage': 'Almacenamiento activo',
|
||||||
|
'admin.organization-detail.stats.deleted-documents': 'Documentos eliminados',
|
||||||
|
'admin.organization-detail.stats.deleted-storage': 'Almacenamiento eliminado',
|
||||||
|
'admin.organization-detail.stats.total-documents': 'Total de documentos',
|
||||||
|
'admin.organization-detail.stats.total-storage': 'Almacenamiento total',
|
||||||
|
|
||||||
|
'admin.users.title': 'Gestión de usuarios',
|
||||||
|
'admin.users.description': 'Gestionar y ver todos los usuarios del sistema',
|
||||||
|
'admin.users.search-placeholder': 'Buscar por nombre, correo o ID...',
|
||||||
|
'admin.users.loading': 'Cargando usuarios...',
|
||||||
|
'admin.users.no-results': 'No se encontraron usuarios que coincidan con tu búsqueda.',
|
||||||
|
'admin.users.empty': 'No se encontraron usuarios.',
|
||||||
|
'admin.users.table.user': 'Usuario',
|
||||||
|
'admin.users.table.id': 'ID',
|
||||||
|
'admin.users.table.status': 'Estado',
|
||||||
|
'admin.users.table.status.verified': 'Verificado',
|
||||||
|
'admin.users.table.status.unverified': 'No verificado',
|
||||||
|
'admin.users.table.orgs': 'Orgs',
|
||||||
|
'admin.users.table.created': 'Creado',
|
||||||
|
'admin.users.pagination.info': 'Mostrando {{ start }} a {{ end }} de {{ total }} {{ total, =1:usuario, usuarios }}',
|
||||||
|
'admin.users.pagination.page-info': 'Página {{ current }} de {{ total }}',
|
||||||
|
|
||||||
|
'admin.user-detail.back': 'Volver a usuarios',
|
||||||
|
'admin.user-detail.loading': 'Cargando detalles del usuario...',
|
||||||
|
'admin.user-detail.unnamed': 'Usuario sin nombre',
|
||||||
|
'admin.user-detail.basic-info.title': 'Información del usuario',
|
||||||
|
'admin.user-detail.basic-info.description': 'Detalles básicos del usuario e información de la cuenta',
|
||||||
|
'admin.user-detail.basic-info.user-id': 'ID de usuario',
|
||||||
|
'admin.user-detail.basic-info.email': 'Correo electrónico',
|
||||||
|
'admin.user-detail.basic-info.name': 'Nombre',
|
||||||
|
'admin.user-detail.basic-info.name-empty': '-',
|
||||||
|
'admin.user-detail.basic-info.email-verified': 'Correo verificado',
|
||||||
|
'admin.user-detail.basic-info.email-verified.yes': 'Sí',
|
||||||
|
'admin.user-detail.basic-info.email-verified.no': 'No',
|
||||||
|
'admin.user-detail.basic-info.max-organizations': 'Máx. organizaciones',
|
||||||
|
'admin.user-detail.basic-info.max-organizations.unlimited': 'Ilimitado',
|
||||||
|
'admin.user-detail.basic-info.created': 'Creado',
|
||||||
|
'admin.user-detail.basic-info.updated': 'Última actualización',
|
||||||
|
'admin.user-detail.roles.title': 'Roles y permisos',
|
||||||
|
'admin.user-detail.roles.description': 'Roles de usuario y niveles de acceso',
|
||||||
|
'admin.user-detail.roles.empty': 'No hay roles asignados',
|
||||||
|
'admin.user-detail.organizations.title': 'Organizaciones ({{ count }})',
|
||||||
|
'admin.user-detail.organizations.description': 'Organizaciones a las que pertenece este usuario',
|
||||||
|
'admin.user-detail.organizations.empty': 'No es miembro de ninguna organización',
|
||||||
|
'admin.user-detail.organizations.table.id': 'ID',
|
||||||
|
'admin.user-detail.organizations.table.name': 'Nombre',
|
||||||
|
'admin.user-detail.organizations.table.created': 'Creada',
|
||||||
|
|
||||||
// Common / Shared
|
// Common / Shared
|
||||||
|
|
||||||
'common.confirm-modal.type-to-confirm': 'Escriba "{{ text }}" para confirmar',
|
'common.confirm-modal.type-to-confirm': 'Escriba "{{ text }}" para confirmar',
|
||||||
|
|||||||
@@ -42,6 +42,20 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||||||
'auth.login.form.forgot-password.label': 'Mot de passe oublié ?',
|
'auth.login.form.forgot-password.label': 'Mot de passe oublié ?',
|
||||||
'auth.login.form.submit': 'Connexion',
|
'auth.login.form.submit': 'Connexion',
|
||||||
|
|
||||||
|
'auth.login.two-factor.title': 'Vérification en deux étapes',
|
||||||
|
'auth.login.two-factor.description.totp': 'Entrez le code de vérification à 6 chiffres de votre application d\'authentification.',
|
||||||
|
'auth.login.two-factor.description.backup-code': 'Entrez l\'un de vos codes de secours pour accéder à votre compte.',
|
||||||
|
'auth.login.two-factor.code.label.totp': 'Code d\'authentification',
|
||||||
|
'auth.login.two-factor.code.label.backup-code': 'Code de secours',
|
||||||
|
'auth.login.two-factor.code.placeholder.backup-code': 'Entrez le code de secours',
|
||||||
|
'auth.login.two-factor.code.required': 'Veuillez entrer le code de vérification',
|
||||||
|
'auth.login.two-factor.trust-device.label': 'Faire confiance à cet appareil pendant 30 jours',
|
||||||
|
'auth.login.two-factor.back': 'Retour à la connexion',
|
||||||
|
'auth.login.two-factor.submit': 'Vérifier',
|
||||||
|
'auth.login.two-factor.verification-failed': 'Échec de la vérification. Veuillez vérifier votre code et réessayer.',
|
||||||
|
'auth.login.two-factor.use-backup-code': 'Utiliser un code de secours',
|
||||||
|
'auth.login.two-factor.use-totp': 'Utiliser l\'application d\'authentification',
|
||||||
|
|
||||||
'auth.register.title': 'S\'inscrire à Papra',
|
'auth.register.title': 'S\'inscrire à Papra',
|
||||||
'auth.register.description': 'Créez un compte pour commencer à utiliser Papra.',
|
'auth.register.description': 'Créez un compte pour commencer à utiliser Papra.',
|
||||||
'auth.register.register-with-email': 'S\'inscrire avec email',
|
'auth.register.register-with-email': 'S\'inscrire avec email',
|
||||||
@@ -104,6 +118,66 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||||||
'user.settings.logout.description': 'Déconnectez-vous de votre compte. Vous pouvez vous reconnecter plus tard.',
|
'user.settings.logout.description': 'Déconnectez-vous de votre compte. Vous pouvez vous reconnecter plus tard.',
|
||||||
'user.settings.logout.button': 'Déconnexion',
|
'user.settings.logout.button': 'Déconnexion',
|
||||||
|
|
||||||
|
'user.settings.two-factor.title': 'Authentification à deux facteurs',
|
||||||
|
'user.settings.two-factor.description': 'Ajoutez une couche de sécurité supplémentaire à votre compte.',
|
||||||
|
'user.settings.two-factor.status.enabled': 'Activée',
|
||||||
|
'user.settings.two-factor.status.disabled': 'Désactivée',
|
||||||
|
'user.settings.two-factor.enable-button': 'Activer l\'A2F',
|
||||||
|
'user.settings.two-factor.disable-button': 'Désactiver l\'A2F',
|
||||||
|
'user.settings.two-factor.regenerate-codes-button': 'Régénérer les codes de secours',
|
||||||
|
|
||||||
|
'user.settings.two-factor.enable-dialog.title': 'Activer l\'authentification à deux facteurs',
|
||||||
|
'user.settings.two-factor.enable-dialog.description': 'Entrez votre mot de passe pour activer l\'A2F.',
|
||||||
|
'user.settings.two-factor.enable-dialog.password.label': 'Mot de passe',
|
||||||
|
'user.settings.two-factor.enable-dialog.password.placeholder': 'Entrez votre mot de passe',
|
||||||
|
'user.settings.two-factor.enable-dialog.password.required': 'Veuillez entrer votre mot de passe',
|
||||||
|
'user.settings.two-factor.enable-dialog.cancel': 'Annuler',
|
||||||
|
'user.settings.two-factor.enable-dialog.submit': 'Continuer',
|
||||||
|
|
||||||
|
'user.settings.two-factor.setup-dialog.title': 'Configurer l\'authentification à deux facteurs',
|
||||||
|
'user.settings.two-factor.setup-dialog.description': 'Scannez ce code QR avec votre application d\'authentification, puis entrez le code de vérification.',
|
||||||
|
'user.settings.two-factor.setup-dialog.qr-loading': 'Chargement du code QR...',
|
||||||
|
'user.settings.two-factor.setup-dialog.step1.title': 'Étape 1 : Scanner le code QR',
|
||||||
|
'user.settings.two-factor.setup-dialog.step1.description': 'Scannez le code QR ci-dessous ou saisissez manuellement la clé de configuration dans votre application d\'authentification.',
|
||||||
|
'user.settings.two-factor.setup-dialog.copy-setup-key': 'Copier la clé de configuration',
|
||||||
|
'user.settings.two-factor.setup-dialog.step2.title': 'Étape 2 : Vérifier le code',
|
||||||
|
'user.settings.two-factor.setup-dialog.step2.description': 'Entrez le code à 6 chiffres généré par votre application d\'authentification pour vérifier et activer l\'authentification à deux facteurs.',
|
||||||
|
'user.settings.two-factor.setup-dialog.code.label': 'Code de vérification',
|
||||||
|
'user.settings.two-factor.setup-dialog.code.placeholder': 'Entrez le code à 6 chiffres',
|
||||||
|
'user.settings.two-factor.setup-dialog.code.required': 'Veuillez entrer le code de vérification',
|
||||||
|
'user.settings.two-factor.setup-dialog.cancel': 'Annuler',
|
||||||
|
'user.settings.two-factor.setup-dialog.verify': 'Vérifier et activer l\'A2F',
|
||||||
|
|
||||||
|
'user.settings.two-factor.backup-codes-dialog.title': 'Codes de secours',
|
||||||
|
'user.settings.two-factor.backup-codes-dialog.description': 'Conservez ces codes de secours dans un endroit sûr. Vous pouvez les utiliser pour accéder à votre compte si vous perdez l\'accès à votre application d\'authentification.',
|
||||||
|
'user.settings.two-factor.backup-codes-dialog.warning': 'Chaque code ne peut être utilisé qu\'une seule fois.',
|
||||||
|
'user.settings.two-factor.backup-codes-dialog.copy': 'Copier les codes de secours',
|
||||||
|
'user.settings.two-factor.backup-codes-dialog.download': 'Télécharger les codes de secours',
|
||||||
|
'user.settings.two-factor.backup-codes-dialog.download-filename': 'papra-2fa-backup-codes.txt',
|
||||||
|
'user.settings.two-factor.backup-codes-dialog.copied': 'Codes copiés dans le presse-papiers',
|
||||||
|
'user.settings.two-factor.backup-codes-dialog.close': 'J\'ai sauvegardé mes codes',
|
||||||
|
|
||||||
|
'user.settings.two-factor.disable-dialog.title': 'Désactiver l\'authentification à deux facteurs',
|
||||||
|
'user.settings.two-factor.disable-dialog.description': 'Entrez votre mot de passe pour désactiver l\'A2F. Cela rendra votre compte moins sécurisé.',
|
||||||
|
'user.settings.two-factor.disable-dialog.password.label': 'Mot de passe',
|
||||||
|
'user.settings.two-factor.disable-dialog.password.placeholder': 'Entrez votre mot de passe',
|
||||||
|
'user.settings.two-factor.disable-dialog.password.required': 'Veuillez entrer votre mot de passe',
|
||||||
|
'user.settings.two-factor.disable-dialog.cancel': 'Annuler',
|
||||||
|
'user.settings.two-factor.disable-dialog.submit': 'Désactiver l\'A2F',
|
||||||
|
|
||||||
|
'user.settings.two-factor.regenerate-dialog.title': 'Régénérer les codes de secours',
|
||||||
|
'user.settings.two-factor.regenerate-dialog.description': 'Cela invalidera tous les codes de secours existants et en générera de nouveaux. Entrez votre mot de passe pour continuer.',
|
||||||
|
'user.settings.two-factor.regenerate-dialog.password.label': 'Mot de passe',
|
||||||
|
'user.settings.two-factor.regenerate-dialog.password.placeholder': 'Entrez votre mot de passe',
|
||||||
|
'user.settings.two-factor.regenerate-dialog.password.required': 'Veuillez entrer votre mot de passe',
|
||||||
|
'user.settings.two-factor.regenerate-dialog.cancel': 'Annuler',
|
||||||
|
'user.settings.two-factor.regenerate-dialog.submit': 'Régénérer les codes',
|
||||||
|
|
||||||
|
'user.settings.two-factor.enabled': 'L\'authentification à deux facteurs a été activée',
|
||||||
|
'user.settings.two-factor.disabled': 'L\'authentification à deux facteurs a été désactivée',
|
||||||
|
'user.settings.two-factor.codes-regenerated': 'Les codes de secours ont été régénérés',
|
||||||
|
'user.settings.two-factor.verification-failed': 'Échec de la vérification. Veuillez vérifier votre code et réessayer.',
|
||||||
|
|
||||||
// Organizations
|
// Organizations
|
||||||
|
|
||||||
'organizations.list.title': 'Vos organisations',
|
'organizations.list.title': 'Vos organisations',
|
||||||
@@ -573,6 +647,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||||||
'layout.menu.webhooks': 'Webhooks',
|
'layout.menu.webhooks': 'Webhooks',
|
||||||
'layout.menu.members': 'Membres',
|
'layout.menu.members': 'Membres',
|
||||||
'layout.menu.invitations': 'Invitations',
|
'layout.menu.invitations': 'Invitations',
|
||||||
|
'layout.menu.admin': 'Administration',
|
||||||
|
|
||||||
'layout.upgrade-cta.title': 'Besoin de plus d\'espace ?',
|
'layout.upgrade-cta.title': 'Besoin de plus d\'espace ?',
|
||||||
'layout.upgrade-cta.description': 'Obtenez 10x plus de stockage + collaboration d\'équipe',
|
'layout.upgrade-cta.description': 'Obtenez 10x plus de stockage + collaboration d\'équipe',
|
||||||
@@ -600,6 +675,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||||||
|
|
||||||
// API errors
|
// API errors
|
||||||
|
|
||||||
|
'api-errors.api.timeout': 'La requête a pris trop de temps et a expiré. Veuillez réessayer.',
|
||||||
'api-errors.document.already_exists': 'Le document existe déjà',
|
'api-errors.document.already_exists': 'Le document existe déjà',
|
||||||
'api-errors.document.size_too_large': 'Le fichier est trop volumineux',
|
'api-errors.document.size_too_large': 'Le fichier est trop volumineux',
|
||||||
'api-errors.intake-emails.already_exists': 'Un email de réception avec cette adresse existe déjà.',
|
'api-errors.intake-emails.already_exists': 'Un email de réception avec cette adresse existe déjà.',
|
||||||
@@ -640,6 +716,15 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||||||
'api-errors.FAILED_TO_UNLINK_LAST_ACCOUNT': 'Échec de la dissociation du dernier compte',
|
'api-errors.FAILED_TO_UNLINK_LAST_ACCOUNT': 'Échec de la dissociation du dernier compte',
|
||||||
'api-errors.ACCOUNT_NOT_FOUND': 'Compte introuvable',
|
'api-errors.ACCOUNT_NOT_FOUND': 'Compte introuvable',
|
||||||
'api-errors.USER_ALREADY_HAS_PASSWORD': 'L\'utilisateur a déjà un mot de passe',
|
'api-errors.USER_ALREADY_HAS_PASSWORD': 'L\'utilisateur a déjà un mot de passe',
|
||||||
|
'api-errors.INVALID_CODE': 'Le code fourni est invalide ou a expiré',
|
||||||
|
'api-errors.OTP_NOT_ENABLED': 'L\'authentification à deux facteurs n\'est pas activée pour ce compte',
|
||||||
|
'api-errors.OTP_HAS_EXPIRED': 'Le code d\'authentification à deux facteurs a expiré',
|
||||||
|
'api-errors.TOTP_NOT_ENABLED': 'Le TOTP n\'est pas activé pour ce compte',
|
||||||
|
'api-errors.TWO_FACTOR_NOT_ENABLED': 'L\'authentification à deux facteurs n\'est pas activée pour ce compte',
|
||||||
|
'api-errors.BACKUP_CODES_NOT_ENABLED': 'Les codes de secours ne sont pas activés pour ce compte',
|
||||||
|
'api-errors.INVALID_BACKUP_CODE': 'Le code de secours fourni est invalide ou a déjà été utilisé',
|
||||||
|
'api-errors.TOO_MANY_ATTEMPTS_REQUEST_NEW_CODE': 'Trop de tentatives. Veuillez demander un nouveau code.',
|
||||||
|
'api-errors.INVALID_TWO_FACTOR_COOKIE': 'Cookie d\'authentification à deux facteurs invalide',
|
||||||
|
|
||||||
// Not found
|
// Not found
|
||||||
|
|
||||||
@@ -712,6 +797,120 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||||||
'subscriptions.usage-warning.message': 'Vous avez utilisé {{ percent }}% de votre stockage de documents. Envisagez de mettre à niveau votre plan pour obtenir plus d\'espace.',
|
'subscriptions.usage-warning.message': 'Vous avez utilisé {{ percent }}% de votre stockage de documents. Envisagez de mettre à niveau votre plan pour obtenir plus d\'espace.',
|
||||||
'subscriptions.usage-warning.upgrade-button': 'Mettre à niveau',
|
'subscriptions.usage-warning.upgrade-button': 'Mettre à niveau',
|
||||||
|
|
||||||
|
// Admin
|
||||||
|
|
||||||
|
'admin.layout.header': 'Administration Papra',
|
||||||
|
'admin.layout.back-to-app': 'Retour à l\'application',
|
||||||
|
'admin.layout.menu.analytics': 'Statistiques',
|
||||||
|
'admin.layout.menu.users': 'Utilisateurs',
|
||||||
|
'admin.layout.menu.organizations': 'Organisations',
|
||||||
|
|
||||||
|
'admin.analytics.title': 'Tableau de bord',
|
||||||
|
'admin.analytics.description': 'Informations et statistiques sur l\'utilisation de Papra.',
|
||||||
|
'admin.analytics.user-count': 'Nombre d\'utilisateurs',
|
||||||
|
'admin.analytics.organization-count': 'Nombre d\'organisations',
|
||||||
|
'admin.analytics.document-count': 'Nombre de documents',
|
||||||
|
'admin.analytics.documents-storage': 'Stockage des documents',
|
||||||
|
'admin.analytics.deleted-documents': 'Documents supprimés',
|
||||||
|
'admin.analytics.deleted-storage': 'Stockage supprimé',
|
||||||
|
|
||||||
|
'admin.organizations.title': 'Gestion des organisations',
|
||||||
|
'admin.organizations.description': 'Gérer et consulter toutes les organisations du système',
|
||||||
|
'admin.organizations.search-placeholder': 'Rechercher par nom ou ID...',
|
||||||
|
'admin.organizations.loading': 'Chargement des organisations...',
|
||||||
|
'admin.organizations.no-results': 'Aucune organisation trouvée correspondant à votre recherche.',
|
||||||
|
'admin.organizations.empty': 'Aucune organisation trouvée.',
|
||||||
|
'admin.organizations.table.id': 'ID',
|
||||||
|
'admin.organizations.table.name': 'Nom',
|
||||||
|
'admin.organizations.table.members': 'Membres',
|
||||||
|
'admin.organizations.table.created': 'Créée',
|
||||||
|
'admin.organizations.table.updated': 'Mise à jour',
|
||||||
|
'admin.organizations.pagination.info': 'Affichage de {{ start }} à {{ end }} sur {{ total }} {{ total, =1:organisation, organisations }}',
|
||||||
|
'admin.organizations.pagination.page-info': 'Page {{ current }} sur {{ total }}',
|
||||||
|
|
||||||
|
'admin.organization-detail.title': 'Détails de l\'organisation',
|
||||||
|
'admin.organization-detail.back': 'Retour aux organisations',
|
||||||
|
'admin.organization-detail.loading.info': 'Chargement des informations...',
|
||||||
|
'admin.organization-detail.loading.stats': 'Chargement des statistiques...',
|
||||||
|
'admin.organization-detail.loading.intake-emails': 'Chargement des adresses de réception...',
|
||||||
|
'admin.organization-detail.loading.webhooks': 'Chargement des webhooks...',
|
||||||
|
'admin.organization-detail.loading.members': 'Chargement des membres...',
|
||||||
|
'admin.organization-detail.basic-info.title': 'Informations de l\'organisation',
|
||||||
|
'admin.organization-detail.basic-info.description': 'Détails de base de l\'organisation',
|
||||||
|
'admin.organization-detail.basic-info.id': 'ID',
|
||||||
|
'admin.organization-detail.basic-info.name': 'Nom',
|
||||||
|
'admin.organization-detail.basic-info.created': 'Créée',
|
||||||
|
'admin.organization-detail.basic-info.updated': 'Mise à jour',
|
||||||
|
'admin.organization-detail.members.title': 'Membres ({{ count }})',
|
||||||
|
'admin.organization-detail.members.description': 'Utilisateurs appartenant à cette organisation',
|
||||||
|
'admin.organization-detail.members.empty': 'Aucun membre trouvé',
|
||||||
|
'admin.organization-detail.members.table.user': 'Utilisateur',
|
||||||
|
'admin.organization-detail.members.table.id': 'ID',
|
||||||
|
'admin.organization-detail.members.table.role': 'Rôle',
|
||||||
|
'admin.organization-detail.members.table.joined': 'Rejoint',
|
||||||
|
'admin.organization-detail.intake-emails.title': 'Adresses de réception ({{ count }})',
|
||||||
|
'admin.organization-detail.intake-emails.description': 'Adresses email pour l\'ingestion de documents',
|
||||||
|
'admin.organization-detail.intake-emails.empty': 'Aucune adresse de réception configurée',
|
||||||
|
'admin.organization-detail.intake-emails.status.enabled': 'Activée',
|
||||||
|
'admin.organization-detail.intake-emails.status.disabled': 'Désactivée',
|
||||||
|
'admin.organization-detail.intake-emails.badge.active': 'Active',
|
||||||
|
'admin.organization-detail.intake-emails.badge.inactive': 'Inactive',
|
||||||
|
'admin.organization-detail.webhooks.title': 'Webhooks ({{ count }})',
|
||||||
|
'admin.organization-detail.webhooks.description': 'Points de terminaison webhook configurés',
|
||||||
|
'admin.organization-detail.webhooks.empty': 'Aucun webhook configuré',
|
||||||
|
'admin.organization-detail.webhooks.badge.active': 'Actif',
|
||||||
|
'admin.organization-detail.webhooks.badge.inactive': 'Inactif',
|
||||||
|
'admin.organization-detail.stats.title': 'Statistiques d\'utilisation',
|
||||||
|
'admin.organization-detail.stats.description': 'Statistiques de documents et de stockage',
|
||||||
|
'admin.organization-detail.stats.active-documents': 'Documents actifs',
|
||||||
|
'admin.organization-detail.stats.active-storage': 'Stockage actif',
|
||||||
|
'admin.organization-detail.stats.deleted-documents': 'Documents supprimés',
|
||||||
|
'admin.organization-detail.stats.deleted-storage': 'Stockage supprimé',
|
||||||
|
'admin.organization-detail.stats.total-documents': 'Total des documents',
|
||||||
|
'admin.organization-detail.stats.total-storage': 'Stockage total',
|
||||||
|
|
||||||
|
'admin.users.title': 'Gestion des utilisateurs',
|
||||||
|
'admin.users.description': 'Gérer et consulter tous les utilisateurs du système',
|
||||||
|
'admin.users.search-placeholder': 'Rechercher par nom, email ou ID...',
|
||||||
|
'admin.users.loading': 'Chargement des utilisateurs...',
|
||||||
|
'admin.users.no-results': 'Aucun utilisateur trouvé correspondant à votre recherche.',
|
||||||
|
'admin.users.empty': 'Aucun utilisateur trouvé.',
|
||||||
|
'admin.users.table.user': 'Utilisateur',
|
||||||
|
'admin.users.table.id': 'ID',
|
||||||
|
'admin.users.table.status': 'Statut',
|
||||||
|
'admin.users.table.status.verified': 'Vérifié',
|
||||||
|
'admin.users.table.status.unverified': 'Non vérifié',
|
||||||
|
'admin.users.table.orgs': 'Orgs',
|
||||||
|
'admin.users.table.created': 'Créé',
|
||||||
|
'admin.users.pagination.info': 'Affichage de {{ start }} à {{ end }} sur {{ total }} {{ total, =1:utilisateur, utilisateurs }}',
|
||||||
|
'admin.users.pagination.page-info': 'Page {{ current }} sur {{ total }}',
|
||||||
|
|
||||||
|
'admin.user-detail.back': 'Retour aux utilisateurs',
|
||||||
|
'admin.user-detail.loading': 'Chargement des détails de l\'utilisateur...',
|
||||||
|
'admin.user-detail.unnamed': 'Utilisateur sans nom',
|
||||||
|
'admin.user-detail.basic-info.title': 'Informations de l\'utilisateur',
|
||||||
|
'admin.user-detail.basic-info.description': 'Détails de base de l\'utilisateur et informations du compte',
|
||||||
|
'admin.user-detail.basic-info.user-id': 'ID utilisateur',
|
||||||
|
'admin.user-detail.basic-info.email': 'Email',
|
||||||
|
'admin.user-detail.basic-info.name': 'Nom',
|
||||||
|
'admin.user-detail.basic-info.name-empty': '-',
|
||||||
|
'admin.user-detail.basic-info.email-verified': 'Email vérifié',
|
||||||
|
'admin.user-detail.basic-info.email-verified.yes': 'Oui',
|
||||||
|
'admin.user-detail.basic-info.email-verified.no': 'Non',
|
||||||
|
'admin.user-detail.basic-info.max-organizations': 'Organisations max',
|
||||||
|
'admin.user-detail.basic-info.max-organizations.unlimited': 'Illimité',
|
||||||
|
'admin.user-detail.basic-info.created': 'Créé',
|
||||||
|
'admin.user-detail.basic-info.updated': 'Dernière mise à jour',
|
||||||
|
'admin.user-detail.roles.title': 'Rôles et permissions',
|
||||||
|
'admin.user-detail.roles.description': 'Rôles et niveaux d\'accès de l\'utilisateur',
|
||||||
|
'admin.user-detail.roles.empty': 'Aucun rôle attribué',
|
||||||
|
'admin.user-detail.organizations.title': 'Organisations ({{ count }})',
|
||||||
|
'admin.user-detail.organizations.description': 'Organisations auxquelles cet utilisateur appartient',
|
||||||
|
'admin.user-detail.organizations.empty': 'Membre d\'aucune organisation',
|
||||||
|
'admin.user-detail.organizations.table.id': 'ID',
|
||||||
|
'admin.user-detail.organizations.table.name': 'Nom',
|
||||||
|
'admin.user-detail.organizations.table.created': 'Créée',
|
||||||
|
|
||||||
// Common / Shared
|
// Common / Shared
|
||||||
|
|
||||||
'common.confirm-modal.type-to-confirm': 'Saisissez "{{ text }}" pour confirmer',
|
'common.confirm-modal.type-to-confirm': 'Saisissez "{{ text }}" pour confirmer',
|
||||||
|
|||||||
@@ -42,6 +42,20 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||||||
'auth.login.form.forgot-password.label': 'Password dimenticata?',
|
'auth.login.form.forgot-password.label': 'Password dimenticata?',
|
||||||
'auth.login.form.submit': 'Accedi',
|
'auth.login.form.submit': 'Accedi',
|
||||||
|
|
||||||
|
'auth.login.two-factor.title': 'Verifica a due fattori',
|
||||||
|
'auth.login.two-factor.description.totp': 'Inserisci il codice di verifica a 6 cifre dalla tua app di autenticazione.',
|
||||||
|
'auth.login.two-factor.description.backup-code': 'Inserisci uno dei tuoi codici di backup per accedere al tuo account.',
|
||||||
|
'auth.login.two-factor.code.label.totp': 'Codice di autenticazione',
|
||||||
|
'auth.login.two-factor.code.label.backup-code': 'Codice di backup',
|
||||||
|
'auth.login.two-factor.code.placeholder.backup-code': 'Inserisci il codice di backup',
|
||||||
|
'auth.login.two-factor.code.required': 'Inserisci il codice di verifica',
|
||||||
|
'auth.login.two-factor.trust-device.label': 'Considera attendibile questo dispositivo per 30 giorni',
|
||||||
|
'auth.login.two-factor.back': 'Torna al login',
|
||||||
|
'auth.login.two-factor.submit': 'Verifica',
|
||||||
|
'auth.login.two-factor.verification-failed': 'Verifica fallita. Controlla il tuo codice e riprova.',
|
||||||
|
'auth.login.two-factor.use-backup-code': 'Usa il codice di backup',
|
||||||
|
'auth.login.two-factor.use-totp': 'Usa l\'app di autenticazione',
|
||||||
|
|
||||||
'auth.register.title': 'Registrati a Papra',
|
'auth.register.title': 'Registrati a Papra',
|
||||||
'auth.register.description': 'Crea un account per iniziare a usare Papra.',
|
'auth.register.description': 'Crea un account per iniziare a usare Papra.',
|
||||||
'auth.register.register-with-email': 'Registrati tramite email',
|
'auth.register.register-with-email': 'Registrati tramite email',
|
||||||
@@ -104,6 +118,66 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||||||
'user.settings.logout.description': 'Esci dal tuo account. Potrai accedere nuovamente in seguito.',
|
'user.settings.logout.description': 'Esci dal tuo account. Potrai accedere nuovamente in seguito.',
|
||||||
'user.settings.logout.button': 'Esci',
|
'user.settings.logout.button': 'Esci',
|
||||||
|
|
||||||
|
'user.settings.two-factor.title': 'Autenticazione a due fattori',
|
||||||
|
'user.settings.two-factor.description': 'Aggiungi un ulteriore livello di sicurezza al tuo account.',
|
||||||
|
'user.settings.two-factor.status.enabled': 'Attivata',
|
||||||
|
'user.settings.two-factor.status.disabled': 'Disattivata',
|
||||||
|
'user.settings.two-factor.enable-button': 'Attiva A2F',
|
||||||
|
'user.settings.two-factor.disable-button': 'Disattiva A2F',
|
||||||
|
'user.settings.two-factor.regenerate-codes-button': 'Rigenera codici di backup',
|
||||||
|
|
||||||
|
'user.settings.two-factor.enable-dialog.title': 'Attiva autenticazione a due fattori',
|
||||||
|
'user.settings.two-factor.enable-dialog.description': 'Inserisci la tua password per attivare l\'A2F.',
|
||||||
|
'user.settings.two-factor.enable-dialog.password.label': 'Password',
|
||||||
|
'user.settings.two-factor.enable-dialog.password.placeholder': 'Inserisci la tua password',
|
||||||
|
'user.settings.two-factor.enable-dialog.password.required': 'Inserisci la tua password',
|
||||||
|
'user.settings.two-factor.enable-dialog.cancel': 'Annulla',
|
||||||
|
'user.settings.two-factor.enable-dialog.submit': 'Continua',
|
||||||
|
|
||||||
|
'user.settings.two-factor.setup-dialog.title': 'Configura autenticazione a due fattori',
|
||||||
|
'user.settings.two-factor.setup-dialog.description': 'Scansiona questo codice QR con la tua app di autenticazione, poi inserisci il codice di verifica.',
|
||||||
|
'user.settings.two-factor.setup-dialog.qr-loading': 'Caricamento codice QR...',
|
||||||
|
'user.settings.two-factor.setup-dialog.step1.title': 'Passo 1: Scansiona il codice QR',
|
||||||
|
'user.settings.two-factor.setup-dialog.step1.description': 'Scansiona il codice QR qui sotto o inserisci manualmente la chiave di configurazione nella tua app di autenticazione.',
|
||||||
|
'user.settings.two-factor.setup-dialog.copy-setup-key': 'Copia chiave di configurazione',
|
||||||
|
'user.settings.two-factor.setup-dialog.step2.title': 'Passo 2: Verifica il codice',
|
||||||
|
'user.settings.two-factor.setup-dialog.step2.description': 'Inserisci il codice a 6 cifre generato dalla tua app di autenticazione per verificare e attivare l\'autenticazione a due fattori.',
|
||||||
|
'user.settings.two-factor.setup-dialog.code.label': 'Codice di verifica',
|
||||||
|
'user.settings.two-factor.setup-dialog.code.placeholder': 'Inserisci il codice a 6 cifre',
|
||||||
|
'user.settings.two-factor.setup-dialog.code.required': 'Inserisci il codice di verifica',
|
||||||
|
'user.settings.two-factor.setup-dialog.cancel': 'Annulla',
|
||||||
|
'user.settings.two-factor.setup-dialog.verify': 'Verifica e attiva A2F',
|
||||||
|
|
||||||
|
'user.settings.two-factor.backup-codes-dialog.title': 'Codici di backup',
|
||||||
|
'user.settings.two-factor.backup-codes-dialog.description': 'Salva questi codici di backup in un luogo sicuro. Puoi usarli per accedere al tuo account se perdi l\'accesso alla tua app di autenticazione.',
|
||||||
|
'user.settings.two-factor.backup-codes-dialog.warning': 'Ogni codice può essere usato solo una volta.',
|
||||||
|
'user.settings.two-factor.backup-codes-dialog.copy': 'Copia codici di backup',
|
||||||
|
'user.settings.two-factor.backup-codes-dialog.download': 'Scarica codici di backup',
|
||||||
|
'user.settings.two-factor.backup-codes-dialog.download-filename': 'papra-2fa-backup-codes.txt',
|
||||||
|
'user.settings.two-factor.backup-codes-dialog.copied': 'Codici copiati negli appunti',
|
||||||
|
'user.settings.two-factor.backup-codes-dialog.close': 'Ho salvato i miei codici',
|
||||||
|
|
||||||
|
'user.settings.two-factor.disable-dialog.title': 'Disattiva autenticazione a due fattori',
|
||||||
|
'user.settings.two-factor.disable-dialog.description': 'Inserisci la tua password per disattivare l\'A2F. Questo renderà il tuo account meno sicuro.',
|
||||||
|
'user.settings.two-factor.disable-dialog.password.label': 'Password',
|
||||||
|
'user.settings.two-factor.disable-dialog.password.placeholder': 'Inserisci la tua password',
|
||||||
|
'user.settings.two-factor.disable-dialog.password.required': 'Inserisci la tua password',
|
||||||
|
'user.settings.two-factor.disable-dialog.cancel': 'Annulla',
|
||||||
|
'user.settings.two-factor.disable-dialog.submit': 'Disattiva A2F',
|
||||||
|
|
||||||
|
'user.settings.two-factor.regenerate-dialog.title': 'Rigenera codici di backup',
|
||||||
|
'user.settings.two-factor.regenerate-dialog.description': 'Questo invaliderà tutti i codici di backup esistenti e ne genererà di nuovi. Inserisci la tua password per continuare.',
|
||||||
|
'user.settings.two-factor.regenerate-dialog.password.label': 'Password',
|
||||||
|
'user.settings.two-factor.regenerate-dialog.password.placeholder': 'Inserisci la tua password',
|
||||||
|
'user.settings.two-factor.regenerate-dialog.password.required': 'Inserisci la tua password',
|
||||||
|
'user.settings.two-factor.regenerate-dialog.cancel': 'Annulla',
|
||||||
|
'user.settings.two-factor.regenerate-dialog.submit': 'Rigenera codici',
|
||||||
|
|
||||||
|
'user.settings.two-factor.enabled': 'L\'autenticazione a due fattori è stata attivata',
|
||||||
|
'user.settings.two-factor.disabled': 'L\'autenticazione a due fattori è stata disattivata',
|
||||||
|
'user.settings.two-factor.codes-regenerated': 'I codici di backup sono stati rigenerati',
|
||||||
|
'user.settings.two-factor.verification-failed': 'Verifica fallita. Controlla il tuo codice e riprova.',
|
||||||
|
|
||||||
// Organizations
|
// Organizations
|
||||||
|
|
||||||
'organizations.list.title': 'Le tue organizzazioni',
|
'organizations.list.title': 'Le tue organizzazioni',
|
||||||
@@ -573,6 +647,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||||||
'layout.menu.webhooks': 'Webhook',
|
'layout.menu.webhooks': 'Webhook',
|
||||||
'layout.menu.members': 'Membri',
|
'layout.menu.members': 'Membri',
|
||||||
'layout.menu.invitations': 'Inviti',
|
'layout.menu.invitations': 'Inviti',
|
||||||
|
'layout.menu.admin': 'Amministrazione',
|
||||||
|
|
||||||
'layout.upgrade-cta.title': 'Serve più spazio?',
|
'layout.upgrade-cta.title': 'Serve più spazio?',
|
||||||
'layout.upgrade-cta.description': 'Ottieni 10x più storage + collaborazione del team',
|
'layout.upgrade-cta.description': 'Ottieni 10x più storage + collaborazione del team',
|
||||||
@@ -600,6 +675,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||||||
|
|
||||||
// API errors
|
// API errors
|
||||||
|
|
||||||
|
'api-errors.api.timeout': 'La richiesta ha impiegato troppo tempo ed è scaduta. Riprova.',
|
||||||
'api-errors.document.already_exists': 'Il documento esiste già',
|
'api-errors.document.already_exists': 'Il documento esiste già',
|
||||||
'api-errors.document.size_too_large': 'Il file è troppo grande',
|
'api-errors.document.size_too_large': 'Il file è troppo grande',
|
||||||
'api-errors.intake-emails.already_exists': 'Un\'email di acquisizione con questo indirizzo esiste già.',
|
'api-errors.intake-emails.already_exists': 'Un\'email di acquisizione con questo indirizzo esiste già.',
|
||||||
@@ -640,6 +716,15 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||||||
'api-errors.FAILED_TO_UNLINK_LAST_ACCOUNT': 'Impossibile scollegare l\'ultimo account',
|
'api-errors.FAILED_TO_UNLINK_LAST_ACCOUNT': 'Impossibile scollegare l\'ultimo account',
|
||||||
'api-errors.ACCOUNT_NOT_FOUND': 'Account non trovato',
|
'api-errors.ACCOUNT_NOT_FOUND': 'Account non trovato',
|
||||||
'api-errors.USER_ALREADY_HAS_PASSWORD': 'L\'utente ha già una password',
|
'api-errors.USER_ALREADY_HAS_PASSWORD': 'L\'utente ha già una password',
|
||||||
|
'api-errors.INVALID_CODE': 'Il codice fornito non è valido o è scaduto',
|
||||||
|
'api-errors.OTP_NOT_ENABLED': 'L\'autenticazione a due fattori non è attivata per questo account',
|
||||||
|
'api-errors.OTP_HAS_EXPIRED': 'Il codice di autenticazione a due fattori è scaduto',
|
||||||
|
'api-errors.TOTP_NOT_ENABLED': 'Il TOTP non è attivato per questo account',
|
||||||
|
'api-errors.TWO_FACTOR_NOT_ENABLED': 'L\'autenticazione a due fattori non è attivata per questo account',
|
||||||
|
'api-errors.BACKUP_CODES_NOT_ENABLED': 'I codici di backup non sono attivati per questo account',
|
||||||
|
'api-errors.INVALID_BACKUP_CODE': 'Il codice di backup fornito non è valido o è già stato utilizzato',
|
||||||
|
'api-errors.TOO_MANY_ATTEMPTS_REQUEST_NEW_CODE': 'Troppi tentativi. Richiedi un nuovo codice.',
|
||||||
|
'api-errors.INVALID_TWO_FACTOR_COOKIE': 'Cookie di autenticazione a due fattori non valido',
|
||||||
|
|
||||||
// Not found
|
// Not found
|
||||||
|
|
||||||
@@ -712,6 +797,120 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||||||
'subscriptions.usage-warning.message': 'Hai utilizzato il {{ percent }}% dello spazio di archiviazione dei documenti. Considera l\'aggiornamento del piano per ottenere più spazio.',
|
'subscriptions.usage-warning.message': 'Hai utilizzato il {{ percent }}% dello spazio di archiviazione dei documenti. Considera l\'aggiornamento del piano per ottenere più spazio.',
|
||||||
'subscriptions.usage-warning.upgrade-button': 'Aggiorna piano',
|
'subscriptions.usage-warning.upgrade-button': 'Aggiorna piano',
|
||||||
|
|
||||||
|
// Admin
|
||||||
|
|
||||||
|
'admin.layout.header': 'Amministrazione Papra',
|
||||||
|
'admin.layout.back-to-app': 'Torna all\'app',
|
||||||
|
'admin.layout.menu.analytics': 'Statistiche',
|
||||||
|
'admin.layout.menu.users': 'Utenti',
|
||||||
|
'admin.layout.menu.organizations': 'Organizzazioni',
|
||||||
|
|
||||||
|
'admin.analytics.title': 'Dashboard',
|
||||||
|
'admin.analytics.description': 'Informazioni e statistiche sull\'utilizzo di Papra.',
|
||||||
|
'admin.analytics.user-count': 'Numero di utenti',
|
||||||
|
'admin.analytics.organization-count': 'Numero di organizzazioni',
|
||||||
|
'admin.analytics.document-count': 'Numero di documenti',
|
||||||
|
'admin.analytics.documents-storage': 'Archiviazione documenti',
|
||||||
|
'admin.analytics.deleted-documents': 'Documenti eliminati',
|
||||||
|
'admin.analytics.deleted-storage': 'Archiviazione eliminata',
|
||||||
|
|
||||||
|
'admin.organizations.title': 'Gestione organizzazioni',
|
||||||
|
'admin.organizations.description': 'Gestisci e visualizza tutte le organizzazioni del sistema',
|
||||||
|
'admin.organizations.search-placeholder': 'Cerca per nome o ID...',
|
||||||
|
'admin.organizations.loading': 'Caricamento organizzazioni...',
|
||||||
|
'admin.organizations.no-results': 'Nessuna organizzazione trovata corrispondente alla tua ricerca.',
|
||||||
|
'admin.organizations.empty': 'Nessuna organizzazione trovata.',
|
||||||
|
'admin.organizations.table.id': 'ID',
|
||||||
|
'admin.organizations.table.name': 'Nome',
|
||||||
|
'admin.organizations.table.members': 'Membri',
|
||||||
|
'admin.organizations.table.created': 'Creata',
|
||||||
|
'admin.organizations.table.updated': 'Aggiornata',
|
||||||
|
'admin.organizations.pagination.info': 'Mostrate da {{ start }} a {{ end }} di {{ total }} {{ total, =1:organizzazione, organizzazioni }}',
|
||||||
|
'admin.organizations.pagination.page-info': 'Pagina {{ current }} di {{ total }}',
|
||||||
|
|
||||||
|
'admin.organization-detail.title': 'Dettagli organizzazione',
|
||||||
|
'admin.organization-detail.back': 'Torna alle organizzazioni',
|
||||||
|
'admin.organization-detail.loading.info': 'Caricamento informazioni organizzazione...',
|
||||||
|
'admin.organization-detail.loading.stats': 'Caricamento statistiche...',
|
||||||
|
'admin.organization-detail.loading.intake-emails': 'Caricamento email di acquisizione...',
|
||||||
|
'admin.organization-detail.loading.webhooks': 'Caricamento webhook...',
|
||||||
|
'admin.organization-detail.loading.members': 'Caricamento membri...',
|
||||||
|
'admin.organization-detail.basic-info.title': 'Informazioni organizzazione',
|
||||||
|
'admin.organization-detail.basic-info.description': 'Dettagli di base dell\'organizzazione',
|
||||||
|
'admin.organization-detail.basic-info.id': 'ID',
|
||||||
|
'admin.organization-detail.basic-info.name': 'Nome',
|
||||||
|
'admin.organization-detail.basic-info.created': 'Creata',
|
||||||
|
'admin.organization-detail.basic-info.updated': 'Aggiornata',
|
||||||
|
'admin.organization-detail.members.title': 'Membri ({{ count }})',
|
||||||
|
'admin.organization-detail.members.description': 'Utenti che appartengono a questa organizzazione',
|
||||||
|
'admin.organization-detail.members.empty': 'Nessun membro trovato',
|
||||||
|
'admin.organization-detail.members.table.user': 'Utente',
|
||||||
|
'admin.organization-detail.members.table.id': 'ID',
|
||||||
|
'admin.organization-detail.members.table.role': 'Ruolo',
|
||||||
|
'admin.organization-detail.members.table.joined': 'Iscritto',
|
||||||
|
'admin.organization-detail.intake-emails.title': 'Email di acquisizione ({{ count }})',
|
||||||
|
'admin.organization-detail.intake-emails.description': 'Indirizzi email per l\'acquisizione documenti',
|
||||||
|
'admin.organization-detail.intake-emails.empty': 'Nessuna email di acquisizione configurata',
|
||||||
|
'admin.organization-detail.intake-emails.status.enabled': 'Abilitata',
|
||||||
|
'admin.organization-detail.intake-emails.status.disabled': 'Disabilitata',
|
||||||
|
'admin.organization-detail.intake-emails.badge.active': 'Attiva',
|
||||||
|
'admin.organization-detail.intake-emails.badge.inactive': 'Inattiva',
|
||||||
|
'admin.organization-detail.webhooks.title': 'Webhook ({{ count }})',
|
||||||
|
'admin.organization-detail.webhooks.description': 'Endpoint webhook configurati',
|
||||||
|
'admin.organization-detail.webhooks.empty': 'Nessun webhook configurato',
|
||||||
|
'admin.organization-detail.webhooks.badge.active': 'Attivo',
|
||||||
|
'admin.organization-detail.webhooks.badge.inactive': 'Inattivo',
|
||||||
|
'admin.organization-detail.stats.title': 'Statistiche di utilizzo',
|
||||||
|
'admin.organization-detail.stats.description': 'Statistiche documenti e archiviazione',
|
||||||
|
'admin.organization-detail.stats.active-documents': 'Documenti attivi',
|
||||||
|
'admin.organization-detail.stats.active-storage': 'Archiviazione attiva',
|
||||||
|
'admin.organization-detail.stats.deleted-documents': 'Documenti eliminati',
|
||||||
|
'admin.organization-detail.stats.deleted-storage': 'Archiviazione eliminata',
|
||||||
|
'admin.organization-detail.stats.total-documents': 'Totale documenti',
|
||||||
|
'admin.organization-detail.stats.total-storage': 'Archiviazione totale',
|
||||||
|
|
||||||
|
'admin.users.title': 'Gestione utenti',
|
||||||
|
'admin.users.description': 'Gestisci e visualizza tutti gli utenti del sistema',
|
||||||
|
'admin.users.search-placeholder': 'Cerca per nome, email o ID...',
|
||||||
|
'admin.users.loading': 'Caricamento utenti...',
|
||||||
|
'admin.users.no-results': 'Nessun utente trovato corrispondente alla tua ricerca.',
|
||||||
|
'admin.users.empty': 'Nessun utente trovato.',
|
||||||
|
'admin.users.table.user': 'Utente',
|
||||||
|
'admin.users.table.id': 'ID',
|
||||||
|
'admin.users.table.status': 'Stato',
|
||||||
|
'admin.users.table.status.verified': 'Verificato',
|
||||||
|
'admin.users.table.status.unverified': 'Non verificato',
|
||||||
|
'admin.users.table.orgs': 'Org',
|
||||||
|
'admin.users.table.created': 'Creato',
|
||||||
|
'admin.users.pagination.info': 'Mostrati da {{ start }} a {{ end }} di {{ total }} {{ total, =1:utente, utenti }}',
|
||||||
|
'admin.users.pagination.page-info': 'Pagina {{ current }} di {{ total }}',
|
||||||
|
|
||||||
|
'admin.user-detail.back': 'Torna agli utenti',
|
||||||
|
'admin.user-detail.loading': 'Caricamento dettagli utente...',
|
||||||
|
'admin.user-detail.unnamed': 'Utente senza nome',
|
||||||
|
'admin.user-detail.basic-info.title': 'Informazioni utente',
|
||||||
|
'admin.user-detail.basic-info.description': 'Dettagli di base dell\'utente e informazioni account',
|
||||||
|
'admin.user-detail.basic-info.user-id': 'ID utente',
|
||||||
|
'admin.user-detail.basic-info.email': 'Email',
|
||||||
|
'admin.user-detail.basic-info.name': 'Nome',
|
||||||
|
'admin.user-detail.basic-info.name-empty': '-',
|
||||||
|
'admin.user-detail.basic-info.email-verified': 'Email verificata',
|
||||||
|
'admin.user-detail.basic-info.email-verified.yes': 'Sì',
|
||||||
|
'admin.user-detail.basic-info.email-verified.no': 'No',
|
||||||
|
'admin.user-detail.basic-info.max-organizations': 'Organizzazioni max',
|
||||||
|
'admin.user-detail.basic-info.max-organizations.unlimited': 'Illimitate',
|
||||||
|
'admin.user-detail.basic-info.created': 'Creato',
|
||||||
|
'admin.user-detail.basic-info.updated': 'Ultimo aggiornamento',
|
||||||
|
'admin.user-detail.roles.title': 'Ruoli e permessi',
|
||||||
|
'admin.user-detail.roles.description': 'Ruoli utente e livelli di accesso',
|
||||||
|
'admin.user-detail.roles.empty': 'Nessun ruolo assegnato',
|
||||||
|
'admin.user-detail.organizations.title': 'Organizzazioni ({{ count }})',
|
||||||
|
'admin.user-detail.organizations.description': 'Organizzazioni a cui appartiene questo utente',
|
||||||
|
'admin.user-detail.organizations.empty': 'Non membro di alcuna organizzazione',
|
||||||
|
'admin.user-detail.organizations.table.id': 'ID',
|
||||||
|
'admin.user-detail.organizations.table.name': 'Nome',
|
||||||
|
'admin.user-detail.organizations.table.created': 'Creata',
|
||||||
|
|
||||||
// Common / Shared
|
// Common / Shared
|
||||||
|
|
||||||
'common.confirm-modal.type-to-confirm': 'Digita "{{ text }}" per confermare',
|
'common.confirm-modal.type-to-confirm': 'Digita "{{ text }}" per confermare',
|
||||||
|
|||||||
@@ -42,6 +42,20 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||||||
'auth.login.form.forgot-password.label': 'Wachtwoord vergeten?',
|
'auth.login.form.forgot-password.label': 'Wachtwoord vergeten?',
|
||||||
'auth.login.form.submit': 'Inloggen',
|
'auth.login.form.submit': 'Inloggen',
|
||||||
|
|
||||||
|
'auth.login.two-factor.title': 'Twee-staps-verificatie',
|
||||||
|
'auth.login.two-factor.description.totp': 'Voer de 6-cijferige verificatiecode van je authenticatie-app in.',
|
||||||
|
'auth.login.two-factor.description.backup-code': 'Voer een van je back-upcodes in om toegang te krijgen tot je account.',
|
||||||
|
'auth.login.two-factor.code.label.totp': 'Authenticatiecode',
|
||||||
|
'auth.login.two-factor.code.label.backup-code': 'Back-upcode',
|
||||||
|
'auth.login.two-factor.code.placeholder.backup-code': 'Voer back-upcode in',
|
||||||
|
'auth.login.two-factor.code.required': 'Voer de verificatiecode in',
|
||||||
|
'auth.login.two-factor.trust-device.label': 'Dit apparaat 30 dagen vertrouwen',
|
||||||
|
'auth.login.two-factor.back': 'Terug naar inloggen',
|
||||||
|
'auth.login.two-factor.submit': 'Verifiëren',
|
||||||
|
'auth.login.two-factor.verification-failed': 'Verificatie mislukt. Controleer je code en probeer het opnieuw.',
|
||||||
|
'auth.login.two-factor.use-backup-code': 'Gebruik in plaats daarvan een back-upcode',
|
||||||
|
'auth.login.two-factor.use-totp': 'Gebruik in plaats daarvan de authenticatie-app',
|
||||||
|
|
||||||
'auth.register.title': 'Registreren bij Papra',
|
'auth.register.title': 'Registreren bij Papra',
|
||||||
'auth.register.description': 'Maak een account aan om Papra te gebruiken.',
|
'auth.register.description': 'Maak een account aan om Papra te gebruiken.',
|
||||||
'auth.register.register-with-email': 'Registreren met e-mail',
|
'auth.register.register-with-email': 'Registreren met e-mail',
|
||||||
@@ -104,6 +118,66 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||||||
'user.settings.logout.description': 'Log uit van uw account. U kunt later weer inloggen.',
|
'user.settings.logout.description': 'Log uit van uw account. U kunt later weer inloggen.',
|
||||||
'user.settings.logout.button': 'Uitloggen',
|
'user.settings.logout.button': 'Uitloggen',
|
||||||
|
|
||||||
|
'user.settings.two-factor.title': 'Twee-factor-authenticatie',
|
||||||
|
'user.settings.two-factor.description': 'Voeg een extra beveiligingslaag toe aan je account.',
|
||||||
|
'user.settings.two-factor.status.enabled': 'Ingeschakeld',
|
||||||
|
'user.settings.two-factor.status.disabled': 'Uitgeschakeld',
|
||||||
|
'user.settings.two-factor.enable-button': '2FA inschakelen',
|
||||||
|
'user.settings.two-factor.disable-button': '2FA uitschakelen',
|
||||||
|
'user.settings.two-factor.regenerate-codes-button': 'Back-upcodes opnieuw genereren',
|
||||||
|
|
||||||
|
'user.settings.two-factor.enable-dialog.title': 'Twee-factor-authenticatie inschakelen',
|
||||||
|
'user.settings.two-factor.enable-dialog.description': 'Voer je wachtwoord in om 2FA in te schakelen.',
|
||||||
|
'user.settings.two-factor.enable-dialog.password.label': 'Wachtwoord',
|
||||||
|
'user.settings.two-factor.enable-dialog.password.placeholder': 'Voer je wachtwoord in',
|
||||||
|
'user.settings.two-factor.enable-dialog.password.required': 'Voer je wachtwoord in',
|
||||||
|
'user.settings.two-factor.enable-dialog.cancel': 'Annuleren',
|
||||||
|
'user.settings.two-factor.enable-dialog.submit': 'Doorgaan',
|
||||||
|
|
||||||
|
'user.settings.two-factor.setup-dialog.title': 'Twee-factor-authenticatie instellen',
|
||||||
|
'user.settings.two-factor.setup-dialog.description': 'Scan deze QR-code met je authenticatie-app en voer vervolgens de verificatiecode in.',
|
||||||
|
'user.settings.two-factor.setup-dialog.qr-loading': 'QR-code laden...',
|
||||||
|
'user.settings.two-factor.setup-dialog.step1.title': 'Stap 1: Scan de QR-code',
|
||||||
|
'user.settings.two-factor.setup-dialog.step1.description': 'Scan de onderstaande QR-code of voer de instellingssleutel handmatig in je authenticatie-app in.',
|
||||||
|
'user.settings.two-factor.setup-dialog.copy-setup-key': 'Instellingssleutel kopiëren',
|
||||||
|
'user.settings.two-factor.setup-dialog.step2.title': 'Stap 2: Verifieer de code',
|
||||||
|
'user.settings.two-factor.setup-dialog.step2.description': 'Voer de 6-cijferige code in die door je authenticatie-app is gegenereerd om twee-factor-authenticatie te verifiëren en in te schakelen.',
|
||||||
|
'user.settings.two-factor.setup-dialog.code.label': 'Verificatiecode',
|
||||||
|
'user.settings.two-factor.setup-dialog.code.placeholder': 'Voer de 6-cijferige code in',
|
||||||
|
'user.settings.two-factor.setup-dialog.code.required': 'Voer de verificatiecode in',
|
||||||
|
'user.settings.two-factor.setup-dialog.cancel': 'Annuleren',
|
||||||
|
'user.settings.two-factor.setup-dialog.verify': 'Verifiëren en 2FA inschakelen',
|
||||||
|
|
||||||
|
'user.settings.two-factor.backup-codes-dialog.title': 'Back-upcodes',
|
||||||
|
'user.settings.two-factor.backup-codes-dialog.description': 'Bewaar deze back-upcodes op een veilige plaats. Je kunt ze gebruiken om toegang te krijgen tot je account als je de toegang tot je authenticatie-app verliest.',
|
||||||
|
'user.settings.two-factor.backup-codes-dialog.warning': 'Elke code kan slechts één keer worden gebruikt.',
|
||||||
|
'user.settings.two-factor.backup-codes-dialog.copy': 'Back-upcodes kopiëren',
|
||||||
|
'user.settings.two-factor.backup-codes-dialog.download': 'Back-upcodes downloaden',
|
||||||
|
'user.settings.two-factor.backup-codes-dialog.download-filename': 'papra-2fa-backup-codes.txt',
|
||||||
|
'user.settings.two-factor.backup-codes-dialog.copied': 'Codes gekopieerd naar klembord',
|
||||||
|
'user.settings.two-factor.backup-codes-dialog.close': 'Ik heb mijn codes opgeslagen',
|
||||||
|
|
||||||
|
'user.settings.two-factor.disable-dialog.title': 'Twee-factor-authenticatie uitschakelen',
|
||||||
|
'user.settings.two-factor.disable-dialog.description': 'Voer je wachtwoord in om 2FA uit te schakelen. Dit maakt je account minder veilig.',
|
||||||
|
'user.settings.two-factor.disable-dialog.password.label': 'Wachtwoord',
|
||||||
|
'user.settings.two-factor.disable-dialog.password.placeholder': 'Voer je wachtwoord in',
|
||||||
|
'user.settings.two-factor.disable-dialog.password.required': 'Voer je wachtwoord in',
|
||||||
|
'user.settings.two-factor.disable-dialog.cancel': 'Annuleren',
|
||||||
|
'user.settings.two-factor.disable-dialog.submit': '2FA uitschakelen',
|
||||||
|
|
||||||
|
'user.settings.two-factor.regenerate-dialog.title': 'Back-upcodes opnieuw genereren',
|
||||||
|
'user.settings.two-factor.regenerate-dialog.description': 'Dit zal alle bestaande back-upcodes ongeldig maken en nieuwe genereren. Voer je wachtwoord in om door te gaan.',
|
||||||
|
'user.settings.two-factor.regenerate-dialog.password.label': 'Wachtwoord',
|
||||||
|
'user.settings.two-factor.regenerate-dialog.password.placeholder': 'Voer je wachtwoord in',
|
||||||
|
'user.settings.two-factor.regenerate-dialog.password.required': 'Voer je wachtwoord in',
|
||||||
|
'user.settings.two-factor.regenerate-dialog.cancel': 'Annuleren',
|
||||||
|
'user.settings.two-factor.regenerate-dialog.submit': 'Codes opnieuw genereren',
|
||||||
|
|
||||||
|
'user.settings.two-factor.enabled': 'Twee-factor-authenticatie is ingeschakeld',
|
||||||
|
'user.settings.two-factor.disabled': 'Twee-factor-authenticatie is uitgeschakeld',
|
||||||
|
'user.settings.two-factor.codes-regenerated': 'Back-upcodes zijn opnieuw gegenereerd',
|
||||||
|
'user.settings.two-factor.verification-failed': 'Verificatie mislukt. Controleer je code en probeer het opnieuw.',
|
||||||
|
|
||||||
// Organizations
|
// Organizations
|
||||||
|
|
||||||
'organizations.list.title': 'Uw organisaties',
|
'organizations.list.title': 'Uw organisaties',
|
||||||
@@ -573,6 +647,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||||||
'layout.menu.webhooks': 'Webhooks',
|
'layout.menu.webhooks': 'Webhooks',
|
||||||
'layout.menu.members': 'Leden',
|
'layout.menu.members': 'Leden',
|
||||||
'layout.menu.invitations': 'Uitnodigingen',
|
'layout.menu.invitations': 'Uitnodigingen',
|
||||||
|
'layout.menu.admin': 'Beheer',
|
||||||
|
|
||||||
'layout.upgrade-cta.title': 'Meer ruimte nodig?',
|
'layout.upgrade-cta.title': 'Meer ruimte nodig?',
|
||||||
'layout.upgrade-cta.description': 'Krijg 10x meer opslag + team samenwerking',
|
'layout.upgrade-cta.description': 'Krijg 10x meer opslag + team samenwerking',
|
||||||
@@ -600,6 +675,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||||||
|
|
||||||
// API errors
|
// API errors
|
||||||
|
|
||||||
|
'api-errors.api.timeout': 'Het verzoek duurde te lang en is verlopen. Probeer het opnieuw.',
|
||||||
'api-errors.document.already_exists': 'Het document bestaat al',
|
'api-errors.document.already_exists': 'Het document bestaat al',
|
||||||
'api-errors.document.size_too_large': 'Het bestand is te groot',
|
'api-errors.document.size_too_large': 'Het bestand is te groot',
|
||||||
'api-errors.intake-emails.already_exists': 'Er bestaat al een intake-e-mail met dit adres.',
|
'api-errors.intake-emails.already_exists': 'Er bestaat al een intake-e-mail met dit adres.',
|
||||||
@@ -640,6 +716,15 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||||||
'api-errors.FAILED_TO_UNLINK_LAST_ACCOUNT': 'Kan laatste account niet loskoppelen',
|
'api-errors.FAILED_TO_UNLINK_LAST_ACCOUNT': 'Kan laatste account niet loskoppelen',
|
||||||
'api-errors.ACCOUNT_NOT_FOUND': 'Account niet gevonden',
|
'api-errors.ACCOUNT_NOT_FOUND': 'Account niet gevonden',
|
||||||
'api-errors.USER_ALREADY_HAS_PASSWORD': 'Gebruiker heeft al een wachtwoord',
|
'api-errors.USER_ALREADY_HAS_PASSWORD': 'Gebruiker heeft al een wachtwoord',
|
||||||
|
'api-errors.INVALID_CODE': 'De opgegeven code is ongeldig of verlopen',
|
||||||
|
'api-errors.OTP_NOT_ENABLED': 'Twee-factor-authenticatie is niet ingeschakeld voor dit account',
|
||||||
|
'api-errors.OTP_HAS_EXPIRED': 'De twee-factor-authenticatiecode is verlopen',
|
||||||
|
'api-errors.TOTP_NOT_ENABLED': 'TOTP is niet ingeschakeld voor dit account',
|
||||||
|
'api-errors.TWO_FACTOR_NOT_ENABLED': 'Twee-factor-authenticatie is niet ingeschakeld voor dit account',
|
||||||
|
'api-errors.BACKUP_CODES_NOT_ENABLED': 'Back-upcodes zijn niet ingeschakeld voor dit account',
|
||||||
|
'api-errors.INVALID_BACKUP_CODE': 'De opgegeven back-upcode is ongeldig of is al gebruikt',
|
||||||
|
'api-errors.TOO_MANY_ATTEMPTS_REQUEST_NEW_CODE': 'Te veel pogingen. Vraag een nieuwe code aan.',
|
||||||
|
'api-errors.INVALID_TWO_FACTOR_COOKIE': 'Ongeldige twee-factor-cookie',
|
||||||
|
|
||||||
// Not found
|
// Not found
|
||||||
|
|
||||||
@@ -712,6 +797,120 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||||||
'subscriptions.usage-warning.message': 'U heeft {{ percent }}% van uw documentopslag gebruikt. Overweeg uw plan te upgraden voor meer ruimte.',
|
'subscriptions.usage-warning.message': 'U heeft {{ percent }}% van uw documentopslag gebruikt. Overweeg uw plan te upgraden voor meer ruimte.',
|
||||||
'subscriptions.usage-warning.upgrade-button': 'Plan upgraden',
|
'subscriptions.usage-warning.upgrade-button': 'Plan upgraden',
|
||||||
|
|
||||||
|
// Admin
|
||||||
|
|
||||||
|
'admin.layout.header': 'Papra beheer',
|
||||||
|
'admin.layout.back-to-app': 'Terug naar app',
|
||||||
|
'admin.layout.menu.analytics': 'Statistieken',
|
||||||
|
'admin.layout.menu.users': 'Gebruikers',
|
||||||
|
'admin.layout.menu.organizations': 'Organisaties',
|
||||||
|
|
||||||
|
'admin.analytics.title': 'Dashboard',
|
||||||
|
'admin.analytics.description': 'Inzichten en statistieken over Papra-gebruik.',
|
||||||
|
'admin.analytics.user-count': 'Aantal gebruikers',
|
||||||
|
'admin.analytics.organization-count': 'Aantal organisaties',
|
||||||
|
'admin.analytics.document-count': 'Aantal documenten',
|
||||||
|
'admin.analytics.documents-storage': 'Documentopslag',
|
||||||
|
'admin.analytics.deleted-documents': 'Verwijderde documenten',
|
||||||
|
'admin.analytics.deleted-storage': 'Verwijderde opslag',
|
||||||
|
|
||||||
|
'admin.organizations.title': 'Organisatiebeheer',
|
||||||
|
'admin.organizations.description': 'Alle organisaties in het systeem beheren en bekijken',
|
||||||
|
'admin.organizations.search-placeholder': 'Zoeken op naam of ID...',
|
||||||
|
'admin.organizations.loading': 'Organisaties laden...',
|
||||||
|
'admin.organizations.no-results': 'Geen organisaties gevonden die overeenkomen met uw zoekopdracht.',
|
||||||
|
'admin.organizations.empty': 'Geen organisaties gevonden.',
|
||||||
|
'admin.organizations.table.id': 'ID',
|
||||||
|
'admin.organizations.table.name': 'Naam',
|
||||||
|
'admin.organizations.table.members': 'Leden',
|
||||||
|
'admin.organizations.table.created': 'Aangemaakt',
|
||||||
|
'admin.organizations.table.updated': 'Bijgewerkt',
|
||||||
|
'admin.organizations.pagination.info': 'Weergave {{ start }} tot {{ end }} van {{ total }} {{ total, =1:organisatie, organisaties }}',
|
||||||
|
'admin.organizations.pagination.page-info': 'Pagina {{ current }} van {{ total }}',
|
||||||
|
|
||||||
|
'admin.organization-detail.title': 'Organisatiedetails',
|
||||||
|
'admin.organization-detail.back': 'Terug naar organisaties',
|
||||||
|
'admin.organization-detail.loading.info': 'Organisatie-informatie laden...',
|
||||||
|
'admin.organization-detail.loading.stats': 'Statistieken laden...',
|
||||||
|
'admin.organization-detail.loading.intake-emails': 'Intake-e-mails laden...',
|
||||||
|
'admin.organization-detail.loading.webhooks': 'Webhooks laden...',
|
||||||
|
'admin.organization-detail.loading.members': 'Leden laden...',
|
||||||
|
'admin.organization-detail.basic-info.title': 'Organisatie-informatie',
|
||||||
|
'admin.organization-detail.basic-info.description': 'Basisdetails van de organisatie',
|
||||||
|
'admin.organization-detail.basic-info.id': 'ID',
|
||||||
|
'admin.organization-detail.basic-info.name': 'Naam',
|
||||||
|
'admin.organization-detail.basic-info.created': 'Aangemaakt',
|
||||||
|
'admin.organization-detail.basic-info.updated': 'Bijgewerkt',
|
||||||
|
'admin.organization-detail.members.title': 'Leden ({{ count }})',
|
||||||
|
'admin.organization-detail.members.description': 'Gebruikers die tot deze organisatie behoren',
|
||||||
|
'admin.organization-detail.members.empty': 'Geen leden gevonden',
|
||||||
|
'admin.organization-detail.members.table.user': 'Gebruiker',
|
||||||
|
'admin.organization-detail.members.table.id': 'ID',
|
||||||
|
'admin.organization-detail.members.table.role': 'Rol',
|
||||||
|
'admin.organization-detail.members.table.joined': 'Toegetreden',
|
||||||
|
'admin.organization-detail.intake-emails.title': 'Intake-e-mails ({{ count }})',
|
||||||
|
'admin.organization-detail.intake-emails.description': 'E-mailadressen voor documentopname',
|
||||||
|
'admin.organization-detail.intake-emails.empty': 'Geen intake-e-mails geconfigureerd',
|
||||||
|
'admin.organization-detail.intake-emails.status.enabled': 'Ingeschakeld',
|
||||||
|
'admin.organization-detail.intake-emails.status.disabled': 'Uitgeschakeld',
|
||||||
|
'admin.organization-detail.intake-emails.badge.active': 'Actief',
|
||||||
|
'admin.organization-detail.intake-emails.badge.inactive': 'Inactief',
|
||||||
|
'admin.organization-detail.webhooks.title': 'Webhooks ({{ count }})',
|
||||||
|
'admin.organization-detail.webhooks.description': 'Geconfigureerde webhook-endpoints',
|
||||||
|
'admin.organization-detail.webhooks.empty': 'Geen webhooks geconfigureerd',
|
||||||
|
'admin.organization-detail.webhooks.badge.active': 'Actief',
|
||||||
|
'admin.organization-detail.webhooks.badge.inactive': 'Inactief',
|
||||||
|
'admin.organization-detail.stats.title': 'Gebruiksstatistieken',
|
||||||
|
'admin.organization-detail.stats.description': 'Document- en opslagstatistieken',
|
||||||
|
'admin.organization-detail.stats.active-documents': 'Actieve documenten',
|
||||||
|
'admin.organization-detail.stats.active-storage': 'Actieve opslag',
|
||||||
|
'admin.organization-detail.stats.deleted-documents': 'Verwijderde documenten',
|
||||||
|
'admin.organization-detail.stats.deleted-storage': 'Verwijderde opslag',
|
||||||
|
'admin.organization-detail.stats.total-documents': 'Totaal documenten',
|
||||||
|
'admin.organization-detail.stats.total-storage': 'Totale opslag',
|
||||||
|
|
||||||
|
'admin.users.title': 'Gebruikersbeheer',
|
||||||
|
'admin.users.description': 'Alle gebruikers in het systeem beheren en bekijken',
|
||||||
|
'admin.users.search-placeholder': 'Zoeken op naam, e-mail of ID...',
|
||||||
|
'admin.users.loading': 'Gebruikers laden...',
|
||||||
|
'admin.users.no-results': 'Geen gebruikers gevonden die overeenkomen met uw zoekopdracht.',
|
||||||
|
'admin.users.empty': 'Geen gebruikers gevonden.',
|
||||||
|
'admin.users.table.user': 'Gebruiker',
|
||||||
|
'admin.users.table.id': 'ID',
|
||||||
|
'admin.users.table.status': 'Status',
|
||||||
|
'admin.users.table.status.verified': 'Geverifieerd',
|
||||||
|
'admin.users.table.status.unverified': 'Niet geverifieerd',
|
||||||
|
'admin.users.table.orgs': 'Orgs',
|
||||||
|
'admin.users.table.created': 'Aangemaakt',
|
||||||
|
'admin.users.pagination.info': 'Weergave {{ start }} tot {{ end }} van {{ total }} {{ total, =1:gebruiker, gebruikers }}',
|
||||||
|
'admin.users.pagination.page-info': 'Pagina {{ current }} van {{ total }}',
|
||||||
|
|
||||||
|
'admin.user-detail.back': 'Terug naar gebruikers',
|
||||||
|
'admin.user-detail.loading': 'Gebruikersdetails laden...',
|
||||||
|
'admin.user-detail.unnamed': 'Naamloze gebruiker',
|
||||||
|
'admin.user-detail.basic-info.title': 'Gebruikersinformatie',
|
||||||
|
'admin.user-detail.basic-info.description': 'Basisgebruikersdetails en accountinformatie',
|
||||||
|
'admin.user-detail.basic-info.user-id': 'Gebruikers-ID',
|
||||||
|
'admin.user-detail.basic-info.email': 'E-mail',
|
||||||
|
'admin.user-detail.basic-info.name': 'Naam',
|
||||||
|
'admin.user-detail.basic-info.name-empty': '-',
|
||||||
|
'admin.user-detail.basic-info.email-verified': 'E-mail geverifieerd',
|
||||||
|
'admin.user-detail.basic-info.email-verified.yes': 'Ja',
|
||||||
|
'admin.user-detail.basic-info.email-verified.no': 'Nee',
|
||||||
|
'admin.user-detail.basic-info.max-organizations': 'Max. organisaties',
|
||||||
|
'admin.user-detail.basic-info.max-organizations.unlimited': 'Onbeperkt',
|
||||||
|
'admin.user-detail.basic-info.created': 'Aangemaakt',
|
||||||
|
'admin.user-detail.basic-info.updated': 'Laatst bijgewerkt',
|
||||||
|
'admin.user-detail.roles.title': 'Rollen en machtigingen',
|
||||||
|
'admin.user-detail.roles.description': 'Gebruikersrollen en toegangsniveaus',
|
||||||
|
'admin.user-detail.roles.empty': 'Geen rollen toegewezen',
|
||||||
|
'admin.user-detail.organizations.title': 'Organisaties ({{ count }})',
|
||||||
|
'admin.user-detail.organizations.description': 'Organisaties waar deze gebruiker lid van is',
|
||||||
|
'admin.user-detail.organizations.empty': 'Geen lid van organisaties',
|
||||||
|
'admin.user-detail.organizations.table.id': 'ID',
|
||||||
|
'admin.user-detail.organizations.table.name': 'Naam',
|
||||||
|
'admin.user-detail.organizations.table.created': 'Aangemaakt',
|
||||||
|
|
||||||
// Common / Shared
|
// Common / Shared
|
||||||
|
|
||||||
'common.confirm-modal.type-to-confirm': 'Typ "{{ text }}" om te bevestigen',
|
'common.confirm-modal.type-to-confirm': 'Typ "{{ text }}" om te bevestigen',
|
||||||
|
|||||||
@@ -42,6 +42,20 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||||||
'auth.login.form.forgot-password.label': 'Zapomniałeś hasła?',
|
'auth.login.form.forgot-password.label': 'Zapomniałeś hasła?',
|
||||||
'auth.login.form.submit': 'Zaloguj się',
|
'auth.login.form.submit': 'Zaloguj się',
|
||||||
|
|
||||||
|
'auth.login.two-factor.title': 'Weryfikacja dwuskładnikowa',
|
||||||
|
'auth.login.two-factor.description.totp': 'Wprowadź 6-cyfrowy kod weryfikacyjny z aplikacji uwierzytelniającej.',
|
||||||
|
'auth.login.two-factor.description.backup-code': 'Wprowadź jeden z kodów zapasowych, aby uzyskać dostęp do konta.',
|
||||||
|
'auth.login.two-factor.code.label.totp': 'Kod uwierzytelniający',
|
||||||
|
'auth.login.two-factor.code.label.backup-code': 'Kod zapasowy',
|
||||||
|
'auth.login.two-factor.code.placeholder.backup-code': 'Wprowadź kod zapasowy',
|
||||||
|
'auth.login.two-factor.code.required': 'Wprowadź kod weryfikacyjny',
|
||||||
|
'auth.login.two-factor.trust-device.label': 'Zaufaj temu urządzeniu przez 30 dni',
|
||||||
|
'auth.login.two-factor.back': 'Powrót do logowania',
|
||||||
|
'auth.login.two-factor.submit': 'Weryfikuj',
|
||||||
|
'auth.login.two-factor.verification-failed': 'Weryfikacja nie powiodła się. Sprawdź kod i spróbuj ponownie.',
|
||||||
|
'auth.login.two-factor.use-backup-code': 'Użyj kodu zapasowego',
|
||||||
|
'auth.login.two-factor.use-totp': 'Użyj aplikacji uwierzytelniającej',
|
||||||
|
|
||||||
'auth.register.title': 'Zarejestruj się w Papra',
|
'auth.register.title': 'Zarejestruj się w Papra',
|
||||||
'auth.register.description': 'Utwórz konto, aby zacząć korzystać z Papra.',
|
'auth.register.description': 'Utwórz konto, aby zacząć korzystać z Papra.',
|
||||||
'auth.register.register-with-email': 'Zarejestruj się przez e-mail',
|
'auth.register.register-with-email': 'Zarejestruj się przez e-mail',
|
||||||
@@ -104,6 +118,66 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||||||
'user.settings.logout.description': 'Wyloguj się ze swojego konta. Możesz zalogować się ponownie później.',
|
'user.settings.logout.description': 'Wyloguj się ze swojego konta. Możesz zalogować się ponownie później.',
|
||||||
'user.settings.logout.button': 'Wyloguj się',
|
'user.settings.logout.button': 'Wyloguj się',
|
||||||
|
|
||||||
|
'user.settings.two-factor.title': 'Uwierzytelnianie dwuskładnikowe',
|
||||||
|
'user.settings.two-factor.description': 'Dodaj dodatkową warstwę zabezpieczeń do swojego konta.',
|
||||||
|
'user.settings.two-factor.status.enabled': 'Włączone',
|
||||||
|
'user.settings.two-factor.status.disabled': 'Wyłączone',
|
||||||
|
'user.settings.two-factor.enable-button': 'Włącz 2FA',
|
||||||
|
'user.settings.two-factor.disable-button': 'Wyłącz 2FA',
|
||||||
|
'user.settings.two-factor.regenerate-codes-button': 'Wygeneruj ponownie kody zapasowe',
|
||||||
|
|
||||||
|
'user.settings.two-factor.enable-dialog.title': 'Włącz uwierzytelnianie dwuskładnikowe',
|
||||||
|
'user.settings.two-factor.enable-dialog.description': 'Wprowadź hasło, aby włączyć 2FA.',
|
||||||
|
'user.settings.two-factor.enable-dialog.password.label': 'Hasło',
|
||||||
|
'user.settings.two-factor.enable-dialog.password.placeholder': 'Wprowadź hasło',
|
||||||
|
'user.settings.two-factor.enable-dialog.password.required': 'Wprowadź hasło',
|
||||||
|
'user.settings.two-factor.enable-dialog.cancel': 'Anuluj',
|
||||||
|
'user.settings.two-factor.enable-dialog.submit': 'Kontynuuj',
|
||||||
|
|
||||||
|
'user.settings.two-factor.setup-dialog.title': 'Skonfiguruj uwierzytelnianie dwuskładnikowe',
|
||||||
|
'user.settings.two-factor.setup-dialog.description': 'Zeskanuj ten kod QR za pomocą aplikacji uwierzytelniającej, a następnie wprowadź kod weryfikacyjny.',
|
||||||
|
'user.settings.two-factor.setup-dialog.qr-loading': 'Ładowanie kodu QR...',
|
||||||
|
'user.settings.two-factor.setup-dialog.step1.title': 'Krok 1: Zeskanuj kod QR',
|
||||||
|
'user.settings.two-factor.setup-dialog.step1.description': 'Zeskanuj poniższy kod QR lub wprowadź ręcznie klucz konfiguracyjny do aplikacji uwierzytelniającej.',
|
||||||
|
'user.settings.two-factor.setup-dialog.copy-setup-key': 'Skopiuj klucz konfiguracyjny',
|
||||||
|
'user.settings.two-factor.setup-dialog.step2.title': 'Krok 2: Zweryfikuj kod',
|
||||||
|
'user.settings.two-factor.setup-dialog.step2.description': 'Wprowadź 6-cyfrowy kod wygenerowany przez aplikację uwierzytelniającą, aby zweryfikować i włączyć uwierzytelnianie dwuskładnikowe.',
|
||||||
|
'user.settings.two-factor.setup-dialog.code.label': 'Kod weryfikacyjny',
|
||||||
|
'user.settings.two-factor.setup-dialog.code.placeholder': 'Wprowadź 6-cyfrowy kod',
|
||||||
|
'user.settings.two-factor.setup-dialog.code.required': 'Wprowadź kod weryfikacyjny',
|
||||||
|
'user.settings.two-factor.setup-dialog.cancel': 'Anuluj',
|
||||||
|
'user.settings.two-factor.setup-dialog.verify': 'Weryfikuj i włącz 2FA',
|
||||||
|
|
||||||
|
'user.settings.two-factor.backup-codes-dialog.title': 'Kody zapasowe',
|
||||||
|
'user.settings.two-factor.backup-codes-dialog.description': 'Zapisz te kody zapasowe w bezpiecznym miejscu. Możesz ich użyć do uzyskania dostępu do konta, jeśli stracisz dostęp do aplikacji uwierzytelniającej.',
|
||||||
|
'user.settings.two-factor.backup-codes-dialog.warning': 'Każdy kod może być użyty tylko raz.',
|
||||||
|
'user.settings.two-factor.backup-codes-dialog.copy': 'Skopiuj kody zapasowe',
|
||||||
|
'user.settings.two-factor.backup-codes-dialog.download': 'Pobierz kody zapasowe',
|
||||||
|
'user.settings.two-factor.backup-codes-dialog.download-filename': 'papra-2fa-backup-codes.txt',
|
||||||
|
'user.settings.two-factor.backup-codes-dialog.copied': 'Kody skopiowane do schowka',
|
||||||
|
'user.settings.two-factor.backup-codes-dialog.close': 'Zapisałem moje kody',
|
||||||
|
|
||||||
|
'user.settings.two-factor.disable-dialog.title': 'Wyłącz uwierzytelnianie dwuskładnikowe',
|
||||||
|
'user.settings.two-factor.disable-dialog.description': 'Wprowadź hasło, aby wyłączyć 2FA. Spowoduje to zmniejszenie bezpieczeństwa konta.',
|
||||||
|
'user.settings.two-factor.disable-dialog.password.label': 'Hasło',
|
||||||
|
'user.settings.two-factor.disable-dialog.password.placeholder': 'Wprowadź hasło',
|
||||||
|
'user.settings.two-factor.disable-dialog.password.required': 'Wprowadź hasło',
|
||||||
|
'user.settings.two-factor.disable-dialog.cancel': 'Anuluj',
|
||||||
|
'user.settings.two-factor.disable-dialog.submit': 'Wyłącz 2FA',
|
||||||
|
|
||||||
|
'user.settings.two-factor.regenerate-dialog.title': 'Wygeneruj ponownie kody zapasowe',
|
||||||
|
'user.settings.two-factor.regenerate-dialog.description': 'Spowoduje to unieważnienie wszystkich istniejących kodów zapasowych i wygenerowanie nowych. Wprowadź hasło, aby kontynuować.',
|
||||||
|
'user.settings.two-factor.regenerate-dialog.password.label': 'Hasło',
|
||||||
|
'user.settings.two-factor.regenerate-dialog.password.placeholder': 'Wprowadź hasło',
|
||||||
|
'user.settings.two-factor.regenerate-dialog.password.required': 'Wprowadź hasło',
|
||||||
|
'user.settings.two-factor.regenerate-dialog.cancel': 'Anuluj',
|
||||||
|
'user.settings.two-factor.regenerate-dialog.submit': 'Wygeneruj ponownie kody',
|
||||||
|
|
||||||
|
'user.settings.two-factor.enabled': 'Uwierzytelnianie dwuskładnikowe zostało włączone',
|
||||||
|
'user.settings.two-factor.disabled': 'Uwierzytelnianie dwuskładnikowe zostało wyłączone',
|
||||||
|
'user.settings.two-factor.codes-regenerated': 'Kody zapasowe zostały wygenerowane ponownie',
|
||||||
|
'user.settings.two-factor.verification-failed': 'Weryfikacja nie powiodła się. Sprawdź kod i spróbuj ponownie.',
|
||||||
|
|
||||||
// Organizations
|
// Organizations
|
||||||
|
|
||||||
'organizations.list.title': 'Twoje organizacje',
|
'organizations.list.title': 'Twoje organizacje',
|
||||||
@@ -573,6 +647,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||||||
'layout.menu.webhooks': 'Webhooki',
|
'layout.menu.webhooks': 'Webhooki',
|
||||||
'layout.menu.members': 'Członkowie',
|
'layout.menu.members': 'Członkowie',
|
||||||
'layout.menu.invitations': 'Zaproszenia',
|
'layout.menu.invitations': 'Zaproszenia',
|
||||||
|
'layout.menu.admin': 'Administracja',
|
||||||
|
|
||||||
'layout.upgrade-cta.title': 'Potrzebujesz więcej miejsca?',
|
'layout.upgrade-cta.title': 'Potrzebujesz więcej miejsca?',
|
||||||
'layout.upgrade-cta.description': 'Uzyskaj 10x więcej przestrzeni + współpracę zespołową',
|
'layout.upgrade-cta.description': 'Uzyskaj 10x więcej przestrzeni + współpracę zespołową',
|
||||||
@@ -600,6 +675,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||||||
|
|
||||||
// API errors
|
// API errors
|
||||||
|
|
||||||
|
'api-errors.api.timeout': 'Żądanie trwało zbyt długo i przekroczyło limit czasu. Spróbuj ponownie.',
|
||||||
'api-errors.document.already_exists': 'Dokument już istnieje',
|
'api-errors.document.already_exists': 'Dokument już istnieje',
|
||||||
'api-errors.document.size_too_large': 'Plik jest zbyt duży',
|
'api-errors.document.size_too_large': 'Plik jest zbyt duży',
|
||||||
'api-errors.intake-emails.already_exists': 'Adres e-mail do przyjęć z tym adresem już istnieje.',
|
'api-errors.intake-emails.already_exists': 'Adres e-mail do przyjęć z tym adresem już istnieje.',
|
||||||
@@ -640,6 +716,15 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||||||
'api-errors.FAILED_TO_UNLINK_LAST_ACCOUNT': 'Nie udało się odłączyć ostatniego konta',
|
'api-errors.FAILED_TO_UNLINK_LAST_ACCOUNT': 'Nie udało się odłączyć ostatniego konta',
|
||||||
'api-errors.ACCOUNT_NOT_FOUND': 'Nie znaleziono konta',
|
'api-errors.ACCOUNT_NOT_FOUND': 'Nie znaleziono konta',
|
||||||
'api-errors.USER_ALREADY_HAS_PASSWORD': 'Użytkownik ma już hasło',
|
'api-errors.USER_ALREADY_HAS_PASSWORD': 'Użytkownik ma już hasło',
|
||||||
|
'api-errors.INVALID_CODE': 'Podany kod jest nieprawidłowy lub wygasł',
|
||||||
|
'api-errors.OTP_NOT_ENABLED': 'Uwierzytelnianie dwuskładnikowe nie jest włączone dla tego konta',
|
||||||
|
'api-errors.OTP_HAS_EXPIRED': 'Kod uwierzytelniania dwuskładnikowego wygasł',
|
||||||
|
'api-errors.TOTP_NOT_ENABLED': 'TOTP nie jest włączone dla tego konta',
|
||||||
|
'api-errors.TWO_FACTOR_NOT_ENABLED': 'Uwierzytelnianie dwuskładnikowe nie jest włączone dla tego konta',
|
||||||
|
'api-errors.BACKUP_CODES_NOT_ENABLED': 'Kody zapasowe nie są włączone dla tego konta',
|
||||||
|
'api-errors.INVALID_BACKUP_CODE': 'Podany kod zapasowy jest nieprawidłowy lub został już użyty',
|
||||||
|
'api-errors.TOO_MANY_ATTEMPTS_REQUEST_NEW_CODE': 'Zbyt wiele prób. Poproś o nowy kod.',
|
||||||
|
'api-errors.INVALID_TWO_FACTOR_COOKIE': 'Nieprawidłowy plik cookie uwierzytelniania dwuskładnikowego',
|
||||||
|
|
||||||
// Not found
|
// Not found
|
||||||
|
|
||||||
@@ -712,6 +797,120 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||||||
'subscriptions.usage-warning.message': 'Wykorzystano {{ percent }}% miejsca na dokumenty. Rozważ aktualizację planu, aby uzyskać więcej miejsca.',
|
'subscriptions.usage-warning.message': 'Wykorzystano {{ percent }}% miejsca na dokumenty. Rozważ aktualizację planu, aby uzyskać więcej miejsca.',
|
||||||
'subscriptions.usage-warning.upgrade-button': 'Ulepsz plan',
|
'subscriptions.usage-warning.upgrade-button': 'Ulepsz plan',
|
||||||
|
|
||||||
|
// Admin
|
||||||
|
|
||||||
|
'admin.layout.header': 'Administracja Papra',
|
||||||
|
'admin.layout.back-to-app': 'Powrót do aplikacji',
|
||||||
|
'admin.layout.menu.analytics': 'Statystyki',
|
||||||
|
'admin.layout.menu.users': 'Użytkownicy',
|
||||||
|
'admin.layout.menu.organizations': 'Organizacje',
|
||||||
|
|
||||||
|
'admin.analytics.title': 'Panel kontrolny',
|
||||||
|
'admin.analytics.description': 'Informacje i statystyki dotyczące korzystania z Papra.',
|
||||||
|
'admin.analytics.user-count': 'Liczba użytkowników',
|
||||||
|
'admin.analytics.organization-count': 'Liczba organizacji',
|
||||||
|
'admin.analytics.document-count': 'Liczba dokumentów',
|
||||||
|
'admin.analytics.documents-storage': 'Przechowywanie dokumentów',
|
||||||
|
'admin.analytics.deleted-documents': 'Usunięte dokumenty',
|
||||||
|
'admin.analytics.deleted-storage': 'Usunięta przestrzeń',
|
||||||
|
|
||||||
|
'admin.organizations.title': 'Zarządzanie organizacjami',
|
||||||
|
'admin.organizations.description': 'Zarządzaj i przeglądaj wszystkie organizacje w systemie',
|
||||||
|
'admin.organizations.search-placeholder': 'Szukaj po nazwie lub ID...',
|
||||||
|
'admin.organizations.loading': 'Ładowanie organizacji...',
|
||||||
|
'admin.organizations.no-results': 'Nie znaleziono organizacji pasujących do wyszukiwania.',
|
||||||
|
'admin.organizations.empty': 'Nie znaleziono organizacji.',
|
||||||
|
'admin.organizations.table.id': 'ID',
|
||||||
|
'admin.organizations.table.name': 'Nazwa',
|
||||||
|
'admin.organizations.table.members': 'Członkowie',
|
||||||
|
'admin.organizations.table.created': 'Utworzono',
|
||||||
|
'admin.organizations.table.updated': 'Zaktualizowano',
|
||||||
|
'admin.organizations.pagination.info': 'Wyświetlanie od {{ start }} do {{ end }} z {{ total }} {{ total, =1:organizacji, organizacji }}',
|
||||||
|
'admin.organizations.pagination.page-info': 'Strona {{ current }} z {{ total }}',
|
||||||
|
|
||||||
|
'admin.organization-detail.title': 'Szczegóły organizacji',
|
||||||
|
'admin.organization-detail.back': 'Powrót do organizacji',
|
||||||
|
'admin.organization-detail.loading.info': 'Ładowanie informacji o organizacji...',
|
||||||
|
'admin.organization-detail.loading.stats': 'Ładowanie statystyk...',
|
||||||
|
'admin.organization-detail.loading.intake-emails': 'Ładowanie adresów przyjęć...',
|
||||||
|
'admin.organization-detail.loading.webhooks': 'Ładowanie webhooków...',
|
||||||
|
'admin.organization-detail.loading.members': 'Ładowanie członków...',
|
||||||
|
'admin.organization-detail.basic-info.title': 'Informacje o organizacji',
|
||||||
|
'admin.organization-detail.basic-info.description': 'Podstawowe szczegóły organizacji',
|
||||||
|
'admin.organization-detail.basic-info.id': 'ID',
|
||||||
|
'admin.organization-detail.basic-info.name': 'Nazwa',
|
||||||
|
'admin.organization-detail.basic-info.created': 'Utworzono',
|
||||||
|
'admin.organization-detail.basic-info.updated': 'Zaktualizowano',
|
||||||
|
'admin.organization-detail.members.title': 'Członkowie ({{ count }})',
|
||||||
|
'admin.organization-detail.members.description': 'Użytkownicy należący do tej organizacji',
|
||||||
|
'admin.organization-detail.members.empty': 'Nie znaleziono członków',
|
||||||
|
'admin.organization-detail.members.table.user': 'Użytkownik',
|
||||||
|
'admin.organization-detail.members.table.id': 'ID',
|
||||||
|
'admin.organization-detail.members.table.role': 'Rola',
|
||||||
|
'admin.organization-detail.members.table.joined': 'Dołączono',
|
||||||
|
'admin.organization-detail.intake-emails.title': 'Adresy przyjęć ({{ count }})',
|
||||||
|
'admin.organization-detail.intake-emails.description': 'Adresy e-mail do przyjmowania dokumentów',
|
||||||
|
'admin.organization-detail.intake-emails.empty': 'Nie skonfigurowano adresów przyjęć',
|
||||||
|
'admin.organization-detail.intake-emails.status.enabled': 'Włączony',
|
||||||
|
'admin.organization-detail.intake-emails.status.disabled': 'Wyłączony',
|
||||||
|
'admin.organization-detail.intake-emails.badge.active': 'Aktywny',
|
||||||
|
'admin.organization-detail.intake-emails.badge.inactive': 'Nieaktywny',
|
||||||
|
'admin.organization-detail.webhooks.title': 'Webhooki ({{ count }})',
|
||||||
|
'admin.organization-detail.webhooks.description': 'Skonfigurowane punkty końcowe webhooków',
|
||||||
|
'admin.organization-detail.webhooks.empty': 'Nie skonfigurowano webhooków',
|
||||||
|
'admin.organization-detail.webhooks.badge.active': 'Aktywny',
|
||||||
|
'admin.organization-detail.webhooks.badge.inactive': 'Nieaktywny',
|
||||||
|
'admin.organization-detail.stats.title': 'Statystyki użycia',
|
||||||
|
'admin.organization-detail.stats.description': 'Statystyki dokumentów i przestrzeni',
|
||||||
|
'admin.organization-detail.stats.active-documents': 'Aktywne dokumenty',
|
||||||
|
'admin.organization-detail.stats.active-storage': 'Aktywna przestrzeń',
|
||||||
|
'admin.organization-detail.stats.deleted-documents': 'Usunięte dokumenty',
|
||||||
|
'admin.organization-detail.stats.deleted-storage': 'Usunięta przestrzeń',
|
||||||
|
'admin.organization-detail.stats.total-documents': 'Wszystkie dokumenty',
|
||||||
|
'admin.organization-detail.stats.total-storage': 'Całkowita przestrzeń',
|
||||||
|
|
||||||
|
'admin.users.title': 'Zarządzanie użytkownikami',
|
||||||
|
'admin.users.description': 'Zarządzaj i przeglądaj wszystkich użytkowników w systemie',
|
||||||
|
'admin.users.search-placeholder': 'Szukaj po nazwie, e-mailu lub ID...',
|
||||||
|
'admin.users.loading': 'Ładowanie użytkowników...',
|
||||||
|
'admin.users.no-results': 'Nie znaleziono użytkowników pasujących do wyszukiwania.',
|
||||||
|
'admin.users.empty': 'Nie znaleziono użytkowników.',
|
||||||
|
'admin.users.table.user': 'Użytkownik',
|
||||||
|
'admin.users.table.id': 'ID',
|
||||||
|
'admin.users.table.status': 'Status',
|
||||||
|
'admin.users.table.status.verified': 'Zweryfikowany',
|
||||||
|
'admin.users.table.status.unverified': 'Niezweryfikowany',
|
||||||
|
'admin.users.table.orgs': 'Org',
|
||||||
|
'admin.users.table.created': 'Utworzono',
|
||||||
|
'admin.users.pagination.info': 'Wyświetlanie od {{ start }} do {{ end }} z {{ total }} {{ total, =1:użytkownika, użytkowników }}',
|
||||||
|
'admin.users.pagination.page-info': 'Strona {{ current }} z {{ total }}',
|
||||||
|
|
||||||
|
'admin.user-detail.back': 'Powrót do użytkowników',
|
||||||
|
'admin.user-detail.loading': 'Ładowanie szczegółów użytkownika...',
|
||||||
|
'admin.user-detail.unnamed': 'Użytkownik bez nazwy',
|
||||||
|
'admin.user-detail.basic-info.title': 'Informacje o użytkowniku',
|
||||||
|
'admin.user-detail.basic-info.description': 'Podstawowe dane użytkownika i informacje o koncie',
|
||||||
|
'admin.user-detail.basic-info.user-id': 'ID użytkownika',
|
||||||
|
'admin.user-detail.basic-info.email': 'E-mail',
|
||||||
|
'admin.user-detail.basic-info.name': 'Nazwa',
|
||||||
|
'admin.user-detail.basic-info.name-empty': '-',
|
||||||
|
'admin.user-detail.basic-info.email-verified': 'E-mail zweryfikowany',
|
||||||
|
'admin.user-detail.basic-info.email-verified.yes': 'Tak',
|
||||||
|
'admin.user-detail.basic-info.email-verified.no': 'Nie',
|
||||||
|
'admin.user-detail.basic-info.max-organizations': 'Maks. organizacji',
|
||||||
|
'admin.user-detail.basic-info.max-organizations.unlimited': 'Nieograniczone',
|
||||||
|
'admin.user-detail.basic-info.created': 'Utworzono',
|
||||||
|
'admin.user-detail.basic-info.updated': 'Ostatnia aktualizacja',
|
||||||
|
'admin.user-detail.roles.title': 'Role i uprawnienia',
|
||||||
|
'admin.user-detail.roles.description': 'Role użytkownika i poziomy dostępu',
|
||||||
|
'admin.user-detail.roles.empty': 'Nie przypisano ról',
|
||||||
|
'admin.user-detail.organizations.title': 'Organizacje ({{ count }})',
|
||||||
|
'admin.user-detail.organizations.description': 'Organizacje, do których należy ten użytkownik',
|
||||||
|
'admin.user-detail.organizations.empty': 'Nie należy do żadnych organizacji',
|
||||||
|
'admin.user-detail.organizations.table.id': 'ID',
|
||||||
|
'admin.user-detail.organizations.table.name': 'Nazwa',
|
||||||
|
'admin.user-detail.organizations.table.created': 'Utworzono',
|
||||||
|
|
||||||
// Common / Shared
|
// Common / Shared
|
||||||
|
|
||||||
'common.confirm-modal.type-to-confirm': 'Wpisz "{{ text }}", aby potwierdzić',
|
'common.confirm-modal.type-to-confirm': 'Wpisz "{{ text }}", aby potwierdzić',
|
||||||
|
|||||||
@@ -42,6 +42,20 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||||||
'auth.login.form.forgot-password.label': 'Esqueceu a senha?',
|
'auth.login.form.forgot-password.label': 'Esqueceu a senha?',
|
||||||
'auth.login.form.submit': 'Entrar',
|
'auth.login.form.submit': 'Entrar',
|
||||||
|
|
||||||
|
'auth.login.two-factor.title': 'Verificação em duas etapas',
|
||||||
|
'auth.login.two-factor.description.totp': 'Digite o código de verificação de 6 dígitos do seu aplicativo autenticador.',
|
||||||
|
'auth.login.two-factor.description.backup-code': 'Digite um dos seus códigos de backup para acessar sua conta.',
|
||||||
|
'auth.login.two-factor.code.label.totp': 'Código de autenticação',
|
||||||
|
'auth.login.two-factor.code.label.backup-code': 'Código de backup',
|
||||||
|
'auth.login.two-factor.code.placeholder.backup-code': 'Digite o código de backup',
|
||||||
|
'auth.login.two-factor.code.required': 'Por favor, digite o código de verificação',
|
||||||
|
'auth.login.two-factor.trust-device.label': 'Confiar neste dispositivo por 30 dias',
|
||||||
|
'auth.login.two-factor.back': 'Voltar ao login',
|
||||||
|
'auth.login.two-factor.submit': 'Verificar',
|
||||||
|
'auth.login.two-factor.verification-failed': 'Falha na verificação. Verifique seu código e tente novamente.',
|
||||||
|
'auth.login.two-factor.use-backup-code': 'Usar código de backup',
|
||||||
|
'auth.login.two-factor.use-totp': 'Usar aplicativo autenticador',
|
||||||
|
|
||||||
'auth.register.title': 'Cadastre-se no Papra',
|
'auth.register.title': 'Cadastre-se no Papra',
|
||||||
'auth.register.description': 'Crie uma conta para começar a usar o Papra.',
|
'auth.register.description': 'Crie uma conta para começar a usar o Papra.',
|
||||||
'auth.register.register-with-email': 'Cadastrar com e-mail',
|
'auth.register.register-with-email': 'Cadastrar com e-mail',
|
||||||
@@ -104,6 +118,66 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||||||
'user.settings.logout.description': 'Encerre a sessão da sua conta. Você poderá acessá-la novamente mais tarde.',
|
'user.settings.logout.description': 'Encerre a sessão da sua conta. Você poderá acessá-la novamente mais tarde.',
|
||||||
'user.settings.logout.button': 'Sair',
|
'user.settings.logout.button': 'Sair',
|
||||||
|
|
||||||
|
'user.settings.two-factor.title': 'Autenticação de dois fatores',
|
||||||
|
'user.settings.two-factor.description': 'Adicione uma camada extra de segurança à sua conta.',
|
||||||
|
'user.settings.two-factor.status.enabled': 'Ativada',
|
||||||
|
'user.settings.two-factor.status.disabled': 'Desativada',
|
||||||
|
'user.settings.two-factor.enable-button': 'Ativar A2F',
|
||||||
|
'user.settings.two-factor.disable-button': 'Desativar A2F',
|
||||||
|
'user.settings.two-factor.regenerate-codes-button': 'Gerar novos códigos de backup',
|
||||||
|
|
||||||
|
'user.settings.two-factor.enable-dialog.title': 'Ativar autenticação de dois fatores',
|
||||||
|
'user.settings.two-factor.enable-dialog.description': 'Digite sua senha para ativar A2F.',
|
||||||
|
'user.settings.two-factor.enable-dialog.password.label': 'Senha',
|
||||||
|
'user.settings.two-factor.enable-dialog.password.placeholder': 'Digite sua senha',
|
||||||
|
'user.settings.two-factor.enable-dialog.password.required': 'Por favor, digite sua senha',
|
||||||
|
'user.settings.two-factor.enable-dialog.cancel': 'Cancelar',
|
||||||
|
'user.settings.two-factor.enable-dialog.submit': 'Continuar',
|
||||||
|
|
||||||
|
'user.settings.two-factor.setup-dialog.title': 'Configurar autenticação de dois fatores',
|
||||||
|
'user.settings.two-factor.setup-dialog.description': 'Escaneie este código QR com seu aplicativo autenticador e depois digite o código de verificação.',
|
||||||
|
'user.settings.two-factor.setup-dialog.qr-loading': 'Carregando código QR...',
|
||||||
|
'user.settings.two-factor.setup-dialog.step1.title': 'Passo 1: Escanear o código QR',
|
||||||
|
'user.settings.two-factor.setup-dialog.step1.description': 'Escaneie o código QR abaixo ou digite manualmente a chave de configuração em seu aplicativo autenticador.',
|
||||||
|
'user.settings.two-factor.setup-dialog.copy-setup-key': 'Copiar chave de configuração',
|
||||||
|
'user.settings.two-factor.setup-dialog.step2.title': 'Passo 2: Verificar o código',
|
||||||
|
'user.settings.two-factor.setup-dialog.step2.description': 'Digite o código de 6 dígitos gerado pelo seu aplicativo autenticador para verificar e ativar a autenticação de dois fatores.',
|
||||||
|
'user.settings.two-factor.setup-dialog.code.label': 'Código de verificação',
|
||||||
|
'user.settings.two-factor.setup-dialog.code.placeholder': 'Digite o código de 6 dígitos',
|
||||||
|
'user.settings.two-factor.setup-dialog.code.required': 'Por favor, digite o código de verificação',
|
||||||
|
'user.settings.two-factor.setup-dialog.cancel': 'Cancelar',
|
||||||
|
'user.settings.two-factor.setup-dialog.verify': 'Verificar e ativar A2F',
|
||||||
|
|
||||||
|
'user.settings.two-factor.backup-codes-dialog.title': 'Códigos de backup',
|
||||||
|
'user.settings.two-factor.backup-codes-dialog.description': 'Salve estes códigos de backup em um local seguro. Você pode usá-los para acessar sua conta se perder o acesso ao seu aplicativo autenticador.',
|
||||||
|
'user.settings.two-factor.backup-codes-dialog.warning': 'Cada código só pode ser usado uma vez.',
|
||||||
|
'user.settings.two-factor.backup-codes-dialog.copy': 'Copiar códigos de backup',
|
||||||
|
'user.settings.two-factor.backup-codes-dialog.download': 'Baixar códigos de backup',
|
||||||
|
'user.settings.two-factor.backup-codes-dialog.download-filename': 'papra-2fa-backup-codes.txt',
|
||||||
|
'user.settings.two-factor.backup-codes-dialog.copied': 'Códigos copiados para a área de transferência',
|
||||||
|
'user.settings.two-factor.backup-codes-dialog.close': 'Salvei meus códigos',
|
||||||
|
|
||||||
|
'user.settings.two-factor.disable-dialog.title': 'Desativar autenticação de dois fatores',
|
||||||
|
'user.settings.two-factor.disable-dialog.description': 'Digite sua senha para desativar A2F. Isso tornará sua conta menos segura.',
|
||||||
|
'user.settings.two-factor.disable-dialog.password.label': 'Senha',
|
||||||
|
'user.settings.two-factor.disable-dialog.password.placeholder': 'Digite sua senha',
|
||||||
|
'user.settings.two-factor.disable-dialog.password.required': 'Por favor, digite sua senha',
|
||||||
|
'user.settings.two-factor.disable-dialog.cancel': 'Cancelar',
|
||||||
|
'user.settings.two-factor.disable-dialog.submit': 'Desativar A2F',
|
||||||
|
|
||||||
|
'user.settings.two-factor.regenerate-dialog.title': 'Gerar novos códigos de backup',
|
||||||
|
'user.settings.two-factor.regenerate-dialog.description': 'Isso invalidará todos os códigos de backup existentes e gerará novos. Digite sua senha para continuar.',
|
||||||
|
'user.settings.two-factor.regenerate-dialog.password.label': 'Senha',
|
||||||
|
'user.settings.two-factor.regenerate-dialog.password.placeholder': 'Digite sua senha',
|
||||||
|
'user.settings.two-factor.regenerate-dialog.password.required': 'Por favor, digite sua senha',
|
||||||
|
'user.settings.two-factor.regenerate-dialog.cancel': 'Cancelar',
|
||||||
|
'user.settings.two-factor.regenerate-dialog.submit': 'Gerar novos códigos',
|
||||||
|
|
||||||
|
'user.settings.two-factor.enabled': 'A autenticação de dois fatores foi ativada',
|
||||||
|
'user.settings.two-factor.disabled': 'A autenticação de dois fatores foi desativada',
|
||||||
|
'user.settings.two-factor.codes-regenerated': 'Os códigos de backup foram gerados novamente',
|
||||||
|
'user.settings.two-factor.verification-failed': 'Falha na verificação. Verifique seu código e tente novamente.',
|
||||||
|
|
||||||
// Organizations
|
// Organizations
|
||||||
|
|
||||||
'organizations.list.title': 'Suas organizações',
|
'organizations.list.title': 'Suas organizações',
|
||||||
@@ -573,6 +647,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||||||
'layout.menu.webhooks': 'Webhooks',
|
'layout.menu.webhooks': 'Webhooks',
|
||||||
'layout.menu.members': 'Membros',
|
'layout.menu.members': 'Membros',
|
||||||
'layout.menu.invitations': 'Convites',
|
'layout.menu.invitations': 'Convites',
|
||||||
|
'layout.menu.admin': 'Administração',
|
||||||
|
|
||||||
'layout.upgrade-cta.title': 'Precisa de mais espaço?',
|
'layout.upgrade-cta.title': 'Precisa de mais espaço?',
|
||||||
'layout.upgrade-cta.description': 'Obtenha 10x mais armazenamento + colaboração em equipe',
|
'layout.upgrade-cta.description': 'Obtenha 10x mais armazenamento + colaboração em equipe',
|
||||||
@@ -600,6 +675,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||||||
|
|
||||||
// API errors
|
// API errors
|
||||||
|
|
||||||
|
'api-errors.api.timeout': 'A solicitação demorou muito e expirou. Por favor, tente novamente.',
|
||||||
'api-errors.document.already_exists': 'O documento já existe',
|
'api-errors.document.already_exists': 'O documento já existe',
|
||||||
'api-errors.document.size_too_large': 'O arquivo é muito grande',
|
'api-errors.document.size_too_large': 'O arquivo é muito grande',
|
||||||
'api-errors.intake-emails.already_exists': 'Um e-mail de entrada com este endereço já existe.',
|
'api-errors.intake-emails.already_exists': 'Um e-mail de entrada com este endereço já existe.',
|
||||||
@@ -640,6 +716,15 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||||||
'api-errors.FAILED_TO_UNLINK_LAST_ACCOUNT': 'Falha ao desvincular a última conta',
|
'api-errors.FAILED_TO_UNLINK_LAST_ACCOUNT': 'Falha ao desvincular a última conta',
|
||||||
'api-errors.ACCOUNT_NOT_FOUND': 'Conta não encontrada',
|
'api-errors.ACCOUNT_NOT_FOUND': 'Conta não encontrada',
|
||||||
'api-errors.USER_ALREADY_HAS_PASSWORD': 'O usuário já possui uma senha',
|
'api-errors.USER_ALREADY_HAS_PASSWORD': 'O usuário já possui uma senha',
|
||||||
|
'api-errors.INVALID_CODE': 'O código fornecido é inválido ou expirou',
|
||||||
|
'api-errors.OTP_NOT_ENABLED': 'A autenticação de dois fatores não está ativada para esta conta',
|
||||||
|
'api-errors.OTP_HAS_EXPIRED': 'O código de autenticação de dois fatores expirou',
|
||||||
|
'api-errors.TOTP_NOT_ENABLED': 'TOTP não está ativado para esta conta',
|
||||||
|
'api-errors.TWO_FACTOR_NOT_ENABLED': 'A autenticação de dois fatores não está ativada para esta conta',
|
||||||
|
'api-errors.BACKUP_CODES_NOT_ENABLED': 'Os códigos de backup não estão ativados para esta conta',
|
||||||
|
'api-errors.INVALID_BACKUP_CODE': 'O código de backup fornecido é inválido ou já foi usado',
|
||||||
|
'api-errors.TOO_MANY_ATTEMPTS_REQUEST_NEW_CODE': 'Muitas tentativas. Por favor, solicite um novo código.',
|
||||||
|
'api-errors.INVALID_TWO_FACTOR_COOKIE': 'Cookie de autenticação de dois fatores inválido',
|
||||||
|
|
||||||
// Not found
|
// Not found
|
||||||
|
|
||||||
@@ -712,6 +797,120 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||||||
'subscriptions.usage-warning.message': 'Você usou {{ percent }}% do seu armazenamento de documentos. Considere atualizar seu plano para obter mais espaço.',
|
'subscriptions.usage-warning.message': 'Você usou {{ percent }}% do seu armazenamento de documentos. Considere atualizar seu plano para obter mais espaço.',
|
||||||
'subscriptions.usage-warning.upgrade-button': 'Atualizar plano',
|
'subscriptions.usage-warning.upgrade-button': 'Atualizar plano',
|
||||||
|
|
||||||
|
// Admin
|
||||||
|
|
||||||
|
'admin.layout.header': 'Administração Papra',
|
||||||
|
'admin.layout.back-to-app': 'Voltar ao app',
|
||||||
|
'admin.layout.menu.analytics': 'Estatísticas',
|
||||||
|
'admin.layout.menu.users': 'Usuários',
|
||||||
|
'admin.layout.menu.organizations': 'Organizações',
|
||||||
|
|
||||||
|
'admin.analytics.title': 'Painel de controle',
|
||||||
|
'admin.analytics.description': 'Informações e estatísticas sobre o uso do Papra.',
|
||||||
|
'admin.analytics.user-count': 'Número de usuários',
|
||||||
|
'admin.analytics.organization-count': 'Número de organizações',
|
||||||
|
'admin.analytics.document-count': 'Número de documentos',
|
||||||
|
'admin.analytics.documents-storage': 'Armazenamento de documentos',
|
||||||
|
'admin.analytics.deleted-documents': 'Documentos excluídos',
|
||||||
|
'admin.analytics.deleted-storage': 'Armazenamento excluído',
|
||||||
|
|
||||||
|
'admin.organizations.title': 'Gerenciamento de organizações',
|
||||||
|
'admin.organizations.description': 'Gerencie e visualize todas as organizações do sistema',
|
||||||
|
'admin.organizations.search-placeholder': 'Buscar por nome ou ID...',
|
||||||
|
'admin.organizations.loading': 'Carregando organizações...',
|
||||||
|
'admin.organizations.no-results': 'Nenhuma organização encontrada correspondente à sua busca.',
|
||||||
|
'admin.organizations.empty': 'Nenhuma organização encontrada.',
|
||||||
|
'admin.organizations.table.id': 'ID',
|
||||||
|
'admin.organizations.table.name': 'Nome',
|
||||||
|
'admin.organizations.table.members': 'Membros',
|
||||||
|
'admin.organizations.table.created': 'Criada',
|
||||||
|
'admin.organizations.table.updated': 'Atualizada',
|
||||||
|
'admin.organizations.pagination.info': 'Exibindo {{ start }} a {{ end }} de {{ total }} {{ total, =1:organização, organizações }}',
|
||||||
|
'admin.organizations.pagination.page-info': 'Página {{ current }} de {{ total }}',
|
||||||
|
|
||||||
|
'admin.organization-detail.title': 'Detalhes da organização',
|
||||||
|
'admin.organization-detail.back': 'Voltar às organizações',
|
||||||
|
'admin.organization-detail.loading.info': 'Carregando informações da organização...',
|
||||||
|
'admin.organization-detail.loading.stats': 'Carregando estatísticas...',
|
||||||
|
'admin.organization-detail.loading.intake-emails': 'Carregando e-mails de entrada...',
|
||||||
|
'admin.organization-detail.loading.webhooks': 'Carregando webhooks...',
|
||||||
|
'admin.organization-detail.loading.members': 'Carregando membros...',
|
||||||
|
'admin.organization-detail.basic-info.title': 'Informações da organização',
|
||||||
|
'admin.organization-detail.basic-info.description': 'Detalhes básicos da organização',
|
||||||
|
'admin.organization-detail.basic-info.id': 'ID',
|
||||||
|
'admin.organization-detail.basic-info.name': 'Nome',
|
||||||
|
'admin.organization-detail.basic-info.created': 'Criada',
|
||||||
|
'admin.organization-detail.basic-info.updated': 'Atualizada',
|
||||||
|
'admin.organization-detail.members.title': 'Membros ({{ count }})',
|
||||||
|
'admin.organization-detail.members.description': 'Usuários que pertencem a esta organização',
|
||||||
|
'admin.organization-detail.members.empty': 'Nenhum membro encontrado',
|
||||||
|
'admin.organization-detail.members.table.user': 'Usuário',
|
||||||
|
'admin.organization-detail.members.table.id': 'ID',
|
||||||
|
'admin.organization-detail.members.table.role': 'Função',
|
||||||
|
'admin.organization-detail.members.table.joined': 'Entrou',
|
||||||
|
'admin.organization-detail.intake-emails.title': 'E-mails de entrada ({{ count }})',
|
||||||
|
'admin.organization-detail.intake-emails.description': 'Endereços de e-mail para ingestão de documentos',
|
||||||
|
'admin.organization-detail.intake-emails.empty': 'Nenhum e-mail de entrada configurado',
|
||||||
|
'admin.organization-detail.intake-emails.status.enabled': 'Ativado',
|
||||||
|
'admin.organization-detail.intake-emails.status.disabled': 'Desativado',
|
||||||
|
'admin.organization-detail.intake-emails.badge.active': 'Ativo',
|
||||||
|
'admin.organization-detail.intake-emails.badge.inactive': 'Inativo',
|
||||||
|
'admin.organization-detail.webhooks.title': 'Webhooks ({{ count }})',
|
||||||
|
'admin.organization-detail.webhooks.description': 'Endpoints de webhook configurados',
|
||||||
|
'admin.organization-detail.webhooks.empty': 'Nenhum webhook configurado',
|
||||||
|
'admin.organization-detail.webhooks.badge.active': 'Ativo',
|
||||||
|
'admin.organization-detail.webhooks.badge.inactive': 'Inativo',
|
||||||
|
'admin.organization-detail.stats.title': 'Estatísticas de uso',
|
||||||
|
'admin.organization-detail.stats.description': 'Estatísticas de documentos e armazenamento',
|
||||||
|
'admin.organization-detail.stats.active-documents': 'Documentos ativos',
|
||||||
|
'admin.organization-detail.stats.active-storage': 'Armazenamento ativo',
|
||||||
|
'admin.organization-detail.stats.deleted-documents': 'Documentos excluídos',
|
||||||
|
'admin.organization-detail.stats.deleted-storage': 'Armazenamento excluído',
|
||||||
|
'admin.organization-detail.stats.total-documents': 'Total de documentos',
|
||||||
|
'admin.organization-detail.stats.total-storage': 'Armazenamento total',
|
||||||
|
|
||||||
|
'admin.users.title': 'Gerenciamento de usuários',
|
||||||
|
'admin.users.description': 'Gerencie e visualize todos os usuários do sistema',
|
||||||
|
'admin.users.search-placeholder': 'Buscar por nome, e-mail ou ID...',
|
||||||
|
'admin.users.loading': 'Carregando usuários...',
|
||||||
|
'admin.users.no-results': 'Nenhum usuário encontrado correspondente à sua busca.',
|
||||||
|
'admin.users.empty': 'Nenhum usuário encontrado.',
|
||||||
|
'admin.users.table.user': 'Usuário',
|
||||||
|
'admin.users.table.id': 'ID',
|
||||||
|
'admin.users.table.status': 'Status',
|
||||||
|
'admin.users.table.status.verified': 'Verificado',
|
||||||
|
'admin.users.table.status.unverified': 'Não verificado',
|
||||||
|
'admin.users.table.orgs': 'Orgs',
|
||||||
|
'admin.users.table.created': 'Criado',
|
||||||
|
'admin.users.pagination.info': 'Exibindo {{ start }} a {{ end }} de {{ total }} {{ total, =1:usuário, usuários }}',
|
||||||
|
'admin.users.pagination.page-info': 'Página {{ current }} de {{ total }}',
|
||||||
|
|
||||||
|
'admin.user-detail.back': 'Voltar aos usuários',
|
||||||
|
'admin.user-detail.loading': 'Carregando detalhes do usuário...',
|
||||||
|
'admin.user-detail.unnamed': 'Usuário sem nome',
|
||||||
|
'admin.user-detail.basic-info.title': 'Informações do usuário',
|
||||||
|
'admin.user-detail.basic-info.description': 'Detalhes básicos do usuário e informações da conta',
|
||||||
|
'admin.user-detail.basic-info.user-id': 'ID do usuário',
|
||||||
|
'admin.user-detail.basic-info.email': 'E-mail',
|
||||||
|
'admin.user-detail.basic-info.name': 'Nome',
|
||||||
|
'admin.user-detail.basic-info.name-empty': '-',
|
||||||
|
'admin.user-detail.basic-info.email-verified': 'E-mail verificado',
|
||||||
|
'admin.user-detail.basic-info.email-verified.yes': 'Sim',
|
||||||
|
'admin.user-detail.basic-info.email-verified.no': 'Não',
|
||||||
|
'admin.user-detail.basic-info.max-organizations': 'Máx. de organizações',
|
||||||
|
'admin.user-detail.basic-info.max-organizations.unlimited': 'Ilimitado',
|
||||||
|
'admin.user-detail.basic-info.created': 'Criado',
|
||||||
|
'admin.user-detail.basic-info.updated': 'Última atualização',
|
||||||
|
'admin.user-detail.roles.title': 'Funções e permissões',
|
||||||
|
'admin.user-detail.roles.description': 'Funções e níveis de acesso do usuário',
|
||||||
|
'admin.user-detail.roles.empty': 'Nenhuma função atribuída',
|
||||||
|
'admin.user-detail.organizations.title': 'Organizações ({{ count }})',
|
||||||
|
'admin.user-detail.organizations.description': 'Organizações às quais este usuário pertence',
|
||||||
|
'admin.user-detail.organizations.empty': 'Não é membro de nenhuma organização',
|
||||||
|
'admin.user-detail.organizations.table.id': 'ID',
|
||||||
|
'admin.user-detail.organizations.table.name': 'Nome',
|
||||||
|
'admin.user-detail.organizations.table.created': 'Criada',
|
||||||
|
|
||||||
// Common / Shared
|
// Common / Shared
|
||||||
|
|
||||||
'common.confirm-modal.type-to-confirm': 'Digite "{{ text }}" para confirmar',
|
'common.confirm-modal.type-to-confirm': 'Digite "{{ text }}" para confirmar',
|
||||||
|
|||||||
@@ -42,6 +42,20 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||||||
'auth.login.form.forgot-password.label': 'Esqueceu-se da palavra-passe?',
|
'auth.login.form.forgot-password.label': 'Esqueceu-se da palavra-passe?',
|
||||||
'auth.login.form.submit': 'Iniciar sessão',
|
'auth.login.form.submit': 'Iniciar sessão',
|
||||||
|
|
||||||
|
'auth.login.two-factor.title': 'Verificação em dois passos',
|
||||||
|
'auth.login.two-factor.description.totp': 'Introduza o código de verificação de 6 dígitos da sua aplicação de autenticação.',
|
||||||
|
'auth.login.two-factor.description.backup-code': 'Introduza um dos seus códigos de segurança para aceder à sua conta.',
|
||||||
|
'auth.login.two-factor.code.label.totp': 'Código de autenticação',
|
||||||
|
'auth.login.two-factor.code.label.backup-code': 'Código de segurança',
|
||||||
|
'auth.login.two-factor.code.placeholder.backup-code': 'Introduza o código de segurança',
|
||||||
|
'auth.login.two-factor.code.required': 'Por favor, introduza o código de verificação',
|
||||||
|
'auth.login.two-factor.trust-device.label': 'Confiar neste dispositivo durante 30 dias',
|
||||||
|
'auth.login.two-factor.back': 'Voltar ao início de sessão',
|
||||||
|
'auth.login.two-factor.submit': 'Verificar',
|
||||||
|
'auth.login.two-factor.verification-failed': 'Falha na verificação. Verifique o seu código e tente novamente.',
|
||||||
|
'auth.login.two-factor.use-backup-code': 'Usar código de segurança',
|
||||||
|
'auth.login.two-factor.use-totp': 'Usar aplicação de autenticação',
|
||||||
|
|
||||||
'auth.register.title': 'Registar no Papra',
|
'auth.register.title': 'Registar no Papra',
|
||||||
'auth.register.description': 'Crie uma conta para começar a usar o Papra.',
|
'auth.register.description': 'Crie uma conta para começar a usar o Papra.',
|
||||||
'auth.register.register-with-email': 'Registar com e-mail',
|
'auth.register.register-with-email': 'Registar com e-mail',
|
||||||
@@ -104,6 +118,66 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||||||
'user.settings.logout.description': 'Terminar sessão da sua conta. Pode iniciar sessão novamente mais tarde.',
|
'user.settings.logout.description': 'Terminar sessão da sua conta. Pode iniciar sessão novamente mais tarde.',
|
||||||
'user.settings.logout.button': 'Terminar sessão',
|
'user.settings.logout.button': 'Terminar sessão',
|
||||||
|
|
||||||
|
'user.settings.two-factor.title': 'Autenticação de dois fatores',
|
||||||
|
'user.settings.two-factor.description': 'Adicione uma camada extra de segurança à sua conta.',
|
||||||
|
'user.settings.two-factor.status.enabled': 'Ativada',
|
||||||
|
'user.settings.two-factor.status.disabled': 'Desativada',
|
||||||
|
'user.settings.two-factor.enable-button': 'Ativar A2F',
|
||||||
|
'user.settings.two-factor.disable-button': 'Desativar A2F',
|
||||||
|
'user.settings.two-factor.regenerate-codes-button': 'Regenerar códigos de segurança',
|
||||||
|
|
||||||
|
'user.settings.two-factor.enable-dialog.title': 'Ativar autenticação de dois fatores',
|
||||||
|
'user.settings.two-factor.enable-dialog.description': 'Introduza a sua palavra-passe para ativar A2F.',
|
||||||
|
'user.settings.two-factor.enable-dialog.password.label': 'Palavra-passe',
|
||||||
|
'user.settings.two-factor.enable-dialog.password.placeholder': 'Introduza a sua palavra-passe',
|
||||||
|
'user.settings.two-factor.enable-dialog.password.required': 'Por favor, introduza a sua palavra-passe',
|
||||||
|
'user.settings.two-factor.enable-dialog.cancel': 'Cancelar',
|
||||||
|
'user.settings.two-factor.enable-dialog.submit': 'Continuar',
|
||||||
|
|
||||||
|
'user.settings.two-factor.setup-dialog.title': 'Configurar autenticação de dois fatores',
|
||||||
|
'user.settings.two-factor.setup-dialog.description': 'Digitalize este código QR com a sua aplicação de autenticação e depois introduza o código de verificação.',
|
||||||
|
'user.settings.two-factor.setup-dialog.qr-loading': 'A carregar código QR...',
|
||||||
|
'user.settings.two-factor.setup-dialog.step1.title': 'Passo 1: Digitalizar o código QR',
|
||||||
|
'user.settings.two-factor.setup-dialog.step1.description': 'Digitalize o código QR abaixo ou introduza manualmente a chave de configuração na sua aplicação de autenticação.',
|
||||||
|
'user.settings.two-factor.setup-dialog.copy-setup-key': 'Copiar chave de configuração',
|
||||||
|
'user.settings.two-factor.setup-dialog.step2.title': 'Passo 2: Verificar o código',
|
||||||
|
'user.settings.two-factor.setup-dialog.step2.description': 'Introduza o código de 6 dígitos gerado pela sua aplicação de autenticação para verificar e ativar a autenticação de dois fatores.',
|
||||||
|
'user.settings.two-factor.setup-dialog.code.label': 'Código de verificação',
|
||||||
|
'user.settings.two-factor.setup-dialog.code.placeholder': 'Introduza o código de 6 dígitos',
|
||||||
|
'user.settings.two-factor.setup-dialog.code.required': 'Por favor, introduza o código de verificação',
|
||||||
|
'user.settings.two-factor.setup-dialog.cancel': 'Cancelar',
|
||||||
|
'user.settings.two-factor.setup-dialog.verify': 'Verificar e ativar A2F',
|
||||||
|
|
||||||
|
'user.settings.two-factor.backup-codes-dialog.title': 'Códigos de segurança',
|
||||||
|
'user.settings.two-factor.backup-codes-dialog.description': 'Guarde estes códigos de segurança num local seguro. Pode usá-los para aceder à sua conta se perder o acesso à sua aplicação de autenticação.',
|
||||||
|
'user.settings.two-factor.backup-codes-dialog.warning': 'Cada código só pode ser usado uma vez.',
|
||||||
|
'user.settings.two-factor.backup-codes-dialog.copy': 'Copiar códigos de segurança',
|
||||||
|
'user.settings.two-factor.backup-codes-dialog.download': 'Transferir códigos de segurança',
|
||||||
|
'user.settings.two-factor.backup-codes-dialog.download-filename': 'papra-2fa-backup-codes.txt',
|
||||||
|
'user.settings.two-factor.backup-codes-dialog.copied': 'Códigos copiados para a área de transferência',
|
||||||
|
'user.settings.two-factor.backup-codes-dialog.close': 'Guardei os meus códigos',
|
||||||
|
|
||||||
|
'user.settings.two-factor.disable-dialog.title': 'Desativar autenticação de dois fatores',
|
||||||
|
'user.settings.two-factor.disable-dialog.description': 'Introduza a sua palavra-passe para desativar A2F. Isto tornará a sua conta menos segura.',
|
||||||
|
'user.settings.two-factor.disable-dialog.password.label': 'Palavra-passe',
|
||||||
|
'user.settings.two-factor.disable-dialog.password.placeholder': 'Introduza a sua palavra-passe',
|
||||||
|
'user.settings.two-factor.disable-dialog.password.required': 'Por favor, introduza a sua palavra-passe',
|
||||||
|
'user.settings.two-factor.disable-dialog.cancel': 'Cancelar',
|
||||||
|
'user.settings.two-factor.disable-dialog.submit': 'Desativar A2F',
|
||||||
|
|
||||||
|
'user.settings.two-factor.regenerate-dialog.title': 'Regenerar códigos de segurança',
|
||||||
|
'user.settings.two-factor.regenerate-dialog.description': 'Isto invalidará todos os códigos de segurança existentes e gerará novos. Introduza a sua palavra-passe para continuar.',
|
||||||
|
'user.settings.two-factor.regenerate-dialog.password.label': 'Palavra-passe',
|
||||||
|
'user.settings.two-factor.regenerate-dialog.password.placeholder': 'Introduza a sua palavra-passe',
|
||||||
|
'user.settings.two-factor.regenerate-dialog.password.required': 'Por favor, introduza a sua palavra-passe',
|
||||||
|
'user.settings.two-factor.regenerate-dialog.cancel': 'Cancelar',
|
||||||
|
'user.settings.two-factor.regenerate-dialog.submit': 'Regenerar códigos',
|
||||||
|
|
||||||
|
'user.settings.two-factor.enabled': 'A autenticação de dois fatores foi ativada',
|
||||||
|
'user.settings.two-factor.disabled': 'A autenticação de dois fatores foi desativada',
|
||||||
|
'user.settings.two-factor.codes-regenerated': 'Os códigos de segurança foram regenerados',
|
||||||
|
'user.settings.two-factor.verification-failed': 'Falha na verificação. Verifique o seu código e tente novamente.',
|
||||||
|
|
||||||
// Organizations
|
// Organizations
|
||||||
|
|
||||||
'organizations.list.title': 'As suas organizações',
|
'organizations.list.title': 'As suas organizações',
|
||||||
@@ -573,6 +647,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||||||
'layout.menu.webhooks': 'Webhooks',
|
'layout.menu.webhooks': 'Webhooks',
|
||||||
'layout.menu.members': 'Membros',
|
'layout.menu.members': 'Membros',
|
||||||
'layout.menu.invitations': 'Convites',
|
'layout.menu.invitations': 'Convites',
|
||||||
|
'layout.menu.admin': 'Administração',
|
||||||
|
|
||||||
'layout.upgrade-cta.title': 'Precisa de mais espaço?',
|
'layout.upgrade-cta.title': 'Precisa de mais espaço?',
|
||||||
'layout.upgrade-cta.description': 'Obtenha 10x mais armazenamento + colaboração em equipa',
|
'layout.upgrade-cta.description': 'Obtenha 10x mais armazenamento + colaboração em equipa',
|
||||||
@@ -600,6 +675,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||||||
|
|
||||||
// API errors
|
// API errors
|
||||||
|
|
||||||
|
'api-errors.api.timeout': 'O pedido demorou muito tempo e expirou. Por favor, tente novamente.',
|
||||||
'api-errors.document.already_exists': 'O documento já existe',
|
'api-errors.document.already_exists': 'O documento já existe',
|
||||||
'api-errors.document.size_too_large': 'O arquivo é muito grande',
|
'api-errors.document.size_too_large': 'O arquivo é muito grande',
|
||||||
'api-errors.intake-emails.already_exists': 'Um e-mail de entrada com este endereço já existe.',
|
'api-errors.intake-emails.already_exists': 'Um e-mail de entrada com este endereço já existe.',
|
||||||
@@ -640,6 +716,15 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||||||
'api-errors.FAILED_TO_UNLINK_LAST_ACCOUNT': 'Falha ao desassociar a última conta',
|
'api-errors.FAILED_TO_UNLINK_LAST_ACCOUNT': 'Falha ao desassociar a última conta',
|
||||||
'api-errors.ACCOUNT_NOT_FOUND': 'Conta não encontrada',
|
'api-errors.ACCOUNT_NOT_FOUND': 'Conta não encontrada',
|
||||||
'api-errors.USER_ALREADY_HAS_PASSWORD': 'O utilizador já tem uma palavra-passe',
|
'api-errors.USER_ALREADY_HAS_PASSWORD': 'O utilizador já tem uma palavra-passe',
|
||||||
|
'api-errors.INVALID_CODE': 'O código fornecido é inválido ou expirou',
|
||||||
|
'api-errors.OTP_NOT_ENABLED': 'A autenticação de dois fatores não está ativada para esta conta',
|
||||||
|
'api-errors.OTP_HAS_EXPIRED': 'O código de autenticação de dois fatores expirou',
|
||||||
|
'api-errors.TOTP_NOT_ENABLED': 'TOTP não está ativado para esta conta',
|
||||||
|
'api-errors.TWO_FACTOR_NOT_ENABLED': 'A autenticação de dois fatores não está ativada para esta conta',
|
||||||
|
'api-errors.BACKUP_CODES_NOT_ENABLED': 'Os códigos de segurança não estão ativados para esta conta',
|
||||||
|
'api-errors.INVALID_BACKUP_CODE': 'O código de segurança fornecido é inválido ou já foi usado',
|
||||||
|
'api-errors.TOO_MANY_ATTEMPTS_REQUEST_NEW_CODE': 'Demasiadas tentativas. Por favor, solicite um novo código.',
|
||||||
|
'api-errors.INVALID_TWO_FACTOR_COOKIE': 'Cookie de autenticação de dois fatores inválido',
|
||||||
|
|
||||||
// Not found
|
// Not found
|
||||||
|
|
||||||
@@ -712,6 +797,120 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||||||
'subscriptions.usage-warning.message': 'Usou {{ percent }}% do seu armazenamento de documentos. Considere atualizar o seu plano para obter mais espaço.',
|
'subscriptions.usage-warning.message': 'Usou {{ percent }}% do seu armazenamento de documentos. Considere atualizar o seu plano para obter mais espaço.',
|
||||||
'subscriptions.usage-warning.upgrade-button': 'Atualizar plano',
|
'subscriptions.usage-warning.upgrade-button': 'Atualizar plano',
|
||||||
|
|
||||||
|
// Admin
|
||||||
|
|
||||||
|
'admin.layout.header': 'Administração Papra',
|
||||||
|
'admin.layout.back-to-app': 'Voltar à aplicação',
|
||||||
|
'admin.layout.menu.analytics': 'Estatísticas',
|
||||||
|
'admin.layout.menu.users': 'Utilizadores',
|
||||||
|
'admin.layout.menu.organizations': 'Organizações',
|
||||||
|
|
||||||
|
'admin.analytics.title': 'Painel de controlo',
|
||||||
|
'admin.analytics.description': 'Informações e estatísticas sobre a utilização do Papra.',
|
||||||
|
'admin.analytics.user-count': 'Número de utilizadores',
|
||||||
|
'admin.analytics.organization-count': 'Número de organizações',
|
||||||
|
'admin.analytics.document-count': 'Número de documentos',
|
||||||
|
'admin.analytics.documents-storage': 'Armazenamento de documentos',
|
||||||
|
'admin.analytics.deleted-documents': 'Documentos eliminados',
|
||||||
|
'admin.analytics.deleted-storage': 'Armazenamento eliminado',
|
||||||
|
|
||||||
|
'admin.organizations.title': 'Gestão de organizações',
|
||||||
|
'admin.organizations.description': 'Gerir e visualizar todas as organizações do sistema',
|
||||||
|
'admin.organizations.search-placeholder': 'Procurar por nome ou ID...',
|
||||||
|
'admin.organizations.loading': 'A carregar organizações...',
|
||||||
|
'admin.organizations.no-results': 'Nenhuma organização encontrada correspondente à sua procura.',
|
||||||
|
'admin.organizations.empty': 'Nenhuma organização encontrada.',
|
||||||
|
'admin.organizations.table.id': 'ID',
|
||||||
|
'admin.organizations.table.name': 'Nome',
|
||||||
|
'admin.organizations.table.members': 'Membros',
|
||||||
|
'admin.organizations.table.created': 'Criada',
|
||||||
|
'admin.organizations.table.updated': 'Atualizada',
|
||||||
|
'admin.organizations.pagination.info': 'A mostrar {{ start }} a {{ end }} de {{ total }} {{ total, =1:organização, organizações }}',
|
||||||
|
'admin.organizations.pagination.page-info': 'Página {{ current }} de {{ total }}',
|
||||||
|
|
||||||
|
'admin.organization-detail.title': 'Detalhes da organização',
|
||||||
|
'admin.organization-detail.back': 'Voltar às organizações',
|
||||||
|
'admin.organization-detail.loading.info': 'A carregar informações da organização...',
|
||||||
|
'admin.organization-detail.loading.stats': 'A carregar estatísticas...',
|
||||||
|
'admin.organization-detail.loading.intake-emails': 'A carregar e-mails de entrada...',
|
||||||
|
'admin.organization-detail.loading.webhooks': 'A carregar webhooks...',
|
||||||
|
'admin.organization-detail.loading.members': 'A carregar membros...',
|
||||||
|
'admin.organization-detail.basic-info.title': 'Informações da organização',
|
||||||
|
'admin.organization-detail.basic-info.description': 'Detalhes básicos da organização',
|
||||||
|
'admin.organization-detail.basic-info.id': 'ID',
|
||||||
|
'admin.organization-detail.basic-info.name': 'Nome',
|
||||||
|
'admin.organization-detail.basic-info.created': 'Criada',
|
||||||
|
'admin.organization-detail.basic-info.updated': 'Atualizada',
|
||||||
|
'admin.organization-detail.members.title': 'Membros ({{ count }})',
|
||||||
|
'admin.organization-detail.members.description': 'Utilizadores que pertencem a esta organização',
|
||||||
|
'admin.organization-detail.members.empty': 'Nenhum membro encontrado',
|
||||||
|
'admin.organization-detail.members.table.user': 'Utilizador',
|
||||||
|
'admin.organization-detail.members.table.id': 'ID',
|
||||||
|
'admin.organization-detail.members.table.role': 'Função',
|
||||||
|
'admin.organization-detail.members.table.joined': 'Aderiu',
|
||||||
|
'admin.organization-detail.intake-emails.title': 'E-mails de entrada ({{ count }})',
|
||||||
|
'admin.organization-detail.intake-emails.description': 'Endereços de e-mail para ingestão de documentos',
|
||||||
|
'admin.organization-detail.intake-emails.empty': 'Nenhum e-mail de entrada configurado',
|
||||||
|
'admin.organization-detail.intake-emails.status.enabled': 'Ativado',
|
||||||
|
'admin.organization-detail.intake-emails.status.disabled': 'Desativado',
|
||||||
|
'admin.organization-detail.intake-emails.badge.active': 'Ativo',
|
||||||
|
'admin.organization-detail.intake-emails.badge.inactive': 'Inativo',
|
||||||
|
'admin.organization-detail.webhooks.title': 'Webhooks ({{ count }})',
|
||||||
|
'admin.organization-detail.webhooks.description': 'Endpoints de webhook configurados',
|
||||||
|
'admin.organization-detail.webhooks.empty': 'Nenhum webhook configurado',
|
||||||
|
'admin.organization-detail.webhooks.badge.active': 'Ativo',
|
||||||
|
'admin.organization-detail.webhooks.badge.inactive': 'Inativo',
|
||||||
|
'admin.organization-detail.stats.title': 'Estatísticas de utilização',
|
||||||
|
'admin.organization-detail.stats.description': 'Estatísticas de documentos e armazenamento',
|
||||||
|
'admin.organization-detail.stats.active-documents': 'Documentos ativos',
|
||||||
|
'admin.organization-detail.stats.active-storage': 'Armazenamento ativo',
|
||||||
|
'admin.organization-detail.stats.deleted-documents': 'Documentos eliminados',
|
||||||
|
'admin.organization-detail.stats.deleted-storage': 'Armazenamento eliminado',
|
||||||
|
'admin.organization-detail.stats.total-documents': 'Total de documentos',
|
||||||
|
'admin.organization-detail.stats.total-storage': 'Armazenamento total',
|
||||||
|
|
||||||
|
'admin.users.title': 'Gestão de utilizadores',
|
||||||
|
'admin.users.description': 'Gerir e visualizar todos os utilizadores do sistema',
|
||||||
|
'admin.users.search-placeholder': 'Procurar por nome, e-mail ou ID...',
|
||||||
|
'admin.users.loading': 'A carregar utilizadores...',
|
||||||
|
'admin.users.no-results': 'Nenhum utilizador encontrado correspondente à sua procura.',
|
||||||
|
'admin.users.empty': 'Nenhum utilizador encontrado.',
|
||||||
|
'admin.users.table.user': 'Utilizador',
|
||||||
|
'admin.users.table.id': 'ID',
|
||||||
|
'admin.users.table.status': 'Estado',
|
||||||
|
'admin.users.table.status.verified': 'Verificado',
|
||||||
|
'admin.users.table.status.unverified': 'Não verificado',
|
||||||
|
'admin.users.table.orgs': 'Orgs',
|
||||||
|
'admin.users.table.created': 'Criado',
|
||||||
|
'admin.users.pagination.info': 'A mostrar {{ start }} a {{ end }} de {{ total }} {{ total, =1:utilizador, utilizadores }}',
|
||||||
|
'admin.users.pagination.page-info': 'Página {{ current }} de {{ total }}',
|
||||||
|
|
||||||
|
'admin.user-detail.back': 'Voltar aos utilizadores',
|
||||||
|
'admin.user-detail.loading': 'A carregar detalhes do utilizador...',
|
||||||
|
'admin.user-detail.unnamed': 'Utilizador sem nome',
|
||||||
|
'admin.user-detail.basic-info.title': 'Informações do utilizador',
|
||||||
|
'admin.user-detail.basic-info.description': 'Detalhes básicos do utilizador e informações da conta',
|
||||||
|
'admin.user-detail.basic-info.user-id': 'ID do utilizador',
|
||||||
|
'admin.user-detail.basic-info.email': 'E-mail',
|
||||||
|
'admin.user-detail.basic-info.name': 'Nome',
|
||||||
|
'admin.user-detail.basic-info.name-empty': '-',
|
||||||
|
'admin.user-detail.basic-info.email-verified': 'E-mail verificado',
|
||||||
|
'admin.user-detail.basic-info.email-verified.yes': 'Sim',
|
||||||
|
'admin.user-detail.basic-info.email-verified.no': 'Não',
|
||||||
|
'admin.user-detail.basic-info.max-organizations': 'Máx. de organizações',
|
||||||
|
'admin.user-detail.basic-info.max-organizations.unlimited': 'Ilimitado',
|
||||||
|
'admin.user-detail.basic-info.created': 'Criado',
|
||||||
|
'admin.user-detail.basic-info.updated': 'Última atualização',
|
||||||
|
'admin.user-detail.roles.title': 'Funções e permissões',
|
||||||
|
'admin.user-detail.roles.description': 'Funções e níveis de acesso do utilizador',
|
||||||
|
'admin.user-detail.roles.empty': 'Nenhuma função atribuída',
|
||||||
|
'admin.user-detail.organizations.title': 'Organizações ({{ count }})',
|
||||||
|
'admin.user-detail.organizations.description': 'Organizações a que este utilizador pertence',
|
||||||
|
'admin.user-detail.organizations.empty': 'Não é membro de nenhuma organização',
|
||||||
|
'admin.user-detail.organizations.table.id': 'ID',
|
||||||
|
'admin.user-detail.organizations.table.name': 'Nome',
|
||||||
|
'admin.user-detail.organizations.table.created': 'Criada',
|
||||||
|
|
||||||
// Common / Shared
|
// Common / Shared
|
||||||
|
|
||||||
'common.confirm-modal.type-to-confirm': 'Digite "{{ text }}" para confirmar',
|
'common.confirm-modal.type-to-confirm': 'Digite "{{ text }}" para confirmar',
|
||||||
|
|||||||
@@ -42,6 +42,20 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||||||
'auth.login.form.forgot-password.label': 'Ai uitat parola?',
|
'auth.login.form.forgot-password.label': 'Ai uitat parola?',
|
||||||
'auth.login.form.submit': 'Autentificare',
|
'auth.login.form.submit': 'Autentificare',
|
||||||
|
|
||||||
|
'auth.login.two-factor.title': 'Verificare în doi pași',
|
||||||
|
'auth.login.two-factor.description.totp': 'Introduceți codul de verificare de 6 cifre din aplicația dvs. de autentificare.',
|
||||||
|
'auth.login.two-factor.description.backup-code': 'Introduceți unul dintre codurile dvs. de rezervă pentru a accesa contul.',
|
||||||
|
'auth.login.two-factor.code.label.totp': 'Cod de autentificare',
|
||||||
|
'auth.login.two-factor.code.label.backup-code': 'Cod de rezervă',
|
||||||
|
'auth.login.two-factor.code.placeholder.backup-code': 'Introduceți codul de rezervă',
|
||||||
|
'auth.login.two-factor.code.required': 'Vă rugăm să introduceți codul de verificare',
|
||||||
|
'auth.login.two-factor.trust-device.label': 'Acordă încredere acestui dispozitiv pentru 30 de zile',
|
||||||
|
'auth.login.two-factor.back': 'Înapoi la autentificare',
|
||||||
|
'auth.login.two-factor.submit': 'Verifică',
|
||||||
|
'auth.login.two-factor.verification-failed': 'Verificare eșuată. Verificați codul și încercați din nou.',
|
||||||
|
'auth.login.two-factor.use-backup-code': 'Folosește cod de rezervă',
|
||||||
|
'auth.login.two-factor.use-totp': 'Folosește aplicația de autentificare',
|
||||||
|
|
||||||
'auth.register.title': 'Înregistrare la Papra',
|
'auth.register.title': 'Înregistrare la Papra',
|
||||||
'auth.register.description': 'Introdu e-mailul pentru a accesa Papra.',
|
'auth.register.description': 'Introdu e-mailul pentru a accesa Papra.',
|
||||||
'auth.register.register-with-email': 'înregistrează-te cu e-mail',
|
'auth.register.register-with-email': 'înregistrează-te cu e-mail',
|
||||||
@@ -104,6 +118,66 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||||||
'user.settings.logout.description': 'Vei fi deconectat din cont. Te poți conecta înapoi ulterior.',
|
'user.settings.logout.description': 'Vei fi deconectat din cont. Te poți conecta înapoi ulterior.',
|
||||||
'user.settings.logout.button': 'Deconectare',
|
'user.settings.logout.button': 'Deconectare',
|
||||||
|
|
||||||
|
'user.settings.two-factor.title': 'Autentificare cu doi factori',
|
||||||
|
'user.settings.two-factor.description': 'Adăugați un nivel suplimentar de securitate contului dvs.',
|
||||||
|
'user.settings.two-factor.status.enabled': 'Activată',
|
||||||
|
'user.settings.two-factor.status.disabled': 'Dezactivată',
|
||||||
|
'user.settings.two-factor.enable-button': 'Activează A2F',
|
||||||
|
'user.settings.two-factor.disable-button': 'Dezactivează A2F',
|
||||||
|
'user.settings.two-factor.regenerate-codes-button': 'Regenerează codurile de rezervă',
|
||||||
|
|
||||||
|
'user.settings.two-factor.enable-dialog.title': 'Activare autentificare cu doi factori',
|
||||||
|
'user.settings.two-factor.enable-dialog.description': 'Introduceți parola pentru a activa A2F.',
|
||||||
|
'user.settings.two-factor.enable-dialog.password.label': 'Parolă',
|
||||||
|
'user.settings.two-factor.enable-dialog.password.placeholder': 'Introduceți parola',
|
||||||
|
'user.settings.two-factor.enable-dialog.password.required': 'Vă rugăm să introduceți parola',
|
||||||
|
'user.settings.two-factor.enable-dialog.cancel': 'Anulează',
|
||||||
|
'user.settings.two-factor.enable-dialog.submit': 'Continuă',
|
||||||
|
|
||||||
|
'user.settings.two-factor.setup-dialog.title': 'Configurare autentificare cu doi factori',
|
||||||
|
'user.settings.two-factor.setup-dialog.description': 'Scanați acest cod QR cu aplicația dvs. de autentificare, apoi introduceți codul de verificare.',
|
||||||
|
'user.settings.two-factor.setup-dialog.qr-loading': 'Se încarcă codul QR...',
|
||||||
|
'user.settings.two-factor.setup-dialog.step1.title': 'Pasul 1: Scanați codul QR',
|
||||||
|
'user.settings.two-factor.setup-dialog.step1.description': 'Scanați codul QR de mai jos sau introduceți manual cheia de configurare în aplicația dvs. de autentificare.',
|
||||||
|
'user.settings.two-factor.setup-dialog.copy-setup-key': 'Copiază cheia de configurare',
|
||||||
|
'user.settings.two-factor.setup-dialog.step2.title': 'Pasul 2: Verificați codul',
|
||||||
|
'user.settings.two-factor.setup-dialog.step2.description': 'Introduceți codul de 6 cifre generat de aplicația dvs. de autentificare pentru a verifica și activa autentificarea cu doi factori.',
|
||||||
|
'user.settings.two-factor.setup-dialog.code.label': 'Cod de verificare',
|
||||||
|
'user.settings.two-factor.setup-dialog.code.placeholder': 'Introduceți codul de 6 cifre',
|
||||||
|
'user.settings.two-factor.setup-dialog.code.required': 'Vă rugăm să introduceți codul de verificare',
|
||||||
|
'user.settings.two-factor.setup-dialog.cancel': 'Anulează',
|
||||||
|
'user.settings.two-factor.setup-dialog.verify': 'Verifică și activează A2F',
|
||||||
|
|
||||||
|
'user.settings.two-factor.backup-codes-dialog.title': 'Coduri de rezervă',
|
||||||
|
'user.settings.two-factor.backup-codes-dialog.description': 'Salvați aceste coduri de rezervă într-un loc sigur. Le puteți folosi pentru a accesa contul dacă pierdeți accesul la aplicația dvs. de autentificare.',
|
||||||
|
'user.settings.two-factor.backup-codes-dialog.warning': 'Fiecare cod poate fi folosit o singură dată.',
|
||||||
|
'user.settings.two-factor.backup-codes-dialog.copy': 'Copiază codurile de rezervă',
|
||||||
|
'user.settings.two-factor.backup-codes-dialog.download': 'Descarcă codurile de rezervă',
|
||||||
|
'user.settings.two-factor.backup-codes-dialog.download-filename': 'papra-2fa-backup-codes.txt',
|
||||||
|
'user.settings.two-factor.backup-codes-dialog.copied': 'Coduri copiate în clipboard',
|
||||||
|
'user.settings.two-factor.backup-codes-dialog.close': 'Am salvat codurile',
|
||||||
|
|
||||||
|
'user.settings.two-factor.disable-dialog.title': 'Dezactivare autentificare cu doi factori',
|
||||||
|
'user.settings.two-factor.disable-dialog.description': 'Introduceți parola pentru a dezactiva A2F. Aceasta va face contul dvs. mai puțin sigur.',
|
||||||
|
'user.settings.two-factor.disable-dialog.password.label': 'Parolă',
|
||||||
|
'user.settings.two-factor.disable-dialog.password.placeholder': 'Introduceți parola',
|
||||||
|
'user.settings.two-factor.disable-dialog.password.required': 'Vă rugăm să introduceți parola',
|
||||||
|
'user.settings.two-factor.disable-dialog.cancel': 'Anulează',
|
||||||
|
'user.settings.two-factor.disable-dialog.submit': 'Dezactivează A2F',
|
||||||
|
|
||||||
|
'user.settings.two-factor.regenerate-dialog.title': 'Regenerare coduri de rezervă',
|
||||||
|
'user.settings.two-factor.regenerate-dialog.description': 'Aceasta va invalida toate codurile de rezervă existente și va genera altele noi. Introduceți parola pentru a continua.',
|
||||||
|
'user.settings.two-factor.regenerate-dialog.password.label': 'Parolă',
|
||||||
|
'user.settings.two-factor.regenerate-dialog.password.placeholder': 'Introduceți parola',
|
||||||
|
'user.settings.two-factor.regenerate-dialog.password.required': 'Vă rugăm să introduceți parola',
|
||||||
|
'user.settings.two-factor.regenerate-dialog.cancel': 'Anulează',
|
||||||
|
'user.settings.two-factor.regenerate-dialog.submit': 'Regenerează codurile',
|
||||||
|
|
||||||
|
'user.settings.two-factor.enabled': 'Autentificarea cu doi factori a fost activată',
|
||||||
|
'user.settings.two-factor.disabled': 'Autentificarea cu doi factori a fost dezactivată',
|
||||||
|
'user.settings.two-factor.codes-regenerated': 'Codurile de rezervă au fost regenerate',
|
||||||
|
'user.settings.two-factor.verification-failed': 'Verificare eșuată. Verificați codul și încercați din nou.',
|
||||||
|
|
||||||
// Organizations
|
// Organizations
|
||||||
|
|
||||||
'organizations.list.title': 'Organizațiile tale',
|
'organizations.list.title': 'Organizațiile tale',
|
||||||
@@ -573,6 +647,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||||||
'layout.menu.webhooks': 'Webhook-uri',
|
'layout.menu.webhooks': 'Webhook-uri',
|
||||||
'layout.menu.members': 'Membri',
|
'layout.menu.members': 'Membri',
|
||||||
'layout.menu.invitations': 'Invitații',
|
'layout.menu.invitations': 'Invitații',
|
||||||
|
'layout.menu.admin': 'Administrare',
|
||||||
|
|
||||||
'layout.upgrade-cta.title': 'Ai nevoie de mai mult spațiu?',
|
'layout.upgrade-cta.title': 'Ai nevoie de mai mult spațiu?',
|
||||||
'layout.upgrade-cta.description': 'Obține de 10x mai mult spațiu de stocare + colaborare în echipă',
|
'layout.upgrade-cta.description': 'Obține de 10x mai mult spațiu de stocare + colaborare în echipă',
|
||||||
@@ -600,6 +675,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||||||
|
|
||||||
// API errors
|
// API errors
|
||||||
|
|
||||||
|
'api-errors.api.timeout': 'Cererea a durat prea mult și a expirat. Vă rugăm să încercați din nou.',
|
||||||
'api-errors.document.already_exists': 'Documentul există deja',
|
'api-errors.document.already_exists': 'Documentul există deja',
|
||||||
'api-errors.document.size_too_large': 'Fișierul este prea mare',
|
'api-errors.document.size_too_large': 'Fișierul este prea mare',
|
||||||
'api-errors.intake-emails.already_exists': 'Un email de primire cu această adresă există deja.',
|
'api-errors.intake-emails.already_exists': 'Un email de primire cu această adresă există deja.',
|
||||||
@@ -640,6 +716,15 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||||||
'api-errors.FAILED_TO_UNLINK_LAST_ACCOUNT': 'Eroare la disocierea ultimului cont',
|
'api-errors.FAILED_TO_UNLINK_LAST_ACCOUNT': 'Eroare la disocierea ultimului cont',
|
||||||
'api-errors.ACCOUNT_NOT_FOUND': 'Contul nu a fost găsit',
|
'api-errors.ACCOUNT_NOT_FOUND': 'Contul nu a fost găsit',
|
||||||
'api-errors.USER_ALREADY_HAS_PASSWORD': 'Utilizatorul are deja o parolă',
|
'api-errors.USER_ALREADY_HAS_PASSWORD': 'Utilizatorul are deja o parolă',
|
||||||
|
'api-errors.INVALID_CODE': 'Codul furnizat este invalid sau a expirat',
|
||||||
|
'api-errors.OTP_NOT_ENABLED': 'Autentificarea cu doi factori nu este activată pentru acest cont',
|
||||||
|
'api-errors.OTP_HAS_EXPIRED': 'Codul de autentificare cu doi factori a expirat',
|
||||||
|
'api-errors.TOTP_NOT_ENABLED': 'TOTP nu este activat pentru acest cont',
|
||||||
|
'api-errors.TWO_FACTOR_NOT_ENABLED': 'Autentificarea cu doi factori nu este activată pentru acest cont',
|
||||||
|
'api-errors.BACKUP_CODES_NOT_ENABLED': 'Codurile de rezervă nu sunt activate pentru acest cont',
|
||||||
|
'api-errors.INVALID_BACKUP_CODE': 'Codul de rezervă furnizat este invalid sau a fost deja folosit',
|
||||||
|
'api-errors.TOO_MANY_ATTEMPTS_REQUEST_NEW_CODE': 'Prea multe încercări. Vă rugăm să solicitați un cod nou.',
|
||||||
|
'api-errors.INVALID_TWO_FACTOR_COOKIE': 'Cookie de autentificare cu doi factori invalid',
|
||||||
|
|
||||||
// Not found
|
// Not found
|
||||||
|
|
||||||
@@ -712,6 +797,120 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||||||
'subscriptions.usage-warning.message': 'Ai folosit {{ percent }}% din spațiul de stocare pentru documente. Ia în considerare actualizarea planului pentru a obține mai mult spațiu.',
|
'subscriptions.usage-warning.message': 'Ai folosit {{ percent }}% din spațiul de stocare pentru documente. Ia în considerare actualizarea planului pentru a obține mai mult spațiu.',
|
||||||
'subscriptions.usage-warning.upgrade-button': 'Actualizează planul',
|
'subscriptions.usage-warning.upgrade-button': 'Actualizează planul',
|
||||||
|
|
||||||
|
// Admin
|
||||||
|
|
||||||
|
'admin.layout.header': 'Administrare Papra',
|
||||||
|
'admin.layout.back-to-app': 'Înapoi la aplicație',
|
||||||
|
'admin.layout.menu.analytics': 'Statistici',
|
||||||
|
'admin.layout.menu.users': 'Utilizatori',
|
||||||
|
'admin.layout.menu.organizations': 'Organizații',
|
||||||
|
|
||||||
|
'admin.analytics.title': 'Tablou de bord',
|
||||||
|
'admin.analytics.description': 'Informații și statistici despre utilizarea Papra.',
|
||||||
|
'admin.analytics.user-count': 'Număr de utilizatori',
|
||||||
|
'admin.analytics.organization-count': 'Număr de organizații',
|
||||||
|
'admin.analytics.document-count': 'Număr de documente',
|
||||||
|
'admin.analytics.documents-storage': 'Stocare documente',
|
||||||
|
'admin.analytics.deleted-documents': 'Documente șterse',
|
||||||
|
'admin.analytics.deleted-storage': 'Stocare ștearsă',
|
||||||
|
|
||||||
|
'admin.organizations.title': 'Gestionare organizații',
|
||||||
|
'admin.organizations.description': 'Gestionați și vizualizați toate organizațiile din sistem',
|
||||||
|
'admin.organizations.search-placeholder': 'Căutare după nume sau ID...',
|
||||||
|
'admin.organizations.loading': 'Se încarcă organizațiile...',
|
||||||
|
'admin.organizations.no-results': 'Nu s-au găsit organizații care să corespundă căutării.',
|
||||||
|
'admin.organizations.empty': 'Nu s-au găsit organizații.',
|
||||||
|
'admin.organizations.table.id': 'ID',
|
||||||
|
'admin.organizations.table.name': 'Nume',
|
||||||
|
'admin.organizations.table.members': 'Membri',
|
||||||
|
'admin.organizations.table.created': 'Creată',
|
||||||
|
'admin.organizations.table.updated': 'Actualizată',
|
||||||
|
'admin.organizations.pagination.info': 'Se afișează {{ start }} până la {{ end }} din {{ total }} {{ total, =1:organizație, organizații }}',
|
||||||
|
'admin.organizations.pagination.page-info': 'Pagina {{ current }} din {{ total }}',
|
||||||
|
|
||||||
|
'admin.organization-detail.title': 'Detalii organizație',
|
||||||
|
'admin.organization-detail.back': 'Înapoi la organizații',
|
||||||
|
'admin.organization-detail.loading.info': 'Se încarcă informațiile organizației...',
|
||||||
|
'admin.organization-detail.loading.stats': 'Se încarcă statisticile...',
|
||||||
|
'admin.organization-detail.loading.intake-emails': 'Se încarcă email-urile de primire...',
|
||||||
|
'admin.organization-detail.loading.webhooks': 'Se încarcă webhook-urile...',
|
||||||
|
'admin.organization-detail.loading.members': 'Se încarcă membrii...',
|
||||||
|
'admin.organization-detail.basic-info.title': 'Informații organizație',
|
||||||
|
'admin.organization-detail.basic-info.description': 'Detalii de bază ale organizației',
|
||||||
|
'admin.organization-detail.basic-info.id': 'ID',
|
||||||
|
'admin.organization-detail.basic-info.name': 'Nume',
|
||||||
|
'admin.organization-detail.basic-info.created': 'Creată',
|
||||||
|
'admin.organization-detail.basic-info.updated': 'Actualizată',
|
||||||
|
'admin.organization-detail.members.title': 'Membri ({{ count }})',
|
||||||
|
'admin.organization-detail.members.description': 'Utilizatori care aparțin acestei organizații',
|
||||||
|
'admin.organization-detail.members.empty': 'Nu s-au găsit membri',
|
||||||
|
'admin.organization-detail.members.table.user': 'Utilizator',
|
||||||
|
'admin.organization-detail.members.table.id': 'ID',
|
||||||
|
'admin.organization-detail.members.table.role': 'Rol',
|
||||||
|
'admin.organization-detail.members.table.joined': 'Alăturat',
|
||||||
|
'admin.organization-detail.intake-emails.title': 'Email-uri de primire ({{ count }})',
|
||||||
|
'admin.organization-detail.intake-emails.description': 'Adrese email pentru preluarea documentelor',
|
||||||
|
'admin.organization-detail.intake-emails.empty': 'Nu sunt configurate email-uri de primire',
|
||||||
|
'admin.organization-detail.intake-emails.status.enabled': 'Activat',
|
||||||
|
'admin.organization-detail.intake-emails.status.disabled': 'Dezactivat',
|
||||||
|
'admin.organization-detail.intake-emails.badge.active': 'Activ',
|
||||||
|
'admin.organization-detail.intake-emails.badge.inactive': 'Inactiv',
|
||||||
|
'admin.organization-detail.webhooks.title': 'Webhook-uri ({{ count }})',
|
||||||
|
'admin.organization-detail.webhooks.description': 'Puncte de acces webhook configurate',
|
||||||
|
'admin.organization-detail.webhooks.empty': 'Nu sunt configurate webhook-uri',
|
||||||
|
'admin.organization-detail.webhooks.badge.active': 'Activ',
|
||||||
|
'admin.organization-detail.webhooks.badge.inactive': 'Inactiv',
|
||||||
|
'admin.organization-detail.stats.title': 'Statistici de utilizare',
|
||||||
|
'admin.organization-detail.stats.description': 'Statistici documente și stocare',
|
||||||
|
'admin.organization-detail.stats.active-documents': 'Documente active',
|
||||||
|
'admin.organization-detail.stats.active-storage': 'Stocare activă',
|
||||||
|
'admin.organization-detail.stats.deleted-documents': 'Documente șterse',
|
||||||
|
'admin.organization-detail.stats.deleted-storage': 'Stocare ștearsă',
|
||||||
|
'admin.organization-detail.stats.total-documents': 'Total documente',
|
||||||
|
'admin.organization-detail.stats.total-storage': 'Stocare totală',
|
||||||
|
|
||||||
|
'admin.users.title': 'Gestionare utilizatori',
|
||||||
|
'admin.users.description': 'Gestionați și vizualizați toți utilizatorii din sistem',
|
||||||
|
'admin.users.search-placeholder': 'Căutare după nume, email sau ID...',
|
||||||
|
'admin.users.loading': 'Se încarcă utilizatorii...',
|
||||||
|
'admin.users.no-results': 'Nu s-au găsit utilizatori care să corespundă căutării.',
|
||||||
|
'admin.users.empty': 'Nu s-au găsit utilizatori.',
|
||||||
|
'admin.users.table.user': 'Utilizator',
|
||||||
|
'admin.users.table.id': 'ID',
|
||||||
|
'admin.users.table.status': 'Status',
|
||||||
|
'admin.users.table.status.verified': 'Verificat',
|
||||||
|
'admin.users.table.status.unverified': 'Neverificat',
|
||||||
|
'admin.users.table.orgs': 'Org',
|
||||||
|
'admin.users.table.created': 'Creat',
|
||||||
|
'admin.users.pagination.info': 'Se afișează {{ start }} până la {{ end }} din {{ total }} {{ total, =1:utilizator, utilizatori }}',
|
||||||
|
'admin.users.pagination.page-info': 'Pagina {{ current }} din {{ total }}',
|
||||||
|
|
||||||
|
'admin.user-detail.back': 'Înapoi la utilizatori',
|
||||||
|
'admin.user-detail.loading': 'Se încarcă detaliile utilizatorului...',
|
||||||
|
'admin.user-detail.unnamed': 'Utilizator fără nume',
|
||||||
|
'admin.user-detail.basic-info.title': 'Informații utilizator',
|
||||||
|
'admin.user-detail.basic-info.description': 'Detalii de bază ale utilizatorului și informații despre cont',
|
||||||
|
'admin.user-detail.basic-info.user-id': 'ID utilizator',
|
||||||
|
'admin.user-detail.basic-info.email': 'Email',
|
||||||
|
'admin.user-detail.basic-info.name': 'Nume',
|
||||||
|
'admin.user-detail.basic-info.name-empty': '-',
|
||||||
|
'admin.user-detail.basic-info.email-verified': 'Email verificat',
|
||||||
|
'admin.user-detail.basic-info.email-verified.yes': 'Da',
|
||||||
|
'admin.user-detail.basic-info.email-verified.no': 'Nu',
|
||||||
|
'admin.user-detail.basic-info.max-organizations': 'Organizații max',
|
||||||
|
'admin.user-detail.basic-info.max-organizations.unlimited': 'Nelimitat',
|
||||||
|
'admin.user-detail.basic-info.created': 'Creat',
|
||||||
|
'admin.user-detail.basic-info.updated': 'Ultima actualizare',
|
||||||
|
'admin.user-detail.roles.title': 'Roluri și permisiuni',
|
||||||
|
'admin.user-detail.roles.description': 'Roluri utilizator și niveluri de acces',
|
||||||
|
'admin.user-detail.roles.empty': 'Nu sunt atribuite roluri',
|
||||||
|
'admin.user-detail.organizations.title': 'Organizații ({{ count }})',
|
||||||
|
'admin.user-detail.organizations.description': 'Organizații din care face parte acest utilizator',
|
||||||
|
'admin.user-detail.organizations.empty': 'Nu este membru al niciunei organizații',
|
||||||
|
'admin.user-detail.organizations.table.id': 'ID',
|
||||||
|
'admin.user-detail.organizations.table.name': 'Nume',
|
||||||
|
'admin.user-detail.organizations.table.created': 'Creată',
|
||||||
|
|
||||||
// Common / Shared
|
// Common / Shared
|
||||||
|
|
||||||
'common.confirm-modal.type-to-confirm': 'Tastează "{{ text }}" pentru confirmare',
|
'common.confirm-modal.type-to-confirm': 'Tastează "{{ text }}" pentru confirmare',
|
||||||
|
|||||||
@@ -42,6 +42,20 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||||||
'auth.login.form.forgot-password.label': '忘记密码?',
|
'auth.login.form.forgot-password.label': '忘记密码?',
|
||||||
'auth.login.form.submit': '登录',
|
'auth.login.form.submit': '登录',
|
||||||
|
|
||||||
|
'auth.login.two-factor.title': '两步验证',
|
||||||
|
'auth.login.two-factor.description.totp': '请输入身份验证应用中的6位验证码。',
|
||||||
|
'auth.login.two-factor.description.backup-code': '请输入您的备用码以访问账户。',
|
||||||
|
'auth.login.two-factor.code.label.totp': '验证码',
|
||||||
|
'auth.login.two-factor.code.label.backup-code': '备用码',
|
||||||
|
'auth.login.two-factor.code.placeholder.backup-code': '输入备用码',
|
||||||
|
'auth.login.two-factor.code.required': '请输入验证码',
|
||||||
|
'auth.login.two-factor.trust-device.label': '信任此设备30天',
|
||||||
|
'auth.login.two-factor.back': '返回登录',
|
||||||
|
'auth.login.two-factor.submit': '验证',
|
||||||
|
'auth.login.two-factor.verification-failed': '验证失败。请检查您的验证码后重试。',
|
||||||
|
'auth.login.two-factor.use-backup-code': '使用备用码',
|
||||||
|
'auth.login.two-factor.use-totp': '使用身份验证应用',
|
||||||
|
|
||||||
'auth.register.title': '注册 Papra',
|
'auth.register.title': '注册 Papra',
|
||||||
'auth.register.description': '创建一个账户以开始使用 Papra。',
|
'auth.register.description': '创建一个账户以开始使用 Papra。',
|
||||||
'auth.register.register-with-email': '使用电子邮件注册',
|
'auth.register.register-with-email': '使用电子邮件注册',
|
||||||
@@ -104,6 +118,66 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||||||
'user.settings.logout.description': '从您的账户登出。您可以稍后再次登录。',
|
'user.settings.logout.description': '从您的账户登出。您可以稍后再次登录。',
|
||||||
'user.settings.logout.button': '登出',
|
'user.settings.logout.button': '登出',
|
||||||
|
|
||||||
|
'user.settings.two-factor.title': '双因素认证',
|
||||||
|
'user.settings.two-factor.description': '为您的账户添加额外的安全保护。',
|
||||||
|
'user.settings.two-factor.status.enabled': '已启用',
|
||||||
|
'user.settings.two-factor.status.disabled': '已禁用',
|
||||||
|
'user.settings.two-factor.enable-button': '启用双因素认证',
|
||||||
|
'user.settings.two-factor.disable-button': '禁用双因素认证',
|
||||||
|
'user.settings.two-factor.regenerate-codes-button': '重新生成备用码',
|
||||||
|
|
||||||
|
'user.settings.two-factor.enable-dialog.title': '启用双因素认证',
|
||||||
|
'user.settings.two-factor.enable-dialog.description': '请输入密码以启用双因素认证。',
|
||||||
|
'user.settings.two-factor.enable-dialog.password.label': '密码',
|
||||||
|
'user.settings.two-factor.enable-dialog.password.placeholder': '输入密码',
|
||||||
|
'user.settings.two-factor.enable-dialog.password.required': '请输入密码',
|
||||||
|
'user.settings.two-factor.enable-dialog.cancel': '取消',
|
||||||
|
'user.settings.two-factor.enable-dialog.submit': '继续',
|
||||||
|
|
||||||
|
'user.settings.two-factor.setup-dialog.title': '设置双因素认证',
|
||||||
|
'user.settings.two-factor.setup-dialog.description': '使用身份验证应用扫描此二维码,然后输入验证码。',
|
||||||
|
'user.settings.two-factor.setup-dialog.qr-loading': '加载二维码中...',
|
||||||
|
'user.settings.two-factor.setup-dialog.step1.title': '步骤1:扫描二维码',
|
||||||
|
'user.settings.two-factor.setup-dialog.step1.description': '扫描下方二维码或手动输入设置密钥到您的身份验证应用中。',
|
||||||
|
'user.settings.two-factor.setup-dialog.copy-setup-key': '复制设置密钥',
|
||||||
|
'user.settings.two-factor.setup-dialog.step2.title': '步骤2:验证码',
|
||||||
|
'user.settings.two-factor.setup-dialog.step2.description': '输入身份验证应用生成的6位验证码以验证并启用双因素认证。',
|
||||||
|
'user.settings.two-factor.setup-dialog.code.label': '验证码',
|
||||||
|
'user.settings.two-factor.setup-dialog.code.placeholder': '输入6位验证码',
|
||||||
|
'user.settings.two-factor.setup-dialog.code.required': '请输入验证码',
|
||||||
|
'user.settings.two-factor.setup-dialog.cancel': '取消',
|
||||||
|
'user.settings.two-factor.setup-dialog.verify': '验证并启用双因素认证',
|
||||||
|
|
||||||
|
'user.settings.two-factor.backup-codes-dialog.title': '备用码',
|
||||||
|
'user.settings.two-factor.backup-codes-dialog.description': '请将这些备用码保存在安全的地方。如果您无法访问身份验证应用,可以使用它们访问账户。',
|
||||||
|
'user.settings.two-factor.backup-codes-dialog.warning': '每个备用码只能使用一次。',
|
||||||
|
'user.settings.two-factor.backup-codes-dialog.copy': '复制备用码',
|
||||||
|
'user.settings.two-factor.backup-codes-dialog.download': '下载备用码',
|
||||||
|
'user.settings.two-factor.backup-codes-dialog.download-filename': 'papra-2fa-backup-codes.txt',
|
||||||
|
'user.settings.two-factor.backup-codes-dialog.copied': '备用码已复制到剪贴板',
|
||||||
|
'user.settings.two-factor.backup-codes-dialog.close': '我已保存备用码',
|
||||||
|
|
||||||
|
'user.settings.two-factor.disable-dialog.title': '禁用双因素认证',
|
||||||
|
'user.settings.two-factor.disable-dialog.description': '请输入密码以禁用双因素认证。这将降低您账户的安全性。',
|
||||||
|
'user.settings.two-factor.disable-dialog.password.label': '密码',
|
||||||
|
'user.settings.two-factor.disable-dialog.password.placeholder': '输入密码',
|
||||||
|
'user.settings.two-factor.disable-dialog.password.required': '请输入密码',
|
||||||
|
'user.settings.two-factor.disable-dialog.cancel': '取消',
|
||||||
|
'user.settings.two-factor.disable-dialog.submit': '禁用双因素认证',
|
||||||
|
|
||||||
|
'user.settings.two-factor.regenerate-dialog.title': '重新生成备用码',
|
||||||
|
'user.settings.two-factor.regenerate-dialog.description': '这将使所有现有备用码失效并生成新的备用码。请输入密码以继续。',
|
||||||
|
'user.settings.two-factor.regenerate-dialog.password.label': '密码',
|
||||||
|
'user.settings.two-factor.regenerate-dialog.password.placeholder': '输入密码',
|
||||||
|
'user.settings.two-factor.regenerate-dialog.password.required': '请输入密码',
|
||||||
|
'user.settings.two-factor.regenerate-dialog.cancel': '取消',
|
||||||
|
'user.settings.two-factor.regenerate-dialog.submit': '重新生成备用码',
|
||||||
|
|
||||||
|
'user.settings.two-factor.enabled': '双因素认证已启用',
|
||||||
|
'user.settings.two-factor.disabled': '双因素认证已禁用',
|
||||||
|
'user.settings.two-factor.codes-regenerated': '备用码已重新生成',
|
||||||
|
'user.settings.two-factor.verification-failed': '验证失败。请检查您的验证码后重试。',
|
||||||
|
|
||||||
// Organizations
|
// Organizations
|
||||||
|
|
||||||
'organizations.list.title': '您的组织',
|
'organizations.list.title': '您的组织',
|
||||||
@@ -573,6 +647,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||||||
'layout.menu.webhooks': 'Webhook',
|
'layout.menu.webhooks': 'Webhook',
|
||||||
'layout.menu.members': '成员',
|
'layout.menu.members': '成员',
|
||||||
'layout.menu.invitations': '邀请',
|
'layout.menu.invitations': '邀请',
|
||||||
|
'layout.menu.admin': '管理',
|
||||||
|
|
||||||
'layout.upgrade-cta.title': '需要更多空间?',
|
'layout.upgrade-cta.title': '需要更多空间?',
|
||||||
'layout.upgrade-cta.description': '获得 10 倍存储和团队协作功能',
|
'layout.upgrade-cta.description': '获得 10 倍存储和团队协作功能',
|
||||||
@@ -600,6 +675,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||||||
|
|
||||||
// API errors
|
// API errors
|
||||||
|
|
||||||
|
'api-errors.api.timeout': '请求耗时过长已超时。请重试。',
|
||||||
'api-errors.document.already_exists': '文档已存在',
|
'api-errors.document.already_exists': '文档已存在',
|
||||||
'api-errors.document.size_too_large': '文件大小过大',
|
'api-errors.document.size_too_large': '文件大小过大',
|
||||||
'api-errors.intake-emails.already_exists': '具有此地址的接收邮箱已存在。',
|
'api-errors.intake-emails.already_exists': '具有此地址的接收邮箱已存在。',
|
||||||
@@ -640,6 +716,15 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||||||
'api-errors.FAILED_TO_UNLINK_LAST_ACCOUNT': '无法解除最后一个账户的关联',
|
'api-errors.FAILED_TO_UNLINK_LAST_ACCOUNT': '无法解除最后一个账户的关联',
|
||||||
'api-errors.ACCOUNT_NOT_FOUND': '账户未找到',
|
'api-errors.ACCOUNT_NOT_FOUND': '账户未找到',
|
||||||
'api-errors.USER_ALREADY_HAS_PASSWORD': '用户已设置密码',
|
'api-errors.USER_ALREADY_HAS_PASSWORD': '用户已设置密码',
|
||||||
|
'api-errors.INVALID_CODE': '提供的验证码无效或已过期',
|
||||||
|
'api-errors.OTP_NOT_ENABLED': '此账户未启用双因素认证',
|
||||||
|
'api-errors.OTP_HAS_EXPIRED': '双因素认证验证码已过期',
|
||||||
|
'api-errors.TOTP_NOT_ENABLED': '此账户未启用TOTP',
|
||||||
|
'api-errors.TWO_FACTOR_NOT_ENABLED': '此账户未启用双因素认证',
|
||||||
|
'api-errors.BACKUP_CODES_NOT_ENABLED': '此账户未启用备用码',
|
||||||
|
'api-errors.INVALID_BACKUP_CODE': '提供的备用码无效或已被使用',
|
||||||
|
'api-errors.TOO_MANY_ATTEMPTS_REQUEST_NEW_CODE': '尝试次数过多。请请求新的验证码。',
|
||||||
|
'api-errors.INVALID_TWO_FACTOR_COOKIE': '无效的双因素认证Cookie',
|
||||||
|
|
||||||
// Not found
|
// Not found
|
||||||
|
|
||||||
@@ -712,6 +797,120 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||||||
'subscriptions.usage-warning.message': '您的文档存储已使用 {{ percent }}%,考虑升级方案以获得更多空间。',
|
'subscriptions.usage-warning.message': '您的文档存储已使用 {{ percent }}%,考虑升级方案以获得更多空间。',
|
||||||
'subscriptions.usage-warning.upgrade-button': '升级方案',
|
'subscriptions.usage-warning.upgrade-button': '升级方案',
|
||||||
|
|
||||||
|
// Admin
|
||||||
|
|
||||||
|
'admin.layout.header': 'Papra 管理',
|
||||||
|
'admin.layout.back-to-app': '返回应用',
|
||||||
|
'admin.layout.menu.analytics': '统计',
|
||||||
|
'admin.layout.menu.users': '用户',
|
||||||
|
'admin.layout.menu.organizations': '组织',
|
||||||
|
|
||||||
|
'admin.analytics.title': '仪表板',
|
||||||
|
'admin.analytics.description': 'Papra 使用情况的信息和统计。',
|
||||||
|
'admin.analytics.user-count': '用户数量',
|
||||||
|
'admin.analytics.organization-count': '组织数量',
|
||||||
|
'admin.analytics.document-count': '文档数量',
|
||||||
|
'admin.analytics.documents-storage': '文档存储',
|
||||||
|
'admin.analytics.deleted-documents': '已删除文档',
|
||||||
|
'admin.analytics.deleted-storage': '已删除存储',
|
||||||
|
|
||||||
|
'admin.organizations.title': '组织管理',
|
||||||
|
'admin.organizations.description': '管理和查看系统中的所有组织',
|
||||||
|
'admin.organizations.search-placeholder': '按名称或 ID 搜索...',
|
||||||
|
'admin.organizations.loading': '正在加载组织...',
|
||||||
|
'admin.organizations.no-results': '未找到匹配的组织。',
|
||||||
|
'admin.organizations.empty': '未找到组织。',
|
||||||
|
'admin.organizations.table.id': 'ID',
|
||||||
|
'admin.organizations.table.name': '名称',
|
||||||
|
'admin.organizations.table.members': '成员',
|
||||||
|
'admin.organizations.table.created': '创建时间',
|
||||||
|
'admin.organizations.table.updated': '更新时间',
|
||||||
|
'admin.organizations.pagination.info': '显示第 {{ start }} 到 {{ end }} 条,共 {{ total }} {{ total, =1:个组织, 个组织 }}',
|
||||||
|
'admin.organizations.pagination.page-info': '第 {{ current }} 页,共 {{ total }} 页',
|
||||||
|
|
||||||
|
'admin.organization-detail.title': '组织详情',
|
||||||
|
'admin.organization-detail.back': '返回组织列表',
|
||||||
|
'admin.organization-detail.loading.info': '正在加载组织信息...',
|
||||||
|
'admin.organization-detail.loading.stats': '正在加载统计信息...',
|
||||||
|
'admin.organization-detail.loading.intake-emails': '正在加载接收邮箱...',
|
||||||
|
'admin.organization-detail.loading.webhooks': '正在加载 Webhook...',
|
||||||
|
'admin.organization-detail.loading.members': '正在加载成员...',
|
||||||
|
'admin.organization-detail.basic-info.title': '组织信息',
|
||||||
|
'admin.organization-detail.basic-info.description': '组织基本信息',
|
||||||
|
'admin.organization-detail.basic-info.id': 'ID',
|
||||||
|
'admin.organization-detail.basic-info.name': '名称',
|
||||||
|
'admin.organization-detail.basic-info.created': '创建时间',
|
||||||
|
'admin.organization-detail.basic-info.updated': '更新时间',
|
||||||
|
'admin.organization-detail.members.title': '成员 ({{ count }})',
|
||||||
|
'admin.organization-detail.members.description': '属于此组织的用户',
|
||||||
|
'admin.organization-detail.members.empty': '未找到成员',
|
||||||
|
'admin.organization-detail.members.table.user': '用户',
|
||||||
|
'admin.organization-detail.members.table.id': 'ID',
|
||||||
|
'admin.organization-detail.members.table.role': '角色',
|
||||||
|
'admin.organization-detail.members.table.joined': '加入时间',
|
||||||
|
'admin.organization-detail.intake-emails.title': '接收邮箱 ({{ count }})',
|
||||||
|
'admin.organization-detail.intake-emails.description': '用于文档接收的邮箱地址',
|
||||||
|
'admin.organization-detail.intake-emails.empty': '未配置接收邮箱',
|
||||||
|
'admin.organization-detail.intake-emails.status.enabled': '已启用',
|
||||||
|
'admin.organization-detail.intake-emails.status.disabled': '已禁用',
|
||||||
|
'admin.organization-detail.intake-emails.badge.active': '活跃',
|
||||||
|
'admin.organization-detail.intake-emails.badge.inactive': '不活跃',
|
||||||
|
'admin.organization-detail.webhooks.title': 'Webhook ({{ count }})',
|
||||||
|
'admin.organization-detail.webhooks.description': '已配置的 Webhook 端点',
|
||||||
|
'admin.organization-detail.webhooks.empty': '未配置 Webhook',
|
||||||
|
'admin.organization-detail.webhooks.badge.active': '活跃',
|
||||||
|
'admin.organization-detail.webhooks.badge.inactive': '不活跃',
|
||||||
|
'admin.organization-detail.stats.title': '使用统计',
|
||||||
|
'admin.organization-detail.stats.description': '文档和存储统计',
|
||||||
|
'admin.organization-detail.stats.active-documents': '活跃文档',
|
||||||
|
'admin.organization-detail.stats.active-storage': '活跃存储',
|
||||||
|
'admin.organization-detail.stats.deleted-documents': '已删除文档',
|
||||||
|
'admin.organization-detail.stats.deleted-storage': '已删除存储',
|
||||||
|
'admin.organization-detail.stats.total-documents': '文档总数',
|
||||||
|
'admin.organization-detail.stats.total-storage': '存储总量',
|
||||||
|
|
||||||
|
'admin.users.title': '用户管理',
|
||||||
|
'admin.users.description': '管理和查看系统中的所有用户',
|
||||||
|
'admin.users.search-placeholder': '按名称、邮箱或 ID 搜索...',
|
||||||
|
'admin.users.loading': '正在加载用户...',
|
||||||
|
'admin.users.no-results': '未找到匹配的用户。',
|
||||||
|
'admin.users.empty': '未找到用户。',
|
||||||
|
'admin.users.table.user': '用户',
|
||||||
|
'admin.users.table.id': 'ID',
|
||||||
|
'admin.users.table.status': '状态',
|
||||||
|
'admin.users.table.status.verified': '已验证',
|
||||||
|
'admin.users.table.status.unverified': '未验证',
|
||||||
|
'admin.users.table.orgs': '组织',
|
||||||
|
'admin.users.table.created': '创建时间',
|
||||||
|
'admin.users.pagination.info': '显示第 {{ start }} 到 {{ end }} 条,共 {{ total }} {{ total, =1:个用户, 个用户 }}',
|
||||||
|
'admin.users.pagination.page-info': '第 {{ current }} 页,共 {{ total }} 页',
|
||||||
|
|
||||||
|
'admin.user-detail.back': '返回用户列表',
|
||||||
|
'admin.user-detail.loading': '正在加载用户详情...',
|
||||||
|
'admin.user-detail.unnamed': '未命名用户',
|
||||||
|
'admin.user-detail.basic-info.title': '用户信息',
|
||||||
|
'admin.user-detail.basic-info.description': '用户基本信息和账户信息',
|
||||||
|
'admin.user-detail.basic-info.user-id': '用户 ID',
|
||||||
|
'admin.user-detail.basic-info.email': '邮箱',
|
||||||
|
'admin.user-detail.basic-info.name': '名称',
|
||||||
|
'admin.user-detail.basic-info.name-empty': '-',
|
||||||
|
'admin.user-detail.basic-info.email-verified': '邮箱已验证',
|
||||||
|
'admin.user-detail.basic-info.email-verified.yes': '是',
|
||||||
|
'admin.user-detail.basic-info.email-verified.no': '否',
|
||||||
|
'admin.user-detail.basic-info.max-organizations': '最大组织数',
|
||||||
|
'admin.user-detail.basic-info.max-organizations.unlimited': '无限制',
|
||||||
|
'admin.user-detail.basic-info.created': '创建时间',
|
||||||
|
'admin.user-detail.basic-info.updated': '最后更新',
|
||||||
|
'admin.user-detail.roles.title': '角色和权限',
|
||||||
|
'admin.user-detail.roles.description': '用户角色和访问级别',
|
||||||
|
'admin.user-detail.roles.empty': '未分配角色',
|
||||||
|
'admin.user-detail.organizations.title': '组织 ({{ count }})',
|
||||||
|
'admin.user-detail.organizations.description': '此用户所属的组织',
|
||||||
|
'admin.user-detail.organizations.empty': '不是任何组织的成员',
|
||||||
|
'admin.user-detail.organizations.table.id': 'ID',
|
||||||
|
'admin.user-detail.organizations.table.name': '名称',
|
||||||
|
'admin.user-detail.organizations.table.created': '创建时间',
|
||||||
|
|
||||||
// Common / Shared
|
// Common / Shared
|
||||||
|
|
||||||
'common.confirm-modal.type-to-confirm': '输入 "{{ text }}" 以确认',
|
'common.confirm-modal.type-to-confirm': '输入 "{{ text }}" 以确认',
|
||||||
|
|||||||
@@ -25,11 +25,11 @@ export const adminRoutes: RouteDefinition = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/organizations',
|
path: '/organizations',
|
||||||
component: () => <div class="p-6 text-muted-foreground">Not implemented yet.</div>,
|
component: lazy(() => import('./organizations/pages/list-organizations.page')),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/settings',
|
path: '/organizations/:organizationId',
|
||||||
component: () => <div class="p-6 text-muted-foreground">Not implemented yet.</div>,
|
component: lazy(() => import('./organizations/pages/organization-detail.page')),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/*404',
|
path: '/*404',
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { Component } from 'solid-js';
|
|||||||
import { formatBytes } from '@corentinth/chisels';
|
import { formatBytes } from '@corentinth/chisels';
|
||||||
import { useQuery } from '@tanstack/solid-query';
|
import { useQuery } from '@tanstack/solid-query';
|
||||||
import { Suspense } from 'solid-js';
|
import { Suspense } from 'solid-js';
|
||||||
|
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||||
import { getDocumentStats, getOrganizationCount, getUserCount } from '../analytics.services';
|
import { getDocumentStats, getOrganizationCount, getUserCount } from '../analytics.services';
|
||||||
|
|
||||||
const AnalyticsCard: Component<{
|
const AnalyticsCard: Component<{
|
||||||
@@ -37,6 +38,8 @@ const AnalyticsCard: Component<{
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const AdminAnalyticsPage: Component = () => {
|
export const AdminAnalyticsPage: Component = () => {
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
const userCountQuery = useQuery(() => ({
|
const userCountQuery = useQuery(() => ({
|
||||||
queryKey: ['admin', 'users', 'count'],
|
queryKey: ['admin', 'users', 'count'],
|
||||||
queryFn: getUserCount,
|
queryFn: getUserCount,
|
||||||
@@ -54,44 +57,44 @@ export const AdminAnalyticsPage: Component = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="px-6 pt-4">
|
<div class="px-6 pt-4">
|
||||||
<h1 class="text-2xl font-medium mb-1">Dashboard</h1>
|
<h1 class="text-2xl font-medium mb-1">{t('admin.analytics.title')}</h1>
|
||||||
<p class="text-muted-foreground">Insights and analytics about Papra usage.</p>
|
<p class="text-muted-foreground">{t('admin.analytics.description')}</p>
|
||||||
|
|
||||||
<div class="mt-6 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div class="mt-6 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
<AnalyticsCard
|
<AnalyticsCard
|
||||||
icon="i-tabler-users"
|
icon="i-tabler-users"
|
||||||
title="User count"
|
title={t('admin.analytics.user-count')}
|
||||||
value={() => userCountQuery.data?.userCount}
|
value={() => userCountQuery.data?.userCount}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AnalyticsCard
|
<AnalyticsCard
|
||||||
icon="i-tabler-building"
|
icon="i-tabler-building"
|
||||||
title="Organization count"
|
title={t('admin.analytics.organization-count')}
|
||||||
value={() => organizationCountQuery.data?.organizationCount}
|
value={() => organizationCountQuery.data?.organizationCount}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AnalyticsCard
|
<AnalyticsCard
|
||||||
icon="i-tabler-file"
|
icon="i-tabler-file"
|
||||||
title="Document count"
|
title={t('admin.analytics.document-count')}
|
||||||
value={() => documentStatsQuery.data?.documentsCount}
|
value={() => documentStatsQuery.data?.documentsCount}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AnalyticsCard
|
<AnalyticsCard
|
||||||
icon="i-tabler-database"
|
icon="i-tabler-database"
|
||||||
title="Documents storage"
|
title={t('admin.analytics.documents-storage')}
|
||||||
value={() => documentStatsQuery.data?.documentsSize}
|
value={() => documentStatsQuery.data?.documentsSize}
|
||||||
formatValue={bytes => formatBytes({ bytes, base: 1000 })}
|
formatValue={bytes => formatBytes({ bytes, base: 1000 })}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AnalyticsCard
|
<AnalyticsCard
|
||||||
icon="i-tabler-file-x"
|
icon="i-tabler-file-x"
|
||||||
title="Deleted documents"
|
title={t('admin.analytics.deleted-documents')}
|
||||||
value={() => documentStatsQuery.data?.deletedDocumentsCount}
|
value={() => documentStatsQuery.data?.deletedDocumentsCount}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AnalyticsCard
|
<AnalyticsCard
|
||||||
icon="i-tabler-database-x"
|
icon="i-tabler-database-x"
|
||||||
title="Deleted storage"
|
title={t('admin.analytics.deleted-storage')}
|
||||||
value={() => documentStatsQuery.data?.deletedDocumentsSize}
|
value={() => documentStatsQuery.data?.deletedDocumentsSize}
|
||||||
formatValue={bytes => formatBytes({ bytes, base: 1000 })}
|
formatValue={bytes => formatBytes({ bytes, base: 1000 })}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,33 +1,31 @@
|
|||||||
import type { ParentComponent } from 'solid-js';
|
import type { ParentComponent } from 'solid-js';
|
||||||
import { A, Navigate } from '@solidjs/router';
|
import { A, Navigate } from '@solidjs/router';
|
||||||
import { Show } from 'solid-js';
|
import { Show } from 'solid-js';
|
||||||
|
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||||
import { Button } from '@/modules/ui/components/button';
|
import { Button } from '@/modules/ui/components/button';
|
||||||
import { Sheet, SheetContent, SheetTrigger } from '@/modules/ui/components/sheet';
|
import { Sheet, SheetContent, SheetTrigger } from '@/modules/ui/components/sheet';
|
||||||
import { SideNav } from '@/modules/ui/layouts/sidenav.layout';
|
import { SideNav } from '@/modules/ui/layouts/sidenav.layout';
|
||||||
import { useCurrentUser } from '@/modules/users/composables/useCurrentUser';
|
import { useCurrentUser } from '@/modules/users/composables/useCurrentUser';
|
||||||
|
|
||||||
const AdminLayout: ParentComponent = (props) => {
|
const AdminLayout: ParentComponent = (props) => {
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
const getNavigationMenu = () => [
|
const getNavigationMenu = () => [
|
||||||
{
|
{
|
||||||
label: 'Analytics',
|
label: t('admin.layout.menu.analytics'),
|
||||||
href: '/admin/analytics',
|
href: '/admin/analytics',
|
||||||
icon: 'i-tabler-chart-bar',
|
icon: 'i-tabler-chart-bar',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Users',
|
label: t('admin.layout.menu.users'),
|
||||||
href: '/admin/users',
|
href: '/admin/users',
|
||||||
icon: 'i-tabler-users',
|
icon: 'i-tabler-users',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Organizations',
|
label: t('admin.layout.menu.organizations'),
|
||||||
href: '/admin/organizations',
|
href: '/admin/organizations',
|
||||||
icon: 'i-tabler-building-community',
|
icon: 'i-tabler-building-community',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
label: 'Settings',
|
|
||||||
href: '/admin/settings',
|
|
||||||
icon: 'i-tabler-settings',
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const sidenav = () => (
|
const sidenav = () => (
|
||||||
@@ -36,7 +34,7 @@ const AdminLayout: ParentComponent = (props) => {
|
|||||||
<A href="/admin" class="flex items-center gap-2 pl-6 h-14 w-260px">
|
<A href="/admin" class="flex items-center gap-2 pl-6 h-14 w-260px">
|
||||||
<div class="i-tabler-layout-dashboard text-primary size-7" />
|
<div class="i-tabler-layout-dashboard text-primary size-7" />
|
||||||
<div class="font-medium text-base">
|
<div class="font-medium text-base">
|
||||||
Papra admin
|
{t('admin.layout.header')}
|
||||||
</div>
|
</div>
|
||||||
</A>
|
</A>
|
||||||
)}
|
)}
|
||||||
@@ -74,7 +72,7 @@ const AdminLayout: ParentComponent = (props) => {
|
|||||||
as={A}
|
as={A}
|
||||||
href="/"
|
href="/"
|
||||||
>
|
>
|
||||||
Back to App
|
{t('admin.layout.back-to-app')}
|
||||||
</Button>
|
</Button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import type { IntakeEmail } from '@/modules/intake-emails/intake-emails.types';
|
||||||
|
import type { Organization } from '@/modules/organizations/organizations.types';
|
||||||
|
import type { User } from '@/modules/users/users.types';
|
||||||
|
import type { Webhook } from '@/modules/webhooks/webhooks.types';
|
||||||
|
import { apiClient } from '@/modules/shared/http/api-client';
|
||||||
|
|
||||||
|
export type OrganizationWithMemberCount = Organization & { memberCount: number };
|
||||||
|
|
||||||
|
export type OrganizationMember = {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
organizationId: string;
|
||||||
|
role: string;
|
||||||
|
createdAt: string;
|
||||||
|
user: User;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OrganizationStats = {
|
||||||
|
documentsCount: number;
|
||||||
|
documentsSize: number;
|
||||||
|
deletedDocumentsCount: number;
|
||||||
|
deletedDocumentsSize: number;
|
||||||
|
totalDocumentsCount: number;
|
||||||
|
totalDocumentsSize: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function listOrganizations({ search, pageIndex = 0, pageSize = 25 }: { search?: string; pageIndex?: number; pageSize?: number }) {
|
||||||
|
const { totalCount, organizations } = await apiClient<{
|
||||||
|
organizations: OrganizationWithMemberCount[];
|
||||||
|
totalCount: number;
|
||||||
|
pageIndex: number;
|
||||||
|
pageSize: number;
|
||||||
|
}>({
|
||||||
|
method: 'GET',
|
||||||
|
path: '/api/admin/organizations',
|
||||||
|
query: { search, pageIndex, pageSize },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { pageIndex, pageSize, totalCount, organizations };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getOrganizationBasicInfo({ organizationId }: { organizationId: string }) {
|
||||||
|
const { organization } = await apiClient<{
|
||||||
|
organization: Organization;
|
||||||
|
}>({
|
||||||
|
method: 'GET',
|
||||||
|
path: `/api/admin/organizations/${organizationId}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { organization };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getOrganizationMembers({ organizationId }: { organizationId: string }) {
|
||||||
|
const { members } = await apiClient<{
|
||||||
|
members: OrganizationMember[];
|
||||||
|
}>({
|
||||||
|
method: 'GET',
|
||||||
|
path: `/api/admin/organizations/${organizationId}/members`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { members };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getOrganizationIntakeEmails({ organizationId }: { organizationId: string }) {
|
||||||
|
const { intakeEmails } = await apiClient<{
|
||||||
|
intakeEmails: IntakeEmail[];
|
||||||
|
}>({
|
||||||
|
method: 'GET',
|
||||||
|
path: `/api/admin/organizations/${organizationId}/intake-emails`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { intakeEmails };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getOrganizationWebhooks({ organizationId }: { organizationId: string }) {
|
||||||
|
const { webhooks } = await apiClient<{
|
||||||
|
webhooks: Webhook[];
|
||||||
|
}>({
|
||||||
|
method: 'GET',
|
||||||
|
path: `/api/admin/organizations/${organizationId}/webhooks`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { webhooks };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getOrganizationStats({ organizationId }: { organizationId: string }) {
|
||||||
|
const { stats } = await apiClient<{
|
||||||
|
stats: OrganizationStats;
|
||||||
|
}>({
|
||||||
|
method: 'GET',
|
||||||
|
path: `/api/admin/organizations/${organizationId}/stats`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { stats };
|
||||||
|
}
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
import type { Component } from 'solid-js';
|
||||||
|
import { A } from '@solidjs/router';
|
||||||
|
import { useQuery } from '@tanstack/solid-query';
|
||||||
|
import { createSolidTable, flexRender, getCoreRowModel, getPaginationRowModel } from '@tanstack/solid-table';
|
||||||
|
import { createSignal, For, Show } from 'solid-js';
|
||||||
|
import { RelativeTime } from '@/modules/i18n/components/RelativeTime';
|
||||||
|
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||||
|
import { Button } from '@/modules/ui/components/button';
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/modules/ui/components/table';
|
||||||
|
import { TextField, TextFieldRoot } from '@/modules/ui/components/textfield';
|
||||||
|
import { listOrganizations } from '../organizations.services';
|
||||||
|
|
||||||
|
export const AdminListOrganizationsPage: Component = () => {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [search, setSearch] = createSignal('');
|
||||||
|
const [pagination, setPagination] = createSignal({ pageIndex: 0, pageSize: 25 });
|
||||||
|
|
||||||
|
const query = useQuery(() => ({
|
||||||
|
queryKey: ['admin', 'organizations', search(), pagination()],
|
||||||
|
queryFn: () => listOrganizations({
|
||||||
|
search: search() || undefined,
|
||||||
|
pageIndex: pagination().pageIndex,
|
||||||
|
pageSize: pagination().pageSize,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const table = createSolidTable({
|
||||||
|
get data() {
|
||||||
|
return query.data?.organizations ?? [];
|
||||||
|
},
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
header: t('admin.organizations.table.id'),
|
||||||
|
accessorKey: 'id',
|
||||||
|
cell: data => (
|
||||||
|
<A
|
||||||
|
href={`/admin/organizations/${data.getValue<string>()}`}
|
||||||
|
class="font-mono hover:underline text-primary"
|
||||||
|
>
|
||||||
|
{data.getValue<string>()}
|
||||||
|
</A>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: t('admin.organizations.table.name'),
|
||||||
|
accessorKey: 'name',
|
||||||
|
cell: data => (
|
||||||
|
<div class="font-medium">
|
||||||
|
{data.getValue<string>()}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: t('admin.organizations.table.members'),
|
||||||
|
accessorKey: 'memberCount',
|
||||||
|
cell: data => (
|
||||||
|
<div class="text-center">
|
||||||
|
{data.getValue<number>()}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: t('admin.organizations.table.created'),
|
||||||
|
accessorKey: 'createdAt',
|
||||||
|
cell: data => <RelativeTime class="text-muted-foreground text-sm" date={new Date(data.getValue<string>())} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: t('admin.organizations.table.updated'),
|
||||||
|
accessorKey: 'updatedAt',
|
||||||
|
cell: data => <RelativeTime class="text-muted-foreground text-sm" date={new Date(data.getValue<string>())} />,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
get rowCount() {
|
||||||
|
return query.data?.totalCount ?? 0;
|
||||||
|
},
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getPaginationRowModel: getPaginationRowModel(),
|
||||||
|
onPaginationChange: setPagination,
|
||||||
|
state: {
|
||||||
|
get pagination() {
|
||||||
|
return pagination();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
manualPagination: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSearch = (e: Event) => {
|
||||||
|
const target = e.target as HTMLInputElement;
|
||||||
|
setSearch(target.value);
|
||||||
|
setPagination({ pageIndex: 0, pageSize: pagination().pageSize });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="p-6">
|
||||||
|
<div class="border-b mb-6 pb-4">
|
||||||
|
<h1 class="text-xl font-bold mb-1">
|
||||||
|
{t('admin.organizations.title')}
|
||||||
|
</h1>
|
||||||
|
<p class="text-sm text-muted-foreground">
|
||||||
|
{t('admin.organizations.description')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<TextFieldRoot class="max-w-sm">
|
||||||
|
<TextField
|
||||||
|
type="text"
|
||||||
|
placeholder={t('admin.organizations.search-placeholder')}
|
||||||
|
value={search()}
|
||||||
|
onInput={handleSearch}
|
||||||
|
/>
|
||||||
|
</TextFieldRoot>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show
|
||||||
|
when={!query.isLoading}
|
||||||
|
fallback={<div class="text-center py-8 text-muted-foreground">{t('admin.organizations.loading')}</div>}
|
||||||
|
>
|
||||||
|
<Show
|
||||||
|
when={(query.data?.organizations.length ?? 0) > 0}
|
||||||
|
fallback={(
|
||||||
|
<div class="text-center py-8 text-muted-foreground">
|
||||||
|
{search() ? t('admin.organizations.no-results') : t('admin.organizations.empty')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div class="border-y">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<For each={table.getHeaderGroups()}>
|
||||||
|
{headerGroup => (
|
||||||
|
<TableRow>
|
||||||
|
<For each={headerGroup.headers}>
|
||||||
|
{header => (
|
||||||
|
<TableHead>
|
||||||
|
{flexRender(header.column.columnDef.header, header.getContext())}
|
||||||
|
</TableHead>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
<For each={table.getRowModel().rows}>
|
||||||
|
{row => (
|
||||||
|
<TableRow>
|
||||||
|
<For each={row.getVisibleCells()}>
|
||||||
|
{cell => (
|
||||||
|
<TableCell>
|
||||||
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between mt-4">
|
||||||
|
<div class="text-sm text-muted-foreground">
|
||||||
|
{t('admin.organizations.pagination.info', {
|
||||||
|
start: table.getState().pagination.pageIndex * table.getState().pagination.pageSize + 1,
|
||||||
|
end: Math.min((table.getState().pagination.pageIndex + 1) * table.getState().pagination.pageSize, query.data?.totalCount ?? 0),
|
||||||
|
total: query.data?.totalCount ?? 0,
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
class="size-8"
|
||||||
|
onClick={() => table.setPageIndex(0)}
|
||||||
|
disabled={!table.getCanPreviousPage()}
|
||||||
|
>
|
||||||
|
<div class="size-4 i-tabler-chevrons-left" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
class="size-8"
|
||||||
|
onClick={() => table.previousPage()}
|
||||||
|
disabled={!table.getCanPreviousPage()}
|
||||||
|
>
|
||||||
|
<div class="size-4 i-tabler-chevron-left" />
|
||||||
|
</Button>
|
||||||
|
<div class="text-sm whitespace-nowrap">
|
||||||
|
{t('admin.organizations.pagination.page-info', {
|
||||||
|
current: table.getState().pagination.pageIndex + 1,
|
||||||
|
total: table.getPageCount(),
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
class="size-8"
|
||||||
|
onClick={() => table.nextPage()}
|
||||||
|
disabled={!table.getCanNextPage()}
|
||||||
|
>
|
||||||
|
<div class="size-4 i-tabler-chevron-right" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
class="size-8"
|
||||||
|
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
|
||||||
|
disabled={!table.getCanNextPage()}
|
||||||
|
>
|
||||||
|
<div class="size-4 i-tabler-chevrons-right" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AdminListOrganizationsPage;
|
||||||
@@ -0,0 +1,325 @@
|
|||||||
|
import type { Component } from 'solid-js';
|
||||||
|
import { formatBytes } from '@corentinth/chisels';
|
||||||
|
import { A, useParams } from '@solidjs/router';
|
||||||
|
import { useQuery } from '@tanstack/solid-query';
|
||||||
|
import { For, Show, Suspense } from 'solid-js';
|
||||||
|
import { RelativeTime } from '@/modules/i18n/components/RelativeTime';
|
||||||
|
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||||
|
import { Badge } from '@/modules/ui/components/badge';
|
||||||
|
import { Button } from '@/modules/ui/components/button';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/modules/ui/components/card';
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/modules/ui/components/table';
|
||||||
|
import { UserListDetail } from '../../users/components/user-list-detail.component';
|
||||||
|
import {
|
||||||
|
getOrganizationBasicInfo,
|
||||||
|
getOrganizationIntakeEmails,
|
||||||
|
getOrganizationMembers,
|
||||||
|
getOrganizationStats,
|
||||||
|
getOrganizationWebhooks,
|
||||||
|
} from '../organizations.services';
|
||||||
|
|
||||||
|
const OrganizationBasicInfo: Component<{ organizationId: string }> = (props) => {
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const query = useQuery(() => ({
|
||||||
|
queryKey: ['admin', 'organizations', props.organizationId, 'basic'],
|
||||||
|
queryFn: () => getOrganizationBasicInfo({ organizationId: props.organizationId }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Show when={query.data}>
|
||||||
|
{data => (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{t('admin.organization-detail.basic-info.title')}</CardTitle>
|
||||||
|
<CardDescription>{t('admin.organization-detail.basic-info.description')}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="space-y-3">
|
||||||
|
<div class="flex justify-between items-start">
|
||||||
|
<span class="text-sm text-muted-foreground">{t('admin.organization-detail.basic-info.id')}</span>
|
||||||
|
<span class="font-mono text-xs">{data().organization.id}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between items-start">
|
||||||
|
<span class="text-sm text-muted-foreground">{t('admin.organization-detail.basic-info.name')}</span>
|
||||||
|
<span class="text-sm font-medium">{data().organization.name}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between items-start">
|
||||||
|
<span class="text-sm text-muted-foreground">{t('admin.organization-detail.basic-info.created')}</span>
|
||||||
|
<RelativeTime class="text-sm" date={new Date(data().organization.createdAt)} />
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between items-start">
|
||||||
|
<span class="text-sm text-muted-foreground">{t('admin.organization-detail.basic-info.updated')}</span>
|
||||||
|
<RelativeTime class="text-sm" date={new Date(data().organization.updatedAt)} />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const OrganizationMembers: Component<{ organizationId: string }> = (props) => {
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const query = useQuery(() => ({
|
||||||
|
queryKey: ['admin', 'organizations', props.organizationId, 'members'],
|
||||||
|
queryFn: () => getOrganizationMembers({ organizationId: props.organizationId }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>
|
||||||
|
{t('admin.organization-detail.members.title', { count: query.data?.members.length ?? 0 })}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>{t('admin.organization-detail.members.description')}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Show when={query.data}>
|
||||||
|
{data => (
|
||||||
|
<Show
|
||||||
|
when={data().members.length > 0}
|
||||||
|
fallback={<p class="text-sm text-muted-foreground">{t('admin.organization-detail.members.empty')}</p>}
|
||||||
|
>
|
||||||
|
<div class="rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>{t('admin.organization-detail.members.table.user')}</TableHead>
|
||||||
|
<TableHead>{t('admin.organization-detail.members.table.id')}</TableHead>
|
||||||
|
<TableHead>{t('admin.organization-detail.members.table.role')}</TableHead>
|
||||||
|
<TableHead>{t('admin.organization-detail.members.table.joined')}</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
<For each={data().members}>
|
||||||
|
{member => (
|
||||||
|
<TableRow>
|
||||||
|
|
||||||
|
<TableCell>
|
||||||
|
<UserListDetail {...member.user} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<A
|
||||||
|
href={`/admin/users/${member.userId}`}
|
||||||
|
class="font-mono hover:underline"
|
||||||
|
>
|
||||||
|
<div class="font-mono text-sm">{member.userId}</div>
|
||||||
|
</A>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="secondary" class="capitalize">
|
||||||
|
{member.role}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<RelativeTime class="text-muted-foreground text-sm" date={new Date(member.createdAt)} />
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const OrganizationIntakeEmails: Component<{ organizationId: string }> = (props) => {
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const query = useQuery(() => ({
|
||||||
|
queryKey: ['admin', 'organizations', props.organizationId, 'intake-emails'],
|
||||||
|
queryFn: () => getOrganizationIntakeEmails({ organizationId: props.organizationId }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>
|
||||||
|
{t('admin.organization-detail.intake-emails.title', { count: query.data?.intakeEmails.length ?? 0 })}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>{t('admin.organization-detail.intake-emails.description')}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Show when={query.data}>
|
||||||
|
{data => (
|
||||||
|
<Show
|
||||||
|
when={data().intakeEmails.length > 0}
|
||||||
|
fallback={<p class="text-sm text-muted-foreground">{t('admin.organization-detail.intake-emails.empty')}</p>}
|
||||||
|
>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<For each={data().intakeEmails}>
|
||||||
|
{email => (
|
||||||
|
<div class="flex items-center justify-between p-3 border rounded-md">
|
||||||
|
<div>
|
||||||
|
<div class="font-mono text-sm">{email.emailAddress}</div>
|
||||||
|
<div class="text-xs text-muted-foreground mt-1">
|
||||||
|
{email.isEnabled ? t('admin.organization-detail.intake-emails.status.enabled') : t('admin.organization-detail.intake-emails.status.disabled')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Badge variant={email.isEnabled ? 'default' : 'outline'}>
|
||||||
|
{email.isEnabled ? t('admin.organization-detail.intake-emails.badge.active') : t('admin.organization-detail.intake-emails.badge.inactive')}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const OrganizationWebhooks: Component<{ organizationId: string }> = (props) => {
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const query = useQuery(() => ({
|
||||||
|
queryKey: ['admin', 'organizations', props.organizationId, 'webhooks'],
|
||||||
|
queryFn: () => getOrganizationWebhooks({ organizationId: props.organizationId }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>
|
||||||
|
{t('admin.organization-detail.webhooks.title', { count: query.data?.webhooks.length ?? 0 })}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>{t('admin.organization-detail.webhooks.description')}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Show when={query.data}>
|
||||||
|
{data => (
|
||||||
|
<Show
|
||||||
|
when={data().webhooks.length > 0}
|
||||||
|
fallback={<p class="text-sm text-muted-foreground">{t('admin.organization-detail.webhooks.empty')}</p>}
|
||||||
|
>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<For each={data().webhooks}>
|
||||||
|
{webhook => (
|
||||||
|
<div class="flex items-center justify-between p-3 border rounded-md">
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="font-medium text-sm truncate">{webhook.name}</div>
|
||||||
|
<div class="font-mono text-xs text-muted-foreground truncate mt-1">{webhook.url}</div>
|
||||||
|
</div>
|
||||||
|
<Badge variant={webhook.enabled ? 'default' : 'outline'} class="ml-2 flex-shrink-0">
|
||||||
|
{webhook.enabled ? t('admin.organization-detail.webhooks.badge.active') : t('admin.organization-detail.webhooks.badge.inactive')}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const OrganizationStats: Component<{ organizationId: string }> = (props) => {
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const query = useQuery(() => ({
|
||||||
|
queryKey: ['admin', 'organizations', props.organizationId, 'stats'],
|
||||||
|
queryFn: () => getOrganizationStats({ organizationId: props.organizationId }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{t('admin.organization-detail.stats.title')}</CardTitle>
|
||||||
|
<CardDescription>{t('admin.organization-detail.stats.description')}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Show when={query.data}>
|
||||||
|
{data => (
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div class="flex justify-between items-start">
|
||||||
|
<span class="text-sm text-muted-foreground">{t('admin.organization-detail.stats.active-documents')}</span>
|
||||||
|
<span class="text-sm font-medium">{data().stats.documentsCount}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between items-start">
|
||||||
|
<span class="text-sm text-muted-foreground">{t('admin.organization-detail.stats.active-storage')}</span>
|
||||||
|
<span class="text-sm font-medium">{formatBytes({ bytes: data().stats.documentsSize, base: 1000 })}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between items-start">
|
||||||
|
<span class="text-sm text-muted-foreground">{t('admin.organization-detail.stats.deleted-documents')}</span>
|
||||||
|
<span class="text-sm font-medium">{data().stats.deletedDocumentsCount}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between items-start">
|
||||||
|
<span class="text-sm text-muted-foreground">{t('admin.organization-detail.stats.deleted-storage')}</span>
|
||||||
|
<span class="text-sm font-medium">{formatBytes({ bytes: data().stats.deletedDocumentsSize, base: 1000 })}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between items-start pt-2 border-t">
|
||||||
|
<span class="text-sm font-medium">{t('admin.organization-detail.stats.total-documents')}</span>
|
||||||
|
<span class="text-sm font-bold">{data().stats.totalDocumentsCount}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between items-start">
|
||||||
|
<span class="text-sm font-medium">{t('admin.organization-detail.stats.total-storage')}</span>
|
||||||
|
<span class="text-sm font-bold">{formatBytes({ bytes: data().stats.totalDocumentsSize, base: 1000 })}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AdminOrganizationDetailPage: Component = () => {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const params = useParams<{ organizationId: string }>();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="p-6 mt-4">
|
||||||
|
<div class="mb-6">
|
||||||
|
<Button as={A} href="/admin/organizations" variant="ghost" size="sm" class="mb-4">
|
||||||
|
<div class="i-tabler-arrow-left size-4 mr-2" />
|
||||||
|
{t('admin.organization-detail.back')}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<h1 class="text-2xl font-bold mb-1">
|
||||||
|
{t('admin.organization-detail.title')}
|
||||||
|
</h1>
|
||||||
|
<p class="text-muted-foreground">
|
||||||
|
{params.organizationId}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="grid gap-6 md:grid-cols-2">
|
||||||
|
<Suspense fallback={<div class="text-center py-4 text-muted-foreground">{t('admin.organization-detail.loading.info')}</div>}>
|
||||||
|
<OrganizationBasicInfo organizationId={params.organizationId} />
|
||||||
|
</Suspense>
|
||||||
|
|
||||||
|
<Suspense fallback={<div class="text-center py-4 text-muted-foreground">{t('admin.organization-detail.loading.stats')}</div>}>
|
||||||
|
<OrganizationStats organizationId={params.organizationId} />
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-6 md:grid-cols-2">
|
||||||
|
<Suspense fallback={<div class="text-center py-4 text-muted-foreground">{t('admin.organization-detail.loading.intake-emails')}</div>}>
|
||||||
|
<OrganizationIntakeEmails organizationId={params.organizationId} />
|
||||||
|
</Suspense>
|
||||||
|
|
||||||
|
<Suspense fallback={<div class="text-center py-4 text-muted-foreground">{t('admin.organization-detail.loading.webhooks')}</div>}>
|
||||||
|
<OrganizationWebhooks organizationId={params.organizationId} />
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Suspense fallback={<div class="text-center py-4 text-muted-foreground">{t('admin.organization-detail.loading.members')}</div>}>
|
||||||
|
<OrganizationMembers organizationId={params.organizationId} />
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AdminOrganizationDetailPage;
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import type { Component } from 'solid-js';
|
||||||
|
import { A } from '@solidjs/router';
|
||||||
|
|
||||||
|
export const UserListDetail: Component<{ id: string; name?: string | null; email: string; href?: string }> = (props) => {
|
||||||
|
return (
|
||||||
|
<A href={props.href ?? `/admin/users/${props.id}`} class="flex items-center gap-2 group">
|
||||||
|
<div class="size-9 flex items-center justify-center rounded bg-muted">
|
||||||
|
<div class="i-tabler-user size-5 group-hover:text-primary" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
|
||||||
|
<div class="font-medium group-hover:text-primary transition">
|
||||||
|
{props.name || '-'}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-muted-foreground text-xs">
|
||||||
|
{props.email}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</A>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -4,13 +4,16 @@ import { useQuery } from '@tanstack/solid-query';
|
|||||||
import { createSolidTable, flexRender, getCoreRowModel, getPaginationRowModel } from '@tanstack/solid-table';
|
import { createSolidTable, flexRender, getCoreRowModel, getPaginationRowModel } from '@tanstack/solid-table';
|
||||||
import { createSignal, For, Show } from 'solid-js';
|
import { createSignal, For, Show } from 'solid-js';
|
||||||
import { RelativeTime } from '@/modules/i18n/components/RelativeTime';
|
import { RelativeTime } from '@/modules/i18n/components/RelativeTime';
|
||||||
|
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||||
import { Badge } from '@/modules/ui/components/badge';
|
import { Badge } from '@/modules/ui/components/badge';
|
||||||
import { Button } from '@/modules/ui/components/button';
|
import { Button } from '@/modules/ui/components/button';
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/modules/ui/components/table';
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/modules/ui/components/table';
|
||||||
import { TextField, TextFieldRoot } from '@/modules/ui/components/textfield';
|
import { TextField, TextFieldRoot } from '@/modules/ui/components/textfield';
|
||||||
|
import { UserListDetail } from '../components/user-list-detail.component';
|
||||||
import { listUsers } from '../users.services';
|
import { listUsers } from '../users.services';
|
||||||
|
|
||||||
export const AdminListUsersPage: Component = () => {
|
export const AdminListUsersPage: Component = () => {
|
||||||
|
const { t } = useI18n();
|
||||||
const [search, setSearch] = createSignal('');
|
const [search, setSearch] = createSignal('');
|
||||||
const [pagination, setPagination] = createSignal({ pageIndex: 0, pageSize: 25 });
|
const [pagination, setPagination] = createSignal({ pageIndex: 0, pageSize: 25 });
|
||||||
|
|
||||||
@@ -27,47 +30,35 @@ export const AdminListUsersPage: Component = () => {
|
|||||||
return query.data?.users ?? [];
|
return query.data?.users ?? [];
|
||||||
},
|
},
|
||||||
columns: [
|
columns: [
|
||||||
|
|
||||||
{
|
{
|
||||||
header: 'ID',
|
header: t('admin.users.table.user'),
|
||||||
|
accessorKey: 'email',
|
||||||
|
cell: data => <UserListDetail {...data.row.original} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: t('admin.users.table.id'),
|
||||||
accessorKey: 'id',
|
accessorKey: 'id',
|
||||||
cell: data => (
|
cell: data => (
|
||||||
<A
|
<A
|
||||||
href={`/admin/users/${data.getValue<string>()}`}
|
href={`/admin/users/${data.getValue<string>()}`}
|
||||||
class="font-mono hover:underline text-primary"
|
class="font-mono hover:underline text-muted-foreground"
|
||||||
>
|
>
|
||||||
{data.getValue<string>()}
|
{data.getValue<string>()}
|
||||||
</A>
|
</A>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: 'Email',
|
header: t('admin.users.table.status'),
|
||||||
accessorKey: 'email',
|
|
||||||
cell: data => (
|
|
||||||
<div class="font-medium">
|
|
||||||
{data.getValue<string>()}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'Name',
|
|
||||||
accessorKey: 'name',
|
|
||||||
cell: data => (
|
|
||||||
<div class="text-muted-foreground">
|
|
||||||
{data.getValue<string | null>() || '-'}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'Status',
|
|
||||||
accessorKey: 'emailVerified',
|
accessorKey: 'emailVerified',
|
||||||
cell: data => (
|
cell: data => (
|
||||||
<Badge variant={data.getValue<boolean>() ? 'default' : 'outline'}>
|
<Badge variant={data.getValue<boolean>() ? 'default' : 'outline'}>
|
||||||
{data.getValue<boolean>() ? 'Verified' : 'Unverified'}
|
{data.getValue<boolean>() ? t('admin.users.table.status.verified') : t('admin.users.table.status.unverified')}
|
||||||
</Badge>
|
</Badge>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: 'Orgs',
|
header: t('admin.users.table.orgs'),
|
||||||
accessorKey: 'organizationCount',
|
accessorKey: 'organizationCount',
|
||||||
cell: data => (
|
cell: data => (
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
@@ -76,7 +67,7 @@ export const AdminListUsersPage: Component = () => {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: 'Created',
|
header: t('admin.users.table.created'),
|
||||||
accessorKey: 'createdAt',
|
accessorKey: 'createdAt',
|
||||||
cell: data => <RelativeTime class="text-muted-foreground text-sm" date={new Date(data.getValue<string>())} />,
|
cell: data => <RelativeTime class="text-muted-foreground text-sm" date={new Date(data.getValue<string>())} />,
|
||||||
},
|
},
|
||||||
@@ -102,13 +93,13 @@ export const AdminListUsersPage: Component = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="p-6 mt-4">
|
<div class="p-6">
|
||||||
<div class="border-b mb-6 pb-4">
|
<div class="border-b mb-6 pb-4">
|
||||||
<h1 class="text-xl font-bold mb-1">
|
<h1 class="text-xl font-bold mb-1">
|
||||||
User Management
|
{t('admin.users.title')}
|
||||||
</h1>
|
</h1>
|
||||||
<p class="text-sm text-muted-foreground">
|
<p class="text-sm text-muted-foreground">
|
||||||
Manage and view all users in the system
|
{t('admin.users.description')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -116,7 +107,7 @@ export const AdminListUsersPage: Component = () => {
|
|||||||
<TextFieldRoot class="max-w-sm">
|
<TextFieldRoot class="max-w-sm">
|
||||||
<TextField
|
<TextField
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search by name, email, or ID..."
|
placeholder={t('admin.users.search-placeholder')}
|
||||||
value={search()}
|
value={search()}
|
||||||
onInput={handleSearch}
|
onInput={handleSearch}
|
||||||
/>
|
/>
|
||||||
@@ -125,13 +116,13 @@ export const AdminListUsersPage: Component = () => {
|
|||||||
|
|
||||||
<Show
|
<Show
|
||||||
when={!query.isLoading}
|
when={!query.isLoading}
|
||||||
fallback={<div class="text-center py-8 text-muted-foreground">Loading users...</div>}
|
fallback={<div class="text-center py-8 text-muted-foreground">{t('admin.users.loading')}</div>}
|
||||||
>
|
>
|
||||||
<Show
|
<Show
|
||||||
when={(query.data?.users.length ?? 0) > 0}
|
when={(query.data?.users.length ?? 0) > 0}
|
||||||
fallback={(
|
fallback={(
|
||||||
<div class="text-center py-8 text-muted-foreground">
|
<div class="text-center py-8 text-muted-foreground">
|
||||||
{search() ? 'No users found matching your search.' : 'No users found.'}
|
{search() ? t('admin.users.no-results') : t('admin.users.empty')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -172,19 +163,11 @@ export const AdminListUsersPage: Component = () => {
|
|||||||
|
|
||||||
<div class="flex items-center justify-between mt-4">
|
<div class="flex items-center justify-between mt-4">
|
||||||
<div class="text-sm text-muted-foreground">
|
<div class="text-sm text-muted-foreground">
|
||||||
Showing
|
{t('admin.users.pagination.info', {
|
||||||
{' '}
|
start: table.getState().pagination.pageIndex * table.getState().pagination.pageSize + 1,
|
||||||
{table.getState().pagination.pageIndex * table.getState().pagination.pageSize + 1}
|
end: Math.min((table.getState().pagination.pageIndex + 1) * table.getState().pagination.pageSize, query.data?.totalCount ?? 0),
|
||||||
{' '}
|
total: query.data?.totalCount ?? 0,
|
||||||
to
|
})}
|
||||||
{' '}
|
|
||||||
{Math.min((table.getState().pagination.pageIndex + 1) * table.getState().pagination.pageSize, query.data?.totalCount ?? 0)}
|
|
||||||
{' '}
|
|
||||||
of
|
|
||||||
{' '}
|
|
||||||
{query.data?.totalCount ?? 0}
|
|
||||||
{' '}
|
|
||||||
users
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center space-x-2">
|
<div class="flex items-center space-x-2">
|
||||||
<Button
|
<Button
|
||||||
@@ -206,13 +189,10 @@ export const AdminListUsersPage: Component = () => {
|
|||||||
<div class="size-4 i-tabler-chevron-left" />
|
<div class="size-4 i-tabler-chevron-left" />
|
||||||
</Button>
|
</Button>
|
||||||
<div class="text-sm whitespace-nowrap">
|
<div class="text-sm whitespace-nowrap">
|
||||||
Page
|
{t('admin.users.pagination.page-info', {
|
||||||
{' '}
|
current: table.getState().pagination.pageIndex + 1,
|
||||||
{table.getState().pagination.pageIndex + 1}
|
total: table.getPageCount(),
|
||||||
{' '}
|
})}
|
||||||
of
|
|
||||||
{' '}
|
|
||||||
{table.getPageCount()}
|
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { A, useParams } from '@solidjs/router';
|
|||||||
import { useQuery } from '@tanstack/solid-query';
|
import { useQuery } from '@tanstack/solid-query';
|
||||||
import { For, Show } from 'solid-js';
|
import { For, Show } from 'solid-js';
|
||||||
import { RelativeTime } from '@/modules/i18n/components/RelativeTime';
|
import { RelativeTime } from '@/modules/i18n/components/RelativeTime';
|
||||||
|
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||||
import { Badge } from '@/modules/ui/components/badge';
|
import { Badge } from '@/modules/ui/components/badge';
|
||||||
import { Button } from '@/modules/ui/components/button';
|
import { Button } from '@/modules/ui/components/button';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/modules/ui/components/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/modules/ui/components/card';
|
||||||
@@ -10,6 +11,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@
|
|||||||
import { getUserDetail } from '../users.services';
|
import { getUserDetail } from '../users.services';
|
||||||
|
|
||||||
export const AdminUserDetailPage: Component = () => {
|
export const AdminUserDetailPage: Component = () => {
|
||||||
|
const { t } = useI18n();
|
||||||
const params = useParams<{ userId: string }>();
|
const params = useParams<{ userId: string }>();
|
||||||
|
|
||||||
const query = useQuery(() => ({
|
const query = useQuery(() => ({
|
||||||
@@ -22,18 +24,18 @@ export const AdminUserDetailPage: Component = () => {
|
|||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<Button as={A} href="/admin/users" variant="ghost" size="sm" class="mb-4">
|
<Button as={A} href="/admin/users" variant="ghost" size="sm" class="mb-4">
|
||||||
<div class="i-tabler-arrow-left size-4 mr-2" />
|
<div class="i-tabler-arrow-left size-4 mr-2" />
|
||||||
Back to Users
|
{t('admin.user-detail.back')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Show
|
<Show
|
||||||
when={!query.isLoading && query.data}
|
when={!query.isLoading && query.data}
|
||||||
fallback={<div class="text-center py-8 text-muted-foreground">Loading user details...</div>}
|
fallback={<div class="text-center py-8 text-muted-foreground">{t('admin.user-detail.loading')}</div>}
|
||||||
>
|
>
|
||||||
{data => (
|
{data => (
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<div class="border-b pb-4">
|
<div class="border-b pb-4">
|
||||||
<h1 class="text-2xl font-bold flex items-center gap-3">
|
<h1 class="text-2xl font-bold flex items-center gap-3">
|
||||||
{data().user.name || 'Unnamed User'}
|
{data().user.name || t('admin.user-detail.unnamed')}
|
||||||
</h1>
|
</h1>
|
||||||
<p class="text-muted-foreground mt-1">{data().user.email}</p>
|
<p class="text-muted-foreground mt-1">{data().user.email}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -41,38 +43,38 @@ export const AdminUserDetailPage: Component = () => {
|
|||||||
<div class="grid gap-6 md:grid-cols-2">
|
<div class="grid gap-6 md:grid-cols-2">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>User Information</CardTitle>
|
<CardTitle>{t('admin.user-detail.basic-info.title')}</CardTitle>
|
||||||
<CardDescription>Basic user details and account information</CardDescription>
|
<CardDescription>{t('admin.user-detail.basic-info.description')}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent class="space-y-3">
|
<CardContent class="space-y-3">
|
||||||
<div class="flex justify-between items-start">
|
<div class="flex justify-between items-start">
|
||||||
<span class="text-sm text-muted-foreground">User ID</span>
|
<span class="text-sm text-muted-foreground">{t('admin.user-detail.basic-info.user-id')}</span>
|
||||||
<span class="font-mono text-xs">{data().user.id}</span>
|
<span class="font-mono text-xs">{data().user.id}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between items-start">
|
<div class="flex justify-between items-start">
|
||||||
<span class="text-sm text-muted-foreground">Email</span>
|
<span class="text-sm text-muted-foreground">{t('admin.user-detail.basic-info.email')}</span>
|
||||||
<span class="text-sm font-medium">{data().user.email}</span>
|
<span class="text-sm font-medium">{data().user.email}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between items-start">
|
<div class="flex justify-between items-start">
|
||||||
<span class="text-sm text-muted-foreground">Name</span>
|
<span class="text-sm text-muted-foreground">{t('admin.user-detail.basic-info.name')}</span>
|
||||||
<span class="text-sm">{data().user.name || '-'}</span>
|
<span class="text-sm">{data().user.name || t('admin.user-detail.basic-info.name-empty')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between items-start">
|
<div class="flex justify-between items-start">
|
||||||
<span class="text-sm text-muted-foreground">Email Verified</span>
|
<span class="text-sm text-muted-foreground">{t('admin.user-detail.basic-info.email-verified')}</span>
|
||||||
<Badge variant={data().user.emailVerified ? 'default' : 'outline'} class="text-xs">
|
<Badge variant={data().user.emailVerified ? 'default' : 'outline'} class="text-xs">
|
||||||
{data().user.emailVerified ? 'Yes' : 'No'}
|
{data().user.emailVerified ? t('admin.user-detail.basic-info.email-verified.yes') : t('admin.user-detail.basic-info.email-verified.no')}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between items-start">
|
<div class="flex justify-between items-start">
|
||||||
<span class="text-sm text-muted-foreground">Max Organizations</span>
|
<span class="text-sm text-muted-foreground">{t('admin.user-detail.basic-info.max-organizations')}</span>
|
||||||
<span class="text-sm">{data().user.maxOrganizationCount ?? 'Unlimited'}</span>
|
<span class="text-sm">{data().user.maxOrganizationCount ?? t('admin.user-detail.basic-info.max-organizations.unlimited')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between items-start">
|
<div class="flex justify-between items-start">
|
||||||
<span class="text-sm text-muted-foreground">Created</span>
|
<span class="text-sm text-muted-foreground">{t('admin.user-detail.basic-info.created')}</span>
|
||||||
<RelativeTime class="text-sm" date={new Date(data().user.createdAt)} />
|
<RelativeTime class="text-sm" date={new Date(data().user.createdAt)} />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between items-start">
|
<div class="flex justify-between items-start">
|
||||||
<span class="text-sm text-muted-foreground">Last Updated</span>
|
<span class="text-sm text-muted-foreground">{t('admin.user-detail.basic-info.updated')}</span>
|
||||||
<RelativeTime class="text-sm" date={new Date(data().user.updatedAt)} />
|
<RelativeTime class="text-sm" date={new Date(data().user.updatedAt)} />
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -80,13 +82,13 @@ export const AdminUserDetailPage: Component = () => {
|
|||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Roles & Permissions</CardTitle>
|
<CardTitle>{t('admin.user-detail.roles.title')}</CardTitle>
|
||||||
<CardDescription>User roles and access levels</CardDescription>
|
<CardDescription>{t('admin.user-detail.roles.description')}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Show
|
<Show
|
||||||
when={data().roles.length > 0}
|
when={data().roles.length > 0}
|
||||||
fallback={<p class="text-sm text-muted-foreground">No roles assigned</p>}
|
fallback={<p class="text-sm text-muted-foreground">{t('admin.user-detail.roles.empty')}</p>}
|
||||||
>
|
>
|
||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex flex-wrap gap-2">
|
||||||
<For each={data().roles}>
|
<For each={data().roles}>
|
||||||
@@ -105,32 +107,44 @@ export const AdminUserDetailPage: Component = () => {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>
|
<CardTitle>
|
||||||
Organizations (
|
{t('admin.user-detail.organizations.title', { count: data().organizations.length })}
|
||||||
{data().organizations.length}
|
|
||||||
)
|
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>Organizations this user belongs to</CardDescription>
|
<CardDescription>{t('admin.user-detail.organizations.description')}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Show
|
<Show
|
||||||
when={data().organizations.length > 0}
|
when={data().organizations.length > 0}
|
||||||
fallback={<p class="text-sm text-muted-foreground">Not a member of any organizations</p>}
|
fallback={<p class="text-sm text-muted-foreground">{t('admin.user-detail.organizations.empty')}</p>}
|
||||||
>
|
>
|
||||||
<div class="rounded-md border">
|
<div class="rounded-md border">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>ID</TableHead>
|
<TableHead>{t('admin.user-detail.organizations.table.id')}</TableHead>
|
||||||
<TableHead>Name</TableHead>
|
<TableHead>{t('admin.user-detail.organizations.table.name')}</TableHead>
|
||||||
<TableHead>Created</TableHead>
|
<TableHead>{t('admin.user-detail.organizations.table.created')}</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
<For each={data().organizations}>
|
<For each={data().organizations}>
|
||||||
{org => (
|
{org => (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell>{org.id}</TableCell>
|
<TableCell>
|
||||||
<TableCell class="font-medium">{org.name}</TableCell>
|
<A
|
||||||
|
href={`/admin/organizations/${org.id}`}
|
||||||
|
class="font-mono text-xs hover:underline text-primary"
|
||||||
|
>
|
||||||
|
{org.id}
|
||||||
|
</A>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<A
|
||||||
|
href={`/admin/organizations/${org.id}`}
|
||||||
|
class="font-medium hover:underline"
|
||||||
|
>
|
||||||
|
{org.name}
|
||||||
|
</A>
|
||||||
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<RelativeTime class="text-muted-foreground text-sm" date={new Date(org.createdAt)} />
|
<RelativeTime class="text-muted-foreground text-sm" date={new Date(org.createdAt)} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|||||||
@@ -20,6 +20,15 @@ export function createDemoAuthClient() {
|
|||||||
requestPasswordReset: () => Promise.resolve({}),
|
requestPasswordReset: () => Promise.resolve({}),
|
||||||
resetPassword: () => Promise.resolve({}),
|
resetPassword: () => Promise.resolve({}),
|
||||||
sendVerificationEmail: () => Promise.resolve({}),
|
sendVerificationEmail: () => Promise.resolve({}),
|
||||||
|
twoFactor: {
|
||||||
|
enable: () => Promise.resolve({ data: null, error: null }),
|
||||||
|
disable: () => Promise.resolve({ data: null, error: null }),
|
||||||
|
getTotpUri: () => Promise.resolve({ data: null, error: null }),
|
||||||
|
verifyTotp: () => Promise.resolve({ data: null, error: null }),
|
||||||
|
generateBackupCodes: () => Promise.resolve({ data: null, error: null }),
|
||||||
|
viewBackupCodes: () => Promise.resolve({ data: null, error: null }),
|
||||||
|
verifyBackupCode: () => Promise.resolve({ data: null, error: null }),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
return new Proxy(baseClient, {
|
return new Proxy(baseClient, {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { Config } from '../config/config';
|
import type { Config } from '../config/config';
|
||||||
|
|
||||||
import type { SsoProviderConfig } from './auth.types';
|
import type { SsoProviderConfig } from './auth.types';
|
||||||
import { genericOAuthClient } from 'better-auth/client/plugins';
|
import { genericOAuthClient, twoFactorClient } from 'better-auth/client/plugins';
|
||||||
import { createAuthClient as createBetterAuthClient } from 'better-auth/solid';
|
import { createAuthClient as createBetterAuthClient } from 'better-auth/solid';
|
||||||
import { buildTimeConfig } from '../config/config';
|
import { buildTimeConfig } from '../config/config';
|
||||||
import { queryClient } from '../shared/query/query-client';
|
import { queryClient } from '../shared/query/query-client';
|
||||||
@@ -13,6 +13,7 @@ export function createAuthClient() {
|
|||||||
baseURL: buildTimeConfig.baseApiUrl,
|
baseURL: buildTimeConfig.baseApiUrl,
|
||||||
plugins: [
|
plugins: [
|
||||||
genericOAuthClient(),
|
genericOAuthClient(),
|
||||||
|
twoFactorClient(),
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -24,6 +25,7 @@ export function createAuthClient() {
|
|||||||
resetPassword: client.resetPassword,
|
resetPassword: client.resetPassword,
|
||||||
sendVerificationEmail: client.sendVerificationEmail,
|
sendVerificationEmail: client.sendVerificationEmail,
|
||||||
useSession: client.useSession,
|
useSession: client.useSession,
|
||||||
|
twoFactor: client.twoFactor,
|
||||||
signOut: async () => {
|
signOut: async () => {
|
||||||
trackingServices.capture({ event: 'User logged out' });
|
trackingServices.capture({ event: 'User logged out' });
|
||||||
const result = await client.signOut();
|
const result = await client.signOut();
|
||||||
@@ -44,6 +46,7 @@ export const {
|
|||||||
requestPasswordReset,
|
requestPasswordReset,
|
||||||
resetPassword,
|
resetPassword,
|
||||||
sendVerificationEmail,
|
sendVerificationEmail,
|
||||||
|
twoFactor,
|
||||||
} = buildTimeConfig.isDemoMode
|
} = buildTimeConfig.isDemoMode
|
||||||
? createDemoAuthClient()
|
? createDemoAuthClient()
|
||||||
: createAuthClient();
|
: createAuthClient();
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import type { Component } from 'solid-js';
|
||||||
|
import {
|
||||||
|
OTPField,
|
||||||
|
OTPFieldGroup,
|
||||||
|
OTPFieldInput,
|
||||||
|
OTPFieldSlot,
|
||||||
|
REGEXP_ONLY_DIGITS,
|
||||||
|
} from '@/modules/ui/components/otp-field';
|
||||||
|
|
||||||
|
export const TotpField: Component<{
|
||||||
|
onComplete?: (args: { totpCode: string }) => void;
|
||||||
|
value?: string;
|
||||||
|
onValueChange?: (value: string) => void;
|
||||||
|
}> = (props) => {
|
||||||
|
return (
|
||||||
|
<OTPField
|
||||||
|
maxLength={6}
|
||||||
|
onComplete={totpCode => props.onComplete?.({ totpCode })}
|
||||||
|
value={props.value}
|
||||||
|
onValueChange={props.onValueChange}
|
||||||
|
>
|
||||||
|
<OTPFieldInput pattern={REGEXP_ONLY_DIGITS} aria-label="Enter the 6-digit verification code" />
|
||||||
|
<OTPFieldGroup>
|
||||||
|
<OTPFieldSlot index={0} />
|
||||||
|
<OTPFieldSlot index={1} />
|
||||||
|
<OTPFieldSlot index={2} />
|
||||||
|
<OTPFieldSlot index={3} />
|
||||||
|
<OTPFieldSlot index={4} />
|
||||||
|
<OTPFieldSlot index={5} />
|
||||||
|
</OTPFieldGroup>
|
||||||
|
</OTPField>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -2,6 +2,7 @@ import type { Component } from 'solid-js';
|
|||||||
import type { SsoProviderConfig } from '../auth.types';
|
import type { SsoProviderConfig } from '../auth.types';
|
||||||
import { buildUrl } from '@corentinth/chisels';
|
import { buildUrl } from '@corentinth/chisels';
|
||||||
import { A, useNavigate } from '@solidjs/router';
|
import { A, useNavigate } from '@solidjs/router';
|
||||||
|
import { useMutation } from '@tanstack/solid-query';
|
||||||
import { createSignal, For, Show } from 'solid-js';
|
import { createSignal, For, Show } from 'solid-js';
|
||||||
import * as v from 'valibot';
|
import * as v from 'valibot';
|
||||||
import { useConfig } from '@/modules/config/config.provider';
|
import { useConfig } from '@/modules/config/config.provider';
|
||||||
@@ -11,16 +12,178 @@ import { useI18nApiErrors } from '@/modules/shared/http/composables/i18n-api-err
|
|||||||
import { Button } from '@/modules/ui/components/button';
|
import { Button } from '@/modules/ui/components/button';
|
||||||
import { Checkbox, CheckboxControl, CheckboxLabel } from '@/modules/ui/components/checkbox';
|
import { Checkbox, CheckboxControl, CheckboxLabel } from '@/modules/ui/components/checkbox';
|
||||||
import { Separator } from '@/modules/ui/components/separator';
|
import { Separator } from '@/modules/ui/components/separator';
|
||||||
|
import { createToast } from '@/modules/ui/components/sonner';
|
||||||
import { TextField, TextFieldLabel, TextFieldRoot } from '@/modules/ui/components/textfield';
|
import { TextField, TextFieldLabel, TextFieldRoot } from '@/modules/ui/components/textfield';
|
||||||
import { AuthLayout } from '../../ui/layouts/auth-layout.component';
|
import { AuthLayout } from '../../ui/layouts/auth-layout.component';
|
||||||
import { authPagesPaths } from '../auth.constants';
|
import { authPagesPaths } from '../auth.constants';
|
||||||
import { getEnabledSsoProviderConfigs, isEmailVerificationRequiredError } from '../auth.models';
|
import { getEnabledSsoProviderConfigs, isEmailVerificationRequiredError } from '../auth.models';
|
||||||
import { authWithProvider, signIn } from '../auth.services';
|
import { authWithProvider, signIn, twoFactor } from '../auth.services';
|
||||||
import { AuthLegalLinks } from '../components/legal-links.component';
|
import { AuthLegalLinks } from '../components/legal-links.component';
|
||||||
import { NoAuthProviderWarning } from '../components/no-auth-provider';
|
import { NoAuthProviderWarning } from '../components/no-auth-provider';
|
||||||
import { SsoProviderButton } from '../components/sso-provider-button.component';
|
import { SsoProviderButton } from '../components/sso-provider-button.component';
|
||||||
|
import { TotpField } from '../components/verify-otp.component';
|
||||||
|
|
||||||
export const EmailLoginForm: Component = () => {
|
const TotpVerificationForm: Component = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [trustDevice, setTrustDevice] = createSignal(false);
|
||||||
|
const [totpCode, setTotpCode] = createSignal('');
|
||||||
|
|
||||||
|
const verifyMutation = useMutation(() => ({
|
||||||
|
mutationFn: async ({ code, trust }: { code: string; trust: boolean }) => {
|
||||||
|
const { error } = await twoFactor.verifyTotp({ code, trustDevice: trust });
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
createToast({ type: 'error', message: t('auth.login.two-factor.verification-failed') });
|
||||||
|
throw new Error(error.message);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
navigate('/');
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const handleTotpComplete = (code: string) => {
|
||||||
|
setTotpCode(code);
|
||||||
|
if (code.length === 6) {
|
||||||
|
verifyMutation.mutate({ code, trust: trustDevice() });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p class="text-muted-foreground mt-1 mb-4">
|
||||||
|
{t('auth.login.two-factor.description.totp')}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-1 mb-4 items-center">
|
||||||
|
<label class="sr-only">{t('auth.login.two-factor.code.label.totp')}</label>
|
||||||
|
<TotpField value={totpCode()} onValueChange={handleTotpComplete} />
|
||||||
|
<Show when={verifyMutation.error}>
|
||||||
|
{getError => <div class="text-red-500 text-sm">{getError().message}</div>}
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Checkbox class="flex items-center gap-2 mt-4" checked={trustDevice()} onChange={setTrustDevice}>
|
||||||
|
<CheckboxControl />
|
||||||
|
<CheckboxLabel class="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
|
||||||
|
{t('auth.login.two-factor.trust-device.label')}
|
||||||
|
</CheckboxLabel>
|
||||||
|
</Checkbox>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const BackupCodeVerificationForm: Component = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [trustDevice, setTrustDevice] = createSignal(false);
|
||||||
|
|
||||||
|
const { form, Form, Field } = createForm({
|
||||||
|
onSubmit: async ({ code }) => {
|
||||||
|
const { error } = await twoFactor.verifyBackupCode({
|
||||||
|
code,
|
||||||
|
trustDevice: trustDevice(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
createToast({ type: 'error', message: t('auth.login.two-factor.verification-failed') });
|
||||||
|
throw new Error(error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
navigate('/');
|
||||||
|
},
|
||||||
|
schema: v.object({
|
||||||
|
code: v.pipe(
|
||||||
|
v.string(),
|
||||||
|
v.nonEmpty(t('auth.login.two-factor.code.required')),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
initialValues: {
|
||||||
|
code: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form>
|
||||||
|
<p class="text-muted-foreground mt-1 mb-4">
|
||||||
|
{t('auth.login.two-factor.description.backup-code')}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Field name="code">
|
||||||
|
{(field, inputProps) => (
|
||||||
|
<TextFieldRoot class="flex flex-col gap-1 mb-4">
|
||||||
|
<TextFieldLabel for="backup-code">{t('auth.login.two-factor.code.label.backup-code')}</TextFieldLabel>
|
||||||
|
<TextField
|
||||||
|
type="text"
|
||||||
|
id="backup-code"
|
||||||
|
placeholder={t('auth.login.two-factor.code.placeholder.backup-code')}
|
||||||
|
{...inputProps}
|
||||||
|
autoFocus
|
||||||
|
value={field.value}
|
||||||
|
aria-invalid={Boolean(field.error)}
|
||||||
|
/>
|
||||||
|
{field.error && <div class="text-red-500 text-sm">{field.error}</div>}
|
||||||
|
</TextFieldRoot>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Checkbox class="flex items-center gap-2 mb-4" checked={trustDevice()} onChange={setTrustDevice}>
|
||||||
|
<CheckboxControl />
|
||||||
|
<CheckboxLabel class="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
|
||||||
|
{t('auth.login.two-factor.trust-device.label')}
|
||||||
|
</CheckboxLabel>
|
||||||
|
</Checkbox>
|
||||||
|
|
||||||
|
<Button type="submit" class="w-full" isLoading={form.submitting}>
|
||||||
|
{t('auth.login.two-factor.submit')}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div class="text-red-500 text-sm mt-4">{form.response.message}</div>
|
||||||
|
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const TwoFactorVerificationForm: Component<{ onBack: () => void }> = (props) => {
|
||||||
|
const [useBackupCode, setUseBackupCode] = createSignal(false);
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Show
|
||||||
|
when={!useBackupCode()}
|
||||||
|
fallback={(
|
||||||
|
<BackupCodeVerificationForm />
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<TotpVerificationForm />
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2 mt-4">
|
||||||
|
<Show
|
||||||
|
when={!useBackupCode()}
|
||||||
|
fallback={(
|
||||||
|
<Button variant="link" class="p-0 h-auto text-muted-foreground" onClick={() => setUseBackupCode(false)}>
|
||||||
|
{t('auth.login.two-factor.use-totp')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Button variant="link" class="p-0 h-auto text-muted-foreground" onClick={() => setUseBackupCode(true)}>
|
||||||
|
{t('auth.login.two-factor.use-backup-code')}
|
||||||
|
</Button>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Button variant="link" class="p-0 h-auto text-muted-foreground" onClick={props.onBack}>
|
||||||
|
{t('auth.login.two-factor.back')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EmailLoginForm: Component<{ onTwoFactorRequired: () => void }> = (props) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { config } = useConfig();
|
const { config } = useConfig();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
@@ -28,7 +191,7 @@ export const EmailLoginForm: Component = () => {
|
|||||||
|
|
||||||
const { form, Form, Field } = createForm({
|
const { form, Form, Field } = createForm({
|
||||||
onSubmit: async ({ email, password, rememberMe }) => {
|
onSubmit: async ({ email, password, rememberMe }) => {
|
||||||
const { error } = await signIn.email({
|
const { data: loginResult, error } = await signIn.email({
|
||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
rememberMe,
|
rememberMe,
|
||||||
@@ -36,6 +199,11 @@ export const EmailLoginForm: Component = () => {
|
|||||||
callbackURL: buildUrl({ baseUrl: config.baseUrl, path: authPagesPaths.emailVerification }),
|
callbackURL: buildUrl({ baseUrl: config.baseUrl, path: authPagesPaths.emailVerification }),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (loginResult && 'twoFactorRedirect' in loginResult && loginResult.twoFactorRedirect) {
|
||||||
|
props.onTwoFactorRequired();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (isEmailVerificationRequiredError({ error })) {
|
if (isEmailVerificationRequiredError({ error })) {
|
||||||
navigate('/email-validation-required');
|
navigate('/email-validation-required');
|
||||||
}
|
}
|
||||||
@@ -106,7 +274,7 @@ export const EmailLoginForm: Component = () => {
|
|||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button type="submit" class="w-full">{t('auth.login.form.submit')}</Button>
|
<Button type="submit" class="w-full" isLoading={form.submitting}>{t('auth.login.form.submit')}</Button>
|
||||||
|
|
||||||
<div class="text-red-500 text-sm mt-4">{form.response.message}</div>
|
<div class="text-red-500 text-sm mt-4">{form.response.message}</div>
|
||||||
|
|
||||||
@@ -119,6 +287,7 @@ export const LoginPage: Component = () => {
|
|||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
const [getShowEmailLoginForm, setShowEmailLoginForm] = createSignal(false);
|
const [getShowEmailLoginForm, setShowEmailLoginForm] = createSignal(false);
|
||||||
|
const [showTwoFactorForm, setShowTwoFactorForm] = createSignal(false);
|
||||||
|
|
||||||
const loginWithProvider = async (provider: SsoProviderConfig) => {
|
const loginWithProvider = async (provider: SsoProviderConfig) => {
|
||||||
await authWithProvider({ provider, config });
|
await authWithProvider({ provider, config });
|
||||||
@@ -126,20 +295,28 @@ export const LoginPage: Component = () => {
|
|||||||
|
|
||||||
const getHasSsoProviders = () => getEnabledSsoProviderConfigs({ config }).length > 0;
|
const getHasSsoProviders = () => getEnabledSsoProviderConfigs({ config }).length > 0;
|
||||||
|
|
||||||
if (!config.auth.providers.email.isEnabled && !getHasSsoProviders()) {
|
const hasNoAuthProviders = !config.auth.providers.email.isEnabled && !getHasSsoProviders();
|
||||||
return <AuthLayout><NoAuthProviderWarning /></AuthLayout>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthLayout>
|
<AuthLayout>
|
||||||
|
<Show when={!hasNoAuthProviders} fallback={<NoAuthProviderWarning />}>
|
||||||
<div class="flex items-center justify-center h-full p-6 sm:pb-32">
|
<div class="flex items-center justify-center h-full p-6 sm:pb-32">
|
||||||
<div class="max-w-sm w-full">
|
<div class="max-w-sm w-full">
|
||||||
|
<Show
|
||||||
|
when={!showTwoFactorForm()}
|
||||||
|
fallback={(
|
||||||
|
<>
|
||||||
|
<h1 class="text-xl font-bold">{t('auth.login.two-factor.title')}</h1>
|
||||||
|
<TwoFactorVerificationForm onBack={() => setShowTwoFactorForm(false)} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
>
|
||||||
<h1 class="text-xl font-bold">{t('auth.login.title')}</h1>
|
<h1 class="text-xl font-bold">{t('auth.login.title')}</h1>
|
||||||
<p class="text-muted-foreground mt-1 mb-4">{t('auth.login.description')}</p>
|
<p class="text-muted-foreground mt-1 mb-4">{t('auth.login.description')}</p>
|
||||||
|
|
||||||
<Show when={config.auth.providers.email.isEnabled}>
|
<Show when={config.auth.providers.email.isEnabled}>
|
||||||
{getShowEmailLoginForm() || !getHasSsoProviders()
|
{getShowEmailLoginForm() || !getHasSsoProviders()
|
||||||
? <EmailLoginForm />
|
? <EmailLoginForm onTwoFactorRequired={() => setShowTwoFactorForm(true)} />
|
||||||
: (
|
: (
|
||||||
<Button onClick={() => setShowEmailLoginForm(true)} class="w-full">
|
<Button onClick={() => setShowEmailLoginForm(true)} class="w-full">
|
||||||
<div class="i-tabler-mail mr-2 size-4.5" />
|
<div class="i-tabler-mail mr-2 size-4.5" />
|
||||||
@@ -177,8 +354,10 @@ export const LoginPage: Component = () => {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<AuthLegalLinks />
|
<AuthLegalLinks />
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</Show>
|
||||||
</AuthLayout>
|
</AuthLayout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,3 +4,10 @@ export function downloadFile({ url, fileName = 'file' }: { url: string; fileName
|
|||||||
link.download = fileName;
|
link.download = fileName;
|
||||||
link.click();
|
link.click();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function downloadTextFile({ content, fileName = 'file.txt' }: { content: string; fileName?: string }) {
|
||||||
|
const blob = new Blob([content], { type: 'text/plain' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
downloadFile({ url, fileName });
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,9 +11,8 @@ import { getCheckoutUrl } from '../subscriptions.services';
|
|||||||
|
|
||||||
// Hardcoded global reduction configuration, will be replaced by a dynamic configuration later
|
// Hardcoded global reduction configuration, will be replaced by a dynamic configuration later
|
||||||
const globalReduction = {
|
const globalReduction = {
|
||||||
enabled: true,
|
enabled: false,
|
||||||
multiplier: 0.5,
|
multiplier: 1,
|
||||||
// 31 december 2025 23h59 Paris time
|
|
||||||
untilDate: new Date('2025-12-31T22:59:59Z'),
|
untilDate: new Date('2025-12-31T22:59:59Z'),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
83
apps/papra-client/src/modules/ui/components/otp-field.tsx
Normal file
83
apps/papra-client/src/modules/ui/components/otp-field.tsx
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import type { DynamicProps, RootProps } from '@corvu/otp-field';
|
||||||
|
import type { Component, ComponentProps, ValidComponent } from 'solid-js';
|
||||||
|
|
||||||
|
import OtpField from '@corvu/otp-field';
|
||||||
|
import { Show, splitProps } from 'solid-js';
|
||||||
|
import { cn } from '@/modules/shared/style/cn';
|
||||||
|
|
||||||
|
export const REGEXP_ONLY_DIGITS = '^\\d*$';
|
||||||
|
export const REGEXP_ONLY_CHARS = '^[a-zA-Z]*$';
|
||||||
|
export const REGEXP_ONLY_DIGITS_AND_CHARS = '^[a-zA-Z0-9]*$';
|
||||||
|
|
||||||
|
type OTPFieldProps<T extends ValidComponent = 'div'> = RootProps<T> & { class?: string };
|
||||||
|
|
||||||
|
function OTPField<T extends ValidComponent = 'div'>(props: DynamicProps<T, OTPFieldProps<T>>) {
|
||||||
|
const [local, others] = splitProps(props as OTPFieldProps, ['class']);
|
||||||
|
return (
|
||||||
|
<OtpField
|
||||||
|
class={cn(
|
||||||
|
'flex items-center gap-2 disabled:cursor-not-allowed has-[:disabled]:opacity-50',
|
||||||
|
local.class,
|
||||||
|
)}
|
||||||
|
{...others}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const OTPFieldInput = OtpField.Input;
|
||||||
|
|
||||||
|
const OTPFieldGroup: Component<ComponentProps<'div'>> = (props) => {
|
||||||
|
const [local, others] = splitProps(props, ['class']);
|
||||||
|
return <div class={cn('flex items-center', local.class)} {...others} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
const OTPFieldSlot: Component<ComponentProps<'div'> & { index: number }> = (props) => {
|
||||||
|
const [local, others] = splitProps(props, ['class', 'index']);
|
||||||
|
const context = OtpField.useContext();
|
||||||
|
const char = () => context.value()[local.index];
|
||||||
|
const showFakeCaret = () => context.value().length === local.index && context.isInserting();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
class={cn(
|
||||||
|
'group relative flex size-10 items-center justify-center border-y border-r border-input text-sm first:rounded-l-md first:border-l last:rounded-r-md',
|
||||||
|
local.class,
|
||||||
|
)}
|
||||||
|
{...others}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class={cn(
|
||||||
|
'absolute inset-0 z-10 transition-all group-first:rounded-l-md group-last:rounded-r-md',
|
||||||
|
context.activeSlots().includes(local.index) && 'ring-2 ring-ring ring-offset-background',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{char()}
|
||||||
|
<Show when={showFakeCaret()}>
|
||||||
|
<div class="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||||
|
<div class="h-4 w-px animate-caret-blink bg-foreground duration-1000" />
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const OTPFieldSeparator: Component<ComponentProps<'div'>> = (props) => {
|
||||||
|
return (
|
||||||
|
<div {...props}>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
class="size-6"
|
||||||
|
>
|
||||||
|
<circle cx="12.1" cy="12.1" r="1" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { OTPField, OTPFieldGroup, OTPFieldInput, OTPFieldSeparator, OTPFieldSlot };
|
||||||
12
apps/papra-client/src/modules/ui/components/qr-code.tsx
Normal file
12
apps/papra-client/src/modules/ui/components/qr-code.tsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import type { Component, ComponentProps } from 'solid-js';
|
||||||
|
import { splitProps } from 'solid-js';
|
||||||
|
import { renderSVG } from 'uqr';
|
||||||
|
|
||||||
|
export const QrCode: Component<{ value: string } & ComponentProps<'div'>> = (props) => {
|
||||||
|
const [local, rest] = splitProps(props, ['value']);
|
||||||
|
|
||||||
|
return (
|
||||||
|
// eslint-disable-next-line solid/no-innerhtml
|
||||||
|
<div innerHTML={renderSVG(local.value)} {...rest} />
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -169,6 +169,7 @@ export const SidenavLayout: ParentComponent<{
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
|
|
||||||
<GlobalDropArea onFilesDrop={uploadDocuments} />
|
<GlobalDropArea onFilesDrop={uploadDocuments} />
|
||||||
<Button onClick={promptImport}>
|
<Button onClick={promptImport}>
|
||||||
<div class="i-tabler-upload size-4" />
|
<div class="i-tabler-upload size-4" />
|
||||||
@@ -187,6 +188,13 @@ export const SidenavLayout: ParentComponent<{
|
|||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|
||||||
|
<Show when={hasPermission('bo:access')}>
|
||||||
|
<Button as={A} href="/admin" variant="outline" class="hidden sm:flex gap-2">
|
||||||
|
<div class="i-tabler-settings size-4" />
|
||||||
|
{t('layout.menu.admin')}
|
||||||
|
</Button>
|
||||||
|
</Show>
|
||||||
|
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger as={Button} class="relative text-base hidden sm:flex" variant="outline" aria-label="User menu" size="icon">
|
<DropdownMenuTrigger as={Button} class="relative text-base hidden sm:flex" variant="outline" aria-label="User menu" size="icon">
|
||||||
<div class="i-tabler-user size-4" />
|
<div class="i-tabler-user size-4" />
|
||||||
@@ -242,12 +250,6 @@ export const SidenavLayout: ParentComponent<{
|
|||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|
||||||
<Show when={hasPermission('bo:access')}>
|
|
||||||
<Button as={A} href="/admin" variant="outline" class="hidden sm:flex" size="icon">
|
|
||||||
<div class="i-tabler-settings size-4.5" />
|
|
||||||
</Button>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1 overflow-auto max-w-screen">
|
<div class="flex-1 overflow-auto max-w-screen">
|
||||||
|
|||||||
30
apps/papra-client/src/modules/users/2fa.models.test.ts
Normal file
30
apps/papra-client/src/modules/users/2fa.models.test.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { describe, expect, test } from 'vitest';
|
||||||
|
import { getSecretFromTotpUri } from './2fa.models';
|
||||||
|
|
||||||
|
describe('2fa models', () => {
|
||||||
|
describe('getSecretFromTotpUri', () => {
|
||||||
|
test('in a valid TOTP URI the secret is a query parameter', () => {
|
||||||
|
expect(
|
||||||
|
getSecretFromTotpUri({
|
||||||
|
totpUri: 'otpauth://totp/Papra:foo.bar%40gmail.com?secret=KFBVEMJQIVFW6RKMJNWTQ42OPBKG63DBK4YWSX2LG4REOQRXGZ3Q&issuer=Papra&digits=6&period=30',
|
||||||
|
}),
|
||||||
|
).to.equal('KFBVEMJQIVFW6RKMJNWTQ42OPBKG63DBK4YWSX2LG4REOQRXGZ3Q');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('if the TOTP URI does not have a secret query parameter, an empty string is returned', () => {
|
||||||
|
expect(
|
||||||
|
getSecretFromTotpUri({
|
||||||
|
totpUri: 'otpauth://totp/Papra:foo.bar%40gmail.com?issuer=Papra&digits=6&period=30',
|
||||||
|
}),
|
||||||
|
).to.equal('');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('if the TOTP URI is malformed, an empty string is returned', () => {
|
||||||
|
expect(
|
||||||
|
getSecretFromTotpUri({
|
||||||
|
totpUri: 'not-a-valid-uri',
|
||||||
|
}),
|
||||||
|
).to.equal('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
7
apps/papra-client/src/modules/users/2fa.models.ts
Normal file
7
apps/papra-client/src/modules/users/2fa.models.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export function getSecretFromTotpUri({ totpUri }: { totpUri: string }): string {
|
||||||
|
try {
|
||||||
|
return new URL(totpUri).searchParams.get('secret') ?? '';
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,470 @@
|
|||||||
|
import type { Component } from 'solid-js';
|
||||||
|
import { useMutation } from '@tanstack/solid-query';
|
||||||
|
import { createSignal, For, Show } from 'solid-js';
|
||||||
|
import * as v from 'valibot';
|
||||||
|
import { twoFactor } from '@/modules/auth/auth.services';
|
||||||
|
import { TotpField } from '@/modules/auth/components/verify-otp.component';
|
||||||
|
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||||
|
import { downloadTextFile } from '@/modules/shared/files/download';
|
||||||
|
import { createForm } from '@/modules/shared/form/form';
|
||||||
|
import { useI18nApiErrors } from '@/modules/shared/http/composables/i18n-api-errors';
|
||||||
|
import { CopyButton } from '@/modules/shared/utils/copy';
|
||||||
|
import { Badge } from '@/modules/ui/components/badge';
|
||||||
|
import { Button } from '@/modules/ui/components/button';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/modules/ui/components/card';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/modules/ui/components/dialog';
|
||||||
|
import { QrCode } from '@/modules/ui/components/qr-code';
|
||||||
|
import { createToast } from '@/modules/ui/components/sonner';
|
||||||
|
import { TextField, TextFieldErrorMessage, TextFieldLabel, TextFieldRoot } from '@/modules/ui/components/textfield';
|
||||||
|
import { getSecretFromTotpUri } from '../2fa.models';
|
||||||
|
|
||||||
|
const EnableTwoFactorDialog: Component<{
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onSuccess: (data: { totpURI: string; backupCodes: string[] }) => void;
|
||||||
|
}> = (props) => {
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const passwordSchema = v.pipe(v.string(), v.minLength(1, t('user.settings.two-factor.enable-dialog.password.required')));
|
||||||
|
|
||||||
|
const { form, Form, Field } = createForm({
|
||||||
|
schema: v.object({
|
||||||
|
password: passwordSchema,
|
||||||
|
}),
|
||||||
|
initialValues: {
|
||||||
|
password: '',
|
||||||
|
},
|
||||||
|
onSubmit: async ({ password }) => {
|
||||||
|
const { data, error } = await twoFactor.enable({ password });
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
createToast({ type: 'error', message: error.message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { totpURI, backupCodes } = data;
|
||||||
|
|
||||||
|
props.onSuccess({ totpURI, backupCodes });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={props.open} onOpenChange={props.onOpenChange}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t('user.settings.two-factor.enable-dialog.title')}</DialogTitle>
|
||||||
|
<DialogDescription>{t('user.settings.two-factor.enable-dialog.description')}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<Form>
|
||||||
|
<Field name="password">
|
||||||
|
{(field, inputProps) => (
|
||||||
|
<TextFieldRoot>
|
||||||
|
<TextFieldLabel for="enable-password">
|
||||||
|
{t('user.settings.two-factor.enable-dialog.password.label')}
|
||||||
|
</TextFieldLabel>
|
||||||
|
<TextField
|
||||||
|
type="password"
|
||||||
|
id="enable-password"
|
||||||
|
placeholder={t('user.settings.two-factor.enable-dialog.password.placeholder')}
|
||||||
|
{...inputProps}
|
||||||
|
value={field.value}
|
||||||
|
aria-invalid={Boolean(field.error)}
|
||||||
|
/>
|
||||||
|
{field.error && <TextFieldErrorMessage>{field.error}</TextFieldErrorMessage>}
|
||||||
|
</TextFieldRoot>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
<DialogFooter class="mt-6">
|
||||||
|
<Button variant="outline" onClick={() => props.onOpenChange(false)}>
|
||||||
|
{t('user.settings.two-factor.enable-dialog.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" isLoading={form.submitting}>
|
||||||
|
{t('user.settings.two-factor.enable-dialog.submit')}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const SetupTwoFactorDialog: Component<{
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
totpUri: string;
|
||||||
|
onSuccess: () => void;
|
||||||
|
}> = (props) => {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const getTotpSecret = () => getSecretFromTotpUri({ totpUri: props.totpUri });
|
||||||
|
const [getTotpCode, setTotpCode] = createSignal<string>('');
|
||||||
|
const { createI18nApiError } = useI18nApiErrors();
|
||||||
|
|
||||||
|
const verifyMutation = useMutation(() => ({
|
||||||
|
mutationFn: async ({ totpCode }: { totpCode: string }) => {
|
||||||
|
const { error } = await twoFactor.verifyTotp({ code: totpCode });
|
||||||
|
if (error) {
|
||||||
|
throw createI18nApiError({ error });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
props.onSuccess();
|
||||||
|
createToast({ type: 'success', message: t('user.settings.two-factor.enabled') });
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={props.open}
|
||||||
|
onOpenChange={props.onOpenChange}
|
||||||
|
>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t('user.settings.two-factor.setup-dialog.title')}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div>
|
||||||
|
|
||||||
|
<h3 class="font-semibold">{t('user.settings.two-factor.setup-dialog.step1.title')}</h3>
|
||||||
|
<p class="mb-4 text-sm text-muted-foreground">
|
||||||
|
{t('user.settings.two-factor.setup-dialog.step1.description')}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="flex flex-col items-center">
|
||||||
|
<QrCode value={props.totpUri} class="w-full max-w-48" />
|
||||||
|
|
||||||
|
<CopyButton text={getTotpSecret()} variant="outline" label={t('user.settings.two-factor.setup-dialog.copy-setup-key')} size="sm" class="mt-2" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 class="mt-8 font-semibold">{t('user.settings.two-factor.setup-dialog.step2.title')}</h3>
|
||||||
|
<p class="mb-4 text-sm text-muted-foreground">
|
||||||
|
{t('user.settings.two-factor.setup-dialog.step2.description')}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="mt-4 flex justify-center">
|
||||||
|
<TotpField value={getTotpCode()} onValueChange={setTotpCode} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show when={verifyMutation.error}>{getError => (<div class="text-red">{getError().message}</div>)}</Show>
|
||||||
|
|
||||||
|
<div class="flex md:flex-row flex-col justify-end gap-2 mt-6">
|
||||||
|
<Button variant="outline" onClick={() => props.onOpenChange(false)}>
|
||||||
|
{t('user.settings.two-factor.setup-dialog.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" isLoading={verifyMutation.isPending} onClick={() => verifyMutation.mutate({ totpCode: getTotpCode() })}>
|
||||||
|
{t('user.settings.two-factor.setup-dialog.verify')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const BackupCodesDialog: Component<{
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
backupCodes: string[];
|
||||||
|
}> = (props) => {
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={props.open} onOpenChange={props.onOpenChange}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t('user.settings.two-factor.backup-codes-dialog.title')}</DialogTitle>
|
||||||
|
<DialogDescription>{t('user.settings.two-factor.backup-codes-dialog.description')}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div>
|
||||||
|
<div class="p-4 rounded-md bg-background border">
|
||||||
|
<div class="grid grid-cols-2 gap-2 font-mono text-sm">
|
||||||
|
<For each={props.backupCodes}>
|
||||||
|
{code => (
|
||||||
|
<div class="text-center">{code}</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-center mt-2 md:flex-row flex-col gap-2">
|
||||||
|
<CopyButton
|
||||||
|
text={props.backupCodes.join('\n')}
|
||||||
|
label={t('user.settings.two-factor.backup-codes-dialog.copy')}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => downloadTextFile({
|
||||||
|
content: props.backupCodes.join('\n'),
|
||||||
|
fileName: t('user.settings.two-factor.backup-codes-dialog.download-filename'),
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<div class="i-tabler-download size-4 mr-2" />
|
||||||
|
{t('user.settings.two-factor.backup-codes-dialog.download')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter class="mt-4">
|
||||||
|
<Button onClick={() => props.onOpenChange(false)}>
|
||||||
|
{t('user.settings.two-factor.backup-codes-dialog.close')}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const DisableTwoFactorDialog: Component<{
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onSuccess: () => void;
|
||||||
|
}> = (props) => {
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const passwordSchema = v.pipe(v.string(), v.minLength(1, t('user.settings.two-factor.disable-dialog.password.required')));
|
||||||
|
|
||||||
|
const { form, Form, Field } = createForm({
|
||||||
|
schema: v.object({
|
||||||
|
password: passwordSchema,
|
||||||
|
}),
|
||||||
|
initialValues: {
|
||||||
|
password: '',
|
||||||
|
},
|
||||||
|
onSubmit: async ({ password }) => {
|
||||||
|
const { error } = await twoFactor.disable({ password });
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
createToast({ type: 'error', message: error.message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
props.onSuccess();
|
||||||
|
createToast({ type: 'success', message: t('user.settings.two-factor.disabled') });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={props.open} onOpenChange={props.onOpenChange}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t('user.settings.two-factor.disable-dialog.title')}</DialogTitle>
|
||||||
|
<DialogDescription>{t('user.settings.two-factor.disable-dialog.description')}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<Form>
|
||||||
|
<Field name="password">
|
||||||
|
{(field, inputProps) => (
|
||||||
|
<TextFieldRoot>
|
||||||
|
<TextFieldLabel for="disable-password">
|
||||||
|
{t('user.settings.two-factor.disable-dialog.password.label')}
|
||||||
|
</TextFieldLabel>
|
||||||
|
<TextField
|
||||||
|
type="password"
|
||||||
|
id="disable-password"
|
||||||
|
placeholder={t('user.settings.two-factor.disable-dialog.password.placeholder')}
|
||||||
|
{...inputProps}
|
||||||
|
value={field.value}
|
||||||
|
aria-invalid={Boolean(field.error)}
|
||||||
|
/>
|
||||||
|
{field.error && <TextFieldErrorMessage>{field.error}</TextFieldErrorMessage>}
|
||||||
|
</TextFieldRoot>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
<DialogFooter class="mt-6">
|
||||||
|
<Button variant="outline" onClick={() => props.onOpenChange(false)}>
|
||||||
|
{t('user.settings.two-factor.disable-dialog.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" variant="destructive" isLoading={form.submitting}>
|
||||||
|
{t('user.settings.two-factor.disable-dialog.submit')}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const RegenerateBackupCodesDialog: Component<{
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onSuccess: (backupCodes: string[]) => void;
|
||||||
|
}> = (props) => {
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const passwordSchema = v.pipe(v.string(), v.minLength(1, t('user.settings.two-factor.regenerate-dialog.password.required')));
|
||||||
|
|
||||||
|
const { form, Form, Field } = createForm({
|
||||||
|
schema: v.object({
|
||||||
|
password: passwordSchema,
|
||||||
|
}),
|
||||||
|
initialValues: {
|
||||||
|
password: '',
|
||||||
|
},
|
||||||
|
onSubmit: async ({ password }) => {
|
||||||
|
const { data, error } = await twoFactor.generateBackupCodes({ password });
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
createToast({ type: 'error', message: error.message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data?.backupCodes) {
|
||||||
|
props.onSuccess(data.backupCodes);
|
||||||
|
createToast({ type: 'success', message: t('user.settings.two-factor.codes-regenerated') });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={props.open} onOpenChange={props.onOpenChange}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t('user.settings.two-factor.regenerate-dialog.title')}</DialogTitle>
|
||||||
|
<DialogDescription>{t('user.settings.two-factor.regenerate-dialog.description')}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<Form>
|
||||||
|
<Field name="password">
|
||||||
|
{(field, inputProps) => (
|
||||||
|
<TextFieldRoot>
|
||||||
|
<TextFieldLabel for="regenerate-password">
|
||||||
|
{t('user.settings.two-factor.regenerate-dialog.password.label')}
|
||||||
|
</TextFieldLabel>
|
||||||
|
<TextField
|
||||||
|
type="password"
|
||||||
|
id="regenerate-password"
|
||||||
|
placeholder={t('user.settings.two-factor.regenerate-dialog.password.placeholder')}
|
||||||
|
{...inputProps}
|
||||||
|
value={field.value}
|
||||||
|
aria-invalid={Boolean(field.error)}
|
||||||
|
/>
|
||||||
|
{field.error && <TextFieldErrorMessage>{field.error}</TextFieldErrorMessage>}
|
||||||
|
</TextFieldRoot>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
<DialogFooter class="mt-6">
|
||||||
|
<Button variant="outline" onClick={() => props.onOpenChange(false)}>
|
||||||
|
{t('user.settings.two-factor.regenerate-dialog.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" isLoading={form.submitting}>
|
||||||
|
{t('user.settings.two-factor.regenerate-dialog.submit')}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type DialogState = 'none' | 'enable-password' | 'setup-qr' | 'backup-codes' | 'disable-password' | 'regenerate-codes';
|
||||||
|
|
||||||
|
export const TwoFactorCard: Component<{ twoFactorEnabled: boolean; onUpdate: () => void }> = (props) => {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [dialogState, setDialogState] = createSignal<DialogState>('none');
|
||||||
|
const [totpUri, setTotpUri] = createSignal<string>('');
|
||||||
|
const [backupCodes, setBackupCodes] = createSignal<string[]>([]);
|
||||||
|
|
||||||
|
const handleEnableSuccess = (data: { totpURI: string; backupCodes: string[] }) => {
|
||||||
|
setTotpUri(data.totpURI);
|
||||||
|
setBackupCodes(data.backupCodes);
|
||||||
|
setDialogState('setup-qr');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSetupSuccess = () => {
|
||||||
|
setDialogState('backup-codes');
|
||||||
|
props.onUpdate();
|
||||||
|
createToast({ type: 'success', message: t('user.settings.two-factor.enabled') });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDisableSuccess = () => {
|
||||||
|
setDialogState('none');
|
||||||
|
props.onUpdate();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRegenerateSuccess = (codes: string[]) => {
|
||||||
|
setBackupCodes(codes);
|
||||||
|
setDialogState('backup-codes');
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeDialog = () => {
|
||||||
|
setDialogState('none');
|
||||||
|
setTotpUri('');
|
||||||
|
setBackupCodes([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Card>
|
||||||
|
<CardHeader class="border-b">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle>{t('user.settings.two-factor.title')}</CardTitle>
|
||||||
|
<CardDescription>{t('user.settings.two-factor.description')}</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Badge variant={props.twoFactorEnabled ? 'default' : 'secondary'}>
|
||||||
|
{props.twoFactorEnabled
|
||||||
|
? t('user.settings.two-factor.status.enabled')
|
||||||
|
: t('user.settings.two-factor.status.disabled')}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="pt-6">
|
||||||
|
<div class="flex flex-row justify-end gap-3">
|
||||||
|
<Show
|
||||||
|
when={props.twoFactorEnabled}
|
||||||
|
fallback={(
|
||||||
|
<Button onClick={() => setDialogState('enable-password')}>
|
||||||
|
{t('user.settings.two-factor.enable-button')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Button variant="outline" onClick={() => setDialogState('regenerate-codes')}>
|
||||||
|
{t('user.settings.two-factor.regenerate-codes-button')}
|
||||||
|
</Button>
|
||||||
|
<Button variant="destructive" onClick={() => setDialogState('disable-password')}>
|
||||||
|
{t('user.settings.two-factor.disable-button')}
|
||||||
|
</Button>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<EnableTwoFactorDialog
|
||||||
|
open={dialogState() === 'enable-password'}
|
||||||
|
onOpenChange={open => !open && closeDialog()}
|
||||||
|
onSuccess={handleEnableSuccess}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SetupTwoFactorDialog
|
||||||
|
open={dialogState() === 'setup-qr'}
|
||||||
|
onOpenChange={open => !open && closeDialog()}
|
||||||
|
totpUri={totpUri()}
|
||||||
|
onSuccess={handleSetupSuccess}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<BackupCodesDialog
|
||||||
|
open={dialogState() === 'backup-codes'}
|
||||||
|
onOpenChange={open => !open && closeDialog()}
|
||||||
|
backupCodes={backupCodes()}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DisableTwoFactorDialog
|
||||||
|
open={dialogState() === 'disable-password'}
|
||||||
|
onOpenChange={open => !open && closeDialog()}
|
||||||
|
onSuccess={handleDisableSuccess}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<RegenerateBackupCodesDialog
|
||||||
|
open={dialogState() === 'regenerate-codes'}
|
||||||
|
onOpenChange={open => !open && closeDialog()}
|
||||||
|
onSuccess={handleRegenerateSuccess}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -10,6 +10,7 @@ import { Button } from '@/modules/ui/components/button';
|
|||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/modules/ui/components/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/modules/ui/components/card';
|
||||||
import { createToast } from '@/modules/ui/components/sonner';
|
import { createToast } from '@/modules/ui/components/sonner';
|
||||||
import { TextField, TextFieldLabel, TextFieldRoot } from '@/modules/ui/components/textfield';
|
import { TextField, TextFieldLabel, TextFieldRoot } from '@/modules/ui/components/textfield';
|
||||||
|
import { TwoFactorCard } from '../components/two-factor-card';
|
||||||
import { useUpdateCurrentUser } from '../users.composables';
|
import { useUpdateCurrentUser } from '../users.composables';
|
||||||
import { nameSchema } from '../users.schemas';
|
import { nameSchema } from '../users.schemas';
|
||||||
import { fetchCurrentUser } from '../users.services';
|
import { fetchCurrentUser } from '../users.services';
|
||||||
@@ -147,6 +148,7 @@ export const UserSettingsPage: Component = () => {
|
|||||||
<div class="mt-6 flex flex-col gap-6">
|
<div class="mt-6 flex flex-col gap-6">
|
||||||
<UserEmailCard email={getUser().email} />
|
<UserEmailCard email={getUser().email} />
|
||||||
<UpdateFullNameCard name={getUser().name} />
|
<UpdateFullNameCard name={getUser().name} />
|
||||||
|
<TwoFactorCard twoFactorEnabled={getUser().twoFactorEnabled} onUpdate={() => query.refetch()} />
|
||||||
<LogoutCard />
|
<LogoutCard />
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export type User = {
|
|||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
emailVerified: boolean;
|
emailVerified: boolean;
|
||||||
maxOrganizationCount: number | null;
|
maxOrganizationCount: number | null;
|
||||||
|
twoFactorEnabled: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type UserMe = User & {
|
export type UserMe = User & {
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import type { BatchItem } from 'drizzle-orm/batch';
|
||||||
|
import type { Migration } from '../migrations.types';
|
||||||
|
import { sql } from 'drizzle-orm';
|
||||||
|
|
||||||
|
export const twoFactorAuthenticationMigration = {
|
||||||
|
name: 'two-factor-authentication',
|
||||||
|
|
||||||
|
up: async ({ db }) => {
|
||||||
|
const tableInfo = await db.run(sql`PRAGMA table_info(users)`);
|
||||||
|
const existingColumns = tableInfo.rows.map(row => row.name);
|
||||||
|
const hasColumn = (columnName: string) => existingColumns.includes(columnName);
|
||||||
|
|
||||||
|
const statements = [
|
||||||
|
sql`
|
||||||
|
CREATE TABLE IF NOT EXISTS "auth_two_factor" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"created_at" integer NOT NULL,
|
||||||
|
"updated_at" integer NOT NULL,
|
||||||
|
"user_id" text,
|
||||||
|
"secret" text,
|
||||||
|
"backup_codes" text,
|
||||||
|
FOREIGN KEY ("user_id") REFERENCES "users"("id") ON UPDATE cascade ON DELETE cascade
|
||||||
|
);
|
||||||
|
`,
|
||||||
|
|
||||||
|
...(!hasColumn('two_factor_enabled') ? [sql`ALTER TABLE "users" ADD "two_factor_enabled" integer DEFAULT false NOT NULL;`] : []),
|
||||||
|
];
|
||||||
|
|
||||||
|
await db.batch(statements.map(statement => db.run(statement) as BatchItem<'sqlite'>) as [BatchItem<'sqlite'>, ...BatchItem<'sqlite'>[]]);
|
||||||
|
},
|
||||||
|
|
||||||
|
down: async ({ db }) => {
|
||||||
|
await db.batch([
|
||||||
|
db.run(sql`DROP TABLE IF EXISTS "auth_two_factor";`),
|
||||||
|
db.run(sql`ALTER TABLE "users" DROP COLUMN "two_factor_enabled";`),
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
} satisfies Migration;
|
||||||
2170
apps/papra-server/src/migrations/meta/0012_snapshot.json
Normal file
2170
apps/papra-server/src/migrations/meta/0012_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -85,6 +85,13 @@
|
|||||||
"when": 1761645190314,
|
"when": 1761645190314,
|
||||||
"tag": "0011_tagging-rule-condition-match-mode",
|
"tag": "0011_tagging-rule-condition-match-mode",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 12,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1766411483931,
|
||||||
|
"tag": "0012_two-factor-authentication",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ describe('migrations registry', () => {
|
|||||||
CREATE TABLE "api_keys" ( "id" text PRIMARY KEY NOT NULL, "created_at" integer NOT NULL, "updated_at" integer NOT NULL, "name" text NOT NULL, "key_hash" text NOT NULL, "prefix" text NOT NULL, "user_id" text NOT NULL, "last_used_at" integer, "expires_at" integer, "permissions" text DEFAULT '[]' NOT NULL, "all_organizations" integer DEFAULT false NOT NULL, FOREIGN KEY ("user_id") REFERENCES "users"("id") ON UPDATE cascade ON DELETE cascade );
|
CREATE TABLE "api_keys" ( "id" text PRIMARY KEY NOT NULL, "created_at" integer NOT NULL, "updated_at" integer NOT NULL, "name" text NOT NULL, "key_hash" text NOT NULL, "prefix" text NOT NULL, "user_id" text NOT NULL, "last_used_at" integer, "expires_at" integer, "permissions" text DEFAULT '[]' NOT NULL, "all_organizations" integer DEFAULT false NOT NULL, FOREIGN KEY ("user_id") REFERENCES "users"("id") ON UPDATE cascade ON DELETE cascade );
|
||||||
CREATE TABLE "auth_accounts" ( "id" text PRIMARY KEY NOT NULL, "created_at" integer NOT NULL, "updated_at" integer NOT NULL, "user_id" text, "account_id" text NOT NULL, "provider_id" text NOT NULL, "access_token" text, "refresh_token" text, "access_token_expires_at" integer, "refresh_token_expires_at" integer, "scope" text, "id_token" text, "password" text, FOREIGN KEY ("user_id") REFERENCES "users"("id") ON UPDATE cascade ON DELETE cascade );
|
CREATE TABLE "auth_accounts" ( "id" text PRIMARY KEY NOT NULL, "created_at" integer NOT NULL, "updated_at" integer NOT NULL, "user_id" text, "account_id" text NOT NULL, "provider_id" text NOT NULL, "access_token" text, "refresh_token" text, "access_token_expires_at" integer, "refresh_token_expires_at" integer, "scope" text, "id_token" text, "password" text, FOREIGN KEY ("user_id") REFERENCES "users"("id") ON UPDATE cascade ON DELETE cascade );
|
||||||
CREATE TABLE "auth_sessions" ( "id" text PRIMARY KEY NOT NULL, "created_at" integer NOT NULL, "updated_at" integer NOT NULL, "token" text NOT NULL, "user_id" text, "expires_at" integer NOT NULL, "ip_address" text, "user_agent" text, "active_organization_id" text, FOREIGN KEY ("user_id") REFERENCES "users"("id") ON UPDATE cascade ON DELETE cascade, FOREIGN KEY ("active_organization_id") REFERENCES "organizations"("id") ON UPDATE cascade ON DELETE set null );
|
CREATE TABLE "auth_sessions" ( "id" text PRIMARY KEY NOT NULL, "created_at" integer NOT NULL, "updated_at" integer NOT NULL, "token" text NOT NULL, "user_id" text, "expires_at" integer NOT NULL, "ip_address" text, "user_agent" text, "active_organization_id" text, FOREIGN KEY ("user_id") REFERENCES "users"("id") ON UPDATE cascade ON DELETE cascade, FOREIGN KEY ("active_organization_id") REFERENCES "organizations"("id") ON UPDATE cascade ON DELETE set null );
|
||||||
|
CREATE TABLE "auth_two_factor" ( "id" text PRIMARY KEY NOT NULL, "created_at" integer NOT NULL, "updated_at" integer NOT NULL, "user_id" text, "secret" text, "backup_codes" text, FOREIGN KEY ("user_id") REFERENCES "users"("id") ON UPDATE cascade ON DELETE cascade );
|
||||||
CREATE TABLE "auth_verifications" ( "id" text PRIMARY KEY NOT NULL, "created_at" integer NOT NULL, "updated_at" integer NOT NULL, "identifier" text NOT NULL, "value" text NOT NULL, "expires_at" integer NOT NULL );
|
CREATE TABLE "auth_verifications" ( "id" text PRIMARY KEY NOT NULL, "created_at" integer NOT NULL, "updated_at" integer NOT NULL, "identifier" text NOT NULL, "value" text NOT NULL, "expires_at" integer NOT NULL );
|
||||||
CREATE TABLE "document_activity_log" ( "id" text PRIMARY KEY NOT NULL, "created_at" integer NOT NULL, "document_id" text NOT NULL, "event" text NOT NULL, "event_data" text, "user_id" text, "tag_id" text, FOREIGN KEY ("document_id") REFERENCES "documents"("id") ON UPDATE cascade ON DELETE cascade, FOREIGN KEY ("user_id") REFERENCES "users"("id") ON UPDATE cascade ON DELETE set null, FOREIGN KEY ("tag_id") REFERENCES "tags"("id") ON UPDATE cascade ON DELETE set null );
|
CREATE TABLE "document_activity_log" ( "id" text PRIMARY KEY NOT NULL, "created_at" integer NOT NULL, "document_id" text NOT NULL, "event" text NOT NULL, "event_data" text, "user_id" text, "tag_id" text, FOREIGN KEY ("document_id") REFERENCES "documents"("id") ON UPDATE cascade ON DELETE cascade, FOREIGN KEY ("user_id") REFERENCES "users"("id") ON UPDATE cascade ON DELETE set null, FOREIGN KEY ("tag_id") REFERENCES "tags"("id") ON UPDATE cascade ON DELETE set null );
|
||||||
CREATE TABLE "documents" ( "id" text PRIMARY KEY NOT NULL, "created_at" integer NOT NULL, "updated_at" integer NOT NULL, "is_deleted" integer DEFAULT false NOT NULL, "deleted_at" integer, "organization_id" text NOT NULL, "created_by" text, "deleted_by" text, "original_name" text NOT NULL, "original_size" integer DEFAULT 0 NOT NULL, "original_storage_key" text NOT NULL, "original_sha256_hash" text NOT NULL, "name" text NOT NULL, "mime_type" text NOT NULL, "content" text DEFAULT '' NOT NULL, file_encryption_key_wrapped TEXT, file_encryption_kek_version TEXT, file_encryption_algorithm TEXT, FOREIGN KEY ("organization_id") REFERENCES "organizations"("id") ON UPDATE cascade ON DELETE cascade, FOREIGN KEY ("created_by") REFERENCES "users"("id") ON UPDATE cascade ON DELETE set null, FOREIGN KEY ("deleted_by") REFERENCES "users"("id") ON UPDATE cascade ON DELETE set null );
|
CREATE TABLE "documents" ( "id" text PRIMARY KEY NOT NULL, "created_at" integer NOT NULL, "updated_at" integer NOT NULL, "is_deleted" integer DEFAULT false NOT NULL, "deleted_at" integer, "organization_id" text NOT NULL, "created_by" text, "deleted_by" text, "original_name" text NOT NULL, "original_size" integer DEFAULT 0 NOT NULL, "original_storage_key" text NOT NULL, "original_sha256_hash" text NOT NULL, "name" text NOT NULL, "mime_type" text NOT NULL, "content" text DEFAULT '' NOT NULL, file_encryption_key_wrapped TEXT, file_encryption_kek_version TEXT, file_encryption_algorithm TEXT, FOREIGN KEY ("organization_id") REFERENCES "organizations"("id") ON UPDATE cascade ON DELETE cascade, FOREIGN KEY ("created_by") REFERENCES "users"("id") ON UPDATE cascade ON DELETE set null, FOREIGN KEY ("deleted_by") REFERENCES "users"("id") ON UPDATE cascade ON DELETE set null );
|
||||||
@@ -118,7 +119,7 @@ describe('migrations registry', () => {
|
|||||||
CREATE TABLE "tagging_rules" ( "id" text PRIMARY KEY NOT NULL, "created_at" integer NOT NULL, "updated_at" integer NOT NULL, "organization_id" text NOT NULL, "name" text NOT NULL, "description" text, "enabled" integer DEFAULT true NOT NULL, "condition_match_mode" text DEFAULT 'all' NOT NULL, FOREIGN KEY ("organization_id") REFERENCES "organizations"("id") ON UPDATE cascade ON DELETE cascade );
|
CREATE TABLE "tagging_rules" ( "id" text PRIMARY KEY NOT NULL, "created_at" integer NOT NULL, "updated_at" integer NOT NULL, "organization_id" text NOT NULL, "name" text NOT NULL, "description" text, "enabled" integer DEFAULT true NOT NULL, "condition_match_mode" text DEFAULT 'all' NOT NULL, FOREIGN KEY ("organization_id") REFERENCES "organizations"("id") ON UPDATE cascade ON DELETE cascade );
|
||||||
CREATE TABLE "tags" ( "id" text PRIMARY KEY NOT NULL, "created_at" integer NOT NULL, "updated_at" integer NOT NULL, "organization_id" text NOT NULL, "name" text NOT NULL, "color" text NOT NULL, "description" text, FOREIGN KEY ("organization_id") REFERENCES "organizations"("id") ON UPDATE cascade ON DELETE cascade );
|
CREATE TABLE "tags" ( "id" text PRIMARY KEY NOT NULL, "created_at" integer NOT NULL, "updated_at" integer NOT NULL, "organization_id" text NOT NULL, "name" text NOT NULL, "color" text NOT NULL, "description" text, FOREIGN KEY ("organization_id") REFERENCES "organizations"("id") ON UPDATE cascade ON DELETE cascade );
|
||||||
CREATE TABLE "user_roles" ( "id" text PRIMARY KEY NOT NULL, "created_at" integer NOT NULL, "updated_at" integer NOT NULL, "user_id" text NOT NULL, "role" text NOT NULL, FOREIGN KEY ("user_id") REFERENCES "users"("id") ON UPDATE cascade ON DELETE cascade );
|
CREATE TABLE "user_roles" ( "id" text PRIMARY KEY NOT NULL, "created_at" integer NOT NULL, "updated_at" integer NOT NULL, "user_id" text NOT NULL, "role" text NOT NULL, FOREIGN KEY ("user_id") REFERENCES "users"("id") ON UPDATE cascade ON DELETE cascade );
|
||||||
CREATE TABLE "users" ( "id" text PRIMARY KEY NOT NULL, "created_at" integer NOT NULL, "updated_at" integer NOT NULL, "email" text NOT NULL, "email_verified" integer DEFAULT false NOT NULL, "name" text, "image" text, "max_organization_count" integer );
|
CREATE TABLE "users" ( "id" text PRIMARY KEY NOT NULL, "created_at" integer NOT NULL, "updated_at" integer NOT NULL, "email" text NOT NULL, "email_verified" integer DEFAULT false NOT NULL, "name" text, "image" text, "max_organization_count" integer , "two_factor_enabled" integer DEFAULT false NOT NULL);
|
||||||
CREATE TABLE "webhook_deliveries" ( "id" text PRIMARY KEY NOT NULL, "created_at" integer NOT NULL, "updated_at" integer NOT NULL, "webhook_id" text NOT NULL, "event_name" text NOT NULL, "request_payload" text NOT NULL, "response_payload" text NOT NULL, "response_status" integer NOT NULL, FOREIGN KEY ("webhook_id") REFERENCES "webhooks"("id") ON UPDATE cascade ON DELETE cascade );
|
CREATE TABLE "webhook_deliveries" ( "id" text PRIMARY KEY NOT NULL, "created_at" integer NOT NULL, "updated_at" integer NOT NULL, "webhook_id" text NOT NULL, "event_name" text NOT NULL, "request_payload" text NOT NULL, "response_payload" text NOT NULL, "response_status" integer NOT NULL, FOREIGN KEY ("webhook_id") REFERENCES "webhooks"("id") ON UPDATE cascade ON DELETE cascade );
|
||||||
CREATE TABLE "webhook_events" ( "id" text PRIMARY KEY NOT NULL, "created_at" integer NOT NULL, "updated_at" integer NOT NULL, "webhook_id" text NOT NULL, "event_name" text NOT NULL, FOREIGN KEY ("webhook_id") REFERENCES "webhooks"("id") ON UPDATE cascade ON DELETE cascade );
|
CREATE TABLE "webhook_events" ( "id" text PRIMARY KEY NOT NULL, "created_at" integer NOT NULL, "updated_at" integer NOT NULL, "webhook_id" text NOT NULL, "event_name" text NOT NULL, FOREIGN KEY ("webhook_id") REFERENCES "webhooks"("id") ON UPDATE cascade ON DELETE cascade );
|
||||||
CREATE TABLE "webhooks" ( "id" text PRIMARY KEY NOT NULL, "created_at" integer NOT NULL, "updated_at" integer NOT NULL, "name" text NOT NULL, "url" text NOT NULL, "secret" text, "enabled" integer DEFAULT true NOT NULL, "created_by" text, "organization_id" text, FOREIGN KEY ("created_by") REFERENCES "users"("id") ON UPDATE cascade ON DELETE set null, FOREIGN KEY ("organization_id") REFERENCES "organizations"("id") ON UPDATE cascade ON DELETE cascade );"
|
CREATE TABLE "webhooks" ( "id" text PRIMARY KEY NOT NULL, "created_at" integer NOT NULL, "updated_at" integer NOT NULL, "name" text NOT NULL, "url" text NOT NULL, "secret" text, "enabled" integer DEFAULT true NOT NULL, "created_by" text, "organization_id" text, FOREIGN KEY ("created_by") REFERENCES "users"("id") ON UPDATE cascade ON DELETE set null, FOREIGN KEY ("organization_id") REFERENCES "organizations"("id") ON UPDATE cascade ON DELETE cascade );"
|
||||||
|
|||||||
@@ -9,13 +9,11 @@ import { organizationsInvitationsImprovementMigration } from './list/0006-organi
|
|||||||
import { documentActivityLogMigration } from './list/0007-document-activity-log.migration';
|
import { documentActivityLogMigration } from './list/0007-document-activity-log.migration';
|
||||||
import { documentActivityLogOnDeleteSetNullMigration } from './list/0008-document-activity-log-on-delete-set-null.migration';
|
import { documentActivityLogOnDeleteSetNullMigration } from './list/0008-document-activity-log-on-delete-set-null.migration';
|
||||||
import { dropLegacyMigrationsMigration } from './list/0009-drop-legacy-migrations.migration';
|
import { dropLegacyMigrationsMigration } from './list/0009-drop-legacy-migrations.migration';
|
||||||
|
|
||||||
import { documentFileEncryptionMigration } from './list/0010-document-file-encryption.migration';
|
import { documentFileEncryptionMigration } from './list/0010-document-file-encryption.migration';
|
||||||
|
|
||||||
import { softDeleteOrganizationsMigration } from './list/0011-soft-delete-organizations.migration';
|
import { softDeleteOrganizationsMigration } from './list/0011-soft-delete-organizations.migration';
|
||||||
import { taggingRuleConditionMatchModeMigration } from './list/0012-tagging-rule-condition-match-mode.migration';
|
import { taggingRuleConditionMatchModeMigration } from './list/0012-tagging-rule-condition-match-mode.migration';
|
||||||
|
|
||||||
import { dropFts5TriggersMigration } from './list/0013-drop-fts-5-triggers.migration';
|
import { dropFts5TriggersMigration } from './list/0013-drop-fts-5-triggers.migration';
|
||||||
|
import { twoFactorAuthenticationMigration } from './list/0014-two-factor-authentication.migration';
|
||||||
|
|
||||||
export const migrations: Migration[] = [
|
export const migrations: Migration[] = [
|
||||||
initialSchemaSetupMigration,
|
initialSchemaSetupMigration,
|
||||||
@@ -31,4 +29,5 @@ export const migrations: Migration[] = [
|
|||||||
softDeleteOrganizationsMigration,
|
softDeleteOrganizationsMigration,
|
||||||
taggingRuleConditionMatchModeMigration,
|
taggingRuleConditionMatchModeMigration,
|
||||||
dropFts5TriggersMigration,
|
dropFts5TriggersMigration,
|
||||||
|
twoFactorAuthenticationMigration,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import type { RouteDefinitionContext } from '../app/server.types';
|
import type { RouteDefinitionContext } from '../app/server.types';
|
||||||
import { registerAnalyticsRoutes } from './analytics/analytics.routes';
|
import { registerAnalyticsRoutes } from './analytics/analytics.routes';
|
||||||
|
import { registerOrganizationManagementRoutes } from './organizations/organizations.routes';
|
||||||
import { registerUserManagementRoutes } from './users/users.routes';
|
import { registerUserManagementRoutes } from './users/users.routes';
|
||||||
|
|
||||||
export function registerAdminRoutes(context: RouteDefinitionContext) {
|
export function registerAdminRoutes(context: RouteDefinitionContext) {
|
||||||
registerAnalyticsRoutes(context);
|
registerAnalyticsRoutes(context);
|
||||||
registerUserManagementRoutes(context);
|
registerUserManagementRoutes(context);
|
||||||
|
registerOrganizationManagementRoutes(context);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,597 @@
|
|||||||
|
import { describe, expect, test } from 'vitest';
|
||||||
|
import { createInMemoryDatabase } from '../../../app/database/database.test-utils';
|
||||||
|
import { createServer } from '../../../app/server';
|
||||||
|
import { createTestServerDependencies } from '../../../app/server.test-utils';
|
||||||
|
import { overrideConfig } from '../../../config/config.test-utils';
|
||||||
|
|
||||||
|
describe('admin organizations routes - permission protection', () => {
|
||||||
|
describe('get /api/admin/organizations', () => {
|
||||||
|
test('when the user has the VIEW_USERS permission, the request succeeds', async () => {
|
||||||
|
const { db } = await createInMemoryDatabase({
|
||||||
|
users: [
|
||||||
|
{ id: 'usr_admin', email: 'admin@example.com', name: 'Admin User' },
|
||||||
|
],
|
||||||
|
userRoles: [
|
||||||
|
{ userId: 'usr_admin', role: 'admin' },
|
||||||
|
],
|
||||||
|
organizations: [
|
||||||
|
{ id: 'org_123456789012345678901234', name: 'Organization 1' },
|
||||||
|
{ id: 'org_abcdefghijklmnopqrstuvwx', name: 'Organization 2' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { app } = createServer(createTestServerDependencies({ db, config: overrideConfig({ env: 'test' }) }));
|
||||||
|
|
||||||
|
const response = await app.request(
|
||||||
|
'/api/admin/organizations',
|
||||||
|
{ method: 'GET' },
|
||||||
|
{ loggedInUserId: 'usr_admin' },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).to.eql(200);
|
||||||
|
const body = (await response.json()) as { organizations: unknown; totalCount: number };
|
||||||
|
expect(body.organizations).to.have.length(2);
|
||||||
|
expect(body.totalCount).to.eql(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('when using search parameter, it filters by name', async () => {
|
||||||
|
const { db } = await createInMemoryDatabase({
|
||||||
|
users: [
|
||||||
|
{ id: 'usr_admin', email: 'admin@example.com', name: 'Admin User' },
|
||||||
|
],
|
||||||
|
userRoles: [
|
||||||
|
{ userId: 'usr_admin', role: 'admin' },
|
||||||
|
],
|
||||||
|
organizations: [
|
||||||
|
{ id: 'org_alpha123456789012345678', name: 'Alpha Corporation' },
|
||||||
|
{ id: 'org_beta1234567890123456789', name: 'Beta LLC' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { app } = createServer(createTestServerDependencies({ db, config: overrideConfig({ env: 'test' }) }));
|
||||||
|
|
||||||
|
const response = await app.request(
|
||||||
|
'/api/admin/organizations?search=Alpha',
|
||||||
|
{ method: 'GET' },
|
||||||
|
{ loggedInUserId: 'usr_admin' },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).to.eql(200);
|
||||||
|
const body = await response.json() as { organizations: { name: string }[]; totalCount: number };
|
||||||
|
expect(body.organizations).to.have.length(1);
|
||||||
|
expect(body.organizations[0]?.name).to.eql('Alpha Corporation');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('when using search parameter with organization ID, it returns exact match', async () => {
|
||||||
|
const { db } = await createInMemoryDatabase({
|
||||||
|
users: [
|
||||||
|
{ id: 'usr_admin', email: 'admin@example.com', name: 'Admin User' },
|
||||||
|
],
|
||||||
|
userRoles: [
|
||||||
|
{ userId: 'usr_admin', role: 'admin' },
|
||||||
|
],
|
||||||
|
organizations: [
|
||||||
|
{ id: 'org_123456789012345678901234', name: 'Alpha Corporation' },
|
||||||
|
{ id: 'org_abcdefghijklmnopqrstuvwx', name: 'Beta LLC' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { app } = createServer(createTestServerDependencies({ db, config: overrideConfig({ env: 'test' }) }));
|
||||||
|
|
||||||
|
const response = await app.request(
|
||||||
|
'/api/admin/organizations?search=org_abcdefghijklmnopqrstuvwx',
|
||||||
|
{ method: 'GET' },
|
||||||
|
{ loggedInUserId: 'usr_admin' },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).to.eql(200);
|
||||||
|
const body = await response.json() as { organizations: { id: string }[]; totalCount: number };
|
||||||
|
expect(body.organizations).to.have.length(1);
|
||||||
|
expect(body.organizations[0]?.id).to.eql('org_abcdefghijklmnopqrstuvwx');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('when the user does not have the VIEW_USERS permission, a 401 error is returned', async () => {
|
||||||
|
const { db } = await createInMemoryDatabase({
|
||||||
|
users: [{ id: 'usr_regular', email: 'user@example.com' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { app } = createServer(createTestServerDependencies({ db, config: overrideConfig({ env: 'test' }) }));
|
||||||
|
|
||||||
|
const response = await app.request(
|
||||||
|
'/api/admin/organizations',
|
||||||
|
{ method: 'GET' },
|
||||||
|
{ loggedInUserId: 'usr_regular' },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).to.eql(401);
|
||||||
|
expect(await response.json()).to.eql({
|
||||||
|
error: {
|
||||||
|
code: 'auth.unauthorized',
|
||||||
|
message: 'Unauthorized',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('when the user is not authenticated, a 401 error is returned', async () => {
|
||||||
|
const { db } = await createInMemoryDatabase();
|
||||||
|
|
||||||
|
const { app } = createServer(createTestServerDependencies({ db, config: overrideConfig({ env: 'test' }) }));
|
||||||
|
|
||||||
|
const response = await app.request(
|
||||||
|
'/api/admin/organizations',
|
||||||
|
{ method: 'GET' },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).to.eql(401);
|
||||||
|
expect(await response.json()).to.eql({
|
||||||
|
error: {
|
||||||
|
code: 'auth.unauthorized',
|
||||||
|
message: 'Unauthorized',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('get /api/admin/organizations/:organizationId', () => {
|
||||||
|
test('when the user has the VIEW_USERS permission, the request succeeds and returns organization basic info', async () => {
|
||||||
|
const { db } = await createInMemoryDatabase({
|
||||||
|
users: [
|
||||||
|
{ id: 'usr_admin', email: 'admin@example.com', name: 'Admin User' },
|
||||||
|
],
|
||||||
|
userRoles: [
|
||||||
|
{ userId: 'usr_admin', role: 'admin' },
|
||||||
|
],
|
||||||
|
organizations: [
|
||||||
|
{ id: 'org_123456789012345678901234', name: 'Test Organization' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { app } = createServer(createTestServerDependencies({ db, config: overrideConfig({ env: 'test' }) }));
|
||||||
|
|
||||||
|
const response = await app.request(
|
||||||
|
'/api/admin/organizations/org_123456789012345678901234',
|
||||||
|
{ method: 'GET' },
|
||||||
|
{ loggedInUserId: 'usr_admin' },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).to.eql(200);
|
||||||
|
const body = await response.json() as { organization: { id: string; name: string } };
|
||||||
|
expect(body.organization.id).to.eql('org_123456789012345678901234');
|
||||||
|
expect(body.organization.name).to.eql('Test Organization');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('when the organization does not exist, a 404 error is returned', async () => {
|
||||||
|
const { db } = await createInMemoryDatabase({
|
||||||
|
users: [
|
||||||
|
{ id: 'usr_admin', email: 'admin@example.com', name: 'Admin User' },
|
||||||
|
],
|
||||||
|
userRoles: [
|
||||||
|
{ userId: 'usr_admin', role: 'admin' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { app } = createServer(createTestServerDependencies({ db, config: overrideConfig({ env: 'test' }) }));
|
||||||
|
|
||||||
|
const response = await app.request(
|
||||||
|
'/api/admin/organizations/org_999999999999999999999999',
|
||||||
|
{ method: 'GET' },
|
||||||
|
{ loggedInUserId: 'usr_admin' },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).to.eql(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('when the user does not have the VIEW_USERS permission, a 401 error is returned', async () => {
|
||||||
|
const { db } = await createInMemoryDatabase({
|
||||||
|
users: [
|
||||||
|
{ id: 'usr_regular', email: 'user@example.com' },
|
||||||
|
],
|
||||||
|
organizations: [
|
||||||
|
{ id: 'org_123456789012345678901234', name: 'Test Organization' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { app } = createServer(createTestServerDependencies({ db, config: overrideConfig({ env: 'test' }) }));
|
||||||
|
|
||||||
|
const response = await app.request(
|
||||||
|
'/api/admin/organizations/org_123456789012345678901234',
|
||||||
|
{ method: 'GET' },
|
||||||
|
{ loggedInUserId: 'usr_regular' },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).to.eql(401);
|
||||||
|
expect(await response.json()).to.eql({
|
||||||
|
error: {
|
||||||
|
code: 'auth.unauthorized',
|
||||||
|
message: 'Unauthorized',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('when the user is not authenticated, a 401 error is returned', async () => {
|
||||||
|
const { db } = await createInMemoryDatabase({
|
||||||
|
organizations: [
|
||||||
|
{ id: 'org_123456789012345678901234', name: 'Test Organization' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { app } = createServer(createTestServerDependencies({ db, config: overrideConfig({ env: 'test' }) }));
|
||||||
|
|
||||||
|
const response = await app.request(
|
||||||
|
'/api/admin/organizations/org_123456789012345678901234',
|
||||||
|
{ method: 'GET' },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).to.eql(401);
|
||||||
|
expect(await response.json()).to.eql({
|
||||||
|
error: {
|
||||||
|
code: 'auth.unauthorized',
|
||||||
|
message: 'Unauthorized',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('get /api/admin/organizations/:organizationId/members', () => {
|
||||||
|
test('when the user has the VIEW_USERS permission, the request succeeds and returns members', async () => {
|
||||||
|
const { db } = await createInMemoryDatabase({
|
||||||
|
users: [
|
||||||
|
{ id: 'usr_admin', email: 'admin@example.com', name: 'Admin User' },
|
||||||
|
{ id: 'usr_member', email: 'member@example.com', name: 'Member User' },
|
||||||
|
],
|
||||||
|
userRoles: [
|
||||||
|
{ userId: 'usr_admin', role: 'admin' },
|
||||||
|
],
|
||||||
|
organizations: [
|
||||||
|
{ id: 'org_123456789012345678901234', name: 'Test Organization' },
|
||||||
|
],
|
||||||
|
organizationMembers: [
|
||||||
|
{ userId: 'usr_member', organizationId: 'org_123456789012345678901234', role: 'member' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { app } = createServer(createTestServerDependencies({ db, config: overrideConfig({ env: 'test' }) }));
|
||||||
|
|
||||||
|
const response = await app.request(
|
||||||
|
'/api/admin/organizations/org_123456789012345678901234/members',
|
||||||
|
{ method: 'GET' },
|
||||||
|
{ loggedInUserId: 'usr_admin' },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).to.eql(200);
|
||||||
|
const body = await response.json() as { members: { userId: string; role: string }[] };
|
||||||
|
expect(body.members).to.have.length(1);
|
||||||
|
expect(body.members[0]?.userId).to.eql('usr_member');
|
||||||
|
expect(body.members[0]?.role).to.eql('member');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('when the organization does not exist, a 404 error is returned', async () => {
|
||||||
|
const { db } = await createInMemoryDatabase({
|
||||||
|
users: [
|
||||||
|
{ id: 'usr_admin', email: 'admin@example.com', name: 'Admin User' },
|
||||||
|
],
|
||||||
|
userRoles: [
|
||||||
|
{ userId: 'usr_admin', role: 'admin' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { app } = createServer(createTestServerDependencies({ db, config: overrideConfig({ env: 'test' }) }));
|
||||||
|
|
||||||
|
const response = await app.request(
|
||||||
|
'/api/admin/organizations/org_999999999999999999999999/members',
|
||||||
|
{ method: 'GET' },
|
||||||
|
{ loggedInUserId: 'usr_admin' },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).to.eql(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('when the user does not have the VIEW_USERS permission, a 401 error is returned', async () => {
|
||||||
|
const { db } = await createInMemoryDatabase({
|
||||||
|
users: [
|
||||||
|
{ id: 'usr_regular', email: 'user@example.com' },
|
||||||
|
],
|
||||||
|
organizations: [
|
||||||
|
{ id: 'org_123456789012345678901234', name: 'Test Organization' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { app } = createServer(createTestServerDependencies({ db, config: overrideConfig({ env: 'test' }) }));
|
||||||
|
|
||||||
|
const response = await app.request(
|
||||||
|
'/api/admin/organizations/org_123456789012345678901234/members',
|
||||||
|
{ method: 'GET' },
|
||||||
|
{ loggedInUserId: 'usr_regular' },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).to.eql(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('when the user is not authenticated, a 401 error is returned', async () => {
|
||||||
|
const { db } = await createInMemoryDatabase({
|
||||||
|
organizations: [
|
||||||
|
{ id: 'org_123456789012345678901234', name: 'Test Organization' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { app } = createServer(createTestServerDependencies({ db, config: overrideConfig({ env: 'test' }) }));
|
||||||
|
|
||||||
|
const response = await app.request(
|
||||||
|
'/api/admin/organizations/org_123456789012345678901234/members',
|
||||||
|
{ method: 'GET' },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).to.eql(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('get /api/admin/organizations/:organizationId/intake-emails', () => {
|
||||||
|
test('when the user has the VIEW_USERS permission, the request succeeds and returns intake emails', async () => {
|
||||||
|
const { db } = await createInMemoryDatabase({
|
||||||
|
users: [
|
||||||
|
{ id: 'usr_admin', email: 'admin@example.com', name: 'Admin User' },
|
||||||
|
],
|
||||||
|
userRoles: [
|
||||||
|
{ userId: 'usr_admin', role: 'admin' },
|
||||||
|
],
|
||||||
|
organizations: [
|
||||||
|
{ id: 'org_123456789012345678901234', name: 'Test Organization' },
|
||||||
|
],
|
||||||
|
intakeEmails: [
|
||||||
|
{ organizationId: 'org_123456789012345678901234', emailAddress: 'intake@example.com', isEnabled: true },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { app } = createServer(createTestServerDependencies({ db, config: overrideConfig({ env: 'test' }) }));
|
||||||
|
|
||||||
|
const response = await app.request(
|
||||||
|
'/api/admin/organizations/org_123456789012345678901234/intake-emails',
|
||||||
|
{ method: 'GET' },
|
||||||
|
{ loggedInUserId: 'usr_admin' },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).to.eql(200);
|
||||||
|
const body = await response.json() as { intakeEmails: { emailAddress: string; isEnabled: boolean }[] };
|
||||||
|
expect(body.intakeEmails).to.have.length(1);
|
||||||
|
expect(body.intakeEmails[0]?.emailAddress).to.eql('intake@example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('when the organization does not exist, a 404 error is returned', async () => {
|
||||||
|
const { db } = await createInMemoryDatabase({
|
||||||
|
users: [
|
||||||
|
{ id: 'usr_admin', email: 'admin@example.com', name: 'Admin User' },
|
||||||
|
],
|
||||||
|
userRoles: [
|
||||||
|
{ userId: 'usr_admin', role: 'admin' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { app } = createServer(createTestServerDependencies({ db, config: overrideConfig({ env: 'test' }) }));
|
||||||
|
|
||||||
|
const response = await app.request(
|
||||||
|
'/api/admin/organizations/org_999999999999999999999999/intake-emails',
|
||||||
|
{ method: 'GET' },
|
||||||
|
{ loggedInUserId: 'usr_admin' },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).to.eql(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('when the user does not have the VIEW_USERS permission, a 401 error is returned', async () => {
|
||||||
|
const { db } = await createInMemoryDatabase({
|
||||||
|
users: [
|
||||||
|
{ id: 'usr_regular', email: 'user@example.com' },
|
||||||
|
],
|
||||||
|
organizations: [
|
||||||
|
{ id: 'org_123456789012345678901234', name: 'Test Organization' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { app } = createServer(createTestServerDependencies({ db, config: overrideConfig({ env: 'test' }) }));
|
||||||
|
|
||||||
|
const response = await app.request(
|
||||||
|
'/api/admin/organizations/org_123456789012345678901234/intake-emails',
|
||||||
|
{ method: 'GET' },
|
||||||
|
{ loggedInUserId: 'usr_regular' },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).to.eql(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('when the user is not authenticated, a 401 error is returned', async () => {
|
||||||
|
const { db } = await createInMemoryDatabase({
|
||||||
|
organizations: [
|
||||||
|
{ id: 'org_123456789012345678901234', name: 'Test Organization' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { app } = createServer(createTestServerDependencies({ db, config: overrideConfig({ env: 'test' }) }));
|
||||||
|
|
||||||
|
const response = await app.request(
|
||||||
|
'/api/admin/organizations/org_123456789012345678901234/intake-emails',
|
||||||
|
{ method: 'GET' },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).to.eql(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('get /api/admin/organizations/:organizationId/webhooks', () => {
|
||||||
|
test('when the user has the VIEW_USERS permission, the request succeeds and returns webhooks', async () => {
|
||||||
|
const { db } = await createInMemoryDatabase({
|
||||||
|
users: [
|
||||||
|
{ id: 'usr_admin', email: 'admin@example.com', name: 'Admin User' },
|
||||||
|
],
|
||||||
|
userRoles: [
|
||||||
|
{ userId: 'usr_admin', role: 'admin' },
|
||||||
|
],
|
||||||
|
organizations: [
|
||||||
|
{ id: 'org_123456789012345678901234', name: 'Test Organization' },
|
||||||
|
],
|
||||||
|
webhooks: [
|
||||||
|
{ organizationId: 'org_123456789012345678901234', name: 'Test Webhook', url: 'https://example.com/webhook', enabled: true },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { app } = createServer(createTestServerDependencies({ db, config: overrideConfig({ env: 'test' }) }));
|
||||||
|
|
||||||
|
const response = await app.request(
|
||||||
|
'/api/admin/organizations/org_123456789012345678901234/webhooks',
|
||||||
|
{ method: 'GET' },
|
||||||
|
{ loggedInUserId: 'usr_admin' },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).to.eql(200);
|
||||||
|
const body = await response.json() as { webhooks: { name: string; url: string; enabled: boolean }[] };
|
||||||
|
expect(body.webhooks).to.have.length(1);
|
||||||
|
expect(body.webhooks[0]?.name).to.eql('Test Webhook');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('when the organization does not exist, a 404 error is returned', async () => {
|
||||||
|
const { db } = await createInMemoryDatabase({
|
||||||
|
users: [
|
||||||
|
{ id: 'usr_admin', email: 'admin@example.com', name: 'Admin User' },
|
||||||
|
],
|
||||||
|
userRoles: [
|
||||||
|
{ userId: 'usr_admin', role: 'admin' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { app } = createServer(createTestServerDependencies({ db, config: overrideConfig({ env: 'test' }) }));
|
||||||
|
|
||||||
|
const response = await app.request(
|
||||||
|
'/api/admin/organizations/org_999999999999999999999999/webhooks',
|
||||||
|
{ method: 'GET' },
|
||||||
|
{ loggedInUserId: 'usr_admin' },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).to.eql(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('when the user does not have the VIEW_USERS permission, a 401 error is returned', async () => {
|
||||||
|
const { db } = await createInMemoryDatabase({
|
||||||
|
users: [
|
||||||
|
{ id: 'usr_regular', email: 'user@example.com' },
|
||||||
|
],
|
||||||
|
organizations: [
|
||||||
|
{ id: 'org_123456789012345678901234', name: 'Test Organization' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { app } = createServer(createTestServerDependencies({ db, config: overrideConfig({ env: 'test' }) }));
|
||||||
|
|
||||||
|
const response = await app.request(
|
||||||
|
'/api/admin/organizations/org_123456789012345678901234/webhooks',
|
||||||
|
{ method: 'GET' },
|
||||||
|
{ loggedInUserId: 'usr_regular' },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).to.eql(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('when the user is not authenticated, a 401 error is returned', async () => {
|
||||||
|
const { db } = await createInMemoryDatabase({
|
||||||
|
organizations: [
|
||||||
|
{ id: 'org_123456789012345678901234', name: 'Test Organization' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { app } = createServer(createTestServerDependencies({ db, config: overrideConfig({ env: 'test' }) }));
|
||||||
|
|
||||||
|
const response = await app.request(
|
||||||
|
'/api/admin/organizations/org_123456789012345678901234/webhooks',
|
||||||
|
{ method: 'GET' },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).to.eql(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('get /api/admin/organizations/:organizationId/stats', () => {
|
||||||
|
test('when the user has the VIEW_USERS permission, the request succeeds and returns stats', async () => {
|
||||||
|
const { db } = await createInMemoryDatabase({
|
||||||
|
users: [
|
||||||
|
{ id: 'usr_admin', email: 'admin@example.com', name: 'Admin User' },
|
||||||
|
],
|
||||||
|
userRoles: [
|
||||||
|
{ userId: 'usr_admin', role: 'admin' },
|
||||||
|
],
|
||||||
|
organizations: [
|
||||||
|
{ id: 'org_123456789012345678901234', name: 'Test Organization' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { app } = createServer(createTestServerDependencies({ db, config: overrideConfig({ env: 'test' }) }));
|
||||||
|
|
||||||
|
const response = await app.request(
|
||||||
|
'/api/admin/organizations/org_123456789012345678901234/stats',
|
||||||
|
{ method: 'GET' },
|
||||||
|
{ loggedInUserId: 'usr_admin' },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).to.eql(200);
|
||||||
|
const body = await response.json() as { stats: { documentsCount: number; documentsSize: number } };
|
||||||
|
expect(body.stats).to.have.property('documentsCount');
|
||||||
|
expect(body.stats).to.have.property('documentsSize');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('when the organization does not exist, a 404 error is returned', async () => {
|
||||||
|
const { db } = await createInMemoryDatabase({
|
||||||
|
users: [
|
||||||
|
{ id: 'usr_admin', email: 'admin@example.com', name: 'Admin User' },
|
||||||
|
],
|
||||||
|
userRoles: [
|
||||||
|
{ userId: 'usr_admin', role: 'admin' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { app } = createServer(createTestServerDependencies({ db, config: overrideConfig({ env: 'test' }) }));
|
||||||
|
|
||||||
|
const response = await app.request(
|
||||||
|
'/api/admin/organizations/org_999999999999999999999999/stats',
|
||||||
|
{ method: 'GET' },
|
||||||
|
{ loggedInUserId: 'usr_admin' },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).to.eql(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('when the user does not have the VIEW_USERS permission, a 401 error is returned', async () => {
|
||||||
|
const { db } = await createInMemoryDatabase({
|
||||||
|
users: [
|
||||||
|
{ id: 'usr_regular', email: 'user@example.com' },
|
||||||
|
],
|
||||||
|
organizations: [
|
||||||
|
{ id: 'org_123456789012345678901234', name: 'Test Organization' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { app } = createServer(createTestServerDependencies({ db, config: overrideConfig({ env: 'test' }) }));
|
||||||
|
|
||||||
|
const response = await app.request(
|
||||||
|
'/api/admin/organizations/org_123456789012345678901234/stats',
|
||||||
|
{ method: 'GET' },
|
||||||
|
{ loggedInUserId: 'usr_regular' },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).to.eql(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('when the user is not authenticated, a 401 error is returned', async () => {
|
||||||
|
const { db } = await createInMemoryDatabase({
|
||||||
|
organizations: [
|
||||||
|
{ id: 'org_123456789012345678901234', name: 'Test Organization' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { app } = createServer(createTestServerDependencies({ db, config: overrideConfig({ env: 'test' }) }));
|
||||||
|
|
||||||
|
const response = await app.request(
|
||||||
|
'/api/admin/organizations/org_123456789012345678901234/stats',
|
||||||
|
{ method: 'GET' },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).to.eql(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
import type { RouteDefinitionContext } from '../../app/server.types';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { createRoleMiddleware, requireAuthentication } from '../../app/auth/auth.middleware';
|
||||||
|
import { createIntakeEmailsRepository } from '../../intake-emails/intake-emails.repository';
|
||||||
|
import { organizationIdSchema } from '../../organizations/organization.schemas';
|
||||||
|
import { createOrganizationNotFoundError } from '../../organizations/organizations.errors';
|
||||||
|
import { createOrganizationsRepository } from '../../organizations/organizations.repository';
|
||||||
|
import { PERMISSIONS } from '../../roles/roles.constants';
|
||||||
|
import { validateParams, validateQuery } from '../../shared/validation/validation';
|
||||||
|
import { createWebhookRepository } from '../../webhooks/webhook.repository';
|
||||||
|
|
||||||
|
export function registerOrganizationManagementRoutes(context: RouteDefinitionContext) {
|
||||||
|
registerListOrganizationsRoute(context);
|
||||||
|
registerGetOrganizationBasicInfoRoute(context);
|
||||||
|
registerGetOrganizationMembersRoute(context);
|
||||||
|
registerGetOrganizationIntakeEmailsRoute(context);
|
||||||
|
registerGetOrganizationWebhooksRoute(context);
|
||||||
|
registerGetOrganizationStatsRoute(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerListOrganizationsRoute({ app, db }: RouteDefinitionContext) {
|
||||||
|
const { requirePermissions } = createRoleMiddleware({ db });
|
||||||
|
|
||||||
|
app.get(
|
||||||
|
'/api/admin/organizations',
|
||||||
|
requireAuthentication(),
|
||||||
|
requirePermissions({
|
||||||
|
requiredPermissions: [PERMISSIONS.VIEW_USERS],
|
||||||
|
}),
|
||||||
|
validateQuery(
|
||||||
|
z.object({
|
||||||
|
search: z.string().optional(),
|
||||||
|
pageIndex: z.coerce.number().min(0).int().optional().default(0),
|
||||||
|
pageSize: z.coerce.number().min(1).max(100).int().optional().default(25),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
async (context) => {
|
||||||
|
const organizationsRepository = createOrganizationsRepository({ db });
|
||||||
|
|
||||||
|
const { search, pageIndex, pageSize } = context.req.valid('query');
|
||||||
|
|
||||||
|
const { organizations, totalCount } = await organizationsRepository.listOrganizations({
|
||||||
|
search,
|
||||||
|
pageIndex,
|
||||||
|
pageSize,
|
||||||
|
});
|
||||||
|
|
||||||
|
return context.json({
|
||||||
|
organizations,
|
||||||
|
totalCount,
|
||||||
|
pageIndex,
|
||||||
|
pageSize,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerGetOrganizationBasicInfoRoute({ app, db }: RouteDefinitionContext) {
|
||||||
|
const { requirePermissions } = createRoleMiddleware({ db });
|
||||||
|
|
||||||
|
app.get(
|
||||||
|
'/api/admin/organizations/:organizationId',
|
||||||
|
requireAuthentication(),
|
||||||
|
requirePermissions({
|
||||||
|
requiredPermissions: [PERMISSIONS.VIEW_USERS],
|
||||||
|
}),
|
||||||
|
validateParams(z.object({
|
||||||
|
organizationId: organizationIdSchema,
|
||||||
|
})),
|
||||||
|
async (context) => {
|
||||||
|
const organizationsRepository = createOrganizationsRepository({ db });
|
||||||
|
|
||||||
|
const { organizationId } = context.req.valid('param');
|
||||||
|
|
||||||
|
const { organization } = await organizationsRepository.getOrganizationById({ organizationId });
|
||||||
|
|
||||||
|
if (!organization) {
|
||||||
|
throw createOrganizationNotFoundError();
|
||||||
|
}
|
||||||
|
|
||||||
|
return context.json({ organization });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerGetOrganizationMembersRoute({ app, db }: RouteDefinitionContext) {
|
||||||
|
const { requirePermissions } = createRoleMiddleware({ db });
|
||||||
|
|
||||||
|
app.get(
|
||||||
|
'/api/admin/organizations/:organizationId/members',
|
||||||
|
requireAuthentication(),
|
||||||
|
requirePermissions({
|
||||||
|
requiredPermissions: [PERMISSIONS.VIEW_USERS],
|
||||||
|
}),
|
||||||
|
validateParams(z.object({
|
||||||
|
organizationId: organizationIdSchema,
|
||||||
|
})),
|
||||||
|
async (context) => {
|
||||||
|
const organizationsRepository = createOrganizationsRepository({ db });
|
||||||
|
|
||||||
|
const { organizationId } = context.req.valid('param');
|
||||||
|
|
||||||
|
const { organization } = await organizationsRepository.getOrganizationById({ organizationId });
|
||||||
|
|
||||||
|
if (!organization) {
|
||||||
|
throw createOrganizationNotFoundError();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { members } = await organizationsRepository.getOrganizationMembers({ organizationId });
|
||||||
|
|
||||||
|
return context.json({ members });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerGetOrganizationIntakeEmailsRoute({ app, db }: RouteDefinitionContext) {
|
||||||
|
const { requirePermissions } = createRoleMiddleware({ db });
|
||||||
|
|
||||||
|
app.get(
|
||||||
|
'/api/admin/organizations/:organizationId/intake-emails',
|
||||||
|
requireAuthentication(),
|
||||||
|
requirePermissions({
|
||||||
|
requiredPermissions: [PERMISSIONS.VIEW_USERS],
|
||||||
|
}),
|
||||||
|
validateParams(z.object({
|
||||||
|
organizationId: organizationIdSchema,
|
||||||
|
})),
|
||||||
|
async (context) => {
|
||||||
|
const organizationsRepository = createOrganizationsRepository({ db });
|
||||||
|
const intakeEmailsRepository = createIntakeEmailsRepository({ db });
|
||||||
|
|
||||||
|
const { organizationId } = context.req.valid('param');
|
||||||
|
|
||||||
|
const { organization } = await organizationsRepository.getOrganizationById({ organizationId });
|
||||||
|
|
||||||
|
if (!organization) {
|
||||||
|
throw createOrganizationNotFoundError();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { intakeEmails } = await intakeEmailsRepository.getOrganizationIntakeEmails({ organizationId });
|
||||||
|
|
||||||
|
return context.json({ intakeEmails });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerGetOrganizationWebhooksRoute({ app, db }: RouteDefinitionContext) {
|
||||||
|
const { requirePermissions } = createRoleMiddleware({ db });
|
||||||
|
|
||||||
|
app.get(
|
||||||
|
'/api/admin/organizations/:organizationId/webhooks',
|
||||||
|
requireAuthentication(),
|
||||||
|
requirePermissions({
|
||||||
|
requiredPermissions: [PERMISSIONS.VIEW_USERS],
|
||||||
|
}),
|
||||||
|
validateParams(z.object({
|
||||||
|
organizationId: organizationIdSchema,
|
||||||
|
})),
|
||||||
|
async (context) => {
|
||||||
|
const organizationsRepository = createOrganizationsRepository({ db });
|
||||||
|
const webhookRepository = createWebhookRepository({ db });
|
||||||
|
|
||||||
|
const { organizationId } = context.req.valid('param');
|
||||||
|
|
||||||
|
const { organization } = await organizationsRepository.getOrganizationById({ organizationId });
|
||||||
|
|
||||||
|
if (!organization) {
|
||||||
|
throw createOrganizationNotFoundError();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { webhooks } = await webhookRepository.getOrganizationWebhooks({ organizationId });
|
||||||
|
|
||||||
|
return context.json({ webhooks });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerGetOrganizationStatsRoute({ app, db }: RouteDefinitionContext) {
|
||||||
|
const { requirePermissions } = createRoleMiddleware({ db });
|
||||||
|
|
||||||
|
app.get(
|
||||||
|
'/api/admin/organizations/:organizationId/stats',
|
||||||
|
requireAuthentication(),
|
||||||
|
requirePermissions({
|
||||||
|
requiredPermissions: [PERMISSIONS.VIEW_USERS],
|
||||||
|
}),
|
||||||
|
validateParams(z.object({
|
||||||
|
organizationId: organizationIdSchema,
|
||||||
|
})),
|
||||||
|
async (context) => {
|
||||||
|
const { createDocumentsRepository } = await import('../../documents/documents.repository');
|
||||||
|
const organizationsRepository = createOrganizationsRepository({ db });
|
||||||
|
|
||||||
|
const { organizationId } = context.req.valid('param');
|
||||||
|
|
||||||
|
const { organization } = await organizationsRepository.getOrganizationById({ organizationId });
|
||||||
|
|
||||||
|
if (!organization) {
|
||||||
|
throw createOrganizationNotFoundError();
|
||||||
|
}
|
||||||
|
|
||||||
|
const documentsRepository = createDocumentsRepository({ db });
|
||||||
|
const stats = await documentsRepository.getOrganizationStats({ organizationId });
|
||||||
|
|
||||||
|
return context.json({ stats });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { createErrorFactory } from '../shared/errors/errors';
|
||||||
|
|
||||||
|
// Error when the authentication is not using an API key but the route is api-key only
|
||||||
|
export const createNotApiKeyAuthError = createErrorFactory({
|
||||||
|
code: 'api_keys.authentication_not_api_key',
|
||||||
|
message: 'Authentication must be done using an API key to access this resource',
|
||||||
|
statusCode: 401,
|
||||||
|
});
|
||||||
@@ -1,17 +1,21 @@
|
|||||||
import type { RouteDefinitionContext } from '../app/server.types';
|
import type { RouteDefinitionContext } from '../app/server.types';
|
||||||
import type { ApiKeyPermissions } from './api-keys.types';
|
import type { ApiKeyPermissions } from './api-keys.types';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
import { createUnauthorizedError } from '../app/auth/auth.errors';
|
||||||
import { requireAuthentication } from '../app/auth/auth.middleware';
|
import { requireAuthentication } from '../app/auth/auth.middleware';
|
||||||
import { getUser } from '../app/auth/auth.models';
|
import { getUser } from '../app/auth/auth.models';
|
||||||
import { createError } from '../shared/errors/errors';
|
import { createError } from '../shared/errors/errors';
|
||||||
|
import { isNil } from '../shared/utils';
|
||||||
import { validateJsonBody, validateParams } from '../shared/validation/validation';
|
import { validateJsonBody, validateParams } from '../shared/validation/validation';
|
||||||
import { API_KEY_PERMISSIONS_VALUES } from './api-keys.constants';
|
import { API_KEY_PERMISSIONS_VALUES } from './api-keys.constants';
|
||||||
|
import { createNotApiKeyAuthError } from './api-keys.errors';
|
||||||
import { createApiKeysRepository } from './api-keys.repository';
|
import { createApiKeysRepository } from './api-keys.repository';
|
||||||
import { apiKeyIdSchema } from './api-keys.schemas';
|
import { apiKeyIdSchema } from './api-keys.schemas';
|
||||||
import { createApiKey } from './api-keys.usecases';
|
import { createApiKey } from './api-keys.usecases';
|
||||||
|
|
||||||
export function registerApiKeysRoutes(context: RouteDefinitionContext) {
|
export function registerApiKeysRoutes(context: RouteDefinitionContext) {
|
||||||
setupCreateApiKeyRoute(context);
|
setupCreateApiKeyRoute(context);
|
||||||
|
setupGetCurrentApiKeyRoute(context); // Should be before the get api keys route otherwise it conflicts ("current" as apiKeyId)
|
||||||
setupGetApiKeysRoute(context);
|
setupGetApiKeysRoute(context);
|
||||||
setupDeleteApiKeyRoute(context);
|
setupDeleteApiKeyRoute(context);
|
||||||
}
|
}
|
||||||
@@ -82,6 +86,38 @@ function setupGetApiKeysRoute({ app, db }: RouteDefinitionContext) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mainly use for authentication verification in client SDKs
|
||||||
|
function setupGetCurrentApiKeyRoute({ app }: RouteDefinitionContext) {
|
||||||
|
app.get(
|
||||||
|
'/api/api-keys/current',
|
||||||
|
async (context) => {
|
||||||
|
const authType = context.get('authType');
|
||||||
|
const apiKey = context.get('apiKey');
|
||||||
|
|
||||||
|
if (isNil(authType)) {
|
||||||
|
throw createUnauthorizedError();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authType !== 'api-key') {
|
||||||
|
throw createNotApiKeyAuthError();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isNil(apiKey)) {
|
||||||
|
// Should not happen as authType is 'api-key', but for type safety
|
||||||
|
throw createUnauthorizedError();
|
||||||
|
}
|
||||||
|
|
||||||
|
return context.json({
|
||||||
|
apiKey: {
|
||||||
|
id: apiKey.id,
|
||||||
|
name: apiKey.name,
|
||||||
|
permissions: apiKey.permissions,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function setupDeleteApiKeyRoute({ app, db }: RouteDefinitionContext) {
|
function setupDeleteApiKeyRoute({ app, db }: RouteDefinitionContext) {
|
||||||
app.delete(
|
app.delete(
|
||||||
'/api/api-keys/:apiKeyId',
|
'/api/api-keys/:apiKeyId',
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import { describe, expect, test } from 'vitest';
|
||||||
|
import { createInMemoryDatabase } from '../../app/database/database.test-utils';
|
||||||
|
import { createServer } from '../../app/server';
|
||||||
|
import { createTestServerDependencies } from '../../app/server.test-utils';
|
||||||
|
import { overrideConfig } from '../../config/config.test-utils';
|
||||||
|
import { ORGANIZATION_ROLES } from '../../organizations/organizations.constants';
|
||||||
|
import { API_KEY_ID_PREFIX, API_KEY_TOKEN_LENGTH } from '../api-keys.constants';
|
||||||
|
|
||||||
|
describe('api-key e2e', () => {
|
||||||
|
describe('get /api/api-keys/current', () => {
|
||||||
|
test('when using an api key, one can request the /api/api-keys/current route to check that the api key is valid', async () => {
|
||||||
|
const { db } = await createInMemoryDatabase({
|
||||||
|
users: [{ id: 'usr_111111111111111111111111', email: 'user@example.com' }],
|
||||||
|
organizations: [{ id: 'org_222222222222222222222222', name: 'Org 1' }],
|
||||||
|
organizationMembers: [{ organizationId: 'org_222222222222222222222222', userId: 'usr_111111111111111111111111', role: ORGANIZATION_ROLES.OWNER }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { app } = createServer(createTestServerDependencies({
|
||||||
|
db,
|
||||||
|
config: overrideConfig({
|
||||||
|
env: 'test',
|
||||||
|
documentsStorage: {
|
||||||
|
driver: 'in-memory',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const createApiKeyResponse = await app.request(
|
||||||
|
'/api/api-keys',
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: 'Test API Key',
|
||||||
|
permissions: ['documents:create'],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{ loggedInUserId: 'usr_111111111111111111111111' },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(createApiKeyResponse.status).toBe(200);
|
||||||
|
const { token, apiKey } = await createApiKeyResponse.json() as { token: string; apiKey: { id: string } };
|
||||||
|
|
||||||
|
const getCurrentApiKeyResponse = await app.request(
|
||||||
|
'/api/api-keys/current',
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await getCurrentApiKeyResponse.json();
|
||||||
|
|
||||||
|
expect(response).to.deep.equal({
|
||||||
|
apiKey: {
|
||||||
|
id: apiKey.id,
|
||||||
|
name: 'Test API Key',
|
||||||
|
permissions: ['documents:create'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getCurrentApiKeyResponse.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('when not using an api key, requesting the /api/api-keys/current route returns an error', async () => {
|
||||||
|
const { db } = await createInMemoryDatabase({
|
||||||
|
users: [{ id: 'usr_111111111111111111111111', email: 'user@example.com' }],
|
||||||
|
organizations: [{ id: 'org_222222222222222222222222', name: 'Org 1' }],
|
||||||
|
organizationMembers: [{ organizationId: 'org_222222222222222222222222', userId: 'usr_111111111111111111111111', role: ORGANIZATION_ROLES.OWNER }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { app } = createServer(createTestServerDependencies({
|
||||||
|
db,
|
||||||
|
config: overrideConfig({
|
||||||
|
env: 'test',
|
||||||
|
documentsStorage: {
|
||||||
|
driver: 'in-memory',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const getCurrentApiKeyResponse = await app.request(
|
||||||
|
'/api/api-keys/current',
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
},
|
||||||
|
{ loggedInUserId: 'usr_111111111111111111111111' },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getCurrentApiKeyResponse.status).toBe(401);
|
||||||
|
const response = await getCurrentApiKeyResponse.json();
|
||||||
|
|
||||||
|
expect(response).to.deep.equal({
|
||||||
|
error: {
|
||||||
|
code: 'api_keys.authentication_not_api_key',
|
||||||
|
message: 'Authentication must be done using an API key to access this resource',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('when not authenticated at all, requesting the /api/api-keys/current route returns an error', async () => {
|
||||||
|
const { db } = await createInMemoryDatabase();
|
||||||
|
|
||||||
|
const { app } = createServer(createTestServerDependencies({
|
||||||
|
db,
|
||||||
|
config: overrideConfig({
|
||||||
|
env: 'test',
|
||||||
|
documentsStorage: {
|
||||||
|
driver: 'in-memory',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const getCurrentApiKeyResponse = await app.request(
|
||||||
|
'/api/api-keys/current',
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getCurrentApiKeyResponse.status).toBe(401);
|
||||||
|
const response = await getCurrentApiKeyResponse.json();
|
||||||
|
|
||||||
|
expect(response).to.deep.equal({
|
||||||
|
error: {
|
||||||
|
code: 'auth.unauthorized',
|
||||||
|
message: 'Unauthorized',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('if the api key used is invalid, requesting the /api/api-keys/current route returns an error', async () => {
|
||||||
|
const { db } = await createInMemoryDatabase();
|
||||||
|
const invalidButLegitApiKeyToken = `${API_KEY_ID_PREFIX}_${'x'.repeat(API_KEY_TOKEN_LENGTH)}`;
|
||||||
|
|
||||||
|
const { app } = createServer(createTestServerDependencies({
|
||||||
|
db,
|
||||||
|
config: overrideConfig({
|
||||||
|
env: 'test',
|
||||||
|
documentsStorage: {
|
||||||
|
driver: 'in-memory',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const getCurrentApiKeyResponse = await app.request(
|
||||||
|
'/api/api-keys/current',
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${invalidButLegitApiKeyToken}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getCurrentApiKeyResponse.status).toBe(401);
|
||||||
|
const response = await getCurrentApiKeyResponse.json();
|
||||||
|
|
||||||
|
expect(response).to.deep.equal({
|
||||||
|
error: {
|
||||||
|
code: 'auth.unauthorized',
|
||||||
|
message: 'Unauthorized',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -56,6 +56,12 @@ export const authConfig = {
|
|||||||
default: false,
|
default: false,
|
||||||
env: 'AUTH_SHOW_LEGAL_LINKS',
|
env: 'AUTH_SHOW_LEGAL_LINKS',
|
||||||
},
|
},
|
||||||
|
firstUserAsAdmin: {
|
||||||
|
doc: 'Automatically assign the admin role to the first user who registers. This is useful for initial setup of self-hosted instances where you need an admin account to manage the platform.',
|
||||||
|
schema: booleanishSchema,
|
||||||
|
default: true,
|
||||||
|
env: 'AUTH_FIRST_USER_AS_ADMIN',
|
||||||
|
},
|
||||||
ipAddressHeaders: {
|
ipAddressHeaders: {
|
||||||
doc: `The header, or comma separated list of headers, to use to get the real IP address of the user, use for rate limiting. Make sur to use a non-spoofable header, one set by your proxy.
|
doc: `The header, or comma separated list of headers, to use to get the real IP address of the user, use for rate limiting. Make sur to use a non-spoofable header, one set by your proxy.
|
||||||
- If behind a standard proxy, you might want to set this to "x-forwarded-for".
|
- If behind a standard proxy, you might want to set this to "x-forwarded-for".
|
||||||
|
|||||||
@@ -5,13 +5,13 @@ import type { AuthEmailsServices } from './auth.emails.services';
|
|||||||
import { expo } from '@better-auth/expo';
|
import { expo } from '@better-auth/expo';
|
||||||
import { betterAuth } from 'better-auth';
|
import { betterAuth } from 'better-auth';
|
||||||
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
|
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
|
||||||
import { genericOAuth } from 'better-auth/plugins';
|
import { genericOAuth, twoFactor } from 'better-auth/plugins';
|
||||||
import { getServerBaseUrl } from '../../config/config.models';
|
import { getServerBaseUrl } from '../../config/config.models';
|
||||||
import { createLogger } from '../../shared/logger/logger';
|
import { createLogger } from '../../shared/logger/logger';
|
||||||
import { usersTable } from '../../users/users.table';
|
import { usersTable } from '../../users/users.table';
|
||||||
import { createForbiddenEmailDomainError } from './auth.errors';
|
import { createForbiddenEmailDomainError } from './auth.errors';
|
||||||
import { getTrustedOrigins, isEmailDomainAllowed } from './auth.models';
|
import { getTrustedOrigins, isEmailDomainAllowed } from './auth.models';
|
||||||
import { accountsTable, sessionsTable, verificationsTable } from './auth.tables';
|
import { accountsTable, sessionsTable, twoFactorTable, verificationsTable } from './auth.tables';
|
||||||
|
|
||||||
export type Auth = ReturnType<typeof getAuth>['auth'];
|
export type Auth = ReturnType<typeof getAuth>['auth'];
|
||||||
|
|
||||||
@@ -74,6 +74,7 @@ export function getAuth({
|
|||||||
account: accountsTable,
|
account: accountsTable,
|
||||||
session: sessionsTable,
|
session: sessionsTable,
|
||||||
verification: verificationsTable,
|
verification: verificationsTable,
|
||||||
|
twoFactor: twoFactorTable,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -127,26 +128,7 @@ export function getAuth({
|
|||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
expo(),
|
expo(),
|
||||||
// Would love to have this but it messes with the error handling in better-auth client
|
twoFactor(),
|
||||||
// {
|
|
||||||
// id: 'better-auth-error-adapter',
|
|
||||||
// onResponse: async (res) => {
|
|
||||||
// // Transform better auth error to our own error
|
|
||||||
// if (res.status < 400) {
|
|
||||||
// return { response: res };
|
|
||||||
// }
|
|
||||||
|
|
||||||
// const body = await res.clone().json();
|
|
||||||
// const code = get(body, 'code', 'unknown');
|
|
||||||
|
|
||||||
// throw createError({
|
|
||||||
// message: get(body, 'message', 'Unknown error'),
|
|
||||||
// code: `auth.${code.toLowerCase()}`,
|
|
||||||
// statusCode: res.status as ContentfulStatusCode,
|
|
||||||
// isInternal: res.status >= 500,
|
|
||||||
// });
|
|
||||||
// },
|
|
||||||
// },
|
|
||||||
|
|
||||||
...(config.auth.providers.customs.length > 0
|
...(config.auth.providers.customs.length > 0
|
||||||
? [genericOAuth({ config: config.auth.providers.customs })]
|
? [genericOAuth({ config: config.auth.providers.customs })]
|
||||||
|
|||||||
@@ -56,3 +56,15 @@ export const verificationsTable = sqliteTable(
|
|||||||
index('auth_verifications_identifier_index').on(table.identifier),
|
index('auth_verifications_identifier_index').on(table.identifier),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const twoFactorTable = sqliteTable(
|
||||||
|
'auth_two_factor',
|
||||||
|
{
|
||||||
|
...createPrimaryKeyField({ prefix: 'auth_2fa' }),
|
||||||
|
...createTimestampColumns(),
|
||||||
|
|
||||||
|
userId: text('user_id').references(() => usersTable.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
|
||||||
|
secret: text('secret'),
|
||||||
|
backupCodes: text('backup_codes'),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { Config } from '../../config/config.types';
|
||||||
import type { DocumentSearchServices } from '../../documents/document-search/document-search.types';
|
import type { DocumentSearchServices } from '../../documents/document-search/document-search.types';
|
||||||
import type { TrackingServices } from '../../tracking/tracking.services';
|
import type { TrackingServices } from '../../tracking/tracking.services';
|
||||||
import type { Database } from '../database/database.types';
|
import type { Database } from '../database/database.types';
|
||||||
@@ -11,9 +12,11 @@ import { registerTrackDocumentCreatedHandler } from '../../documents/events/trac
|
|||||||
import { registerTriggerWebhooksOnDocumentCreatedHandler } from '../../documents/events/webhook.document-created';
|
import { registerTriggerWebhooksOnDocumentCreatedHandler } from '../../documents/events/webhook.document-created';
|
||||||
import { registerTriggerWebhooksOnDocumentTrashedHandler } from '../../documents/events/webhook.document-trashed';
|
import { registerTriggerWebhooksOnDocumentTrashedHandler } from '../../documents/events/webhook.document-trashed';
|
||||||
import { registerTriggerWebhooksOnDocumentUpdatedHandler } from '../../documents/events/webhook.document-updated';
|
import { registerTriggerWebhooksOnDocumentUpdatedHandler } from '../../documents/events/webhook.document-updated';
|
||||||
|
import { registerFirstUserAdminEventHandler } from '../../roles/event-handlers/first-user-admin.user-created';
|
||||||
import { registerTrackingUserCreatedEventHandler } from '../../users/event-handlers/tracking.user-created';
|
import { registerTrackingUserCreatedEventHandler } from '../../users/event-handlers/tracking.user-created';
|
||||||
|
|
||||||
export function registerEventHandlers(deps: { trackingServices: TrackingServices; eventServices: EventServices; db: Database; documentSearchServices: DocumentSearchServices }) {
|
export function registerEventHandlers(deps: { trackingServices: TrackingServices; eventServices: EventServices; db: Database; documentSearchServices: DocumentSearchServices; config: Config }) {
|
||||||
|
registerFirstUserAdminEventHandler(deps);
|
||||||
registerTrackingUserCreatedEventHandler(deps);
|
registerTrackingUserCreatedEventHandler(deps);
|
||||||
registerTriggerWebhooksOnDocumentCreatedHandler(deps);
|
registerTriggerWebhooksOnDocumentCreatedHandler(deps);
|
||||||
registerInsertActivityLogOnDocumentCreatedHandler(deps);
|
registerInsertActivityLogOnDocumentCreatedHandler(deps);
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { createTimeoutMiddleware } from './timeout.middleware';
|
|||||||
describe('middlewares', () => {
|
describe('middlewares', () => {
|
||||||
describe('timeoutMiddleware', () => {
|
describe('timeoutMiddleware', () => {
|
||||||
test('when a request last longer than the config timeout, a 504 error is raised', async () => {
|
test('when a request last longer than the config timeout, a 504 error is raised', async () => {
|
||||||
const config = overrideConfig({ server: { routeTimeoutMs: 50 } });
|
const config = overrideConfig({ server: { defaultRouteTimeoutMs: 50 } });
|
||||||
|
|
||||||
const app = new Hono<ServerInstanceGenerics>();
|
const app = new Hono<ServerInstanceGenerics>();
|
||||||
registerErrorMiddleware({ app });
|
registerErrorMiddleware({ app });
|
||||||
@@ -45,5 +45,107 @@ describe('middlewares', () => {
|
|||||||
expect(response2.status).to.eql(200);
|
expect(response2.status).to.eql(200);
|
||||||
expect(await response2.json()).to.eql({ status: 'ok' });
|
expect(await response2.json()).to.eql({ status: 'ok' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('route-specific timeout overrides default timeout for matching routes', async () => {
|
||||||
|
const config = overrideConfig({
|
||||||
|
server: {
|
||||||
|
defaultRouteTimeoutMs: 50,
|
||||||
|
routeTimeouts: [
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
route: '/api/upload/:id',
|
||||||
|
timeoutMs: 200,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const app = new Hono<ServerInstanceGenerics>();
|
||||||
|
registerErrorMiddleware({ app });
|
||||||
|
|
||||||
|
// POST to matching route with longer timeout - should not timeout
|
||||||
|
app.post(
|
||||||
|
'/api/upload/:id',
|
||||||
|
createTimeoutMiddleware({ config }),
|
||||||
|
async (context) => {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
return context.json({ status: 'ok' });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// GET to same route - should timeout with default
|
||||||
|
app.get(
|
||||||
|
'/api/upload/:id',
|
||||||
|
createTimeoutMiddleware({ config }),
|
||||||
|
async (context) => {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
return context.json({ status: 'ok' });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Different route - should timeout with default
|
||||||
|
app.post(
|
||||||
|
'/api/other',
|
||||||
|
createTimeoutMiddleware({ config }),
|
||||||
|
async (context) => {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
return context.json({ status: 'ok' });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// POST to matching pattern should succeed
|
||||||
|
const response1 = await app.request('/api/upload/123', { method: 'POST' });
|
||||||
|
expect(response1.status).to.eql(200);
|
||||||
|
|
||||||
|
// GET to same path should timeout (method mismatch)
|
||||||
|
const response2 = await app.request('/api/upload/123', { method: 'GET' });
|
||||||
|
expect(response2.status).to.eql(504);
|
||||||
|
|
||||||
|
// POST to different path should timeout (path mismatch)
|
||||||
|
const response3 = await app.request('/api/other', { method: 'POST' });
|
||||||
|
expect(response3.status).to.eql(504);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('when registered globally with .use(), route-specific timeouts should work', async () => {
|
||||||
|
const config = overrideConfig({
|
||||||
|
server: {
|
||||||
|
defaultRouteTimeoutMs: 50,
|
||||||
|
routeTimeouts: [
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
route: '/api/organizations/:orgId/documents',
|
||||||
|
timeoutMs: 200,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const app = new Hono<ServerInstanceGenerics>();
|
||||||
|
registerErrorMiddleware({ app });
|
||||||
|
|
||||||
|
// Register middleware globally (like in server.ts)
|
||||||
|
app.use(createTimeoutMiddleware({ config }));
|
||||||
|
|
||||||
|
// Route that should have extended timeout
|
||||||
|
app.post('/api/organizations/:orgId/documents', async (context) => {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
return context.json({ status: 'upload ok' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Route that should use default timeout
|
||||||
|
app.get('/api/other', async (context) => {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
return context.json({ status: 'ok' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST to upload route should succeed (extended timeout)
|
||||||
|
const response1 = await app.request('/api/organizations/org-123/documents', { method: 'POST' });
|
||||||
|
expect(response1.status).to.eql(200);
|
||||||
|
expect(await response1.json()).to.eql({ status: 'upload ok' });
|
||||||
|
|
||||||
|
// GET to other route should timeout (default timeout)
|
||||||
|
const response2 = await app.request('/api/other', { method: 'GET' });
|
||||||
|
expect(response2.status).to.eql(504);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,11 +1,42 @@
|
|||||||
import type { Config } from '../../config/config.types';
|
import type { Config } from '../../config/config.types';
|
||||||
import type { Context } from '../server.types';
|
import type { Context } from '../server.types';
|
||||||
import { createMiddleware } from 'hono/factory';
|
import { createMiddleware } from 'hono/factory';
|
||||||
|
import { routePath } from 'hono/route';
|
||||||
import { createError } from '../../shared/errors/errors';
|
import { createError } from '../../shared/errors/errors';
|
||||||
|
|
||||||
|
function getTimeoutForRoute({
|
||||||
|
defaultRouteTimeoutMs,
|
||||||
|
routeTimeouts,
|
||||||
|
method,
|
||||||
|
path,
|
||||||
|
}: {
|
||||||
|
defaultRouteTimeoutMs: number;
|
||||||
|
routeTimeouts: { method: string; route: string; timeoutMs: number }[];
|
||||||
|
method: string;
|
||||||
|
path: string;
|
||||||
|
}): number {
|
||||||
|
const matchingRoute = routeTimeouts.find((routeConfig) => {
|
||||||
|
if (routeConfig.method !== method) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (routeConfig.route !== path) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
return matchingRoute?.timeoutMs ?? defaultRouteTimeoutMs;
|
||||||
|
}
|
||||||
|
|
||||||
export function createTimeoutMiddleware({ config }: { config: Config }) {
|
export function createTimeoutMiddleware({ config }: { config: Config }) {
|
||||||
return createMiddleware(async (context: Context, next) => {
|
return createMiddleware(async (context: Context, next) => {
|
||||||
const { server: { routeTimeoutMs } } = config;
|
const method = context.req.method;
|
||||||
|
const path = routePath(context, -1); // Get the last matched route path, without the -1 we get /* for all routes
|
||||||
|
const { defaultRouteTimeoutMs, routeTimeouts } = config.server;
|
||||||
|
|
||||||
|
const timeoutMs = getTimeoutForRoute({ defaultRouteTimeoutMs, routeTimeouts, method, path });
|
||||||
|
|
||||||
let timerId: NodeJS.Timeout | undefined;
|
let timerId: NodeJS.Timeout | undefined;
|
||||||
|
|
||||||
@@ -16,7 +47,7 @@ export function createTimeoutMiddleware({ config }: { config: Config }) {
|
|||||||
message: 'The request timed out',
|
message: 'The request timed out',
|
||||||
statusCode: 504,
|
statusCode: 504,
|
||||||
}),
|
}),
|
||||||
), routeTimeoutMs);
|
), timeoutMs);
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export function createTestServerDependencies(overrides: Partial<GlobalDependenci
|
|||||||
const subscriptionsServices = overrides.subscriptionsServices ?? createSubscriptionsServices({ config });
|
const subscriptionsServices = overrides.subscriptionsServices ?? createSubscriptionsServices({ config });
|
||||||
const documentSearchServices = overrides.documentSearchServices ?? createDocumentSearchServices({ db, config });
|
const documentSearchServices = overrides.documentSearchServices ?? createDocumentSearchServices({ db, config });
|
||||||
|
|
||||||
registerEventHandlers({ eventServices, trackingServices, db, documentSearchServices });
|
registerEventHandlers({ eventServices, trackingServices, db, documentSearchServices, config });
|
||||||
|
|
||||||
return {
|
return {
|
||||||
config,
|
config,
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { intakeEmailsConfig } from '../intake-emails/intake-emails.config';
|
|||||||
import { organizationsConfig } from '../organizations/organizations.config';
|
import { organizationsConfig } from '../organizations/organizations.config';
|
||||||
import { organizationPlansConfig } from '../plans/plans.config';
|
import { organizationPlansConfig } from '../plans/plans.config';
|
||||||
import { createLogger } from '../shared/logger/logger';
|
import { createLogger } from '../shared/logger/logger';
|
||||||
|
import { IN_MS } from '../shared/units';
|
||||||
import { isString } from '../shared/utils';
|
import { isString } from '../shared/utils';
|
||||||
import { subscriptionsConfig } from '../subscriptions/subscriptions.config';
|
import { subscriptionsConfig } from '../subscriptions/subscriptions.config';
|
||||||
import { tasksConfig } from '../tasks/tasks.config';
|
import { tasksConfig } from '../tasks/tasks.config';
|
||||||
@@ -84,12 +85,29 @@ export const configDefinition = {
|
|||||||
default: '0.0.0.0',
|
default: '0.0.0.0',
|
||||||
env: 'SERVER_HOSTNAME',
|
env: 'SERVER_HOSTNAME',
|
||||||
},
|
},
|
||||||
routeTimeoutMs: {
|
defaultRouteTimeoutMs: {
|
||||||
doc: 'The maximum time in milliseconds for a route to complete before timing out',
|
doc: 'The maximum time in milliseconds for a route to complete before timing out',
|
||||||
schema: z.coerce.number().int().positive(),
|
schema: z.coerce.number().int().positive(),
|
||||||
default: 20_000,
|
default: 20 * IN_MS.SECOND,
|
||||||
env: 'SERVER_API_ROUTES_TIMEOUT_MS',
|
env: 'SERVER_API_ROUTES_TIMEOUT_MS',
|
||||||
},
|
},
|
||||||
|
routeTimeouts: {
|
||||||
|
doc: 'Route-specific timeout overrides. Allows setting different timeouts for specific HTTP method and route paths.',
|
||||||
|
schema: z.array(
|
||||||
|
z.object({
|
||||||
|
method: z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS']),
|
||||||
|
route: z.string(),
|
||||||
|
timeoutMs: z.number().int().positive(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
default: [
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
route: '/api/organizations/:organizationId/documents',
|
||||||
|
timeoutMs: 5 * IN_MS.MINUTE,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
corsOrigins: {
|
corsOrigins: {
|
||||||
doc: 'The CORS origin for the api server',
|
doc: 'The CORS origin for the api server',
|
||||||
schema: z.union([
|
schema: z.union([
|
||||||
|
|||||||
@@ -6,8 +6,11 @@ import { getUser } from '../app/auth/auth.models';
|
|||||||
import { organizationIdSchema } from '../organizations/organization.schemas';
|
import { organizationIdSchema } from '../organizations/organization.schemas';
|
||||||
import { createOrganizationsRepository } from '../organizations/organizations.repository';
|
import { createOrganizationsRepository } from '../organizations/organizations.repository';
|
||||||
import { ensureUserIsInOrganization } from '../organizations/organizations.usecases';
|
import { ensureUserIsInOrganization } from '../organizations/organizations.usecases';
|
||||||
|
import { createPlansRepository } from '../plans/plans.repository';
|
||||||
|
import { getOrganizationPlan } from '../plans/plans.usecases';
|
||||||
import { getFileStreamFromMultipartForm } from '../shared/streams/file-upload';
|
import { getFileStreamFromMultipartForm } from '../shared/streams/file-upload';
|
||||||
import { validateJsonBody, validateParams, validateQuery } from '../shared/validation/validation';
|
import { validateJsonBody, validateParams, validateQuery } from '../shared/validation/validation';
|
||||||
|
import { createSubscriptionsRepository } from '../subscriptions/subscriptions.repository';
|
||||||
import { createDocumentIsNotDeletedError } from './documents.errors';
|
import { createDocumentIsNotDeletedError } from './documents.errors';
|
||||||
import { formatDocumentForApi, formatDocumentsForApi, isDocumentSizeLimitEnabled } from './documents.models';
|
import { formatDocumentForApi, formatDocumentsForApi, isDocumentSizeLimitEnabled } from './documents.models';
|
||||||
import { createDocumentsRepository } from './documents.repository';
|
import { createDocumentsRepository } from './documents.repository';
|
||||||
@@ -45,12 +48,17 @@ function setupCreateDocumentRoute({ app, ...deps }: RouteDefinitionContext) {
|
|||||||
const organizationsRepository = createOrganizationsRepository({ db });
|
const organizationsRepository = createOrganizationsRepository({ db });
|
||||||
await ensureUserIsInOrganization({ userId, organizationId, organizationsRepository });
|
await ensureUserIsInOrganization({ userId, organizationId, organizationsRepository });
|
||||||
|
|
||||||
const { maxUploadSize } = config.documentsStorage;
|
// Get organization's plan-specific upload limit
|
||||||
|
const plansRepository = createPlansRepository({ config });
|
||||||
|
const subscriptionsRepository = createSubscriptionsRepository({ db });
|
||||||
|
|
||||||
|
const { organizationPlan } = await getOrganizationPlan({ organizationId, plansRepository, subscriptionsRepository });
|
||||||
|
const { maxFileSize } = organizationPlan.limits;
|
||||||
|
|
||||||
const { fileStream, fileName, mimeType } = await getFileStreamFromMultipartForm({
|
const { fileStream, fileName, mimeType } = await getFileStreamFromMultipartForm({
|
||||||
body: context.req.raw.body,
|
body: context.req.raw.body,
|
||||||
headers: context.req.header(),
|
headers: context.req.header(),
|
||||||
maxFileSize: isDocumentSizeLimitEnabled({ maxUploadSize }) ? maxUploadSize : undefined,
|
maxFileSize: isDocumentSizeLimitEnabled({ maxUploadSize: maxFileSize }) ? maxFileSize : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
const createDocument = createDocumentCreationUsecase({ ...deps });
|
const createDocument = createDocumentCreationUsecase({ ...deps });
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { createTestEventServices } from '../app/events/events.test-utils';
|
|||||||
import { overrideConfig } from '../config/config.test-utils';
|
import { overrideConfig } from '../config/config.test-utils';
|
||||||
import { ORGANIZATION_ROLES } from '../organizations/organizations.constants';
|
import { ORGANIZATION_ROLES } from '../organizations/organizations.constants';
|
||||||
import { createOrganizationDocumentStorageLimitReachedError } from '../organizations/organizations.errors';
|
import { createOrganizationDocumentStorageLimitReachedError } from '../organizations/organizations.errors';
|
||||||
|
import { createDeterministicIdGenerator } from '../shared/random/ids';
|
||||||
import { collectReadableStreamToString, createReadableStream } from '../shared/streams/readable-stream';
|
import { collectReadableStreamToString, createReadableStream } from '../shared/streams/readable-stream';
|
||||||
import { createTaggingRulesRepository } from '../tagging-rules/tagging-rules.repository';
|
import { createTaggingRulesRepository } from '../tagging-rules/tagging-rules.repository';
|
||||||
import { createTagsRepository } from '../tags/tags.repository';
|
import { createTagsRepository } from '../tags/tags.repository';
|
||||||
@@ -244,6 +245,83 @@ describe('documents usecases', () => {
|
|||||||
}]);
|
}]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('when restoring a deleted document via duplicate upload, the optimistically saved new file should be cleaned up to prevent orphan files', async () => {
|
||||||
|
const taskServices = createInMemoryTaskServices();
|
||||||
|
const { db } = await createInMemoryDatabase({
|
||||||
|
users: [{ id: 'user-1', email: 'user-1@example.com' }],
|
||||||
|
organizations: [{ id: 'organization-1', name: 'Organization 1' }],
|
||||||
|
organizationMembers: [{ organizationId: 'organization-1', userId: 'user-1', role: ORGANIZATION_ROLES.OWNER }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const config = overrideConfig({
|
||||||
|
organizationPlans: { isFreePlanUnlimited: true },
|
||||||
|
documentsStorage: { driver: 'in-memory' },
|
||||||
|
});
|
||||||
|
const documentsRepository = createDocumentsRepository({ db });
|
||||||
|
const inMemoryDocumentsStorageService = inMemoryStorageDriverFactory();
|
||||||
|
|
||||||
|
const createDocument = createDocumentCreationUsecase({
|
||||||
|
db,
|
||||||
|
config,
|
||||||
|
generateDocumentId: createDeterministicIdGenerator({ prefix: 'doc' }),
|
||||||
|
documentsStorageService: inMemoryDocumentsStorageService,
|
||||||
|
taskServices,
|
||||||
|
eventServices: createTestEventServices(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const userId = 'user-1';
|
||||||
|
const organizationId = 'organization-1';
|
||||||
|
|
||||||
|
// Step 1: Upload a file
|
||||||
|
const { document: document1 } = await createDocument({
|
||||||
|
fileStream: createReadableStream({ content: 'Hello, world!' }),
|
||||||
|
fileName: 'file.pdf',
|
||||||
|
mimeType: 'application/pdf',
|
||||||
|
userId,
|
||||||
|
organizationId,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(document1.id).to.eql('doc_000000000000000000000001');
|
||||||
|
expect(
|
||||||
|
Array.from(inMemoryDocumentsStorageService._getStorage().keys()),
|
||||||
|
).to.eql([
|
||||||
|
'organization-1/originals/doc_000000000000000000000001.pdf',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Step 2: Delete the document (soft delete)
|
||||||
|
await trashDocument({
|
||||||
|
documentId: document1.id,
|
||||||
|
organizationId,
|
||||||
|
userId,
|
||||||
|
documentsRepository,
|
||||||
|
eventServices: createTestEventServices(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { document: trashedDoc } = await documentsRepository.getDocumentById({ documentId: document1.id, organizationId });
|
||||||
|
expect(trashedDoc?.isDeleted).to.eql(true);
|
||||||
|
|
||||||
|
// Step 3: Upload the same file again - this should restore the original document
|
||||||
|
const { document: restoredDocument } = await createDocument({
|
||||||
|
fileStream: createReadableStream({ content: 'Hello, world!' }),
|
||||||
|
fileName: 'file.pdf',
|
||||||
|
mimeType: 'application/pdf',
|
||||||
|
userId,
|
||||||
|
organizationId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// The document should be restored (same ID)
|
||||||
|
expect(restoredDocument.id).to.eql('doc_000000000000000000000001');
|
||||||
|
expect(restoredDocument.isDeleted).to.eql(false);
|
||||||
|
|
||||||
|
// Step 5: Verify no orphan files remain in storage
|
||||||
|
// The optimistically saved file (doc_2.pdf) should have been cleaned up during restoration
|
||||||
|
expect(
|
||||||
|
Array.from(inMemoryDocumentsStorageService._getStorage().keys()),
|
||||||
|
).to.eql([
|
||||||
|
'organization-1/originals/doc_000000000000000000000001.pdf',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
test('when there is an issue when inserting the document in the db, the file should not be saved in the storage', async () => {
|
test('when there is an issue when inserting the document in the db, the file should not be saved in the storage', async () => {
|
||||||
const taskServices = createInMemoryTaskServices();
|
const taskServices = createInMemoryTaskServices();
|
||||||
const { db } = await createInMemoryDatabase({
|
const { db } = await createInMemoryDatabase({
|
||||||
|
|||||||
@@ -235,9 +235,10 @@ async function handleExistingDocument({
|
|||||||
newDocumentStorageKey: string;
|
newDocumentStorageKey: string;
|
||||||
logger: Logger;
|
logger: Logger;
|
||||||
}) {
|
}) {
|
||||||
if (!existingDocument.isDeleted) {
|
// Delete the newly uploaded file since we'll be using the existing document's file
|
||||||
await documentsStorageService.deleteFile({ storageKey: newDocumentStorageKey });
|
await documentsStorageService.deleteFile({ storageKey: newDocumentStorageKey });
|
||||||
|
|
||||||
|
if (!existingDocument.isDeleted) {
|
||||||
throw createDocumentAlreadyExistsError();
|
throw createDocumentAlreadyExistsError();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { createServer } from '../../app/server';
|
|||||||
import { createTestServerDependencies } from '../../app/server.test-utils';
|
import { createTestServerDependencies } from '../../app/server.test-utils';
|
||||||
import { overrideConfig } from '../../config/config.test-utils';
|
import { overrideConfig } from '../../config/config.test-utils';
|
||||||
import { ORGANIZATION_ROLES } from '../../organizations/organizations.constants';
|
import { ORGANIZATION_ROLES } from '../../organizations/organizations.constants';
|
||||||
|
import { PLUS_PLAN_ID, PRO_PLAN_ID } from '../../plans/plans.constants';
|
||||||
import { documentsTable } from '../documents.table';
|
import { documentsTable } from '../documents.table';
|
||||||
import { inMemoryStorageDriverFactory } from '../storage/drivers/memory/memory.storage-driver';
|
import { inMemoryStorageDriverFactory } from '../storage/drivers/memory/memory.storage-driver';
|
||||||
|
|
||||||
@@ -247,5 +248,123 @@ describe('documents e2e', () => {
|
|||||||
expect(retrievedDocument).to.eql({ ...document, tags: [] });
|
expect(retrievedDocument).to.eql({ ...document, tags: [] });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('organizations on Plus plan should be able to upload files up to 100 MiB (not limited by global config)', async () => {
|
||||||
|
const { db } = await createInMemoryDatabase({
|
||||||
|
users: [{ id: 'usr_111111111111111111111111', email: 'user@example.com' }],
|
||||||
|
organizations: [{ id: 'org_222222222222222222222222', name: 'Plus Org', customerId: 'cus_plus123' }],
|
||||||
|
organizationMembers: [{ organizationId: 'org_222222222222222222222222', userId: 'usr_111111111111111111111111', role: ORGANIZATION_ROLES.OWNER }],
|
||||||
|
organizationSubscriptions: [{
|
||||||
|
id: 'sub_plus123',
|
||||||
|
customerId: 'cus_plus123',
|
||||||
|
organizationId: 'org_222222222222222222222222',
|
||||||
|
planId: PLUS_PLAN_ID,
|
||||||
|
status: 'active',
|
||||||
|
seatsCount: 5,
|
||||||
|
currentPeriodStart: new Date('2024-01-01'),
|
||||||
|
currentPeriodEnd: new Date('2024-02-01'),
|
||||||
|
cancelAtPeriodEnd: false,
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { app } = createServer(createTestServerDependencies({
|
||||||
|
db,
|
||||||
|
config: overrideConfig({
|
||||||
|
env: 'test',
|
||||||
|
documentsStorage: {
|
||||||
|
driver: 'in-memory',
|
||||||
|
// Global config set to 10 MiB (simulating free tier limit)
|
||||||
|
maxUploadSize: 1024 * 1024 * 10, // 10 MiB
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// File size: 50 MiB - exceeds global config (10 MiB) but within Plus plan limit (100 MiB)
|
||||||
|
const fileSizeBytes = 1024 * 1024 * 50; // 50 MiB
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', new File(['a'.repeat(fileSizeBytes)], 'large-document.txt', { type: 'text/plain' }));
|
||||||
|
const body = new Response(formData);
|
||||||
|
|
||||||
|
const createDocumentResponse = await app.request(
|
||||||
|
'/api/organizations/org_222222222222222222222222/documents',
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
...Object.fromEntries(body.headers.entries()),
|
||||||
|
},
|
||||||
|
body: await body.arrayBuffer(),
|
||||||
|
},
|
||||||
|
{ loggedInUserId: 'usr_111111111111111111111111' },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should succeed because Plus plan allows 100 MiB
|
||||||
|
expect(createDocumentResponse.status).to.eql(200);
|
||||||
|
const { document } = (await createDocumentResponse.json()) as { document: Document };
|
||||||
|
|
||||||
|
expect(document).to.include({
|
||||||
|
name: 'large-document.txt',
|
||||||
|
mimeType: 'text/plain',
|
||||||
|
originalSize: fileSizeBytes,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('organizations on Pro plan should be able to upload files up to 500 MiB (not limited by global config)', async () => {
|
||||||
|
const { db } = await createInMemoryDatabase({
|
||||||
|
users: [{ id: 'usr_111111111111111111111111', email: 'user@example.com' }],
|
||||||
|
organizations: [{ id: 'org_333333333333333333333333', name: 'Pro Org', customerId: 'cus_pro123' }],
|
||||||
|
organizationMembers: [{ organizationId: 'org_333333333333333333333333', userId: 'usr_111111111111111111111111', role: ORGANIZATION_ROLES.OWNER }],
|
||||||
|
organizationSubscriptions: [{
|
||||||
|
id: 'sub_pro123',
|
||||||
|
customerId: 'cus_pro123',
|
||||||
|
organizationId: 'org_333333333333333333333333',
|
||||||
|
planId: PRO_PLAN_ID,
|
||||||
|
status: 'active',
|
||||||
|
seatsCount: 20,
|
||||||
|
currentPeriodStart: new Date('2024-01-01'),
|
||||||
|
currentPeriodEnd: new Date('2024-02-01'),
|
||||||
|
cancelAtPeriodEnd: false,
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { app } = createServer(createTestServerDependencies({
|
||||||
|
db,
|
||||||
|
config: overrideConfig({
|
||||||
|
env: 'test',
|
||||||
|
documentsStorage: {
|
||||||
|
driver: 'in-memory',
|
||||||
|
// Global config set to 10 MiB (simulating free tier limit)
|
||||||
|
maxUploadSize: 1024 * 1024 * 10, // 10 MiB
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// File size: 200 MiB - exceeds global config (10 MiB) but within Pro plan limit (500 MiB)
|
||||||
|
const fileSizeBytes = 1024 * 1024 * 200; // 200 MiB
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', new File(['a'.repeat(fileSizeBytes)], 'very-large-document.txt', { type: 'text/plain' }));
|
||||||
|
const body = new Response(formData);
|
||||||
|
|
||||||
|
const createDocumentResponse = await app.request(
|
||||||
|
'/api/organizations/org_333333333333333333333333/documents',
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
...Object.fromEntries(body.headers.entries()),
|
||||||
|
},
|
||||||
|
body: await body.arrayBuffer(),
|
||||||
|
},
|
||||||
|
{ loggedInUserId: 'usr_111111111111111111111111' },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should succeed because Pro plan allows 500 MiB
|
||||||
|
expect(createDocumentResponse.status).to.eql(200);
|
||||||
|
const { document } = (await createDocumentResponse.json()) as { document: Document };
|
||||||
|
|
||||||
|
expect(document).to.include({
|
||||||
|
name: 'very-large-document.txt',
|
||||||
|
mimeType: 'text/plain',
|
||||||
|
originalSize: fileSizeBytes,
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { ConfigDefinition } from 'figue';
|
import type { ConfigDefinition } from 'figue';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { booleanishSchema } from '../config/config.schemas';
|
import { booleanishSchema } from '../config/config.schemas';
|
||||||
|
import { IN_MS } from '../shared/units';
|
||||||
import { isString } from '../shared/utils';
|
import { isString } from '../shared/utils';
|
||||||
import { defaultIgnoredPatterns } from './ingestion-folders.constants';
|
import { defaultIgnoredPatterns } from './ingestion-folders.constants';
|
||||||
|
|
||||||
@@ -27,7 +28,7 @@ export const ingestionFolderConfig = {
|
|||||||
pollingInterval: {
|
pollingInterval: {
|
||||||
doc: 'When polling is used, this is the interval at which the watcher checks for changes in the ingestion folder (in milliseconds)',
|
doc: 'When polling is used, this is the interval at which the watcher checks for changes in the ingestion folder (in milliseconds)',
|
||||||
schema: z.coerce.number().int().positive(),
|
schema: z.coerce.number().int().positive(),
|
||||||
default: 2_000,
|
default: 2 * IN_MS.SECOND,
|
||||||
env: 'INGESTION_FOLDER_WATCHER_POLLING_INTERVAL_MS',
|
env: 'INGESTION_FOLDER_WATCHER_POLLING_INTERVAL_MS',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import type { OrganizationInvitation } from './organizations.types';
|
import type { OrganizationInvitation } from './organizations.types';
|
||||||
import { isAfter } from 'date-fns';
|
import { isAfter } from 'date-fns';
|
||||||
import { ORGANIZATION_INVITATION_STATUS } from './organizations.constants';
|
import { eq, like } from 'drizzle-orm';
|
||||||
|
import { escapeLikeWildcards } from '../shared/db/sql.helpers';
|
||||||
|
import { isNilOrEmptyString } from '../shared/utils';
|
||||||
|
import { ORGANIZATION_ID_REGEX, ORGANIZATION_INVITATION_STATUS } from './organizations.constants';
|
||||||
|
import { organizationsTable } from './organizations.table';
|
||||||
|
|
||||||
export function ensureInvitationStatus({ invitation, now = new Date() }: { invitation?: OrganizationInvitation | null | undefined; now?: Date }) {
|
export function ensureInvitationStatus({ invitation, now = new Date() }: { invitation?: OrganizationInvitation | null | undefined; now?: Date }) {
|
||||||
if (!invitation) {
|
if (!invitation) {
|
||||||
@@ -17,3 +21,20 @@ export function ensureInvitationStatus({ invitation, now = new Date() }: { invit
|
|||||||
|
|
||||||
return { ...invitation, status: ORGANIZATION_INVITATION_STATUS.EXPIRED };
|
return { ...invitation, status: ORGANIZATION_INVITATION_STATUS.EXPIRED };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createSearchOrganizationWhereClause({ search }: { search?: string }) {
|
||||||
|
const trimmedSearch = search?.trim();
|
||||||
|
|
||||||
|
if (isNilOrEmptyString(trimmedSearch)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ORGANIZATION_ID_REGEX.test(trimmedSearch)) {
|
||||||
|
return eq(organizationsTable.id, trimmedSearch);
|
||||||
|
}
|
||||||
|
|
||||||
|
const escapedSearch = escapeLikeWildcards(trimmedSearch);
|
||||||
|
const likeSearch = `%${escapedSearch}%`;
|
||||||
|
|
||||||
|
return like(organizationsTable.name, likeSearch);
|
||||||
|
}
|
||||||
|
|||||||
@@ -250,4 +250,167 @@ describe('organizations repository', () => {
|
|||||||
expect(organizationCount).to.equal(2);
|
expect(organizationCount).to.equal(2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('listOrganizations', () => {
|
||||||
|
test('when no organizations exist, an empty list is returned', async () => {
|
||||||
|
const { db } = await createInMemoryDatabase();
|
||||||
|
const { listOrganizations } = createOrganizationsRepository({ db });
|
||||||
|
|
||||||
|
const result = await listOrganizations({});
|
||||||
|
|
||||||
|
expect(result).to.deep.equal({
|
||||||
|
organizations: [],
|
||||||
|
totalCount: 0,
|
||||||
|
pageIndex: 0,
|
||||||
|
pageSize: 25,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('when multiple organizations exist, all organizations are returned with member counts', async () => {
|
||||||
|
const { db } = await createInMemoryDatabase({
|
||||||
|
users: [
|
||||||
|
{ id: 'user_1', email: 'user1@example.com', name: 'User 1' },
|
||||||
|
{ id: 'user_2', email: 'user2@example.com', name: 'User 2' },
|
||||||
|
],
|
||||||
|
organizations: [
|
||||||
|
{ id: 'org_1', name: 'Alpha Corp', createdAt: new Date('2025-01-02') },
|
||||||
|
{ id: 'org_2', name: 'Beta LLC', createdAt: new Date('2025-01-01') },
|
||||||
|
],
|
||||||
|
organizationMembers: [
|
||||||
|
{ userId: 'user_1', organizationId: 'org_1', role: 'owner' },
|
||||||
|
{ userId: 'user_2', organizationId: 'org_1', role: 'member' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const { listOrganizations } = createOrganizationsRepository({ db });
|
||||||
|
|
||||||
|
const result = await listOrganizations({});
|
||||||
|
|
||||||
|
expect(result.organizations).to.have.length(2);
|
||||||
|
expect(result.totalCount).to.equal(2);
|
||||||
|
expect(result.pageIndex).to.equal(0);
|
||||||
|
expect(result.pageSize).to.equal(25);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
result.organizations.map(org => ({
|
||||||
|
id: org.id,
|
||||||
|
name: org.name,
|
||||||
|
memberCount: org.memberCount,
|
||||||
|
})),
|
||||||
|
).to.deep.equal([
|
||||||
|
{ id: 'org_1', name: 'Alpha Corp', memberCount: 2 },
|
||||||
|
{ id: 'org_2', name: 'Beta LLC', memberCount: 0 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('when searching by organization ID, only the exact matching organization is returned', async () => {
|
||||||
|
const { db } = await createInMemoryDatabase({
|
||||||
|
organizations: [
|
||||||
|
{ id: 'org_123456789012345678901234', name: 'Alpha Corp' },
|
||||||
|
{ id: 'org_abcdefghijklmnopqrstuvwx', name: 'Beta LLC' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const { listOrganizations } = createOrganizationsRepository({ db });
|
||||||
|
|
||||||
|
const result = await listOrganizations({ search: 'org_abcdefghijklmnopqrstuvwx' });
|
||||||
|
|
||||||
|
expect(result.organizations).to.have.length(1);
|
||||||
|
expect(result.organizations[0]?.id).to.equal('org_abcdefghijklmnopqrstuvwx');
|
||||||
|
expect(result.totalCount).to.equal(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('when searching by partial name, matching organizations are returned', async () => {
|
||||||
|
const { db } = await createInMemoryDatabase({
|
||||||
|
organizations: [
|
||||||
|
{ id: 'org_1', name: 'Alpha Corporation', createdAt: new Date('2025-01-02') },
|
||||||
|
{ id: 'org_2', name: 'Beta LLC', createdAt: new Date('2025-01-03') },
|
||||||
|
{ id: 'org_3', name: 'Alpha Industries', createdAt: new Date('2025-01-01') },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const { listOrganizations } = createOrganizationsRepository({ db });
|
||||||
|
|
||||||
|
const result = await listOrganizations({ search: 'Alpha' });
|
||||||
|
|
||||||
|
expect(result.organizations).to.have.length(2);
|
||||||
|
expect(result.totalCount).to.equal(2);
|
||||||
|
expect(result.organizations.map(org => org.name)).to.deep.equal([
|
||||||
|
'Alpha Corporation',
|
||||||
|
'Alpha Industries',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('when searching with an empty string, all organizations are returned', async () => {
|
||||||
|
const { db } = await createInMemoryDatabase({
|
||||||
|
organizations: [
|
||||||
|
{ id: 'org_1', name: 'Alpha Corp' },
|
||||||
|
{ id: 'org_2', name: 'Beta LLC' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const { listOrganizations } = createOrganizationsRepository({ db });
|
||||||
|
|
||||||
|
const result = await listOrganizations({ search: ' ' });
|
||||||
|
|
||||||
|
expect(result.organizations).to.have.length(2);
|
||||||
|
expect(result.totalCount).to.equal(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('when using pagination, only the requested page is returned', async () => {
|
||||||
|
const { db } = await createInMemoryDatabase({
|
||||||
|
organizations: [
|
||||||
|
{ id: 'org_1', name: 'Org 1' },
|
||||||
|
{ id: 'org_2', name: 'Org 2' },
|
||||||
|
{ id: 'org_3', name: 'Org 3' },
|
||||||
|
{ id: 'org_4', name: 'Org 4' },
|
||||||
|
{ id: 'org_5', name: 'Org 5' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const { listOrganizations } = createOrganizationsRepository({ db });
|
||||||
|
|
||||||
|
const firstPage = await listOrganizations({ pageIndex: 0, pageSize: 2 });
|
||||||
|
const secondPage = await listOrganizations({ pageIndex: 1, pageSize: 2 });
|
||||||
|
|
||||||
|
expect(firstPage.organizations).to.have.length(2);
|
||||||
|
expect(firstPage.totalCount).to.equal(5);
|
||||||
|
expect(secondPage.organizations).to.have.length(2);
|
||||||
|
expect(secondPage.totalCount).to.equal(5);
|
||||||
|
expect(firstPage.organizations[0]?.id).to.not.equal(secondPage.organizations[0]?.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('when searching with pagination, the total count reflects the search results', async () => {
|
||||||
|
const { db } = await createInMemoryDatabase({
|
||||||
|
organizations: [
|
||||||
|
{ id: 'org_1', name: 'Tech Corp 1' },
|
||||||
|
{ id: 'org_2', name: 'Tech Corp 2' },
|
||||||
|
{ id: 'org_3', name: 'Tech Corp 3' },
|
||||||
|
{ id: 'org_4', name: 'Media LLC' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const { listOrganizations } = createOrganizationsRepository({ db });
|
||||||
|
|
||||||
|
const result = await listOrganizations({ search: 'Tech', pageIndex: 0, pageSize: 2 });
|
||||||
|
|
||||||
|
expect(result.organizations).to.have.length(2);
|
||||||
|
expect(result.totalCount).to.equal(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('when soft-deleted organizations exist, they are excluded from the results', async () => {
|
||||||
|
const { db } = await createInMemoryDatabase({
|
||||||
|
users: [{ id: 'user_1', email: 'user1@test.com' }],
|
||||||
|
organizations: [
|
||||||
|
{ id: 'org_1', name: 'Active Org', createdAt: new Date('2025-01-02') },
|
||||||
|
{ id: 'org_2', name: 'Deleted Org', createdAt: new Date('2025-01-03'), deletedAt: new Date('2025-05-15'), deletedBy: 'user_1', scheduledPurgeAt: new Date('2025-06-15') },
|
||||||
|
{ id: 'org_3', name: 'Another Active Org', createdAt: new Date('2025-01-01') },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const { listOrganizations } = createOrganizationsRepository({ db });
|
||||||
|
|
||||||
|
const result = await listOrganizations({});
|
||||||
|
|
||||||
|
expect(result.organizations).to.have.length(2);
|
||||||
|
expect(result.totalCount).to.equal(2);
|
||||||
|
expect(result.organizations.map(org => org.name)).to.deep.equal([
|
||||||
|
'Active Org',
|
||||||
|
'Another Active Org',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,13 +2,14 @@ import type { Database } from '../app/database/database.types';
|
|||||||
import type { DbInsertableOrganization, OrganizationInvitationStatus, OrganizationRole } from './organizations.types';
|
import type { DbInsertableOrganization, OrganizationInvitationStatus, OrganizationRole } from './organizations.types';
|
||||||
import { injectArguments } from '@corentinth/chisels';
|
import { injectArguments } from '@corentinth/chisels';
|
||||||
import { addDays, startOfDay } from 'date-fns';
|
import { addDays, startOfDay } from 'date-fns';
|
||||||
import { and, count, eq, getTableColumns, gte, isNotNull, isNull, lte } from 'drizzle-orm';
|
import { and, count, desc, eq, getTableColumns, gte, isNotNull, isNull, lte } from 'drizzle-orm';
|
||||||
import { omit } from 'lodash-es';
|
import { omit } from 'lodash-es';
|
||||||
|
import { withPagination } from '../shared/db/pagination';
|
||||||
import { omitUndefined } from '../shared/utils';
|
import { omitUndefined } from '../shared/utils';
|
||||||
import { usersTable } from '../users/users.table';
|
import { usersTable } from '../users/users.table';
|
||||||
import { ORGANIZATION_INVITATION_STATUS, ORGANIZATION_ROLES } from './organizations.constants';
|
import { ORGANIZATION_INVITATION_STATUS, ORGANIZATION_ROLES } from './organizations.constants';
|
||||||
import { createOrganizationNotFoundError } from './organizations.errors';
|
import { createOrganizationNotFoundError } from './organizations.errors';
|
||||||
import { ensureInvitationStatus } from './organizations.repository.models';
|
import { createSearchOrganizationWhereClause, ensureInvitationStatus } from './organizations.repository.models';
|
||||||
import { organizationInvitationsTable, organizationMembersTable, organizationsTable } from './organizations.table';
|
import { organizationInvitationsTable, organizationMembersTable, organizationsTable } from './organizations.table';
|
||||||
|
|
||||||
export type OrganizationsRepository = ReturnType<typeof createOrganizationsRepository>;
|
export type OrganizationsRepository = ReturnType<typeof createOrganizationsRepository>;
|
||||||
@@ -50,6 +51,7 @@ export function createOrganizationsRepository({ db }: { db: Database }) {
|
|||||||
getUserDeletedOrganizations,
|
getUserDeletedOrganizations,
|
||||||
getExpiredSoftDeletedOrganizations,
|
getExpiredSoftDeletedOrganizations,
|
||||||
getOrganizationCount,
|
getOrganizationCount,
|
||||||
|
listOrganizations,
|
||||||
},
|
},
|
||||||
{ db },
|
{ db },
|
||||||
);
|
);
|
||||||
@@ -553,3 +555,52 @@ async function getOrganizationCount({ db }: { db: Database }) {
|
|||||||
organizationCount,
|
organizationCount,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function listOrganizations({
|
||||||
|
db,
|
||||||
|
search,
|
||||||
|
pageIndex = 0,
|
||||||
|
pageSize = 25,
|
||||||
|
}: {
|
||||||
|
db: Database;
|
||||||
|
search?: string;
|
||||||
|
pageIndex?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
}) {
|
||||||
|
const searchWhereClause = createSearchOrganizationWhereClause({ search });
|
||||||
|
const whereClause = searchWhereClause
|
||||||
|
? and(searchWhereClause, isNull(organizationsTable.deletedAt))
|
||||||
|
: isNull(organizationsTable.deletedAt);
|
||||||
|
|
||||||
|
const query = db
|
||||||
|
.select({
|
||||||
|
...getTableColumns(organizationsTable),
|
||||||
|
memberCount: count(organizationMembersTable.id),
|
||||||
|
})
|
||||||
|
.from(organizationsTable)
|
||||||
|
.leftJoin(
|
||||||
|
organizationMembersTable,
|
||||||
|
eq(organizationsTable.id, organizationMembersTable.organizationId),
|
||||||
|
)
|
||||||
|
.where(whereClause)
|
||||||
|
.groupBy(organizationsTable.id)
|
||||||
|
.$dynamic();
|
||||||
|
|
||||||
|
const organizations = await withPagination(query, {
|
||||||
|
orderByColumn: desc(organizationsTable.createdAt),
|
||||||
|
pageIndex,
|
||||||
|
pageSize,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [{ totalCount = 0 } = {}] = await db
|
||||||
|
.select({ totalCount: count() })
|
||||||
|
.from(organizationsTable)
|
||||||
|
.where(whereClause);
|
||||||
|
|
||||||
|
return {
|
||||||
|
organizations,
|
||||||
|
totalCount,
|
||||||
|
pageIndex,
|
||||||
|
pageSize,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { Config } from '../config/config.types';
|
|||||||
import type { OrganizationPlanRecord } from './plans.types';
|
import type { OrganizationPlanRecord } from './plans.types';
|
||||||
import { injectArguments } from '@corentinth/chisels';
|
import { injectArguments } from '@corentinth/chisels';
|
||||||
import { isDocumentSizeLimitEnabled } from '../documents/documents.models';
|
import { isDocumentSizeLimitEnabled } from '../documents/documents.models';
|
||||||
|
import { IN_BYTES } from '../shared/units';
|
||||||
import { FREE_PLAN_ID, PLUS_PLAN_ID, PRO_PLAN_ID } from './plans.constants';
|
import { FREE_PLAN_ID, PLUS_PLAN_ID, PRO_PLAN_ID } from './plans.constants';
|
||||||
import { createPlanNotFoundError } from './plans.errors';
|
import { createPlanNotFoundError } from './plans.errors';
|
||||||
|
|
||||||
@@ -30,7 +31,7 @@ export function getOrganizationPlansRecords({ config }: { config: Config }) {
|
|||||||
id: FREE_PLAN_ID,
|
id: FREE_PLAN_ID,
|
||||||
name: 'Free',
|
name: 'Free',
|
||||||
limits: {
|
limits: {
|
||||||
maxDocumentStorageBytes: isFreePlanUnlimited ? Number.POSITIVE_INFINITY : 1024 * 1024 * 500, // 500 MiB
|
maxDocumentStorageBytes: isFreePlanUnlimited ? Number.POSITIVE_INFINITY : 500 * IN_BYTES.MEGABYTE,
|
||||||
maxIntakeEmailsCount: isFreePlanUnlimited ? Number.POSITIVE_INFINITY : 1,
|
maxIntakeEmailsCount: isFreePlanUnlimited ? Number.POSITIVE_INFINITY : 1,
|
||||||
maxOrganizationsMembersCount: isFreePlanUnlimited ? Number.POSITIVE_INFINITY : 3,
|
maxOrganizationsMembersCount: isFreePlanUnlimited ? Number.POSITIVE_INFINITY : 3,
|
||||||
maxFileSize: isDocumentSizeLimitEnabled({ maxUploadSize }) ? maxUploadSize : Number.POSITIVE_INFINITY,
|
maxFileSize: isDocumentSizeLimitEnabled({ maxUploadSize }) ? maxUploadSize : Number.POSITIVE_INFINITY,
|
||||||
@@ -42,10 +43,10 @@ export function getOrganizationPlansRecords({ config }: { config: Config }) {
|
|||||||
monthlyPriceId: config.organizationPlans.plusPlanMonthlyPriceId,
|
monthlyPriceId: config.organizationPlans.plusPlanMonthlyPriceId,
|
||||||
annualPriceId: config.organizationPlans.plusPlanAnnualPriceId,
|
annualPriceId: config.organizationPlans.plusPlanAnnualPriceId,
|
||||||
limits: {
|
limits: {
|
||||||
maxDocumentStorageBytes: 1024 * 1024 * 1024 * 5, // 5 GiB
|
maxDocumentStorageBytes: 5 * IN_BYTES.GIGABYTE, // 5 GiB
|
||||||
maxIntakeEmailsCount: 10,
|
maxIntakeEmailsCount: 10,
|
||||||
maxOrganizationsMembersCount: 10,
|
maxOrganizationsMembersCount: 10,
|
||||||
maxFileSize: 1024 * 1024 * 100, // 100 MiB
|
maxFileSize: 100 * IN_BYTES.MEGABYTE, // 100 MiB
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
[PRO_PLAN_ID]: {
|
[PRO_PLAN_ID]: {
|
||||||
@@ -54,10 +55,10 @@ export function getOrganizationPlansRecords({ config }: { config: Config }) {
|
|||||||
monthlyPriceId: config.organizationPlans.proPlanMonthlyPriceId,
|
monthlyPriceId: config.organizationPlans.proPlanMonthlyPriceId,
|
||||||
annualPriceId: config.organizationPlans.proPlanAnnualPriceId,
|
annualPriceId: config.organizationPlans.proPlanAnnualPriceId,
|
||||||
limits: {
|
limits: {
|
||||||
maxDocumentStorageBytes: 1024 * 1024 * 1024 * 50, // 50 GiB
|
maxDocumentStorageBytes: 50 * IN_BYTES.GIGABYTE, // 50 GiB
|
||||||
maxIntakeEmailsCount: 100,
|
maxIntakeEmailsCount: 100,
|
||||||
maxOrganizationsMembersCount: 50,
|
maxOrganizationsMembersCount: 50,
|
||||||
maxFileSize: 1024 * 1024 * 500, // 500 MiB
|
maxFileSize: 500 * IN_BYTES.MEGABYTE, // 500 MiB
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { createNoopLogger } from '@crowlog/logger';
|
||||||
|
import { describe, expect, test } from 'vitest';
|
||||||
|
import { createInMemoryDatabase } from '../../app/database/database.test-utils';
|
||||||
|
import { createEventServices } from '../../app/events/events.services';
|
||||||
|
import { overrideConfig } from '../../config/config.test-utils';
|
||||||
|
import { nextTick } from '../../shared/async/defer.test-utils';
|
||||||
|
import { usersTable } from '../../users/users.table';
|
||||||
|
import { userRolesTable } from '../roles.table';
|
||||||
|
import { registerFirstUserAdminEventHandler } from './first-user-admin.user-created';
|
||||||
|
|
||||||
|
describe('first user admin assignment', () => {
|
||||||
|
describe('when the feature is disabled', () => {
|
||||||
|
test('the first user does not receive admin role', async () => {
|
||||||
|
const user = { id: 'usr_1', email: 'first@example.com', createdAt: new Date('2026-01-01') };
|
||||||
|
|
||||||
|
const { db } = await createInMemoryDatabase({ users: [user] });
|
||||||
|
const eventServices = createEventServices();
|
||||||
|
|
||||||
|
const config = overrideConfig({ auth: { firstUserAsAdmin: false } });
|
||||||
|
|
||||||
|
registerFirstUserAdminEventHandler({ eventServices, config, db, logger: createNoopLogger() });
|
||||||
|
|
||||||
|
eventServices.emitEvent({ eventName: 'user.created', payload: { userId: user.id, ...user } });
|
||||||
|
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
const roles = await db.select().from(userRolesTable);
|
||||||
|
expect(roles).to.deep.equal([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when the feature is enabled', () => {
|
||||||
|
test('the first user receives the admin role automatically', async () => {
|
||||||
|
const user = { id: 'usr_1', email: 'first@example.com', createdAt: new Date('2026-01-01') };
|
||||||
|
|
||||||
|
const { db } = await createInMemoryDatabase({ users: [user] });
|
||||||
|
const eventServices = createEventServices();
|
||||||
|
|
||||||
|
const config = overrideConfig({ auth: { firstUserAsAdmin: true } });
|
||||||
|
|
||||||
|
registerFirstUserAdminEventHandler({ eventServices, config, db, logger: createNoopLogger() });
|
||||||
|
|
||||||
|
eventServices.emitEvent({ eventName: 'user.created', payload: { userId: user.id, ...user } });
|
||||||
|
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
const roles = await db.select().from(userRolesTable);
|
||||||
|
expect(
|
||||||
|
roles.map(({ userId, role }) => ({ userId, role })),
|
||||||
|
).to.deep.equal([
|
||||||
|
{ userId: 'usr_1', role: 'admin' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('if the first user already has an admin role, it does not fail', async () => {
|
||||||
|
const user = { id: 'usr_1', email: 'first@example.com', createdAt: new Date('2026-01-01') };
|
||||||
|
const { db } = await createInMemoryDatabase({
|
||||||
|
users: [user],
|
||||||
|
userRoles: [{ userId: user.id, role: 'admin' }],
|
||||||
|
});
|
||||||
|
const eventServices = createEventServices();
|
||||||
|
|
||||||
|
const config = overrideConfig({ auth: { firstUserAsAdmin: true } });
|
||||||
|
|
||||||
|
registerFirstUserAdminEventHandler({ eventServices, config, db, logger: createNoopLogger() });
|
||||||
|
|
||||||
|
eventServices.emitEvent({ eventName: 'user.created', payload: { userId: user.id, ...user } });
|
||||||
|
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
const roles = await db.select().from(userRolesTable);
|
||||||
|
expect(
|
||||||
|
roles.map(({ userId, role }) => ({ userId, role })),
|
||||||
|
).to.deep.equal([
|
||||||
|
{ userId: 'usr_1', role: 'admin' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the second user does not receive the admin role', async () => {
|
||||||
|
const { db } = await createInMemoryDatabase();
|
||||||
|
const eventServices = createEventServices();
|
||||||
|
|
||||||
|
const config = overrideConfig({ auth: { firstUserAsAdmin: true } });
|
||||||
|
|
||||||
|
registerFirstUserAdminEventHandler({ eventServices, config, db, logger: createNoopLogger() });
|
||||||
|
|
||||||
|
const firstUser = { id: 'usr_1', email: 'first@example.com', createdAt: new Date('2026-01-01') };
|
||||||
|
await db.insert(usersTable).values(firstUser);
|
||||||
|
|
||||||
|
eventServices.emitEvent({ eventName: 'user.created', payload: { userId: firstUser.id, ...firstUser } });
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
const secondUser = { id: 'usr_2', email: 'second@example.com', createdAt: new Date('2026-01-02') };
|
||||||
|
await db.insert(usersTable).values(secondUser);
|
||||||
|
|
||||||
|
eventServices.emitEvent({ eventName: 'user.created', payload: { userId: secondUser.id, ...secondUser } });
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
const roles = await db.select().from(userRolesTable);
|
||||||
|
expect(
|
||||||
|
roles.map(({ userId, role }) => ({ userId, role })),
|
||||||
|
).to.deep.equal([
|
||||||
|
{ userId: 'usr_1', role: 'admin' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('when multiple users are already created, no one receives the admin role', async () => {
|
||||||
|
const users = [
|
||||||
|
{ id: 'usr_1', email: 'user1@example.com', createdAt: new Date('2026-01-01') },
|
||||||
|
{ id: 'usr_2', email: 'user2@example.com', createdAt: new Date('2026-01-01') },
|
||||||
|
].map(user => ({ ...user, userId: user.id }));
|
||||||
|
|
||||||
|
const { db } = await createInMemoryDatabase({ users });
|
||||||
|
const eventServices = createEventServices();
|
||||||
|
|
||||||
|
const config = overrideConfig({ auth: { firstUserAsAdmin: true } });
|
||||||
|
|
||||||
|
registerFirstUserAdminEventHandler({ eventServices, config, db, logger: createNoopLogger() });
|
||||||
|
|
||||||
|
eventServices.emitEvent({ eventName: 'user.created', payload: users[0]! });
|
||||||
|
eventServices.emitEvent({ eventName: 'user.created', payload: users[1]! });
|
||||||
|
|
||||||
|
await nextTick();
|
||||||
|
const roles = await db.select().from(userRolesTable);
|
||||||
|
|
||||||
|
expect(roles.length).to.equal(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import type { Database } from '../../app/database/database.types';
|
||||||
|
import type { EventServices } from '../../app/events/events.services';
|
||||||
|
import type { Config } from '../../config/config.types';
|
||||||
|
import type { Logger } from '../../shared/logger/logger';
|
||||||
|
import { createLogger } from '../../shared/logger/logger';
|
||||||
|
import { createUsersRepository } from '../../users/users.repository';
|
||||||
|
import { ROLES } from '../roles.constants';
|
||||||
|
import { createRolesRepository } from '../roles.repository';
|
||||||
|
|
||||||
|
export function registerFirstUserAdminEventHandler({
|
||||||
|
eventServices,
|
||||||
|
config,
|
||||||
|
logger = createLogger({ namespace: 'events:first-user-admin' }),
|
||||||
|
db,
|
||||||
|
}: {
|
||||||
|
eventServices: EventServices;
|
||||||
|
config: Config;
|
||||||
|
db: Database;
|
||||||
|
logger?: Logger;
|
||||||
|
}) {
|
||||||
|
const usersRepository = createUsersRepository({ db });
|
||||||
|
const rolesRepository = createRolesRepository({ db });
|
||||||
|
|
||||||
|
if (!config.auth.firstUserAsAdmin) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
eventServices.onEvent({
|
||||||
|
eventName: 'user.created',
|
||||||
|
handlerName: 'roles.assign-admin-to-first-user',
|
||||||
|
handler: async ({ userId }) => {
|
||||||
|
if (!config.auth.firstUserAsAdmin) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { userCount } = await usersRepository.getUserCount();
|
||||||
|
|
||||||
|
if (userCount !== 1) {
|
||||||
|
logger.debug({ userId, userCount }, 'User is not the first user, skipping admin assignment');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await rolesRepository.assignRoleToUser({ userId, role: ROLES.ADMIN });
|
||||||
|
|
||||||
|
logger.info({ userId }, 'Admin role assigned to first user');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,3 +1,7 @@
|
|||||||
|
export const ROLES = {
|
||||||
|
ADMIN: 'admin',
|
||||||
|
} as const;
|
||||||
|
|
||||||
export const PERMISSIONS = {
|
export const PERMISSIONS = {
|
||||||
BO_ACCESS: 'bo:access',
|
BO_ACCESS: 'bo:access',
|
||||||
VIEW_USERS: 'users:view',
|
VIEW_USERS: 'users:view',
|
||||||
@@ -5,11 +9,11 @@ export const PERMISSIONS = {
|
|||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const PERMISSIONS_BY_ROLE = {
|
export const PERMISSIONS_BY_ROLE = {
|
||||||
admin: [
|
[ROLES.ADMIN]: [
|
||||||
PERMISSIONS.VIEW_USERS,
|
PERMISSIONS.VIEW_USERS,
|
||||||
PERMISSIONS.BO_ACCESS,
|
PERMISSIONS.BO_ACCESS,
|
||||||
PERMISSIONS.VIEW_ANALYTICS,
|
PERMISSIONS.VIEW_ANALYTICS,
|
||||||
],
|
],
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const ROLES = Object.keys(PERMISSIONS_BY_ROLE) as (keyof typeof PERMISSIONS_BY_ROLE)[];
|
export const ROLES_LIST = Object.values(ROLES);
|
||||||
|
|||||||
@@ -38,8 +38,7 @@ async function assignRoleToUser({ userId, role, db }: { userId: string; role: Ro
|
|||||||
userId,
|
userId,
|
||||||
role,
|
role,
|
||||||
})
|
})
|
||||||
.onConflictDoNothing()
|
.onConflictDoNothing();
|
||||||
.returning();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function removeRoleFromUser({ userId, role, db }: { userId: string; role: Role; db: Database }) {
|
async function removeRoleFromUser({ userId, role, db }: { userId: string; role: Role; db: Database }) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { PERMISSIONS, ROLES } from './roles.constants';
|
import type { PERMISSIONS, ROLES_LIST } from './roles.constants';
|
||||||
|
|
||||||
export type Role = typeof ROLES[number];
|
export type Role = typeof ROLES_LIST[number];
|
||||||
export type Permission = typeof PERMISSIONS[keyof typeof PERMISSIONS];
|
export type Permission = typeof PERMISSIONS[keyof typeof PERMISSIONS];
|
||||||
|
|||||||
54
apps/papra-server/src/modules/shared/db/sql.helpers.test.ts
Normal file
54
apps/papra-server/src/modules/shared/db/sql.helpers.test.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { describe, expect, test } from 'vitest';
|
||||||
|
import { escapeLikeWildcards } from './sql.helpers';
|
||||||
|
|
||||||
|
describe('sql helpers', () => {
|
||||||
|
describe('escapeLikeWildcards', () => {
|
||||||
|
test('when input contains percent sign, it is escaped', () => {
|
||||||
|
const result = escapeLikeWildcards('hello%world');
|
||||||
|
|
||||||
|
expect(result).to.equal('hello\\%world');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('when input contains underscore, it is escaped', () => {
|
||||||
|
const result = escapeLikeWildcards('hello_world');
|
||||||
|
|
||||||
|
expect(result).to.equal('hello\\_world');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('when input contains both percent and underscore, both are escaped', () => {
|
||||||
|
const result = escapeLikeWildcards('test%value_name');
|
||||||
|
|
||||||
|
expect(result).to.equal('test\\%value\\_name');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('when input contains multiple wildcards, all are escaped', () => {
|
||||||
|
const result = escapeLikeWildcards('%%__%%');
|
||||||
|
|
||||||
|
expect(result).to.equal('\\%\\%\\_\\_\\%\\%');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('when input contains backslashes, they are escaped', () => {
|
||||||
|
const result = escapeLikeWildcards('hello\\world');
|
||||||
|
|
||||||
|
expect(result).to.equal('hello\\\\world');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('when input contains backslashes and wildcards, all are escaped', () => {
|
||||||
|
const result = escapeLikeWildcards('test\\%value');
|
||||||
|
|
||||||
|
expect(result).to.equal('test\\\\\\%value');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('when input contains no wildcards, it is returned unchanged', () => {
|
||||||
|
const result = escapeLikeWildcards('hello world');
|
||||||
|
|
||||||
|
expect(result).to.equal('hello world');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('when input is empty string, empty string is returned', () => {
|
||||||
|
const result = escapeLikeWildcards('');
|
||||||
|
|
||||||
|
expect(result).to.equal('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
3
apps/papra-server/src/modules/shared/db/sql.helpers.ts
Normal file
3
apps/papra-server/src/modules/shared/db/sql.helpers.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export function escapeLikeWildcards(input: string): string {
|
||||||
|
return input.replace(/\\/g, '\\\\').replace(/[%_]/g, '\\$&');
|
||||||
|
}
|
||||||
16
apps/papra-server/src/modules/shared/units.ts
Normal file
16
apps/papra-server/src/modules/shared/units.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
export const IN_MS = {
|
||||||
|
SECOND: 1_000,
|
||||||
|
MINUTE: 60_000, // 60 * 1_000
|
||||||
|
HOUR: 3_600_000, // 60 * 60 * 1_000
|
||||||
|
DAY: 86_400_000, // 24 * 60 * 60 * 1_000
|
||||||
|
WEEK: 604_800_000, // 7 * 24 * 60 * 60 * 1_000
|
||||||
|
MONTH: 2_630_016_000, // 30.44 * 24 * 60 * 60 * 1_000 -- approximation using average month length
|
||||||
|
YEAR: 31_556_736_000, // 365.24 * 24 * 60 * 60 * 1_000 -- approximation using average year length
|
||||||
|
};
|
||||||
|
|
||||||
|
export const IN_BYTES = {
|
||||||
|
KILOBYTE: 1_024,
|
||||||
|
MEGABYTE: 1_048_576, // 1_024 * 1_024
|
||||||
|
GIGABYTE: 1_073_741_824, // 1_024 * 1_024 * 1_024
|
||||||
|
TERABYTE: 1_099_511_627_776, // 1_024 * 1_024 * 1_024 * 1_024
|
||||||
|
};
|
||||||
@@ -2,6 +2,7 @@ import type { ConfigDefinition } from 'figue';
|
|||||||
import type { TasksDriverName } from './drivers/tasks-driver.constants';
|
import type { TasksDriverName } from './drivers/tasks-driver.constants';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { booleanishSchema } from '../config/config.schemas';
|
import { booleanishSchema } from '../config/config.schemas';
|
||||||
|
import { IN_MS } from '../shared/units';
|
||||||
import { tasksDriverNames } from './drivers/tasks-driver.constants';
|
import { tasksDriverNames } from './drivers/tasks-driver.constants';
|
||||||
|
|
||||||
export const tasksConfig = {
|
export const tasksConfig = {
|
||||||
@@ -35,7 +36,7 @@ export const tasksConfig = {
|
|||||||
pollIntervalMs: {
|
pollIntervalMs: {
|
||||||
doc: 'The interval at which the task persistence driver polls for new tasks',
|
doc: 'The interval at which the task persistence driver polls for new tasks',
|
||||||
schema: z.coerce.number().int().positive(),
|
schema: z.coerce.number().int().positive(),
|
||||||
default: 1_000,
|
default: 1 * IN_MS.SECOND,
|
||||||
env: 'TASKS_PERSISTENCE_DRIVERS_LIBSQL_POLL_INTERVAL_MS',
|
env: 'TASKS_PERSISTENCE_DRIVERS_LIBSQL_POLL_INTERVAL_MS',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
import { describe, expect, test } from 'vitest';
|
|
||||||
import { escapeLikeWildcards } from './users.repository.models';
|
|
||||||
|
|
||||||
describe('users repository models', () => {
|
|
||||||
describe('escapeLikeWildcards', () => {
|
|
||||||
test('escape % and _ characters by prefixing them with a backslash', () => {
|
|
||||||
expect(escapeLikeWildcards('100%_sure')).to.eql('100\\%\\_sure');
|
|
||||||
|
|
||||||
expect(escapeLikeWildcards('hello')).to.eql('hello');
|
|
||||||
expect(escapeLikeWildcards(' ')).to.eql(' ');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('backslashes are also escaped', () => {
|
|
||||||
expect(escapeLikeWildcards('C:\\path\\to\\file_%')).to.eql('C:\\\\path\\\\to\\\\file\\_\\%');
|
|
||||||
expect(escapeLikeWildcards('\\%_')).to.eql('\\\\\\%\\_');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,12 +1,9 @@
|
|||||||
import { eq, like, or } from 'drizzle-orm';
|
import { eq, like, or } from 'drizzle-orm';
|
||||||
|
import { escapeLikeWildcards } from '../shared/db/sql.helpers';
|
||||||
import { isNilOrEmptyString } from '../shared/utils';
|
import { isNilOrEmptyString } from '../shared/utils';
|
||||||
import { USER_ID_REGEX } from './users.constants';
|
import { USER_ID_REGEX } from './users.constants';
|
||||||
import { usersTable } from './users.table';
|
import { usersTable } from './users.table';
|
||||||
|
|
||||||
export function escapeLikeWildcards(input: string) {
|
|
||||||
return input.replace(/\\/g, '\\\\').replace(/[%_]/g, '\\$&');
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createSearchUserWhereClause({ search }: { search?: string }) {
|
export function createSearchUserWhereClause({ search }: { search?: string }) {
|
||||||
const trimmedSearch = search?.trim();
|
const trimmedSearch = search?.trim();
|
||||||
|
|
||||||
|
|||||||
@@ -97,8 +97,8 @@ describe('users repository', () => {
|
|||||||
test('when searching by user ID, only the exact matching user is returned', async () => {
|
test('when searching by user ID, only the exact matching user is returned', async () => {
|
||||||
const { db } = await createInMemoryDatabase({
|
const { db } = await createInMemoryDatabase({
|
||||||
users: [
|
users: [
|
||||||
{ id: 'usr_123456789012345678901234', email: 'alice@example.com', name: 'Alice' },
|
{ id: 'usr_123456789012345678901234', email: 'alice@example.com', name: 'Alice', createdAt: new Date('2025-01-01') },
|
||||||
{ id: 'usr_abcdefghijklmnopqrstuvwx', email: 'bob@example.com', name: 'Bob' },
|
{ id: 'usr_abcdefghijklmnopqrstuvwx', email: 'bob@example.com', name: 'Bob', createdAt: new Date('2025-01-02') },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
const { listUsers } = createUsersRepository({ db });
|
const { listUsers } = createUsersRepository({ db });
|
||||||
@@ -113,9 +113,9 @@ describe('users repository', () => {
|
|||||||
test('when searching by partial email, matching users are returned', async () => {
|
test('when searching by partial email, matching users are returned', async () => {
|
||||||
const { db } = await createInMemoryDatabase({
|
const { db } = await createInMemoryDatabase({
|
||||||
users: [
|
users: [
|
||||||
{ id: 'usr_1', email: 'alice@example.com', name: 'Alice' },
|
{ id: 'usr_1', email: 'alice@example.com', name: 'Alice', createdAt: new Date('2025-01-01') },
|
||||||
{ id: 'usr_2', email: 'bob@example.com', name: 'Bob' },
|
{ id: 'usr_2', email: 'bob@example.com', name: 'Bob', createdAt: new Date('2025-01-02') },
|
||||||
{ id: 'usr_3', email: 'alice.smith@test.com', name: 'Alice Smith' },
|
{ id: 'usr_3', email: 'alice.smith@test.com', name: 'Alice Smith', createdAt: new Date('2025-01-03') },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
const { listUsers } = createUsersRepository({ db });
|
const { listUsers } = createUsersRepository({ db });
|
||||||
@@ -125,17 +125,17 @@ describe('users repository', () => {
|
|||||||
expect(result.users).to.have.length(2);
|
expect(result.users).to.have.length(2);
|
||||||
expect(result.totalCount).to.equal(2);
|
expect(result.totalCount).to.equal(2);
|
||||||
expect(result.users.map(u => u.email)).to.deep.equal([
|
expect(result.users.map(u => u.email)).to.deep.equal([
|
||||||
'alice@example.com',
|
|
||||||
'alice.smith@test.com',
|
'alice.smith@test.com',
|
||||||
|
'alice@example.com',
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('when searching by partial name, matching users are returned', async () => {
|
test('when searching by partial name, matching users are returned', async () => {
|
||||||
const { db } = await createInMemoryDatabase({
|
const { db } = await createInMemoryDatabase({
|
||||||
users: [
|
users: [
|
||||||
{ id: 'usr_1', email: 'alice@example.com', name: 'Alice Johnson' },
|
{ id: 'usr_1', email: 'alice@example.com', name: 'Alice Johnson', createdAt: new Date('2025-01-01') },
|
||||||
{ id: 'usr_2', email: 'bob@example.com', name: 'Bob Smith' },
|
{ id: 'usr_2', email: 'bob@example.com', name: 'Bob Smith', createdAt: new Date('2025-01-02') },
|
||||||
{ id: 'usr_3', email: 'charlie@example.com', name: 'Charlie Johnson' },
|
{ id: 'usr_3', email: 'charlie@example.com', name: 'Charlie Johnson', createdAt: new Date('2025-01-03') },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
const { listUsers } = createUsersRepository({ db });
|
const { listUsers } = createUsersRepository({ db });
|
||||||
@@ -145,16 +145,16 @@ describe('users repository', () => {
|
|||||||
expect(result.users).to.have.length(2);
|
expect(result.users).to.have.length(2);
|
||||||
expect(result.totalCount).to.equal(2);
|
expect(result.totalCount).to.equal(2);
|
||||||
expect(result.users.map(u => u.name)).to.deep.equal([
|
expect(result.users.map(u => u.name)).to.deep.equal([
|
||||||
'Alice Johnson',
|
|
||||||
'Charlie Johnson',
|
'Charlie Johnson',
|
||||||
|
'Alice Johnson',
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('when searching with an empty string, all users are returned', async () => {
|
test('when searching with an empty string, all users are returned', async () => {
|
||||||
const { db } = await createInMemoryDatabase({
|
const { db } = await createInMemoryDatabase({
|
||||||
users: [
|
users: [
|
||||||
{ id: 'usr_1', email: 'alice@example.com', name: 'Alice' },
|
{ id: 'usr_1', email: 'alice@example.com', name: 'Alice', createdAt: new Date('2025-01-01') },
|
||||||
{ id: 'usr_2', email: 'bob@example.com', name: 'Bob' },
|
{ id: 'usr_2', email: 'bob@example.com', name: 'Bob', createdAt: new Date('2025-01-02') },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
const { listUsers } = createUsersRepository({ db });
|
const { listUsers } = createUsersRepository({ db });
|
||||||
@@ -168,11 +168,11 @@ describe('users repository', () => {
|
|||||||
test('when using pagination, only the requested page is returned', async () => {
|
test('when using pagination, only the requested page is returned', async () => {
|
||||||
const { db } = await createInMemoryDatabase({
|
const { db } = await createInMemoryDatabase({
|
||||||
users: [
|
users: [
|
||||||
{ id: 'usr_1', email: 'user1@example.com', name: 'User 1' },
|
{ id: 'usr_1', email: 'user1@example.com', name: 'User 1', createdAt: new Date('2025-01-01') },
|
||||||
{ id: 'usr_2', email: 'user2@example.com', name: 'User 2' },
|
{ id: 'usr_2', email: 'user2@example.com', name: 'User 2', createdAt: new Date('2025-01-02') },
|
||||||
{ id: 'usr_3', email: 'user3@example.com', name: 'User 3' },
|
{ id: 'usr_3', email: 'user3@example.com', name: 'User 3', createdAt: new Date('2025-01-03') },
|
||||||
{ id: 'usr_4', email: 'user4@example.com', name: 'User 4' },
|
{ id: 'usr_4', email: 'user4@example.com', name: 'User 4', createdAt: new Date('2025-01-04') },
|
||||||
{ id: 'usr_5', email: 'user5@example.com', name: 'User 5' },
|
{ id: 'usr_5', email: 'user5@example.com', name: 'User 5', createdAt: new Date('2025-01-05') },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
const { listUsers } = createUsersRepository({ db });
|
const { listUsers } = createUsersRepository({ db });
|
||||||
@@ -190,10 +190,10 @@ describe('users repository', () => {
|
|||||||
test('when searching with pagination, the total count reflects the search results', async () => {
|
test('when searching with pagination, the total count reflects the search results', async () => {
|
||||||
const { db } = await createInMemoryDatabase({
|
const { db } = await createInMemoryDatabase({
|
||||||
users: [
|
users: [
|
||||||
{ id: 'usr_1', email: 'alice1@example.com', name: 'Alice 1' },
|
{ id: 'usr_1', email: 'alice1@example.com', name: 'Alice 1', createdAt: new Date('2025-01-01') },
|
||||||
{ id: 'usr_2', email: 'alice2@example.com', name: 'Alice 2' },
|
{ id: 'usr_2', email: 'alice2@example.com', name: 'Alice 2', createdAt: new Date('2025-01-02') },
|
||||||
{ id: 'usr_3', email: 'alice3@example.com', name: 'Alice 3' },
|
{ id: 'usr_3', email: 'alice3@example.com', name: 'Alice 3', createdAt: new Date('2025-01-03') },
|
||||||
{ id: 'usr_4', email: 'bob@example.com', name: 'Bob' },
|
{ id: 'usr_4', email: 'bob@example.com', name: 'Bob', createdAt: new Date('2025-01-04') },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
const { listUsers } = createUsersRepository({ db });
|
const { listUsers } = createUsersRepository({ db });
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ function setupGetCurrentUserRoute({ app, db }: RouteDefinitionContext) {
|
|||||||
'createdAt',
|
'createdAt',
|
||||||
'updatedAt',
|
'updatedAt',
|
||||||
'planId',
|
'planId',
|
||||||
|
'twoFactorEnabled',
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export const usersTable = sqliteTable(
|
|||||||
name: text('name'),
|
name: text('name'),
|
||||||
image: text('image'),
|
image: text('image'),
|
||||||
maxOrganizationCount: integer('max_organization_count', { mode: 'number' }),
|
maxOrganizationCount: integer('max_organization_count', { mode: 'number' }),
|
||||||
|
twoFactorEnabled: integer('two_factor_enabled', { mode: 'boolean' }).notNull().default(false),
|
||||||
},
|
},
|
||||||
table => [
|
table => [
|
||||||
index('users_email_index').on(table.email),
|
index('users_email_index').on(table.email),
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ async function buildServices({ config }: { config: Config }): Promise<GlobalDepe
|
|||||||
|
|
||||||
// --- Services initialization
|
// --- Services initialization
|
||||||
await taskServices.initialize();
|
await taskServices.initialize();
|
||||||
registerEventHandlers({ eventServices, trackingServices, db, documentSearchServices });
|
registerEventHandlers({ eventServices, trackingServices, db, documentSearchServices, config });
|
||||||
|
|
||||||
return {
|
return {
|
||||||
config,
|
config,
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
# @papra/api-sdk
|
# @papra/api-sdk
|
||||||
|
|
||||||
|
## 1.1.3
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- [#698](https://github.com/papra-hq/papra/pull/698) [`815f6f9`](https://github.com/papra-hq/papra/commit/815f6f94f84478fef049f9baea9b0b30b56906a2) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Fixed prepublishing assets
|
||||||
|
|
||||||
## 1.1.2
|
## 1.1.2
|
||||||
|
|
||||||
### Patch Changes
|
### Patch Changes
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user