Compare commits

...

36 Commits

Author SHA1 Message Date
Corentin Thomasset
32b4129e2c wip 2025-10-07 23:42:44 +02:00
Corentin Thomasset
9a6e822e71 feat(docker): drop support for armv7 (#532) 2025-10-06 23:56:24 +02:00
Corentin Thomasset
e52bc261db feat(organizations): added max members count check for organization invitations (#536) 2025-10-05 15:11:08 +02:00
Corentin Thomasset
624ad62c53 feat(orgs): added usage page and related components (#534)
- Implemented a new page to view organization usage, including document storage, intake emails, and member counts.
- Added translations for the new usage features in multiple languages (DE, EN, ES, FR, IT, PL, PT-BR, RO).
- Created a `UsageWarningCard` to alert users when they are nearing their storage limits.
- Updated the sidebar and organization settings layout to include a link to the usage page.
- Added API endpoints to fetch organization usage data and handle limits.
- Introduced a `ProgressCircle` component for visual representation of usage statistics.
- Refactored utility functions to handle positive infinity values in usage calculations.
2025-10-05 02:45:21 +02:00
Corentin Thomasset
630f9cc328 feat(subscriptions): add billing interval options (#533) 2025-10-04 21:57:09 +02:00
Corentin Thomasset
9f5be458fe feat(subscriptions): added cta and subscription management features (#523) 2025-10-04 14:58:42 +02:00
Corentin Thomasset
1bfdb8aa66 chore(release): update versions (#525)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-10-04 11:47:47 +02:00
Corentin Thomasset
2e2bb6fbbd chore(changeset): added changeset for ip env variable (#531) 2025-10-02 22:48:38 +00:00
Corentin Thomasset
d09b9ed70d feat(auth): add IP address header configuration and logging support (#530) 2025-10-03 00:41:42 +02:00
Corentin Thomasset
e1571d2b87 fix(auth): enhance logging to include additional arguments in log messages (#529) 2025-10-02 22:36:06 +00:00
Corentin Thomasset
c9a66e4aa8 fix(docs): update env variable name for OwlRelay configuration (#528) 2025-10-02 20:29:55 +00:00
Corentin Thomasset
9fa2df4235 feat(package): add module type to root package.json (#526) 2025-10-01 14:15:23 +00:00
Corentin Thomasset
c84a921988 feat(tags): update tag color validation to allow uppercase letters (#524)
* feat(tags): update tag color validation to allow uppercase letters

* Update .changeset/quiet-peas-mate.md

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-10-01 14:09:49 +00:00
Corentin Thomasset
9b5f3993c3 chore(release): update versions (#518)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-09-30 11:51:10 +02:00
Corentin Thomasset
b28772317c fix(file-upload): set default parameter charset to utf8 (#521) 2025-09-29 21:20:43 +02:00
Corentin Thomasset
a3f9f05c66 feat(organizations): restrict organization deletion to owners only (#517) 2025-09-26 01:49:59 +02:00
Corentin Thomasset
0616635cd6 chore(release): update versions (#509)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-09-24 17:00:01 +02:00
Corentin Thomasset
9e7a3ba70b chore(version): update version bump for api keys permissions changes (#516) 2025-09-24 16:49:11 +02:00
Corentin Thomasset
04990b986e docs(api-endpoints): added explications on how to use api keys (#515) 2025-09-24 14:41:14 +00:00
Corentin Thomasset
097b6bf2b7 feat(api-keys): added format check for api tokens to avoid unnecessary db call (#514) 2025-09-24 14:32:34 +00:00
Corentin Thomasset
cb3ce6b1d8 feat(api-keys): add organization permissions for api keys (#512) 2025-09-24 15:25:48 +02:00
Corentin Thomasset
405ba645f6 feat(docker): disable Better Auth telemetry in Dockerfiles (#511) 2025-09-21 20:56:43 +00:00
Corentin Thomasset
ab6fd6ad10 feat(tasks): update figue to allow for fallback task worker ids env variables (#510) 2025-09-21 22:53:04 +02:00
Corentin Thomasset
782f70ff66 feat(tasks): add option to disable PRAGMA statements in migrations (#508) 2025-09-20 22:07:34 +00:00
Corentin Thomasset
1abbf18e94 chore(release): update versions (#505)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-09-20 14:59:01 +02:00
Corentin Thomasset
6bcb2a71e9 feat(intake-emails): add intake email username pattern config (#506)
Co-authored-by: Alexander <goldengamerlp@users.noreply.github.com>
2025-09-19 20:37:25 +02:00
Corentin Thomasset
936bc2bd0a refactor(intake-emails): split username creation from addresses management (#504) 2025-09-18 01:59:29 +02:00
Corentin Thomasset
2efe7321cd chore(release): update versions (#494)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-09-14 11:31:31 +02:00
Corentin Thomasset
947bdf8385 docs(CONTRIBUTING): add IDE setup instructions for ESLint in VS Code (#502)
* docs(CONTRIBUTING): add IDE setup instructions for ESLint in VS Code

* Update CONTRIBUTING.md

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-09-14 09:18:37 +00:00
Corentin Thomasset
b5bf0cca4b fix(upload): disable client size guard when maxUploadSize <= 0 (#501) 2025-09-14 10:44:29 +02:00
Corentin Thomasset
208a561668 feat(tasks): added libsql task service driver (#500) 2025-09-13 22:42:08 +02:00
Corentin Thomasset
40cb1d71d5 fix(documents): enhance file fetching security by setting appropriate headers (#499) 2025-09-13 15:46:34 +02:00
Corentin Thomasset
3da13f7591 refactor(document-page): remove "open in new tab" button (#498) 2025-09-13 15:29:51 +02:00
Corentin Thomasset
2a444aad31 chore(tests): set timezone in vitest configurations (#497) 2025-09-13 09:25:40 +00:00
Corentin Thomasset
47d8bbd356 refactor(utils): added isString and isNonEmptyString utility functions (#495) 2025-09-12 22:22:01 +02:00
Corentin Thomasset
ed4d7e4a00 fix(folder-ingestion): allow cross docker volume file moving (#493) 2025-09-10 22:48:56 +02:00
147 changed files with 4740 additions and 363 deletions

View File

@@ -0,0 +1,7 @@
---
type: feature
isBreaking: false
version: 25.10.2
date: '2025-10-07'
---
Switched to calver versioning

1
.changelog/version Normal file
View File

@@ -0,0 +1 @@
25.10.2

View File

@@ -0,0 +1,6 @@
---
"@papra/app-client": patch
"@papra/app-server": patch
---
Drop docker armv7 support

View File

@@ -0,0 +1,6 @@
---
"@papra/app-client": patch
"@papra/app-server": patch
---
Added a page to view organization usage

View File

@@ -0,0 +1,81 @@
name: Changelog Release
on:
push:
branches:
- main
concurrency: ${{ github.workflow }}-${{ github.ref }}
jobs:
changelog-release:
name: Changelog Release
runs-on: ubuntu-latest
permissions:
contents: write
actions: write
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with:
fetch-depth: 0 # Need full history for git log in changelog
- name: Install pnpm
uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: 'pnpm'
- name: Install dependencies
run: pnpm i
- name: Check for pending changelogs
id: check_pending
run: |
if [ -d ".changelog/pending" ] && [ "$(ls -A .changelog/pending/*.md 2>/dev/null)" ]; then
echo "has_pending=true" >> $GITHUB_OUTPUT
echo "Found pending changelog entries"
else
echo "has_pending=false" >> $GITHUB_OUTPUT
echo "No pending changelog entries"
fi
- name: Get next version
if: steps.check_pending.outputs.has_pending == 'true'
id: next_version
run: |
cd packages/changelog
NEXT_VERSION=$(pnpm --silent changelog:next-version)
echo "version=$NEXT_VERSION" >> $GITHUB_OUTPUT
echo "Next version: $NEXT_VERSION"
- name: Release changelog
if: steps.check_pending.outputs.has_pending == 'true'
run: |
cd packages/changelog
pnpm changelog:release -v ${{ steps.next_version.outputs.version }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Commit changelog and version
if: steps.check_pending.outputs.has_pending == 'true'
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add .changelog
git commit -m "chore(release): ${{ steps.next_version.outputs.version }}"
git push
- name: Create git tag
if: steps.check_pending.outputs.has_pending == 'true'
run: |
git tag "v${{ steps.next_version.outputs.version }}"
git push origin "v${{ steps.next_version.outputs.version }}"
- name: Trigger Docker build
if: steps.check_pending.outputs.has_pending == 'true'
run: |
gh workflow run release-docker.yaml -f version="${{ steps.next_version.outputs.version }}"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -44,7 +44,7 @@ jobs:
with:
context: .
file: ./docker/Dockerfile
platforms: linux/amd64,linux/arm64,linux/arm/v7
platforms: linux/amd64,linux/arm64
push: true
tags: |
corentinth/papra:latest-root
@@ -57,7 +57,7 @@ jobs:
with:
context: .
file: ./docker/Dockerfile.rootless
platforms: linux/amd64,linux/arm64,linux/arm/v7
platforms: linux/amd64,linux/arm64
push: true
tags: |
corentinth/papra:latest

2
.gitignore vendored
View File

@@ -35,6 +35,8 @@ cache
*.db-shm
*.db-wal
*.sqlite
*.sqlite-shm
*.sqlite-wal
local-documents
ingestion

206
CLAUDE.md Normal file
View File

@@ -0,0 +1,206 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Papra is a minimalistic document management and archiving platform built as a monorepo using PNPM workspaces. The project includes a SolidJS frontend, HonoJS backend, CLI tools, and supporting packages.
It's open-source and designed for easy self-hosting using Docker, and also offers a cloud-hosted SaaS version.
## Architecture
### Monorepo Structure
- **apps/papra-server**: Backend API server (HonoJS + Drizzle ORM + Better Auth)
- **apps/papra-client**: Frontend application (SolidJS + UnoCSS + Shadcn Solid)
- **apps/docs**: Documentation site (Astro + Starlight)
- **packages/lecture**: Text extraction library for documents
- **packages/api-sdk**: API client SDK
- **packages/cli**: Command-line interface
- **packages/webhooks**: Webhook types and utilities
### Backend Architecture (apps/papra-server)
The backend follows a modular architecture with feature-based modules:
- **Module pattern**: Each feature lives in `src/modules/<feature>/` with:
- `*.repository.ts`: Database access layer (Drizzle ORM queries)
- `*.usecases.ts`: Business logic and orchestration
- `*.routes.ts`: HTTP route handlers (Hono)
- `*.services.ts`: Service layer for external integrations
- `*.table.ts`: Drizzle schema definitions
- `*.types.ts`: TypeScript type definitions
- `*.errors.ts`: Error definitions
- **Core modules**: `app`, `shared`, `config`, `tasks`
- **Feature modules**: `documents`, `organizations`, `users`, `tags`, `tagging-rules`, `intake-emails`, `ingestion-folders`, `webhooks`, `api-keys`, `subscriptions`, etc.
- **Database**: Uses Drizzle ORM with SQLite/Turso (libsql). Schema is in `*.table.ts` files, migrations in `src/migrations/`
- **Authentication**: Better Auth library for user auth
- **Task system**: Background job processing using Cadence MQ, a custom made queue system (papra-hq/cadence-mq)
- **Document storage**: Abstracted storage supporting local filesystem, S3, and Azure Blob
### Frontend Architecture (apps/papra-client)
- **SolidJS** for reactivity with router (`@solidjs/router`)
- **Module pattern**: Features in `src/modules/<feature>/` with:
- `components/`: UI components
- `pages/`: Route components
- `*.services.ts`: API client calls
- `*.provider.tsx`: Context providers
- `*.types.ts`: Type definitions
- **Routing**: Defined in `src/routes.tsx`
- **Styling**: UnoCSS for atomic CSS with Shadcn Solid components
- **State**: TanStack Query for server state, local storage for client state
- **i18n**: TypeScript-based translations in `src/locales/*.dictionary.ts`
### Dependency Injection Pattern
The server uses a dependency injection pattern with `@corentinth/chisels/injectArguments` to create testable services that accept dependencies as parameters.
## Development Commands
### Initial Setup
```bash
# Install dependencies
pnpm install
# Build all packages (required before running apps)
pnpm build:packages
```
### Backend Development
```bash
cd apps/papra-server
# Run database migrations
pnpm migrate:up
# Start development server (localhost:1221)
pnpm dev
# Run tests
pnpm test # All tests
pnpm test:watch # Watch mode
pnpm test:unit # Unit tests only
pnpm test:int # Integration tests only
# Lint and typecheck
pnpm lint
pnpm typecheck
# Database management
pnpm db:studio # Open Drizzle Studio
pnpm migrate:create "migration_name" # Create new migration
```
### Frontend Development
```bash
cd apps/papra-client
# Start development server (localhost:3000)
pnpm dev
# Run tests
pnpm test
pnpm test:watch
pnpm test:e2e # Playwright E2E tests
# Lint and typecheck
pnpm lint
pnpm typecheck
# i18n key synchronization
pnpm script:sync-i18n-key-order
```
### Package Development
```bash
cd packages/<package-name>
# Build package
pnpm build
pnpm build:watch # Watch mode (or pnpm dev)
# Run tests
pnpm test
pnpm test:watch
```
### Root-level Commands
```bash
# Run tests across all packages
pnpm test
pnpm test:watch
# Build all packages
pnpm build:packages
# Version management (changesets)
pnpm changeset # Create changeset
pnpm version # Apply changesets and bump versions
# Docker builds
pnpm docker:build:root
pnpm docker:build:root:amd64
pnpm docker:build:root:arm64
```
### Documentation Development
```bash
cd apps/docs
pnpm dev # localhost:4321
```
## Testing Guidelines
- Use **Vitest** for all testing
- Test files: `*.test.ts` for unit tests, `*.int.test.ts` for integration tests
- Use business-oriented test names (avoid `it('should return true')`)
- Integration tests may use Testcontainers (Azurite, LocalStack)
- All new features require test coverage
## Code Style
- **ESLint config**: `@antfu/eslint-config` (auto-fix on save recommended)
- **Conventions**:
- Use functional programming where possible
- Prefer clarity and maintainability over performance
- Use meaningful names for variables, functions, and components
- Follow Conventional Commits for commit messages
- **Type safety**: Strict TypeScript throughout
## i18n
- Language files in `apps/papra-client/src/locales/*.dictionary.ts`
- Reference `en.dictionary.ts` for all keys (English is fallback)
- Fully type-safe with TypeScript
- Update `i18n.constants.ts` when adding new languages
- Use `pnpm script:sync-i18n-key-order` to sync key order
## Contributing Flow
1. Open an issue before submitting PRs for features/bugs
2. Target the `main` branch (continuously deployed to production)
3. Keep PRs small and atomic
4. Ensure CI is green (linting, type checking, testing, building)
5. PRs are squashed on merge
## Key Technologies
- **Frontend**: SolidJS, UnoCSS, Shadcn Solid, TanStack Query, Vite
- **Backend**: HonoJS, Drizzle ORM, Better Auth, Zod, Cadence MQ
- **Database**: SQLite/Turso (libsql)
- **Testing**: Vitest, Playwright, Testcontainers
- **Monorepo**: PNPM workspaces with catalog for shared dependencies
- **Build**: esbuild (backend), Vite (frontend), tsdown (packages)

View File

@@ -105,6 +105,73 @@ We recommend running the app locally for development. Follow these steps:
6. Open your browser and navigate to `http://localhost:3000`.
### IDE Setup
#### ESLint Extension
We recommend installing the [ESLint extension](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint) for VS Code to get real-time linting feedback and automatic code fixing.
The linting configuration is based on [@antfu/eslint-config](https://github.com/antfu/eslint-config), you can find specific IDE configurations in their repository.
<details>
<summary>Recommended VS Code Settings</summary>
Create or update your `.vscode/settings.json` file with the following configuration:
```json
{
// Disable the default formatter, use eslint instead
"prettier.enable": false,
"editor.formatOnSave": false,
// Auto fix
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit",
"source.organizeImports": "never"
},
// Silent the stylistic rules in your IDE, but still auto fix them
"eslint.rules.customizations": [
{ "rule": "style/*", "severity": "off", "fixable": true },
{ "rule": "format/*", "severity": "off", "fixable": true },
{ "rule": "*-indent", "severity": "off", "fixable": true },
{ "rule": "*-spacing", "severity": "off", "fixable": true },
{ "rule": "*-spaces", "severity": "off", "fixable": true },
{ "rule": "*-order", "severity": "off", "fixable": true },
{ "rule": "*-dangle", "severity": "off", "fixable": true },
{ "rule": "*-newline", "severity": "off", "fixable": true },
{ "rule": "*quotes", "severity": "off", "fixable": true },
{ "rule": "*semi", "severity": "off", "fixable": true }
],
// Enable eslint for all supported languages
"eslint.validate": [
"javascript",
"javascriptreact",
"typescript",
"typescriptreact",
"vue",
"html",
"markdown",
"json",
"jsonc",
"yaml",
"toml",
"xml",
"gql",
"graphql",
"astro",
"svelte",
"css",
"less",
"scss",
"pcss",
"postcss"
]
}
```
</details>
### Testing
We use **Vitest** for testing. Each package comes with its own testing commands.

View File

@@ -1,5 +1,11 @@
# @papra/docs
## 0.6.1
### Patch Changes
- [#512](https://github.com/papra-hq/papra/pull/512) [`cb3ce6b`](https://github.com/papra-hq/papra/commit/cb3ce6b1d8d5dba09cbf0d2964f14b1c93220571) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added organizations permissions for api keys
## 0.6.0
### Minor Changes

View File

@@ -1,7 +1,7 @@
{
"name": "@papra/docs",
"type": "module",
"version": "0.6.0",
"version": "0.6.1",
"private": true,
"packageManager": "pnpm@10.12.3",
"description": "Papra documentation website",
@@ -37,7 +37,7 @@
"@unocss/reset": "^0.64.0",
"eslint": "^9.17.0",
"eslint-plugin-astro": "^1.3.1",
"figue": "^2.2.2",
"figue": "^3.1.1",
"lodash-es": "^4.17.21",
"marked": "^15.0.6",
"typescript": "^5.7.3",

View File

@@ -1,5 +1,5 @@
import type { ConfigDefinition, ConfigDefinitionElement } from 'figue';
import { isArray, isEmpty, isNil } from 'lodash-es';
import { castArray, isArray, isEmpty, isNil } from 'lodash-es';
import { marked } from 'marked';
import { configDefinition } from '../../papra-server/src/modules/config/config';
@@ -46,16 +46,21 @@ const rows = configDetails
};
});
const mdSections = rows.map(({ documentation, env, path, defaultValue }) => `
### ${env}
const mdSections = rows.map(({ documentation, env, path, defaultValue }) => {
const envs = castArray(env);
const [firstEnv, ...restEnvs] = envs;
return `
### ${firstEnv}
${documentation}
- Path: \`${path.join('.')}\`
- Environment variable: \`${env}\`
- Environment variable: \`${firstEnv}\` ${restEnvs.length ? `, with fallback to: ${restEnvs.map(e => `\`${e}\``).join(', ')}` : ''}
- Default value: \`${defaultValue}\`
`.trim()).join('\n\n---\n\n');
`.trim();
}).join('\n\n---\n\n');
function wrapText(text: string, maxLength = 75) {
const words = text.split(' ');
@@ -80,10 +85,12 @@ function wrapText(text: string, maxLength = 75) {
const fullDotEnv = rows.map(({ env, defaultValue, documentation }) => {
const isEmptyDefaultValue = isNil(defaultValue) || (isArray(defaultValue) && isEmpty(defaultValue)) || defaultValue === '';
const envs = castArray(env);
const [firstEnv] = envs;
return [
...wrapText(documentation),
`# ${env}=${isEmptyDefaultValue ? '' : defaultValue}`,
`# ${firstEnv}=${isEmptyDefaultValue ? '' : defaultValue}`,
].join('\n');
}).join('\n\n');

View File

@@ -39,7 +39,7 @@ By integrating Papra with OwlRelay, your instance will generate email addresses
3. **Configure your Papra instance**
Once you have created your API key, you can configure your Papra instance to receive emails by setting the `OWLRELAY_API_KEY` and `OWLRELAY_WEBHOOK_SECRET` environment variables.
Once you have created your API key, you can configure your Papra instance to receive emails by setting the `OWLRELAY_API_KEY` and `INTAKE_EMAILS_WEBHOOK_SECRET` environment variables.
```bash
# Enable intake emails

View File

@@ -18,8 +18,107 @@ The public API uses a bearer token for authentication. You can get a token by lo
</details>
To authenticate your requests, include the token in the `Authorization` header with the `Bearer` prefix:
```
Authorization: Bearer YOUR_API_TOKEN
```
### Examples
**Using cURL:**
```bash
curl -H "Authorization: Bearer YOUR_API_TOKEN" \
https://api.papra.app/api/organizations
```
**Using JavaScript (fetch):**
```javascript
const response = await fetch('https://api.papra.app/api/organizations', {
headers: {
'Authorization': 'Bearer YOUR_API_TOKEN',
'Content-Type': 'application/json'
}
})
```
### API Key Permissions
When creating an API key, you can select from the following permissions:
**Organizations:**
- `organizations:create` - Create new organizations
- `organizations:read` - Read organization information and list organizations of the user
- `organizations:update` - Update organization details
- `organizations:delete` - Delete organizations
**Documents:**
- `documents:create` - Upload and create new documents
- `documents:read` - Read and download documents
- `documents:update` - Update document metadata and content
- `documents:delete` - Delete documents
**Tags:**
- `tags:create` - Create new tags
- `tags:read` - Read tag information and list tags
- `tags:update` - Update tag details
- `tags:delete` - Delete tags
## Endpoints
### List organizations
**GET** `/api/organizations`
List all organizations accessible to the authenticated user.
- Required API key permissions: `organizations:read`
- Response (JSON)
- `organizations`: The list of organizations.
### Create an organization
**POST** `/api/organizations`
Create a new organization.
- Required API key permissions: `organizations:create`
- Body (JSON)
- `name`: The organization name (3-50 characters).
- Response (JSON)
- `organization`: The created organization.
### Get an organization
**GET** `/api/organizations/:organizationId`
Get an organization by its ID.
- Required API key permissions: `organizations:read`
- Response (JSON)
- `organization`: The organization.
### Update an organization
**PUT** `/api/organizations/:organizationId`
Update an organization's name.
- Required API key permissions: `organizations:update`
- Body (JSON)
- `name`: The new organization name (3-50 characters).
- Response (JSON)
- `organization`: The updated organization.
### Delete an organization
**DELETE** `/api/organizations/:organizationId`
Delete an organization by its ID.
- Required API key permissions: `organizations:delete`
- Response: empty (204 status code)
### Create a document
**POST** `/api/organizations/:organizationId/documents`

View File

@@ -1,5 +1,33 @@
# @papra/app-client
## 0.9.6
## 0.9.5
### Patch Changes
- [#517](https://github.com/papra-hq/papra/pull/517) [`a3f9f05`](https://github.com/papra-hq/papra/commit/a3f9f05c664b4995b62db59f2e9eda8a3bfef0de) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Prevented organization deletion by non-organization owner
## 0.9.4
### Patch Changes
- [#512](https://github.com/papra-hq/papra/pull/512) [`cb3ce6b`](https://github.com/papra-hq/papra/commit/cb3ce6b1d8d5dba09cbf0d2964f14b1c93220571) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added organizations permissions for api keys
## 0.9.3
### Patch Changes
- [#506](https://github.com/papra-hq/papra/pull/506) [`6bcb2a7`](https://github.com/papra-hq/papra/commit/6bcb2a71e990d534dd12d84e64a38f2b2baea25a) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added the possibility to define patterns for email intake username generation
## 0.9.2
### Patch Changes
- [#501](https://github.com/papra-hq/papra/pull/501) [`b5bf0cc`](https://github.com/papra-hq/papra/commit/b5bf0cca4b571495329cb553da06e0d334ee8968) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Fix an issue preventing to disable the max upload size
- [#498](https://github.com/papra-hq/papra/pull/498) [`3da13f7`](https://github.com/papra-hq/papra/commit/3da13f759155df5d7c532160a7ea582385db63b6) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Removed the "open in new tab" button for security improvement (xss prevention)
## 0.9.1
### Patch Changes

View File

@@ -1,7 +1,7 @@
{
"name": "@papra/app-client",
"type": "module",
"version": "0.9.1",
"version": "0.9.6",
"private": true,
"packageManager": "pnpm@10.12.3",
"description": "Papra frontend client",
@@ -21,12 +21,10 @@
"serve": "vite preview",
"lint": "eslint .",
"lint:fix": "eslint --fix .",
"test": "pnpm check-i18n-types-outdated && vitest run",
"test": "vitest run",
"test:watch": "vitest watch",
"test:e2e": "playwright test",
"typecheck": "tsc --noEmit",
"check-i18n-types-outdated": "pnpm script:generate-i18n-types && git diff --exit-code -- src/modules/i18n/locales.types.ts > /dev/null || (echo \"Locales types are outdated, please run 'pnpm script:generate-i18n-types' and commit the changes.\" && exit 1)",
"script:get-missing-i18n-keys": "tsx src/scripts/get-missing-i18n-keys.script.ts",
"script:sync-i18n-key-order": "tsx src/scripts/sync-i18n-key-order.script.ts"
},
"dependencies": {

View File

@@ -21,10 +21,10 @@
--accent-foreground: 0 0% 9%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--destructive-foreground: 0 0% 0%;
--warning: 31 98% 50%;
--warning-foreground: 0 0% 98%;
--warning-foreground: 0 0% 0%;
--border: 0 0% 89.8%;
--input: 0 0% 89.8%;
@@ -59,7 +59,7 @@
--destructive-foreground: 0 0% 98%;
--warning: 31 98% 50%;
--warning-foreground: 0 0% 98%;
--warning-foreground: 0 0% 0%;
--border: 0 0% 14.9%;
--input: 0 0% 14.9%;

View File

@@ -143,6 +143,17 @@ export const translations: Partial<TranslationsDictionary> = {
'organization.settings.delete.confirm.confirm-button': 'Organisation löschen',
'organization.settings.delete.confirm.cancel-button': 'Abbrechen',
'organization.settings.delete.success': 'Organisation gelöscht',
'organization.settings.delete.only-owner': 'Nur der Organisationsinhaber kann diese Organisation löschen.',
'organization.usage.page.title': 'Nutzung',
'organization.usage.page.description': 'Sehen Sie die aktuelle Nutzung und Limits Ihrer Organisation.',
'organization.usage.storage.title': 'Dokumentenspeicher',
'organization.usage.storage.description': 'Gesamtspeicher, der von Ihren Dokumenten verwendet wird',
'organization.usage.intake-emails.title': 'Eingangs-E-Mails',
'organization.usage.intake-emails.description': 'Anzahl der Eingangs-E-Mail-Adressen',
'organization.usage.members.title': 'Mitglieder',
'organization.usage.members.description': 'Anzahl der Mitglieder in der Organisation',
'organization.usage.unlimited': 'Unbegrenzt',
'organizations.members.title': 'Mitglieder',
'organizations.members.description': 'Verwalten Sie Ihre Organisationsmitglieder',
@@ -417,6 +428,13 @@ export const translations: Partial<TranslationsDictionary> = {
// API keys
'api-keys.permissions.select-all': 'Alle auswählen',
'api-keys.permissions.deselect-all': 'Alle abwählen',
'api-keys.permissions.organizations.title': 'Organisationen',
'api-keys.permissions.organizations.organizations:create': 'Organisationen erstellen',
'api-keys.permissions.organizations.organizations:read': 'Organisationen lesen',
'api-keys.permissions.organizations.organizations:update': 'Organisationen aktualisieren',
'api-keys.permissions.organizations.organizations:delete': 'Organisationen löschen',
'api-keys.permissions.documents.title': 'Dokumente',
'api-keys.permissions.documents.documents:create': 'Dokumente erstellen',
'api-keys.permissions.documents.documents:read': 'Dokumente lesen',
@@ -512,11 +530,16 @@ export const translations: Partial<TranslationsDictionary> = {
'layout.menu.settings': 'Einstellungen',
'layout.menu.account': 'Konto',
'layout.menu.general-settings': 'Allgemeine Einstellungen',
'layout.menu.usage': 'Nutzung',
'layout.menu.intake-emails': 'E-Mail-Eingang',
'layout.menu.webhooks': 'Webhooks',
'layout.menu.members': 'Mitglieder',
'layout.menu.invitations': 'Einladungen',
'layout.upgrade-cta.title': 'Brauchen Sie mehr Platz?',
'layout.upgrade-cta.description': '10x mehr Speicher + Team-Zusammenarbeit',
'layout.upgrade-cta.button': 'Auf Plus upgraden',
'layout.theme.light': 'Heller Modus',
'layout.theme.dark': 'Dunkler Modus',
'layout.theme.system': 'Systemmodus',
@@ -541,7 +564,8 @@ export const translations: Partial<TranslationsDictionary> = {
'api-errors.document.already_exists': 'Das Dokument existiert bereits',
'api-errors.document.size_too_large': 'Die Datei ist zu groß',
'api-errors.intake_email.limit_reached': 'Die maximale Anzahl an Eingangse-Mails für diese Organisation wurde erreicht. Bitte aktualisieren Sie Ihren Plan, um weitere Eingangse-Mails zu erstellen.',
'api-errors.intake-emails.already_exists': 'Eine Eingang-Email mit dieser Adresse existiert bereits.',
'api-errors.intake_email.limit_reached': 'Die maximale Anzahl an Eingang-EMails für diese Organisation wurde erreicht. Bitte aktualisieren Sie Ihren Plan, um weitere Eingangse-Mails zu erstellen.',
'api-errors.user.max_organization_count_reached': 'Sie haben die maximale Anzahl an Organisationen erreicht, die Sie erstellen können. Wenn Sie weitere erstellen möchten, kontaktieren Sie bitte den Support.',
'api-errors.default': 'Beim Verarbeiten Ihrer Anfrage ist ein Fehler aufgetreten.',
'api-errors.organization.invitation_already_exists': 'Eine Einladung für diese E-Mail existiert bereits in dieser Organisation.',
@@ -551,6 +575,7 @@ export const translations: Partial<TranslationsDictionary> = {
'api-errors.tags.already_exists': 'Ein Tag mit diesem Namen existiert bereits für diese Organisation',
'api-errors.internal.error': 'Beim Verarbeiten Ihrer Anfrage ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut.',
'api-errors.auth.invalid_origin': 'Ungültige Anwendungs-Ursprung. Wenn Sie Papra selbst hosten, stellen Sie sicher, dass Ihre APP_BASE_URL-Umgebungsvariable mit Ihrer aktuellen URL übereinstimmt. Weitere Details finden Sie unter https://docs.papra.app/resources/troubleshooting/#invalid-application-origin',
'api-errors.organization.max_members_count_reached': 'Die maximale Anzahl an Mitgliedern und ausstehenden Einladungen für diese Organisation wurde erreicht. Bitte aktualisieren Sie Ihren Plan, um weitere Mitglieder hinzuzufügen.',
// Not found
@@ -574,4 +599,47 @@ export const translations: Partial<TranslationsDictionary> = {
'color-picker.select-color': 'Farbe auswählen',
'color-picker.select-a-color': 'Eine Farbe auswählen',
// Subscriptions
'subscriptions.checkout-success.title': 'Zahlung erfolgreich!',
'subscriptions.checkout-success.description': 'Ihr Abonnement wurde erfolgreich aktiviert.',
'subscriptions.checkout-success.thank-you': 'Vielen Dank für Ihr Upgrade auf Papra Plus. Sie haben jetzt Zugriff auf alle Premium-Funktionen.',
'subscriptions.checkout-success.go-to-organizations': 'Zu Organisationen',
'subscriptions.checkout-success.redirecting': 'Weiterleitung in {{ count }} Sekunde{{ plural }}...',
'subscriptions.checkout-cancel.title': 'Zahlung abgebrochen',
'subscriptions.checkout-cancel.description': 'Ihr Abonnement-Upgrade wurde abgebrochen.',
'subscriptions.checkout-cancel.no-charges': 'Es wurden keine Gebühren von Ihrem Konto abgebucht. Sie können es jederzeit erneut versuchen.',
'subscriptions.checkout-cancel.back-to-organizations': 'Zurück zu Organisationen',
'subscriptions.checkout-cancel.need-help': 'Benötigen Sie Hilfe?',
'subscriptions.checkout-cancel.contact-support': 'Support kontaktieren',
'subscriptions.upgrade-dialog.title': 'Auf Plus upgraden',
'subscriptions.upgrade-dialog.description': 'Schalten Sie leistungsstarke Funktionen für Ihre Organisation frei',
'subscriptions.upgrade-dialog.contact-us': 'Kontaktieren Sie uns',
'subscriptions.upgrade-dialog.enterprise-plans': 'wenn Sie benutzerdefinierte Enterprise-Pläne benötigen.',
'subscriptions.upgrade-dialog.current-plan': 'Aktueller Plan',
'subscriptions.upgrade-dialog.recommended': 'Empfohlen',
'subscriptions.upgrade-dialog.per-month': '/Monat',
'subscriptions.upgrade-dialog.upgrade-now': 'Jetzt upgraden',
'subscriptions.plan.free.name': 'Kostenloser Plan',
'subscriptions.plan.plus.name': 'Plus',
'subscriptions.features.storage-size': 'Dokumentenspeichergröße',
'subscriptions.features.members': 'Organisationsmitglieder',
'subscriptions.features.members-count': '{{ count }} Mitglieder',
'subscriptions.features.email-intakes': 'E-Mail-Eingänge',
'subscriptions.features.email-intakes-count-singular': '{{ count }} Adresse',
'subscriptions.features.email-intakes-count-plural': '{{ count }} Adressen',
'subscriptions.features.max-upload-size': 'Maximale Upload-Dateigröße',
'subscriptions.features.support': 'Support',
'subscriptions.features.support-community': 'Community-Support',
'subscriptions.features.support-email': 'E-Mail-Support',
'subscriptions.billing-interval.monthly': 'Monatlich',
'subscriptions.billing-interval.annual': 'Jährlich',
'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',
};

View File

@@ -141,6 +141,17 @@ export const translations = {
'organization.settings.delete.confirm.confirm-button': 'Delete organization',
'organization.settings.delete.confirm.cancel-button': 'Cancel',
'organization.settings.delete.success': 'Organization deleted',
'organization.settings.delete.only-owner': 'Only the organization owner can delete this organization.',
'organization.usage.page.title': 'Usage',
'organization.usage.page.description': 'View your organization\'s current usage and limits.',
'organization.usage.storage.title': 'Document storage',
'organization.usage.storage.description': 'Total storage used by your documents',
'organization.usage.intake-emails.title': 'Intake emails',
'organization.usage.intake-emails.description': 'Number of intake email addresses',
'organization.usage.members.title': 'Members',
'organization.usage.members.description': 'Number of members in the organization',
'organization.usage.unlimited': 'Unlimited',
'organizations.members.title': 'Members',
'organizations.members.description': 'Manage your organization members',
@@ -415,6 +426,13 @@ export const translations = {
// API keys
'api-keys.permissions.select-all': 'Select all',
'api-keys.permissions.deselect-all': 'Deselect all',
'api-keys.permissions.organizations.title': 'Organizations',
'api-keys.permissions.organizations.organizations:create': 'Create organizations',
'api-keys.permissions.organizations.organizations:read': 'Read organizations',
'api-keys.permissions.organizations.organizations:update': 'Update organizations',
'api-keys.permissions.organizations.organizations:delete': 'Delete organizations',
'api-keys.permissions.documents.title': 'Documents',
'api-keys.permissions.documents.documents:create': 'Create documents',
'api-keys.permissions.documents.documents:read': 'Read documents',
@@ -510,11 +528,16 @@ export const translations = {
'layout.menu.settings': 'Settings',
'layout.menu.account': 'Account',
'layout.menu.general-settings': 'General settings',
'layout.menu.usage': 'Usage',
'layout.menu.intake-emails': 'Intake emails',
'layout.menu.webhooks': 'Webhooks',
'layout.menu.members': 'Members',
'layout.menu.invitations': 'Invitations',
'layout.upgrade-cta.title': 'Need more space?',
'layout.upgrade-cta.description': 'Get 10x more storage + team collaboration',
'layout.upgrade-cta.button': 'Upgrade to Plus',
'layout.theme.light': 'Light mode',
'layout.theme.dark': 'Dark mode',
'layout.theme.system': 'System mode',
@@ -539,6 +562,7 @@ export const translations = {
'api-errors.document.already_exists': 'The document already exists',
'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_email.limit_reached': 'The maximum number of intake emails for this organization has been reached. Please upgrade your plan to create more intake emails.',
'api-errors.user.max_organization_count_reached': 'You have reached the maximum number of organizations you can create, if you need to create more, please contact support.',
'api-errors.default': 'An error occurred while processing your request.',
@@ -549,6 +573,7 @@ export const translations = {
'api-errors.tags.already_exists': 'A tag with this name already exists for this organization',
'api-errors.internal.error': 'An error occurred while processing your request. Please try again later.',
'api-errors.auth.invalid_origin': 'Invalid application origin. If you are self-hosting Papra, ensure your APP_BASE_URL environment variable matches your current url. For more details see https://docs.papra.app/resources/troubleshooting/#invalid-application-origin',
'api-errors.organization.max_members_count_reached': 'The maximum number of members and pending invitations for this organization has been reached. Please upgrade your plan to add more members.',
// Not found
@@ -572,4 +597,47 @@ export const translations = {
'color-picker.select-color': 'Select color',
'color-picker.select-a-color': 'Select a color',
// Subscriptions
'subscriptions.checkout-success.title': 'Payment Successful!',
'subscriptions.checkout-success.description': 'Your subscription has been activated successfully.',
'subscriptions.checkout-success.thank-you': 'Thank you for upgrading to Papra Plus. You now have access to all premium features.',
'subscriptions.checkout-success.go-to-organizations': 'Go to Organizations',
'subscriptions.checkout-success.redirecting': 'Redirecting in {{ count }} second{{ plural }}...',
'subscriptions.checkout-cancel.title': 'Payment Canceled',
'subscriptions.checkout-cancel.description': 'Your subscription upgrade was canceled.',
'subscriptions.checkout-cancel.no-charges': 'No charges have been made to your account. You can try again anytime you\'re ready.',
'subscriptions.checkout-cancel.back-to-organizations': 'Back to Organizations',
'subscriptions.checkout-cancel.need-help': 'Need help?',
'subscriptions.checkout-cancel.contact-support': 'Contact support',
'subscriptions.upgrade-dialog.title': 'Upgrade to Plus',
'subscriptions.upgrade-dialog.description': 'Unlock powerful features for your organization',
'subscriptions.upgrade-dialog.contact-us': 'Contact us',
'subscriptions.upgrade-dialog.enterprise-plans': 'if you need custom enterprise plans.',
'subscriptions.upgrade-dialog.current-plan': 'Current Plan',
'subscriptions.upgrade-dialog.recommended': 'Recommended',
'subscriptions.upgrade-dialog.per-month': '/month',
'subscriptions.upgrade-dialog.upgrade-now': 'Upgrade now',
'subscriptions.plan.free.name': 'Free plan',
'subscriptions.plan.plus.name': 'Plus',
'subscriptions.features.storage-size': 'Document storage size',
'subscriptions.features.members': 'Organization Members',
'subscriptions.features.members-count': '{{ count }} members',
'subscriptions.features.email-intakes': 'Email Intakes',
'subscriptions.features.email-intakes-count-singular': '{{ count }} address',
'subscriptions.features.email-intakes-count-plural': '{{ count }} addresses',
'subscriptions.features.max-upload-size': 'Max upload file size',
'subscriptions.features.support': 'Support',
'subscriptions.features.support-community': 'Community support',
'subscriptions.features.support-email': 'Email support',
'subscriptions.billing-interval.monthly': 'Monthly',
'subscriptions.billing-interval.annual': 'Annual',
'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',
} as const;

View File

@@ -143,6 +143,17 @@ export const translations: Partial<TranslationsDictionary> = {
'organization.settings.delete.confirm.confirm-button': 'Eliminar organización',
'organization.settings.delete.confirm.cancel-button': 'Cancelar',
'organization.settings.delete.success': 'Organización eliminada',
'organization.settings.delete.only-owner': 'Solo el propietario de la organización puede eliminar esta organización.',
'organization.usage.page.title': 'Uso',
'organization.usage.page.description': 'Ver el uso y los límites actuales de su organización.',
'organization.usage.storage.title': 'Almacenamiento de documentos',
'organization.usage.storage.description': 'Almacenamiento total usado por sus documentos',
'organization.usage.intake-emails.title': 'Correos de ingesta',
'organization.usage.intake-emails.description': 'Número de direcciones de correo de ingesta',
'organization.usage.members.title': 'Miembros',
'organization.usage.members.description': 'Número de miembros en la organización',
'organization.usage.unlimited': 'Ilimitado',
'organizations.members.title': 'Miembros',
'organizations.members.description': 'Administra los miembros de tu organización',
@@ -417,6 +428,13 @@ export const translations: Partial<TranslationsDictionary> = {
// API keys
'api-keys.permissions.select-all': 'Seleccionar todo',
'api-keys.permissions.deselect-all': 'Deseleccionar todo',
'api-keys.permissions.organizations.title': 'Organizaciones',
'api-keys.permissions.organizations.organizations:create': 'Crear organizaciones',
'api-keys.permissions.organizations.organizations:read': 'Leer organizaciones',
'api-keys.permissions.organizations.organizations:update': 'Actualizar organizaciones',
'api-keys.permissions.organizations.organizations:delete': 'Eliminar organizaciones',
'api-keys.permissions.documents.title': 'Documentos',
'api-keys.permissions.documents.documents:create': 'Crear documentos',
'api-keys.permissions.documents.documents:read': 'Leer documentos',
@@ -512,11 +530,16 @@ export const translations: Partial<TranslationsDictionary> = {
'layout.menu.settings': 'Ajustes',
'layout.menu.account': 'Cuenta',
'layout.menu.general-settings': 'Ajustes generales',
'layout.menu.usage': 'Uso',
'layout.menu.intake-emails': 'Correos de ingreso',
'layout.menu.webhooks': 'Webhooks',
'layout.menu.members': 'Miembros',
'layout.menu.invitations': 'Invitaciones',
'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.button': 'Actualizar a Plus',
'layout.theme.light': 'Modo claro',
'layout.theme.dark': 'Modo oscuro',
'layout.theme.system': 'Modo del sistema',
@@ -541,6 +564,7 @@ export const translations: Partial<TranslationsDictionary> = {
'api-errors.document.already_exists': 'El documento ya existe',
'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_email.limit_reached': 'Se ha alcanzado el número máximo de correos de ingreso para esta organización. Por favor, mejora tu plan para crear más correos de ingreso.',
'api-errors.user.max_organization_count_reached': 'Has alcanzado el número máximo de organizaciones que puedes crear, si necesitas crear más, contacta al soporte.',
'api-errors.default': 'Ocurrió un error al procesar tu solicitud.',
@@ -551,6 +575,7 @@ export const translations: Partial<TranslationsDictionary> = {
'api-errors.tags.already_exists': 'Ya existe una etiqueta con este nombre en esta organización',
'api-errors.internal.error': 'Ocurrió un error al procesar tu solicitud. Por favor, inténtalo de nuevo.',
'api-errors.auth.invalid_origin': 'Origen de la aplicación inválido. Si estás alojando Papra, asegúrate de que la variable de entorno APP_BASE_URL coincida con tu URL actual. Para más detalles, consulta https://docs.papra.app/resources/troubleshooting/#invalid-application-origin',
'api-errors.organization.max_members_count_reached': 'Se ha alcanzado el número máximo de miembros e invitaciones pendientes para esta organización. Por favor, actualiza tu plan para añadir más miembros.',
// Not found
@@ -574,4 +599,47 @@ export const translations: Partial<TranslationsDictionary> = {
'color-picker.select-color': 'Seleccionar color',
'color-picker.select-a-color': 'Selecciona un color',
// Subscriptions
'subscriptions.checkout-success.title': '¡Pago exitoso!',
'subscriptions.checkout-success.description': 'Tu suscripción ha sido activada exitosamente.',
'subscriptions.checkout-success.thank-you': 'Gracias por actualizar a Papra Plus. Ahora tienes acceso a todas las funciones premium.',
'subscriptions.checkout-success.go-to-organizations': 'Ir a Organizaciones',
'subscriptions.checkout-success.redirecting': 'Redirigiendo en {{ count }} segundo{{ plural }}...',
'subscriptions.checkout-cancel.title': 'Pago cancelado',
'subscriptions.checkout-cancel.description': 'Tu actualización de suscripción fue cancelada.',
'subscriptions.checkout-cancel.no-charges': 'No se han realizado cargos a tu cuenta. Puedes intentarlo de nuevo cuando estés listo.',
'subscriptions.checkout-cancel.back-to-organizations': 'Volver a Organizaciones',
'subscriptions.checkout-cancel.need-help': '¿Necesitas ayuda?',
'subscriptions.checkout-cancel.contact-support': 'Contactar soporte',
'subscriptions.upgrade-dialog.title': 'Actualizar a Plus',
'subscriptions.upgrade-dialog.description': 'Desbloquea funciones poderosas para tu organización',
'subscriptions.upgrade-dialog.contact-us': 'Contáctanos',
'subscriptions.upgrade-dialog.enterprise-plans': 'si necesitas planes empresariales personalizados.',
'subscriptions.upgrade-dialog.current-plan': 'Plan actual',
'subscriptions.upgrade-dialog.recommended': 'Recomendado',
'subscriptions.upgrade-dialog.per-month': '/mes',
'subscriptions.upgrade-dialog.upgrade-now': 'Actualizar ahora',
'subscriptions.plan.free.name': 'Plan gratuito',
'subscriptions.plan.plus.name': 'Plus',
'subscriptions.features.storage-size': 'Tamaño de almacenamiento de documentos',
'subscriptions.features.members': 'Miembros de la organización',
'subscriptions.features.members-count': '{{ count }} miembros',
'subscriptions.features.email-intakes': 'Entradas de correo',
'subscriptions.features.email-intakes-count-singular': '{{ count }} dirección',
'subscriptions.features.email-intakes-count-plural': '{{ count }} direcciones',
'subscriptions.features.max-upload-size': 'Tamaño máximo de archivo de carga',
'subscriptions.features.support': 'Soporte',
'subscriptions.features.support-community': 'Soporte de la comunidad',
'subscriptions.features.support-email': 'Soporte por correo',
'subscriptions.billing-interval.monthly': 'Mensual',
'subscriptions.billing-interval.annual': 'Anual',
'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',
};

View File

@@ -143,6 +143,17 @@ export const translations: Partial<TranslationsDictionary> = {
'organization.settings.delete.confirm.confirm-button': 'Supprimer l\'organisation',
'organization.settings.delete.confirm.cancel-button': 'Annuler',
'organization.settings.delete.success': 'Organisation supprimée',
'organization.settings.delete.only-owner': 'Seul le propriétaire de l\'organisation peut supprimer cette organisation.',
'organization.usage.page.title': 'Utilisation',
'organization.usage.page.description': 'Consultez l\'utilisation actuelle et les limites de votre organisation.',
'organization.usage.storage.title': 'Stockage de documents',
'organization.usage.storage.description': 'Stockage total utilisé par vos documents',
'organization.usage.intake-emails.title': 'E-mails d\'ingestion',
'organization.usage.intake-emails.description': 'Nombre d\'adresses e-mail d\'ingestion',
'organization.usage.members.title': 'Membres',
'organization.usage.members.description': 'Nombre de membres dans l\'organisation',
'organization.usage.unlimited': 'Illimité',
'organizations.members.title': 'Membres',
'organizations.members.description': 'Gérez les membres de votre organisation.',
@@ -417,6 +428,13 @@ export const translations: Partial<TranslationsDictionary> = {
// API keys
'api-keys.permissions.select-all': 'Tout sélectionner',
'api-keys.permissions.deselect-all': 'Tout désélectionner',
'api-keys.permissions.organizations.title': 'Organisations',
'api-keys.permissions.organizations.organizations:create': 'Créer des organisations',
'api-keys.permissions.organizations.organizations:read': 'Lire des organisations',
'api-keys.permissions.organizations.organizations:update': 'Mettre à jour des organisations',
'api-keys.permissions.organizations.organizations:delete': 'Supprimer des organisations',
'api-keys.permissions.documents.title': 'Documents',
'api-keys.permissions.documents.documents:create': 'Créer des documents',
'api-keys.permissions.documents.documents:read': 'Lire des documents',
@@ -512,11 +530,16 @@ export const translations: Partial<TranslationsDictionary> = {
'layout.menu.settings': 'Paramètres',
'layout.menu.account': 'Compte',
'layout.menu.general-settings': 'Paramètres généraux',
'layout.menu.usage': 'Utilisation',
'layout.menu.intake-emails': 'Adresses de réception',
'layout.menu.webhooks': 'Webhooks',
'layout.menu.members': 'Membres',
'layout.menu.invitations': 'Invitations',
'layout.upgrade-cta.title': 'Besoin de plus d\'espace ?',
'layout.upgrade-cta.description': 'Obtenez 10x plus de stockage + collaboration d\'équipe',
'layout.upgrade-cta.button': 'Passer à Plus',
'layout.theme.light': 'Mode clair',
'layout.theme.dark': 'Mode sombre',
'layout.theme.system': 'Mode système',
@@ -541,6 +564,7 @@ export const translations: Partial<TranslationsDictionary> = {
'api-errors.document.already_exists': 'Le document existe déjà',
'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_email.limit_reached': 'Le nombre maximum d\'emails de réception pour cette organisation a été atteint. Veuillez mettre à niveau votre plan pour créer plus d\'emails de réception.',
'api-errors.user.max_organization_count_reached': 'Vous avez atteint le nombre maximum d\'organisations que vous pouvez créer, si vous avez besoin de créer plus, veuillez contacter le support.',
'api-errors.default': 'Une erreur est survenue lors du traitement de votre requête.',
@@ -551,6 +575,7 @@ export const translations: Partial<TranslationsDictionary> = {
'api-errors.tags.already_exists': 'Un tag avec ce nom existe déjà pour cette organisation',
'api-errors.internal.error': 'Une erreur est survenue lors du traitement de votre requête. Veuillez réessayer.',
'api-errors.auth.invalid_origin': 'Origine de l\'application invalide. Si vous hébergez Papra, assurez-vous que la variable d\'environnement APP_BASE_URL correspond à votre URL actuelle. Pour plus de détails, consultez https://docs.papra.app/resources/troubleshooting/#invalid-application-origin',
'api-errors.organization.max_members_count_reached': 'Le nombre maximum de membres et d\'invitations en attente pour cette organisation a été atteint. Veuillez mettre à niveau votre plan pour ajouter plus de membres.',
// Not found
@@ -574,4 +599,47 @@ export const translations: Partial<TranslationsDictionary> = {
'color-picker.select-color': 'Sélectionner la couleur',
'color-picker.select-a-color': 'Sélectionner une couleur',
// Subscriptions
'subscriptions.checkout-success.title': 'Paiement réussi !',
'subscriptions.checkout-success.description': 'Votre abonnement a été activé avec succès.',
'subscriptions.checkout-success.thank-you': 'Merci d\'avoir mis à niveau vers Papra Plus. Vous avez maintenant accès à toutes les fonctionnalités premium.',
'subscriptions.checkout-success.go-to-organizations': 'Aller aux Organisations',
'subscriptions.checkout-success.redirecting': 'Redirection dans {{ count }} seconde{{ plural }}...',
'subscriptions.checkout-cancel.title': 'Paiement annulé',
'subscriptions.checkout-cancel.description': 'Votre mise à niveau d\'abonnement a été annulée.',
'subscriptions.checkout-cancel.no-charges': 'Aucun frais n\'a été prélevé sur votre compte. Vous pouvez réessayer à tout moment.',
'subscriptions.checkout-cancel.back-to-organizations': 'Retour aux Organisations',
'subscriptions.checkout-cancel.need-help': 'Besoin d\'aide ?',
'subscriptions.checkout-cancel.contact-support': 'Contacter le support',
'subscriptions.upgrade-dialog.title': 'Passer à Plus',
'subscriptions.upgrade-dialog.description': 'Débloquez des fonctionnalités puissantes pour votre organisation',
'subscriptions.upgrade-dialog.contact-us': 'Contactez-nous',
'subscriptions.upgrade-dialog.enterprise-plans': 'si vous avez besoin de plans d\'entreprise personnalisés.',
'subscriptions.upgrade-dialog.current-plan': 'Plan actuel',
'subscriptions.upgrade-dialog.recommended': 'Recommandé',
'subscriptions.upgrade-dialog.per-month': '/mois',
'subscriptions.upgrade-dialog.upgrade-now': 'Mettre à niveau',
'subscriptions.plan.free.name': 'Plan gratuit',
'subscriptions.plan.plus.name': 'Plus',
'subscriptions.features.storage-size': 'Taille de stockage de documents',
'subscriptions.features.members': 'Membres de l\'organisation',
'subscriptions.features.members-count': '{{ count }} membres',
'subscriptions.features.email-intakes': 'Emails de réception',
'subscriptions.features.email-intakes-count-singular': '{{ count }} adresse',
'subscriptions.features.email-intakes-count-plural': '{{ count }} adresses',
'subscriptions.features.max-upload-size': 'Taille maximale de téléchargement',
'subscriptions.features.support': 'Support',
'subscriptions.features.support-community': 'Support communautaire',
'subscriptions.features.support-email': 'Support par email',
'subscriptions.billing-interval.monthly': 'Mensuel',
'subscriptions.billing-interval.annual': 'Annuel',
'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',
};

View File

@@ -143,6 +143,17 @@ export const translations: Partial<TranslationsDictionary> = {
'organization.settings.delete.confirm.confirm-button': 'Elimina organizzazione',
'organization.settings.delete.confirm.cancel-button': 'Annulla',
'organization.settings.delete.success': 'Organizzazione eliminata',
'organization.settings.delete.only-owner': 'Solo il proprietario dell\'organizzazione può eliminare questa organizzazione.',
'organization.usage.page.title': 'Utilizzo',
'organization.usage.page.description': 'Visualizza l\'utilizzo attuale e i limiti della tua organizzazione.',
'organization.usage.storage.title': 'Archiviazione documenti',
'organization.usage.storage.description': 'Archiviazione totale utilizzata dai tuoi documenti',
'organization.usage.intake-emails.title': 'Email di acquisizione',
'organization.usage.intake-emails.description': 'Numero di indirizzi email di acquisizione',
'organization.usage.members.title': 'Membri',
'organization.usage.members.description': 'Numero di membri nell\'organizzazione',
'organization.usage.unlimited': 'Illimitato',
'organizations.members.title': 'Membri',
'organizations.members.description': 'Gestisci i membri della tua organizzazione',
@@ -417,6 +428,13 @@ export const translations: Partial<TranslationsDictionary> = {
// API keys
'api-keys.permissions.select-all': 'Seleziona tutto',
'api-keys.permissions.deselect-all': 'Deseleziona tutto',
'api-keys.permissions.organizations.title': 'Organizzazioni',
'api-keys.permissions.organizations.organizations:create': 'Crea organizzazioni',
'api-keys.permissions.organizations.organizations:read': 'Leggi organizzazioni',
'api-keys.permissions.organizations.organizations:update': 'Aggiorna organizzazioni',
'api-keys.permissions.organizations.organizations:delete': 'Elimina organizzazioni',
'api-keys.permissions.documents.title': 'Documenti',
'api-keys.permissions.documents.documents:create': 'Crea documenti',
'api-keys.permissions.documents.documents:read': 'Leggi documenti',
@@ -512,11 +530,16 @@ export const translations: Partial<TranslationsDictionary> = {
'layout.menu.settings': 'Impostazioni',
'layout.menu.account': 'Account',
'layout.menu.general-settings': 'Impostazioni generali',
'layout.menu.usage': 'Utilizzo',
'layout.menu.intake-emails': 'Email di acquisizione',
'layout.menu.webhooks': 'Webhook',
'layout.menu.members': 'Membri',
'layout.menu.invitations': 'Inviti',
'layout.upgrade-cta.title': 'Serve più spazio?',
'layout.upgrade-cta.description': 'Ottieni 10x più storage + collaborazione del team',
'layout.upgrade-cta.button': 'Aggiorna a Plus',
'layout.theme.light': 'Modalità chiara',
'layout.theme.dark': 'Modalità scura',
'layout.theme.system': 'Modalità sistema',
@@ -541,6 +564,7 @@ export const translations: Partial<TranslationsDictionary> = {
'api-errors.document.already_exists': 'Il documento esiste già',
'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_email.limit_reached': 'È stato raggiunto il numero massimo di email di acquisizione per questa organizzazione. Aggiorna il tuo piano per crearne altre.',
'api-errors.user.max_organization_count_reached': 'Hai raggiunto il numero massimo di organizzazioni che puoi creare, se hai bisogno di crearne altre contatta il supporto.',
'api-errors.default': 'Si è verificato un errore durante l\'elaborazione della richiesta.',
@@ -551,6 +575,7 @@ export const translations: Partial<TranslationsDictionary> = {
'api-errors.tags.already_exists': 'Esiste già un tag con questo nome per questa organizzazione',
'api-errors.internal.error': 'Si è verificato un errore durante l\'elaborazione della richiesta. Riprova.',
'api-errors.auth.invalid_origin': 'Origine dell\'applicazione non valida. Se stai ospitando Papra, assicurati che la variabile di ambiente APP_BASE_URL corrisponda all\'URL corrente. Per maggiori dettagli, consulta https://docs.papra.app/resources/troubleshooting/#invalid-application-origin',
'api-errors.organization.max_members_count_reached': 'È stato raggiunto il numero massimo di membri e inviti in sospeso per questa organizzazione. Aggiorna il tuo piano per aggiungere altri membri.',
// Not found
@@ -574,4 +599,47 @@ export const translations: Partial<TranslationsDictionary> = {
'color-picker.select-color': 'Seleziona colore',
'color-picker.select-a-color': 'Seleziona un colore',
// Subscriptions
'subscriptions.checkout-success.title': 'Pagamento riuscito!',
'subscriptions.checkout-success.description': 'Il tuo abbonamento è stato attivato con successo.',
'subscriptions.checkout-success.thank-you': 'Grazie per l\'upgrade a Papra Plus. Ora hai accesso a tutte le funzionalità premium.',
'subscriptions.checkout-success.go-to-organizations': 'Vai alle Organizzazioni',
'subscriptions.checkout-success.redirecting': 'Reindirizzamento tra {{ count }} secondo{{ plural }}...',
'subscriptions.checkout-cancel.title': 'Pagamento annullato',
'subscriptions.checkout-cancel.description': 'L\'upgrade del tuo abbonamento è stato annullato.',
'subscriptions.checkout-cancel.no-charges': 'Non sono stati effettuati addebiti sul tuo account. Puoi riprovare quando sei pronto.',
'subscriptions.checkout-cancel.back-to-organizations': 'Torna alle Organizzazioni',
'subscriptions.checkout-cancel.need-help': 'Hai bisogno di aiuto?',
'subscriptions.checkout-cancel.contact-support': 'Contatta il supporto',
'subscriptions.upgrade-dialog.title': 'Passa a Plus',
'subscriptions.upgrade-dialog.description': 'Sblocca funzionalità potenti per la tua organizzazione',
'subscriptions.upgrade-dialog.contact-us': 'Contattaci',
'subscriptions.upgrade-dialog.enterprise-plans': 'se hai bisogno di piani aziendali personalizzati.',
'subscriptions.upgrade-dialog.current-plan': 'Piano attuale',
'subscriptions.upgrade-dialog.recommended': 'Consigliato',
'subscriptions.upgrade-dialog.per-month': '/mese',
'subscriptions.upgrade-dialog.upgrade-now': 'Aggiorna ora',
'subscriptions.plan.free.name': 'Piano gratuito',
'subscriptions.plan.plus.name': 'Plus',
'subscriptions.features.storage-size': 'Dimensione archiviazione documenti',
'subscriptions.features.members': 'Membri dell\'organizzazione',
'subscriptions.features.members-count': '{{ count }} membri',
'subscriptions.features.email-intakes': 'Email di acquisizione',
'subscriptions.features.email-intakes-count-singular': '{{ count }} indirizzo',
'subscriptions.features.email-intakes-count-plural': '{{ count }} indirizzi',
'subscriptions.features.max-upload-size': 'Dimensione massima file caricamento',
'subscriptions.features.support': 'Supporto',
'subscriptions.features.support-community': 'Supporto della comunità',
'subscriptions.features.support-email': 'Supporto via email',
'subscriptions.billing-interval.monthly': 'Mensile',
'subscriptions.billing-interval.annual': 'Annuale',
'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',
};

View File

@@ -143,6 +143,17 @@ export const translations: Partial<TranslationsDictionary> = {
'organization.settings.delete.confirm.confirm-button': 'Usuń organizację',
'organization.settings.delete.confirm.cancel-button': 'Anuluj',
'organization.settings.delete.success': 'Organizacja została usunięta',
'organization.settings.delete.only-owner': 'Tylko właściciel organizacji może usunąć tę organizację.',
'organization.usage.page.title': 'Użycie',
'organization.usage.page.description': 'Zobacz aktualne użycie i limity Twojej organizacji.',
'organization.usage.storage.title': 'Przechowywanie dokumentów',
'organization.usage.storage.description': 'Całkowite miejsce używane przez Twoje dokumenty',
'organization.usage.intake-emails.title': 'E-maile przychodzące',
'organization.usage.intake-emails.description': 'Liczba adresów e-mail przychodzących',
'organization.usage.members.title': 'Członkowie',
'organization.usage.members.description': 'Liczba członków w organizacji',
'organization.usage.unlimited': 'Nieograniczone',
'organizations.members.title': 'Członkowie',
'organizations.members.description': 'Zarządzaj członkami swojej organizacji',
@@ -417,6 +428,13 @@ export const translations: Partial<TranslationsDictionary> = {
// API keys
'api-keys.permissions.select-all': 'Zaznacz wszystko',
'api-keys.permissions.deselect-all': 'Odznacz wszystko',
'api-keys.permissions.organizations.title': 'Organizacje',
'api-keys.permissions.organizations.organizations:create': 'Tworzenie organizacji',
'api-keys.permissions.organizations.organizations:read': 'Odczyt organizacji',
'api-keys.permissions.organizations.organizations:update': 'Aktualizacja organizacji',
'api-keys.permissions.organizations.organizations:delete': 'Usuwanie organizacji',
'api-keys.permissions.documents.title': 'Dokumenty',
'api-keys.permissions.documents.documents:create': 'Tworzenie dokumentów',
'api-keys.permissions.documents.documents:read': 'Odczyt dokumentów',
@@ -512,11 +530,16 @@ export const translations: Partial<TranslationsDictionary> = {
'layout.menu.settings': 'Ustawienia',
'layout.menu.account': 'Konto',
'layout.menu.general-settings': 'Ustawienia ogólne',
'layout.menu.usage': 'Użycie',
'layout.menu.intake-emails': 'Adresy przyjęć',
'layout.menu.webhooks': 'Webhooki',
'layout.menu.members': 'Członkowie',
'layout.menu.invitations': 'Zaproszenia',
'layout.upgrade-cta.title': 'Potrzebujesz więcej miejsca?',
'layout.upgrade-cta.description': 'Uzyskaj 10x więcej przestrzeni + współpracę zespołową',
'layout.upgrade-cta.button': 'Przejdź na Plus',
'layout.theme.light': 'Tryb jasny',
'layout.theme.dark': 'Tryb ciemny',
'layout.theme.system': 'Tryb systemowy',
@@ -541,6 +564,7 @@ export const translations: Partial<TranslationsDictionary> = {
'api-errors.document.already_exists': 'Dokument już istnieje',
'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_email.limit_reached': 'Osiągnięto maksymalną liczbę adresów e-mail do przyjęć dla tej organizacji. Aby utworzyć więcej adresów e-mail do przyjęć, zaktualizuj swój plan.',
'api-errors.user.max_organization_count_reached': 'Osiągnięto maksymalną liczbę organizacji, które możesz utworzyć. Jeśli potrzebujesz utworzyć więcej, skontaktuj się z pomocą techniczną.',
'api-errors.default': 'Wystąpił błąd podczas przetwarzania żądania.',
@@ -551,6 +575,7 @@ export const translations: Partial<TranslationsDictionary> = {
'api-errors.tags.already_exists': 'Tag o tej nazwie już istnieje w tej organizacji',
'api-errors.internal.error': 'Wystąpił błąd podczas przetwarzania żądania. Spróbuj ponownie później.',
'api-errors.auth.invalid_origin': 'Nieprawidłowa lokalizacja aplikacji. Jeśli hostujesz Papra, upewnij się, że zmienna środowiskowa APP_BASE_URL odpowiada bieżącemu adresowi URL. Aby uzyskać więcej informacji, zobacz https://docs.papra.app/resources/troubleshooting/#invalid-application-origin',
'api-errors.organization.max_members_count_reached': 'Osiągnięto maksymalną liczbę członków i oczekujących zaproszeń dla tej organizacji. Zaktualizuj swój plan, aby dodać więcej członków.',
// Not found
@@ -574,4 +599,47 @@ export const translations: Partial<TranslationsDictionary> = {
'color-picker.select-color': 'Wybierz kolor',
'color-picker.select-a-color': 'Wybierz kolor',
// Subscriptions
'subscriptions.checkout-success.title': 'Płatność zakończona sukcesem!',
'subscriptions.checkout-success.description': 'Twoja subskrypcja została pomyślnie aktywowana.',
'subscriptions.checkout-success.thank-you': 'Dziękujemy za przejście na Papra Plus. Teraz masz dostęp do wszystkich funkcji premium.',
'subscriptions.checkout-success.go-to-organizations': 'Przejdź do Organizacji',
'subscriptions.checkout-success.redirecting': 'Przekierowanie za {{ count }} sekund{{ plural }}...',
'subscriptions.checkout-cancel.title': 'Płatność anulowana',
'subscriptions.checkout-cancel.description': 'Twoja aktualizacja subskrypcji została anulowana.',
'subscriptions.checkout-cancel.no-charges': 'Nie pobrano żadnych opłat z Twojego konta. Możesz spróbować ponownie w dowolnym momencie.',
'subscriptions.checkout-cancel.back-to-organizations': 'Powrót do Organizacji',
'subscriptions.checkout-cancel.need-help': 'Potrzebujesz pomocy?',
'subscriptions.checkout-cancel.contact-support': 'Skontaktuj się z pomocą techniczną',
'subscriptions.upgrade-dialog.title': 'Przejdź na Plus',
'subscriptions.upgrade-dialog.description': 'Odblokuj zaawansowane funkcje dla swojej organizacji',
'subscriptions.upgrade-dialog.contact-us': 'Skontaktuj się z nami',
'subscriptions.upgrade-dialog.enterprise-plans': 'jeśli potrzebujesz niestandardowych planów biznesowych.',
'subscriptions.upgrade-dialog.current-plan': 'Obecny plan',
'subscriptions.upgrade-dialog.recommended': 'Polecane',
'subscriptions.upgrade-dialog.per-month': '/miesiąc',
'subscriptions.upgrade-dialog.upgrade-now': 'Ulepsz teraz',
'subscriptions.plan.free.name': 'Plan darmowy',
'subscriptions.plan.plus.name': 'Plus',
'subscriptions.features.storage-size': 'Rozmiar przechowywania dokumentów',
'subscriptions.features.members': 'Członkowie organizacji',
'subscriptions.features.members-count': '{{ count }} członków',
'subscriptions.features.email-intakes': 'Adresy e-mail do przyjęć',
'subscriptions.features.email-intakes-count-singular': '{{ count }} adres',
'subscriptions.features.email-intakes-count-plural': '{{ count }} adresy',
'subscriptions.features.max-upload-size': 'Maksymalny rozmiar pliku',
'subscriptions.features.support': 'Wsparcie',
'subscriptions.features.support-community': 'Wsparcie społeczności',
'subscriptions.features.support-email': 'Wsparcie e-mail',
'subscriptions.billing-interval.monthly': 'Miesięcznie',
'subscriptions.billing-interval.annual': 'Rocznie',
'subscriptions.usage-warning.message': 'Wykorzystano {{ percent }}% miejsca na dokumenty. Rozważ aktualizację planu, aby uzyskać więcej miejsca.',
'subscriptions.usage-warning.upgrade-button': 'Ulepsz plan',
};

View File

@@ -143,6 +143,17 @@ export const translations: Partial<TranslationsDictionary> = {
'organization.settings.delete.confirm.confirm-button': 'Excluir organização',
'organization.settings.delete.confirm.cancel-button': 'Cancelar',
'organization.settings.delete.success': 'Organização excluída',
'organization.settings.delete.only-owner': 'Apenas o proprietário da organização pode excluir esta organização.',
'organization.usage.page.title': 'Uso',
'organization.usage.page.description': 'Visualize o uso atual e os limites da sua organização.',
'organization.usage.storage.title': 'Armazenamento de documentos',
'organization.usage.storage.description': 'Armazenamento total usado pelos seus documentos',
'organization.usage.intake-emails.title': 'E-mails de entrada',
'organization.usage.intake-emails.description': 'Número de endereços de e-mail de entrada',
'organization.usage.members.title': 'Membros',
'organization.usage.members.description': 'Número de membros na organização',
'organization.usage.unlimited': 'Ilimitado',
'organizations.members.title': 'Membros',
'organizations.members.description': 'Gerencie os membros da sua organização',
@@ -417,6 +428,13 @@ export const translations: Partial<TranslationsDictionary> = {
// API keys
'api-keys.permissions.select-all': 'Selecionar tudo',
'api-keys.permissions.deselect-all': 'Desmarcar tudo',
'api-keys.permissions.organizations.title': 'Organizações',
'api-keys.permissions.organizations.organizations:create': 'Criar organizações',
'api-keys.permissions.organizations.organizations:read': 'Ler organizações',
'api-keys.permissions.organizations.organizations:update': 'Atualizar organizações',
'api-keys.permissions.organizations.organizations:delete': 'Excluir organizações',
'api-keys.permissions.documents.title': 'Documentos',
'api-keys.permissions.documents.documents:create': 'Criar documentos',
'api-keys.permissions.documents.documents:read': 'Ler documentos',
@@ -512,11 +530,16 @@ export const translations: Partial<TranslationsDictionary> = {
'layout.menu.settings': 'Configurações',
'layout.menu.account': 'Conta',
'layout.menu.general-settings': 'Configurações gerais',
'layout.menu.usage': 'Uso',
'layout.menu.intake-emails': 'E-mails de entrada',
'layout.menu.webhooks': 'Webhooks',
'layout.menu.members': 'Membros',
'layout.menu.invitations': 'Convites',
'layout.upgrade-cta.title': 'Precisa de mais espaço?',
'layout.upgrade-cta.description': 'Obtenha 10x mais armazenamento + colaboração em equipe',
'layout.upgrade-cta.button': 'Atualizar para Plus',
'layout.theme.light': 'Tema claro',
'layout.theme.dark': 'Tema escuro',
'layout.theme.system': 'Tema do sistema',
@@ -541,6 +564,7 @@ export const translations: Partial<TranslationsDictionary> = {
'api-errors.document.already_exists': 'O documento já existe',
'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_email.limit_reached': 'O número máximo de e-mails de entrada para esta organização foi atingido. Faça um upgrade no seu plano para criar mais e-mails de entrada.',
'api-errors.user.max_organization_count_reached': 'Você atingiu o número máximo de organizações que pode criar. Se precisar criar mais, entre em contato com o suporte.',
'api-errors.default': 'Ocorreu um erro ao processar sua solicitação.',
@@ -551,6 +575,7 @@ export const translations: Partial<TranslationsDictionary> = {
'api-errors.tags.already_exists': 'Já existe uma tag com este nome nesta organização',
'api-errors.internal.error': 'Ocorreu um erro ao processar sua solicitação. Por favor, tente novamente.',
'api-errors.auth.invalid_origin': 'Origem da aplicação inválida. Se você está hospedando o Papra, certifique-se de que a variável de ambiente APP_BASE_URL corresponde à sua URL atual. Para mais detalhes, consulte https://docs.papra.app/resources/troubleshooting/#invalid-application-origin',
'api-errors.organization.max_members_count_reached': 'O número máximo de membros e convites pendentes para esta organização foi atingido. Atualize seu plano para adicionar mais membros.',
// Not found
@@ -574,4 +599,47 @@ export const translations: Partial<TranslationsDictionary> = {
'color-picker.select-color': 'Selecionar cor',
'color-picker.select-a-color': 'Selecione uma cor',
// Subscriptions
'subscriptions.checkout-success.title': 'Pagamento bem-sucedido!',
'subscriptions.checkout-success.description': 'Sua assinatura foi ativada com sucesso.',
'subscriptions.checkout-success.thank-you': 'Obrigado por fazer upgrade para o Papra Plus. Agora você tem acesso a todos os recursos premium.',
'subscriptions.checkout-success.go-to-organizations': 'Ir para Organizações',
'subscriptions.checkout-success.redirecting': 'Redirecionando em {{ count }} segundo{{ plural }}...',
'subscriptions.checkout-cancel.title': 'Pagamento cancelado',
'subscriptions.checkout-cancel.description': 'Seu upgrade de assinatura foi cancelado.',
'subscriptions.checkout-cancel.no-charges': 'Nenhuma cobrança foi feita em sua conta. Você pode tentar novamente quando estiver pronto.',
'subscriptions.checkout-cancel.back-to-organizations': 'Voltar para Organizações',
'subscriptions.checkout-cancel.need-help': 'Precisa de ajuda?',
'subscriptions.checkout-cancel.contact-support': 'Contatar suporte',
'subscriptions.upgrade-dialog.title': 'Fazer upgrade para Plus',
'subscriptions.upgrade-dialog.description': 'Desbloqueie recursos poderosos para sua organização',
'subscriptions.upgrade-dialog.contact-us': 'Entre em contato',
'subscriptions.upgrade-dialog.enterprise-plans': 'se você precisar de planos empresariais personalizados.',
'subscriptions.upgrade-dialog.current-plan': 'Plano atual',
'subscriptions.upgrade-dialog.recommended': 'Recomendado',
'subscriptions.upgrade-dialog.per-month': '/mês',
'subscriptions.upgrade-dialog.upgrade-now': 'Fazer upgrade agora',
'subscriptions.plan.free.name': 'Plano gratuito',
'subscriptions.plan.plus.name': 'Plus',
'subscriptions.features.storage-size': 'Tamanho de armazenamento de documentos',
'subscriptions.features.members': 'Membros da organização',
'subscriptions.features.members-count': '{{ count }} membros',
'subscriptions.features.email-intakes': 'E-mails de entrada',
'subscriptions.features.email-intakes-count-singular': '{{ count }} endereço',
'subscriptions.features.email-intakes-count-plural': '{{ count }} endereços',
'subscriptions.features.max-upload-size': 'Tamanho máximo de upload',
'subscriptions.features.support': 'Suporte',
'subscriptions.features.support-community': 'Suporte da comunidade',
'subscriptions.features.support-email': 'Suporte por e-mail',
'subscriptions.billing-interval.monthly': 'Mensal',
'subscriptions.billing-interval.annual': 'Anual',
'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',
};

View File

@@ -143,6 +143,17 @@ export const translations: Partial<TranslationsDictionary> = {
'organization.settings.delete.confirm.confirm-button': 'Eliminar organização',
'organization.settings.delete.confirm.cancel-button': 'Cancelar',
'organization.settings.delete.success': 'Organização eliminada',
'organization.settings.delete.only-owner': 'Apenas o proprietário da organização pode eliminar esta organização.',
'organization.usage.page.title': 'Uso',
'organization.usage.page.description': 'Visualize o uso atual e os limites da sua organização.',
'organization.usage.storage.title': 'Armazenamento de documentos',
'organization.usage.storage.description': 'Armazenamento total usado pelos seus documentos',
'organization.usage.intake-emails.title': 'E-mails de entrada',
'organization.usage.intake-emails.description': 'Número de endereços de e-mail de entrada',
'organization.usage.members.title': 'Membros',
'organization.usage.members.description': 'Número de membros na organização',
'organization.usage.unlimited': 'Ilimitado',
'organizations.members.title': 'Membros',
'organizations.members.description': 'Gira os membros da sua organização',
@@ -417,6 +428,13 @@ export const translations: Partial<TranslationsDictionary> = {
// API keys
'api-keys.permissions.select-all': 'Selecionar tudo',
'api-keys.permissions.deselect-all': 'Desselecionar tudo',
'api-keys.permissions.organizations.title': 'Organizações',
'api-keys.permissions.organizations.organizations:create': 'Criar organizações',
'api-keys.permissions.organizations.organizations:read': 'Ler organizações',
'api-keys.permissions.organizations.organizations:update': 'Atualizar organizações',
'api-keys.permissions.organizations.organizations:delete': 'Eliminar organizações',
'api-keys.permissions.documents.title': 'Documentos',
'api-keys.permissions.documents.documents:create': 'Criar documentos',
'api-keys.permissions.documents.documents:read': 'Ler documentos',
@@ -512,11 +530,16 @@ export const translations: Partial<TranslationsDictionary> = {
'layout.menu.settings': 'Definições',
'layout.menu.account': 'Conta',
'layout.menu.general-settings': 'Definições gerais',
'layout.menu.usage': 'Uso',
'layout.menu.intake-emails': 'E-mails de entrada',
'layout.menu.webhooks': 'Webhooks',
'layout.menu.members': 'Membros',
'layout.menu.invitations': 'Convites',
'layout.upgrade-cta.title': 'Precisa de mais espaço?',
'layout.upgrade-cta.description': 'Obtenha 10x mais armazenamento + colaboração em equipa',
'layout.upgrade-cta.button': 'Actualizar para Plus',
'layout.theme.light': 'Tema claro',
'layout.theme.dark': 'Tema escuro',
'layout.theme.system': 'Tema do sistema',
@@ -541,6 +564,7 @@ export const translations: Partial<TranslationsDictionary> = {
'api-errors.document.already_exists': 'O documento já existe',
'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_email.limit_reached': 'O número máximo de e-mails de entrada para esta organização foi atingido. Faça um upgrade no seu plano para criar mais e-mails de entrada.',
'api-errors.user.max_organization_count_reached': 'Atingiu o número máximo de organizações que pode criar. Se precisar de criar mais, entre em contato com o suporte.',
'api-errors.default': 'Ocorreu um erro ao processar a solicitação.',
@@ -551,6 +575,7 @@ export const translations: Partial<TranslationsDictionary> = {
'api-errors.tags.already_exists': 'Já existe uma etiqueta com este nome nesta organização',
'api-errors.internal.error': 'Ocorreu um erro ao processar a solicitação. Por favor, tente novamente.',
'api-errors.auth.invalid_origin': 'Origem da aplicação inválida. Se você está hospedando o Papra, certifique-se de que a variável de ambiente APP_BASE_URL corresponde à sua URL atual. Para mais detalhes, consulte https://docs.papra.app/resources/troubleshooting/#invalid-application-origin',
'api-errors.organization.max_members_count_reached': 'O número máximo de membros e convites pendentes para esta organização foi atingido. Atualize o seu plano para adicionar mais membros.',
// Not found
@@ -574,4 +599,47 @@ export const translations: Partial<TranslationsDictionary> = {
'color-picker.select-color': 'Selecionar cor',
'color-picker.select-a-color': 'Selecione uma cor',
// Subscriptions
'subscriptions.checkout-success.title': 'Pagamento bem-sucedido!',
'subscriptions.checkout-success.description': 'A sua subscrição foi ativada com sucesso.',
'subscriptions.checkout-success.thank-you': 'Obrigado por fazer upgrade para o Papra Plus. Agora tem acesso a todos os recursos premium.',
'subscriptions.checkout-success.go-to-organizations': 'Ir para Organizações',
'subscriptions.checkout-success.redirecting': 'A redirecionar em {{ count }} segundo{{ plural }}...',
'subscriptions.checkout-cancel.title': 'Pagamento cancelado',
'subscriptions.checkout-cancel.description': 'O seu upgrade de subscrição foi cancelado.',
'subscriptions.checkout-cancel.no-charges': 'Nenhuma cobrança foi feita na sua conta. Pode tentar novamente quando estiver pronto.',
'subscriptions.checkout-cancel.back-to-organizations': 'Voltar para Organizações',
'subscriptions.checkout-cancel.need-help': 'Precisa de ajuda?',
'subscriptions.checkout-cancel.contact-support': 'Contactar suporte',
'subscriptions.upgrade-dialog.title': 'Atualizar para Plus',
'subscriptions.upgrade-dialog.description': 'Desbloqueie recursos poderosos para a sua organização',
'subscriptions.upgrade-dialog.contact-us': 'Contacte-nos',
'subscriptions.upgrade-dialog.enterprise-plans': 'se precisar de planos empresariais personalizados.',
'subscriptions.upgrade-dialog.current-plan': 'Plano atual',
'subscriptions.upgrade-dialog.recommended': 'Recomendado',
'subscriptions.upgrade-dialog.per-month': '/mês',
'subscriptions.upgrade-dialog.upgrade-now': 'Atualizar agora',
'subscriptions.plan.free.name': 'Plano gratuito',
'subscriptions.plan.plus.name': 'Plus',
'subscriptions.features.storage-size': 'Tamanho de armazenamento de documentos',
'subscriptions.features.members': 'Membros da organização',
'subscriptions.features.members-count': '{{ count }} membros',
'subscriptions.features.email-intakes': 'E-mails de entrada',
'subscriptions.features.email-intakes-count-singular': '{{ count }} endereço',
'subscriptions.features.email-intakes-count-plural': '{{ count }} endereços',
'subscriptions.features.max-upload-size': 'Tamanho máximo de upload',
'subscriptions.features.support': 'Suporte',
'subscriptions.features.support-community': 'Suporte da comunidade',
'subscriptions.features.support-email': 'Suporte por e-mail',
'subscriptions.billing-interval.monthly': 'Mensal',
'subscriptions.billing-interval.annual': 'Anual',
'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',
};

View File

@@ -143,6 +143,17 @@ export const translations: Partial<TranslationsDictionary> = {
'organization.settings.delete.confirm.confirm-button': 'Șterge organizație',
'organization.settings.delete.confirm.cancel-button': 'Anulează',
'organization.settings.delete.success': 'Organizație ștearsă cu succes',
'organization.settings.delete.only-owner': 'Doar proprietarul organizației poate șterge această organizație.',
'organization.usage.page.title': 'Utilizare',
'organization.usage.page.description': 'Vizualizează utilizarea curentă și limitele organizației tale.',
'organization.usage.storage.title': 'Stocare documente',
'organization.usage.storage.description': 'Spațiul total folosit de documentele tale',
'organization.usage.intake-emails.title': 'E-mailuri de intrare',
'organization.usage.intake-emails.description': 'Număr de adrese de e-mail de intrare',
'organization.usage.members.title': 'Membri',
'organization.usage.members.description': 'Număr de membri în organizație',
'organization.usage.unlimited': 'Nelimitat',
'organizations.members.title': 'Membri',
'organizations.members.description': 'Gestionează membrii organizației tale',
@@ -417,6 +428,13 @@ export const translations: Partial<TranslationsDictionary> = {
// API keys
'api-keys.permissions.select-all': 'Selectează tot',
'api-keys.permissions.deselect-all': 'Deselectează tot',
'api-keys.permissions.organizations.title': 'Organizații',
'api-keys.permissions.organizations.organizations:create': 'Creează organizații',
'api-keys.permissions.organizations.organizations:read': 'Citește organizații',
'api-keys.permissions.organizations.organizations:update': 'Actualizează organizații',
'api-keys.permissions.organizations.organizations:delete': 'Șterge organizații',
'api-keys.permissions.documents.title': 'Documente',
'api-keys.permissions.documents.documents:create': 'Creează documente',
'api-keys.permissions.documents.documents:read': 'Citește documente',
@@ -512,11 +530,16 @@ export const translations: Partial<TranslationsDictionary> = {
'layout.menu.settings': 'Setări',
'layout.menu.account': 'Cont',
'layout.menu.general-settings': 'Setări generale',
'layout.menu.usage': 'Utilizare',
'layout.menu.intake-emails': 'Email-uri de primire',
'layout.menu.webhooks': 'Webhook-uri',
'layout.menu.members': 'Membri',
'layout.menu.invitations': 'Invitații',
'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.button': 'Actualizează la Plus',
'layout.theme.light': 'Mod luminos',
'layout.theme.dark': 'Mod intunecat',
'layout.theme.system': 'Modul sistemului',
@@ -541,6 +564,7 @@ export const translations: Partial<TranslationsDictionary> = {
'api-errors.document.already_exists': 'Documentul există deja',
'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_email.limit_reached': 'Numărul maxim de email-uri de primire pentru această organizație a fost atins. Te rugăm să-ți îmbunătățești planul pentru a crea mai multe email-uri de primire.',
'api-errors.user.max_organization_count_reached': 'Ai atins numărul maxim de organizații pe care le poți crea. Dacă ai nevoie să creezi mai multe, te rugăm să contactezi asistența.',
'api-errors.default': 'A apărut o eroare la procesarea cererii.',
@@ -551,6 +575,7 @@ export const translations: Partial<TranslationsDictionary> = {
'api-errors.tags.already_exists': 'O etichetă cu acest nume există deja pentru aceasta organizație',
'api-errors.internal.error': 'A apărut o eroare la procesarea cererii. Te rugăm să încerci din nou.',
'api-errors.auth.invalid_origin': 'Origine invalidă a aplicației. Dacă hospedezi Papra, asigură-te că variabila de mediu APP_BASE_URL corespunde URL-ului actual. Pentru mai multe detalii, consulta https://docs.papra.app/resources/troubleshooting/#invalid-application-origin',
'api-errors.organization.max_members_count_reached': 'Numărul maxim de membri și invitații în așteptare pentru această organizație a fost atins. Te rugăm să îți actualizezi planul pentru a adăuga mai mulți membri.',
// Not found
@@ -574,4 +599,47 @@ export const translations: Partial<TranslationsDictionary> = {
'color-picker.select-color': 'Selectează culoarea',
'color-picker.select-a-color': 'Selectează o culoare',
// Subscriptions
'subscriptions.checkout-success.title': 'Plată reușită!',
'subscriptions.checkout-success.description': 'Abonamentul tău a fost activat cu succes.',
'subscriptions.checkout-success.thank-you': 'Mulțumim pentru că ai făcut upgrade la Papra Plus. Acum ai acces la toate funcționalitățile premium.',
'subscriptions.checkout-success.go-to-organizations': 'Mergi la Organizații',
'subscriptions.checkout-success.redirecting': 'Redirecționare în {{ count }} secundă{{ plural }}...',
'subscriptions.checkout-cancel.title': 'Plată anulată',
'subscriptions.checkout-cancel.description': 'Upgrade-ul abonamentului tău a fost anulat.',
'subscriptions.checkout-cancel.no-charges': 'Nu au fost efectuate taxe pe contul tău. Poți încerca din nou oricând ești gata.',
'subscriptions.checkout-cancel.back-to-organizations': 'Înapoi la Organizații',
'subscriptions.checkout-cancel.need-help': 'Ai nevoie de ajutor?',
'subscriptions.checkout-cancel.contact-support': 'Contactează asistența',
'subscriptions.upgrade-dialog.title': 'Upgrade la Plus',
'subscriptions.upgrade-dialog.description': 'Deblochează funcționalități puternice pentru organizația ta',
'subscriptions.upgrade-dialog.contact-us': 'Contactează-ne',
'subscriptions.upgrade-dialog.enterprise-plans': 'dacă ai nevoie de planuri enterprise personalizate.',
'subscriptions.upgrade-dialog.current-plan': 'Plan curent',
'subscriptions.upgrade-dialog.recommended': 'Recomandat',
'subscriptions.upgrade-dialog.per-month': '/lună',
'subscriptions.upgrade-dialog.upgrade-now': 'Upgrade acum',
'subscriptions.plan.free.name': 'Plan gratuit',
'subscriptions.plan.plus.name': 'Plus',
'subscriptions.features.storage-size': 'Dimensiune stocare documente',
'subscriptions.features.members': 'Membri ai organizației',
'subscriptions.features.members-count': '{{ count }} membri',
'subscriptions.features.email-intakes': 'Email-uri de primire',
'subscriptions.features.email-intakes-count-singular': '{{ count }} adresă',
'subscriptions.features.email-intakes-count-plural': '{{ count }} adrese',
'subscriptions.features.max-upload-size': 'Dimensiune maximă fișier upload',
'subscriptions.features.support': 'Asistență',
'subscriptions.features.support-community': 'Asistență comunitate',
'subscriptions.features.support-email': 'Asistență email',
'subscriptions.billing-interval.monthly': 'Lunar',
'subscriptions.billing-interval.annual': 'Anual',
'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',
};

View File

@@ -5,6 +5,15 @@
// } as const;
export const API_KEY_PERMISSIONS = [
{
section: 'organizations',
permissions: [
'organizations:create',
'organizations:read',
'organizations:update',
'organizations:delete',
],
},
{
section: 'documents',
permissions: [

View File

@@ -2,6 +2,7 @@ import type { Component } from 'solid-js';
import type { TranslationKeys } from '@/modules/i18n/locales.types';
import { createSignal, For } from 'solid-js';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { Button } from '@/modules/ui/components/button';
import { Checkbox, CheckboxControl, CheckboxLabel } from '@/modules/ui/components/checkbox';
import { API_KEY_PERMISSIONS } from '../api-keys.constants';
@@ -42,34 +43,78 @@ export const ApiKeyPermissionsPicker: Component<{ permissions: string[]; onChang
props.onChange(permissions());
};
return (
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<For each={getPermissionsSections()}>
{section => (
<div>
<p class="text-muted-foreground text-xs">{section.title}</p>
const toggleSection = (sectionName: typeof API_KEY_PERMISSIONS[number]['section']) => {
const section = API_KEY_PERMISSIONS.find(s => s.section === sectionName);
if (!section) {
return;
}
<div class="pl-4 flex flex-col gap-4 mt-4">
<For each={section.permissions}>
{permission => (
<Checkbox
class="flex items-center gap-2"
checked={isPermissionSelected(permission.name)}
onChange={() => togglePermission(permission.name)}
>
<CheckboxControl />
<div class="flex flex-col gap-1">
<CheckboxLabel class="text-sm leading-none">
{permission.description}
</CheckboxLabel>
</div>
</Checkbox>
)}
</For>
const sectionPermissions: ReadonlyArray<string> = section.permissions;
const currentPermissions = permissions();
const allSelected = sectionPermissions.every(p => currentPermissions.includes(p));
setPermissions((prev) => {
if (allSelected) {
return [...prev.filter(p => !sectionPermissions.includes(p))];
}
return [...new Set([...prev, ...sectionPermissions])];
});
};
return (
<div class="p-6 pb-8 border rounded-md mt-2">
<div class="flex flex-col gap-6">
<For each={getPermissionsSections()}>
{section => (
<div>
<Button variant="link" class="text-muted-foreground text-xs p-0 h-auto hover:no-underline" onClick={() => toggleSection(section.section)}>{section.title}</Button>
<div class="pl-4 flex flex-col mt-2">
<For each={section.permissions}>
{permission => (
<Checkbox
class="flex items-center gap-2"
checked={isPermissionSelected(permission.name)}
onChange={() => togglePermission(permission.name)}
>
<CheckboxControl />
<div class="flex flex-col gap-1">
<CheckboxLabel class="text-sm leading-none py-1">
{permission.description}
</CheckboxLabel>
</div>
</Checkbox>
)}
</For>
</div>
</div>
</div>
)}
</For>
)}
</For>
</div>
<div class="flex items-center gap-2 mt-6 border-t pt-6">
<Button
variant="outline"
size="sm"
class="disabled:(op-100! border-op-50 text-muted-foreground)"
onClick={() => setPermissions(API_KEY_PERMISSIONS.flatMap(section => section.permissions))}
disabled={permissions().length === API_KEY_PERMISSIONS.flatMap(section => section.permissions).length}
>
{t('api-keys.permissions.select-all')}
</Button>
<Button
variant="outline"
size="sm"
class="disabled:(op-100! border-op-50 text-muted-foreground)"
onClick={() => setPermissions([])}
disabled={permissions().length === 0}
>
{t('api-keys.permissions.deselect-all')}
</Button>
</div>
</div>
);
};

View File

@@ -96,9 +96,7 @@ export const CreateApiKeyPage: Component = () => {
<div>
<p class="text-sm font-bold">{t('api-keys.create.form.permissions.label')}</p>
<div class="p-6 pb-8 border rounded-md mt-2">
<ApiKeyPermissionsPicker permissions={field.value ?? []} onChange={permissions => setValue(form, 'permissions', permissions)} />
</div>
<ApiKeyPermissionsPicker permissions={field.value ?? []} onChange={permissions => setValue(form, 'permissions', permissions)} />
{field.error && <div class="text-red-500 text-sm">{field.error}</div>}
</div>

View File

@@ -27,6 +27,7 @@ export const ConfigProvider: ParentComponent = (props) => {
const query = useQuery(() => ({
queryKey: ['config'],
queryFn: fetchPublicConfig,
refetchOnWindowFocus: false,
}));
const mergeConfigs = (runtimeConfig: RuntimePublicConfig): Config => {

View File

@@ -72,9 +72,10 @@ export const DocumentUploadProvider: ParentComponent = (props) => {
setState('open');
await Promise.all(files.map(async (file) => {
const { maxUploadSize } = config.documentsStorage;
updateTaskStatus({ file, status: 'uploading' });
if (file.size > config.documentsStorage.maxUploadSize) {
if (maxUploadSize > 0 && file.size > maxUploadSize) {
updateTaskStatus({ file, status: 'error', error: Object.assign(new Error('File too large'), { code: 'document.size_too_large' }) });
return;
}

View File

@@ -214,15 +214,6 @@ export const DocumentPage: Component = () => {
{t('documents.actions.download')}
</Button>
<Button
variant="outline"
onClick={() => window.open(getDataUrl()!, '_blank')}
size="sm"
>
<div class="i-tabler-eye size-4 mr-2"></div>
{t('documents.actions.open-in-new-tab')}
</Button>
{getDocument().isDeleted
? (
<Button

View File

@@ -10,7 +10,7 @@ import { useConfig } from '@/modules/config/config.provider';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { useConfirmModal } from '@/modules/shared/confirm';
import { createForm } from '@/modules/shared/form/form';
import { isHttpErrorWithCode } from '@/modules/shared/http/http-errors';
import { useI18nApiErrors } from '@/modules/shared/http/composables/i18n-api-errors';
import { queryClient } from '@/modules/shared/query/query-client';
import { cn } from '@/modules/shared/style/cn';
import { Alert, AlertDescription } from '@/modules/ui/components/alert';
@@ -187,6 +187,7 @@ export const IntakeEmailsPage: Component = () => {
const params = useParams();
const { confirm } = useConfirmModal();
const { getErrorMessage } = useI18nApiErrors({ t });
const query = useQuery(() => ({
queryKey: ['organizations', params.organizationId, 'intake-emails'],
@@ -196,16 +197,12 @@ export const IntakeEmailsPage: Component = () => {
const createEmail = async () => {
const [,error] = await safely(createIntakeEmail({ organizationId: params.organizationId }));
if (isHttpErrorWithCode({ error, code: 'intake_email.limit_reached' })) {
if (error) {
createToast({
message: t('api-errors.intake_email.limit_reached'),
message: getErrorMessage({ error }),
type: 'error',
});
return;
}
if (error) {
throw error;
}

View File

@@ -0,0 +1,102 @@
import type { Component } from 'solid-js';
import { formatBytes } from '@corentinth/chisels';
import { useParams } from '@solidjs/router';
import { useQuery } from '@tanstack/solid-query';
import { Show, Suspense } from 'solid-js';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { fetchOrganizationUsage } from '@/modules/subscriptions/subscriptions.services';
import { Card, CardContent } from '@/modules/ui/components/card';
import { ProgressCircle } from '@/modules/ui/components/progress-circle';
import { Separator } from '@/modules/ui/components/separator';
const UsageCardLine: Component<{
title: string;
description: string;
used: number;
limit: number | null;
formatValue?: (value: number) => string;
}> = (props) => {
const { t } = useI18n();
const percentage = () => {
if (props.limit === null) {
return 0;
}
return Math.min((props.used / props.limit) * 100, 100);
};
const formatValue = (value: number) => {
return props.formatValue ? props.formatValue(value) : value.toString();
};
return (
<div class="flex gap-4 items-center ">
<ProgressCircle value={percentage()} size="xs" class="flex-shrink-0" />
<div class="flex-1">
<div class="font-medium leading-none">{props.title}</div>
<div class="text-sm text-muted-foreground">{props.description}</div>
</div>
<div class="text-muted-foreground leading-none">{ `${formatValue(props.used)} / ${props.limit === null ? t('organization.usage.unlimited') : formatValue(props.limit)}${props.limit ? ` - ${percentage().toFixed(2)}%` : ''}`}</div>
</div>
);
};
export const OrganizationUsagePage: Component = () => {
const params = useParams();
const { t } = useI18n();
const query = useQuery(() => ({
queryKey: ['organizations', params.organizationId, 'usage'],
queryFn: () => fetchOrganizationUsage({ organizationId: params.organizationId }),
}));
return (
<div class="p-6 mt-10 pb-32 mx-auto max-w-screen-md w-full">
<Suspense>
<Show when={query.data}>
{getData => (
<>
<h1 class="text-xl font-semibold mb-2">
{t('organization.usage.page.title')}
</h1>
<p class="text-muted-foreground mb-6">
{t('organization.usage.page.description')}
</p>
<Card>
<CardContent class="pt-6 flex flex-col gap-4">
<UsageCardLine
title={t('organization.usage.storage.title')}
description={t('organization.usage.storage.description')}
used={getData().usage.documentsStorage.used}
limit={getData().usage.documentsStorage.limit}
formatValue={bytes => formatBytes({ bytes, base: 1000 })}
/>
<Separator />
<UsageCardLine
title={t('organization.usage.intake-emails.title')}
description={t('organization.usage.intake-emails.description')}
used={getData().usage.intakeEmailsCount.used}
limit={getData().usage.intakeEmailsCount.limit}
/>
<Separator />
<UsageCardLine
title={t('organization.usage.members.title')}
description={t('organization.usage.members.description')}
used={getData().usage.membersCount.used}
limit={getData().usage.membersCount.limit}
/>
</CardContent>
</Card>
</>
)}
</Show>
</Suspense>
</div>
);
};

View File

@@ -15,7 +15,7 @@ import { Button } from '@/modules/ui/components/button';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/modules/ui/components/card';
import { createToast } from '@/modules/ui/components/sonner';
import { TextField, TextFieldLabel, TextFieldRoot } from '@/modules/ui/components/textfield';
import { useDeleteOrganization, useUpdateOrganization } from '../organizations.composables';
import { useCurrentUserRole, useDeleteOrganization, useUpdateOrganization } from '../organizations.composables';
import { organizationNameSchema } from '../organizations.schemas';
import { fetchOrganization } from '../organizations.services';
@@ -24,6 +24,8 @@ const DeleteOrganizationCard: Component<{ organization: Organization }> = (props
const { confirm } = useConfirmModal();
const { t } = useI18n();
const { getIsOwner, query } = useCurrentUserRole({ organizationId: props.organization.id });
const handleDelete = async () => {
const confirmed = await confirm({
title: t('organization.settings.delete.confirm.title'),
@@ -54,10 +56,16 @@ const DeleteOrganizationCard: Component<{ organization: Organization }> = (props
</CardDescription>
</CardHeader>
<CardFooter class="pt-6">
<Button onClick={handleDelete} variant="destructive">
<CardFooter class="pt-6 gap-4">
<Button onClick={handleDelete} variant="destructive" disabled={!getIsOwner()}>
{t('organization.settings.delete.confirm.confirm-button')}
</Button>
<Show when={query.isSuccess && !getIsOwner()}>
<span class="text-sm text-muted-foreground">
{t('organization.settings.delete.only-owner')}
</span>
</Show>
</CardFooter>
</Card>
</div>

View File

@@ -0,0 +1,2 @@
export const FREE_PLAN_ID = 'free';
export const PLUS_PLAN_ID = 'plus';

View File

@@ -1,5 +1,6 @@
import type { TranslationKeys } from '@/modules/i18n/locales.types';
import { get } from 'lodash-es';
import { FetchError } from 'ofetch';
import { useI18n } from '@/modules/i18n/i18n.provider';
function codeToKey(code: string): TranslationKeys {
@@ -30,6 +31,11 @@ export function useI18nApiErrors({ t = useI18n().t }: { t?: ReturnType<typeof us
return translation;
}
// Fetch error message is not helpful
if (error instanceof FetchError) {
return getDefaultErrorMessage();
}
if (typeof error === 'object' && error && 'message' in error && typeof error.message === 'string') {
return error.message;
}

View File

@@ -0,0 +1,220 @@
import type { DialogTriggerProps } from '@kobalte/core/dialog';
import type { Component, JSX } from 'solid-js';
import { safely } from '@corentinth/chisels';
import { createSignal } from 'solid-js';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { PLUS_PLAN_ID } from '@/modules/plans/plans.constants';
import { cn } from '@/modules/shared/style/cn';
import { Button } from '@/modules/ui/components/button';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/modules/ui/components/dialog';
import { getCheckoutUrl } from '../subscriptions.services';
type PlanCardProps = {
name: string;
features: {
storageSize: number;
members: number;
emailIntakes: number;
maxUploadSize: number;
support: string;
};
isRecommended?: boolean;
isCurrent?: boolean;
price: number;
onUpgrade?: () => Promise<void>;
};
const PlanCard: Component<PlanCardProps> = (props) => {
const { t } = useI18n();
const [getIsUpgradeLoading, setIsUpgradeLoading] = createSignal(false);
const featureItems = [
{
icon: 'i-tabler-database',
title: t('subscriptions.features.storage-size'),
value: `${props.features.storageSize}GB`,
},
{
icon: 'i-tabler-users',
title: t('subscriptions.features.members'),
value: t('subscriptions.features.members-count', { count: props.features.members }),
},
{
icon: 'i-tabler-mail',
title: t('subscriptions.features.email-intakes'),
value: props.features.emailIntakes === 1
? t('subscriptions.features.email-intakes-count-singular', { count: props.features.emailIntakes })
: t('subscriptions.features.email-intakes-count-plural', { count: props.features.emailIntakes }),
},
{
icon: 'i-tabler-file-upload',
title: t('subscriptions.features.max-upload-size'),
value: `${props.features.maxUploadSize}MB`,
},
{
icon: 'i-tabler-headset',
title: t('subscriptions.features.support'),
value: props.features.support,
},
];
const upgrade = async () => {
if (!props.onUpgrade) {
return;
}
setIsUpgradeLoading(true);
await safely(props.onUpgrade());
setIsUpgradeLoading(false);
};
return (
<div class="border rounded-xl">
<div class="p-4">
<div class="text-sm font-medium text-muted-foreground flex items-center gap-2 justify-between">
<span>{props.name}</span>
{props.isCurrent && <span class="text-xs font-medium text-muted-foreground bg-muted rounded-md px-2 py-1">{t('subscriptions.upgrade-dialog.current-plan')}</span>}
{props.isRecommended && <div class="text-xs font-medium text-primary bg-primary/10 rounded-md px-2 py-1">{t('subscriptions.upgrade-dialog.recommended')}</div>}
</div>
<div class="text-xl font-semibold flex items-center gap-2">
$
{props.price}
<span class="text-sm font-normal text-muted-foreground">{t('subscriptions.upgrade-dialog.per-month')}</span>
</div>
<hr class="my-4" />
<div class="flex flex-col gap-3 ">
{featureItems.map(feature => (
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div class={`p-1.5 rounded-lg ${props.isCurrent ? 'bg-muted text-muted-foreground' : 'bg-primary/10 text-primary'}`}>
<div class={`size-5 ${feature.icon}`}></div>
</div>
<div>
<div class="font-medium text-sm">{feature.value}</div>
<div class="text-xs text-muted-foreground">{feature.title}</div>
</div>
</div>
</div>
))}
</div>
{ props.onUpgrade && (
<>
<hr class="my-4" />
<Button onClick={upgrade} class="w-full" autofocus isLoading={getIsUpgradeLoading()}>
{t('subscriptions.upgrade-dialog.upgrade-now')}
<div class="i-tabler-arrow-right size-5 ml-2"></div>
</Button>
</>
)}
</div>
</div>
);
};
type UpgradeDialogProps = {
children: (props: DialogTriggerProps) => JSX.Element;
organizationId: string;
};
export const UpgradeDialog: Component<UpgradeDialogProps> = (props) => {
const { t } = useI18n();
const [getIsOpen, setIsOpen] = createSignal(false);
const defaultBillingInterval: 'monthly' | 'annual' = 'annual';
const [getBillingInterval, setBillingInterval] = createSignal<'monthly' | 'annual'>(defaultBillingInterval);
const onUpgrade = async () => {
const { checkoutUrl } = await getCheckoutUrl({ organizationId: props.organizationId, planId: PLUS_PLAN_ID, billingInterval: getBillingInterval() });
window.location.href = checkoutUrl;
};
// Simplified plan configuration - only the values
const currentPlan = {
name: t('subscriptions.plan.free.name'),
monthlyPrice: 0,
annualPrice: 0,
features: {
storageSize: 0.5, // 500MB = 0.5GB
members: 3,
emailIntakes: 1,
maxUploadSize: 25,
support: t('subscriptions.features.support-community'),
},
isCurrent: true,
};
const plusPlan = {
name: t('subscriptions.plan.plus.name'),
monthlyPrice: 9,
annualPrice: 90,
features: {
storageSize: 5,
members: 10,
emailIntakes: 10,
maxUploadSize: 100,
support: t('subscriptions.features.support-email'),
},
isRecommended: true,
};
const getPlanPrice = (plan: { monthlyPrice: number; annualPrice: number }) => {
return getBillingInterval() === 'monthly' ? plan.monthlyPrice : Math.round(100 * plan.annualPrice / 12) / 100;
};
return (
<Dialog open={getIsOpen()} onOpenChange={setIsOpen}>
<DialogTrigger as={props.children} />
<DialogContent class="sm:max-w-xl">
<DialogHeader>
<div class="flex items-center gap-3">
<div class="p-2 bg-primary/10 rounded-lg">
<div class="i-tabler-sparkles size-7 text-primary"></div>
</div>
<div>
<DialogTitle class="text-xl mb-0">{t('subscriptions.upgrade-dialog.title')}</DialogTitle>
<DialogDescription class="text-sm text-muted-foreground">
{t('subscriptions.upgrade-dialog.description')}
</DialogDescription>
</div>
</div>
</DialogHeader>
<div class="mt-2 flex flex-col items-center">
<div class="inline-flex items-center justify-center border rounded-lg bg-muted p-1 gap-2">
<Button
size="sm"
variant="ghost"
class={cn('text-sm', { 'bg-primary/10 text-primary hover:(bg-primary/10 text-primary)': getBillingInterval() === 'monthly' })}
onClick={() => setBillingInterval('monthly')}
>
{t('subscriptions.billing-interval.monthly')}
</Button>
<Button
size="sm"
variant="ghost"
class={cn('text-sm pr-1.5', { 'bg-primary/10 text-primary hover:(bg-primary/10 text-primary)': getBillingInterval() === 'annual' })}
onClick={() => setBillingInterval('annual')}
>
{t('subscriptions.billing-interval.annual')}
<span class="ml-2 text-xs text-muted-foreground rounded bg-primary/10 text-primary px-1 py-0.5">-20%</span>
</Button>
</div>
</div>
<div class="mt-2 grid grid-cols-1 md:grid-cols-2 gap-2 ">
<div>
<PlanCard {...currentPlan} price={getPlanPrice(currentPlan)} />
<p class="text-muted-foreground text-xs p-4 ml-1">
<a href="https://papra.app/contact" class="underline" target="_blank" rel="noreferrer">{t('subscriptions.upgrade-dialog.contact-us')}</a>
{' '}
{t('subscriptions.upgrade-dialog.enterprise-plans')}
</p>
</div>
<PlanCard {...plusPlan} onUpgrade={onUpgrade} price={getPlanPrice(plusPlan)} />
</div>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,95 @@
import type { Component } from 'solid-js';
import { makePersisted } from '@solid-primitives/storage';
import { useQuery } from '@tanstack/solid-query';
import { createSignal, Show } from 'solid-js';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { Button } from '@/modules/ui/components/button';
import { fetchOrganizationUsage } from '../subscriptions.services';
import { UpgradeDialog } from './upgrade-dialog.component';
const ONE_DAY_IN_MS = 24/* hours */ * 60/* minutes */ * 60/* seconds */ * 1000/* milliseconds */;
export const UsageWarningCard: Component<{ organizationId: string }> = (props) => {
const { t } = useI18n();
const getOrganizationId = () => props.organizationId;
// TODO: mutualize the creation of the storage key
const [getDismissedDate, setDismissedDate] = makePersisted(createSignal<number | null>(null), { name: `papra:${getOrganizationId()}:usage-warning-dismissed`, storage: localStorage });
const query = useQuery(() => ({
queryKey: ['organizations', getOrganizationId(), 'usage'],
queryFn: () => fetchOrganizationUsage({ organizationId: getOrganizationId() }),
refetchOnWindowFocus: false,
}));
const getStorageSizeUsedPercent = () => {
const { data: usageData } = query;
if (!usageData || usageData.limits.maxDocumentsSize === null) {
return 0;
}
return (usageData.usage.documentsStorage.used / usageData.limits.maxDocumentsSize) * 100;
};
const shouldShow = () => {
const { data: usageData } = query;
if (!usageData) {
return false;
}
const dismissedAt = getDismissedDate();
const storagePercent = getStorageSizeUsedPercent();
const isOver80Percent = storagePercent >= 80;
if (!isOver80Percent) {
return false;
}
if (dismissedAt) {
const now = Date.now();
// Show the warning if the banner was dismissed more than 24h ago
return dismissedAt + ONE_DAY_IN_MS < now;
}
return isOver80Percent;
};
return (
<Show when={shouldShow()}>
<div class="bg-destructive/10 border-b border-b-destructive text-red-500 px-6 py-3 flex items-center gap-4 ">
<div class="max-w-5xl mx-auto flex sm:items-center gap-2 flex-col sm:flex-row">
<span class="text-sm">
<span class="i-tabler-alert-triangle size-5 inline-block mb--1 mr-2" />
{t('subscriptions.usage-warning.message', { percent: getStorageSizeUsedPercent().toFixed(2) })}
</span>
<UpgradeDialog organizationId={getOrganizationId()}>
{triggerProps => (
<Button
variant="outline"
size="sm"
class="flex-shrink-0"
{...triggerProps}
>
{t('subscriptions.usage-warning.upgrade-button')}
</Button>
)}
</UpgradeDialog>
</div>
<Button
variant="ghost"
size="icon"
class="ml-auto op-50 hover:op-100 transition flex-shrink-0 hidden sm:flex"
onClick={() => setDismissedDate(Date.now())}
>
<span class="i-tabler-x size-5" />
</Button>
</div>
</Show>
);
};

View File

@@ -0,0 +1,47 @@
import type { Component } from 'solid-js';
import { A } from '@solidjs/router';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { Button } from '@/modules/ui/components/button';
export const CheckoutCancelPage: Component = () => {
const { t } = useI18n();
return (
<div class="flex items-center justify-center min-h-screen p-6 bg-background">
<div class="max-w-md w-full text-center">
<div class="flex justify-center mb-6">
<div class="p-4 bg-muted rounded-full">
<div class="i-tabler-x size-16 text-muted-foreground" />
</div>
</div>
<h1 class="text-3xl font-bold mb-3">
{t('subscriptions.checkout-cancel.title')}
</h1>
<p class="text-muted-foreground mb-1">
{t('subscriptions.checkout-cancel.description')}
</p>
<p class="text-muted-foreground mb-8">
{t('subscriptions.checkout-cancel.no-charges')}
</p>
<div class="flex flex-col gap-3">
<Button as={A} href="/" size="lg" class="w-full">
{t('subscriptions.checkout-cancel.back-to-organizations')}
<div class="i-tabler-arrow-left size-5 mr-2 order-first" />
</Button>
<p class="text-sm text-muted-foreground">
{t('subscriptions.checkout-cancel.need-help')}
{' '}
<a href="https://papra.app/contact" class="underline hover:no-underline" target="_blank" rel="noreferrer">
{t('subscriptions.checkout-cancel.contact-support')}
</a>
</p>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,76 @@
import type { Component } from 'solid-js';
import { A, useNavigate, useSearchParams } from '@solidjs/router';
import { createEffect, createSignal, Show } from 'solid-js';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { Button } from '@/modules/ui/components/button';
export const CheckoutSuccessPage: Component = () => {
const { t } = useI18n();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [countdown, setCountdown] = createSignal(5);
createEffect(() => {
const sessionId = searchParams.sessionId;
// If no session ID, redirect immediately
if (!sessionId) {
navigate('/');
return;
}
// Start countdown
const interval = setInterval(() => {
setCountdown((prev) => {
if (prev <= 1) {
clearInterval(interval);
navigate('/');
return 0;
}
return prev - 1;
});
}, 1000);
return () => clearInterval(interval);
});
return (
<div class="flex items-center justify-center min-h-screen p-6 bg-background">
<div class="max-w-md w-full text-center">
<div class="flex justify-center mb-6">
<div class="p-4 bg-primary/10 rounded-full">
<div class="i-tabler-check size-16 text-primary" />
</div>
</div>
<h1 class="text-3xl font-bold mb-3">
{t('subscriptions.checkout-success.title')}
</h1>
<p class="text-muted-foreground mb-1">
{t('subscriptions.checkout-success.description')}
</p>
<p class="text-muted-foreground mb-8">
{t('subscriptions.checkout-success.thank-you')}
</p>
<div class="flex flex-col gap-3">
<Button as={A} href="/" size="lg" class="w-full">
{t('subscriptions.checkout-success.go-to-organizations')}
<div class="i-tabler-arrow-right size-5 ml-2" />
</Button>
<Show when={countdown() > 0}>
<p class="text-sm text-muted-foreground">
{t('subscriptions.checkout-success.redirecting', {
count: countdown(),
plural: countdown() !== 1 ? 's' : '',
})}
</p>
</Show>
</div>
</div>
</div>
);
};

View File

@@ -1,12 +1,13 @@
import type { OrganizationSubscription } from './subscriptions.types';
import { apiClient } from '../shared/http/api-client';
export async function getCheckoutUrl({ organizationId, planId }: { organizationId: string; planId: string }) {
export async function getCheckoutUrl({ organizationId, planId, billingInterval }: { organizationId: string; planId: string; billingInterval: 'monthly' | 'annual' }) {
const { checkoutUrl } = await apiClient<{ checkoutUrl: string }>({
method: 'POST',
path: `/api/organizations/${organizationId}/checkout-session`,
body: {
planId,
billingInterval,
},
});
@@ -22,11 +23,27 @@ export async function getCustomerPortalUrl({ organizationId }: { organizationId:
return { customerPortalUrl };
}
export async function getOrganizationSubscription({ organizationId }: { organizationId: string }) {
const { subscription } = await apiClient<{ subscription: OrganizationSubscription }>({
export async function fetchOrganizationSubscription({ organizationId }: { organizationId: string }) {
const { subscription, plan } = await apiClient<{ subscription: OrganizationSubscription; plan: { id: string; name: string } }>({
method: 'GET',
path: `/api/organizations/${organizationId}/subscription`,
});
return { subscription };
return { subscription, plan };
}
export async function fetchOrganizationUsage({ organizationId }: { organizationId: string }) {
const { usage, limits } = await apiClient<{
usage: {
documentsStorage: { used: number; limit: number | null };
intakeEmailsCount: { used: number; limit: number | null };
membersCount: { used: number; limit: number | null };
};
limits: { maxDocumentsSize: number | null; maxIntakeEmailsCount: number | null; maxOrganizationsMembersCount: number | null };
}>({
method: 'GET',
path: `/api/organizations/${organizationId}/usage`,
});
return { usage, limits };
}

View File

@@ -34,7 +34,7 @@ export function DialogContent<T extends ValidComponent = 'div'>(props: Polymorph
/>
<DialogPrimitive.Content
class={cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-card p-6 shadow-lg duration-200 data-[expanded]:(animate-in fade-in-0 zoom-in-95 slide-in-from-left-1/2 slide-in-from-top-48% duration-200) data-[closed]:(animate-out fade-out-0 zoom-out-95 slide-out-to-left-1/2 slide-out-to-top-48% duration-200) md:w-full sm:rounded-lg',
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-card p-6 shadow-lg duration-200 data-[expanded]:(animate-in fade-in-0 zoom-in-95 slide-in-from-left-1/2 slide-in-from-top-48% duration-200) data-[closed]:(animate-out fade-out-0 zoom-out-95 slide-out-to-left-1/2 slide-out-to-top-48% duration-200) md:w-full sm:rounded-lg max-h-100vh overflow-y-auto',
local.class,
)}
{...rest}

View File

@@ -0,0 +1,96 @@
import type { Component, ComponentProps } from 'solid-js';
import { mergeProps, splitProps } from 'solid-js';
import { cn } from '@/modules/shared/style/cn';
type Size = 'xs' | 'sm' | 'md' | 'lg' | 'xl';
const sizes: Record<Size, { radius: number; strokeWidth: number }> = {
xs: { radius: 15, strokeWidth: 4 },
sm: { radius: 19, strokeWidth: 4 },
md: { radius: 32, strokeWidth: 6 },
lg: { radius: 52, strokeWidth: 8 },
xl: { radius: 80, strokeWidth: 10 },
};
type ProgressCircleProps = ComponentProps<'div'> & {
value?: number;
size?: Size;
radius?: number;
strokeWidth?: number;
showAnimation?: boolean;
};
const ProgressCircle: Component<ProgressCircleProps> = (rawProps) => {
const props = mergeProps({ size: 'md' as Size, showAnimation: true }, rawProps);
const [local, others] = splitProps(props, [
'class',
'children',
'value',
'size',
'radius',
'strokeWidth',
'showAnimation',
]);
const value = () => getLimitedValue(local.value);
const radius = () => local.radius ?? sizes[local.size].radius;
const strokeWidth = () => local.strokeWidth ?? sizes[local.size].strokeWidth;
const normalizedRadius = () => radius() - strokeWidth() / 2;
const circumference = () => normalizedRadius() * 2 * Math.PI;
const strokeDashoffset = () => (value() / 100) * circumference();
const offset = () => circumference() - strokeDashoffset();
return (
<div class={cn('flex flex-col items-center justify-center', local.class)} {...others}>
<svg
width={radius() * 2}
height={radius() * 2}
viewBox={`0 0 ${radius() * 2} ${radius() * 2}`}
class="-rotate-90"
aria-hidden="true"
>
<circle
r={normalizedRadius()}
cx={radius()}
cy={radius()}
stroke-width={strokeWidth()}
fill="transparent"
stroke=""
stroke-linecap="round"
class={cn('stroke-secondary transition-colors ease-linear')}
/>
{value() >= 0
? (
<circle
r={normalizedRadius()}
cx={radius()}
cy={radius()}
stroke-width={strokeWidth()}
stroke-dasharray={`${circumference()} ${circumference()}`}
stroke-dashoffset={offset()}
fill="transparent"
stroke=""
stroke-linecap="round"
class={cn(
'stroke-primary transition-colors ease-linear',
local.showAnimation ? 'transition-all duration-300 ease-in-out' : '',
)}
/>
)
: null}
</svg>
<div class={cn('absolute flex')}>{local.children}</div>
</div>
);
};
function getLimitedValue(input: number | undefined) {
if (input === undefined) {
return 0;
} else if (input > 100) {
return 100;
}
return input;
}
export { ProgressCircle };

View File

@@ -14,6 +14,11 @@ export const OrganizationSettingsLayout: ParentComponent = (props) => {
href: `/organizations/${params.organizationId}/settings`,
icon: 'i-tabler-settings',
},
{
label: t('layout.menu.usage'),
href: `/organizations/${params.organizationId}/settings/usage`,
icon: 'i-tabler-chart-bar',
},
{
label: t('layout.menu.intake-emails'),
href: `/organizations/${params.organizationId}/settings/intake-emails`,

View File

@@ -5,10 +5,14 @@ import type { Organization } from '@/modules/organizations/organizations.types';
import { useNavigate, useParams } from '@solidjs/router';
import { createQueries, useQuery } from '@tanstack/solid-query';
import { get } from 'lodash-es';
import { createEffect, on } from 'solid-js';
import { createEffect, on, Show } from 'solid-js';
import { useConfig } from '@/modules/config/config.provider';
import { DocumentUploadProvider } from '@/modules/documents/components/document-import-status.component';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { fetchOrganization, fetchOrganizations } from '@/modules/organizations/organizations.services';
import { UpgradeDialog } from '@/modules/subscriptions/components/upgrade-dialog.component';
import { fetchOrganizationSubscription } from '@/modules/subscriptions/subscriptions.services';
import { Button } from '../components/button';
import {
Select,
SelectContent,
@@ -18,6 +22,48 @@ import {
} from '../components/select';
import { SideNav, SidenavLayout } from './sidenav.layout';
const UpgradeCTAFooter: Component<{ organizationId: string }> = (props) => {
const { t } = useI18n();
const { config } = useConfig();
const query = useQuery(() => ({
queryKey: ['organizations', 'subscription'],
queryFn: () => fetchOrganizationSubscription({ organizationId: props.organizationId }),
}));
const shouldShowUpgradeCTA = () => {
if (!config.isSubscriptionsEnabled) {
return false;
}
return query.data && query.data.plan.id === 'free';
};
return (
<Show when={shouldShowUpgradeCTA()}>
<div class="p-4 mx-4 mt-4 bg-background bg-gradient-to-br from-primary/15 to-transparent rounded-lg">
<div class="flex items-center gap-2 text-sm font-medium">
<div class="i-tabler-sparkles size-4 text-primary"></div>
{t('layout.upgrade-cta.title')}
</div>
<div class="text-xs mt-1 mb-3 text-muted-foreground">
{t('layout.upgrade-cta.description')}
</div>
<UpgradeDialog organizationId={props.organizationId}>
{dialogProps => (
<Button size="sm" class="w-full font-semibold" {...dialogProps}>
{t('layout.upgrade-cta.button')}
<div class="i-tabler-arrow-right size-4 ml-1"></div>
</Button>
)}
</UpgradeDialog>
</div>
</Show>
);
};
const OrganizationLayoutSideNav: Component = () => {
const navigate = useNavigate();
const params = useParams();
@@ -98,6 +144,7 @@ const OrganizationLayoutSideNav: Component = () => {
<SideNav
mainMenu={getMainMenuItems()}
footerMenu={getFooterMenuItems()}
footer={() => <UpgradeCTAFooter organizationId={params.organizationId} />}
header={() =>
(
<div class="px-6 pt-4 max-w-285px min-w-0">

View File

@@ -12,6 +12,7 @@ import { GlobalDropArea } from '@/modules/documents/components/global-drop-area.
import { useI18n } from '@/modules/i18n/i18n.provider';
import { usePendingInvitationsCount } from '@/modules/invitations/composables/usePendingInvitationsCount';
import { cn } from '@/modules/shared/style/cn';
import { UsageWarningCard } from '@/modules/subscriptions/components/usage-warning-card';
import { useThemeStore } from '@/modules/theme/theme.store';
import { Button } from '@/modules/ui/components/button';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger } from '../components/dropdown-menu';
@@ -47,6 +48,7 @@ export const SideNav: Component<{
footerMenu?: MenuItem[];
header?: Component;
footer?: Component;
preFooter?: Component;
}> = (props) => {
const { config } = useConfig();
@@ -106,7 +108,7 @@ export const SideNav: Component<{
</a>
</div>
{(props.header || props.mainMenu || props.footerMenu || props.footer) && (
{(props.header || props.mainMenu || props.footerMenu || props.footer || props.preFooter) && (
<div class="h-full flex flex-col pb-6 flex-1">
{props.header && <props.header />}
@@ -118,6 +120,8 @@ export const SideNav: Component<{
<div class="flex-1"></div>
{props.preFooter && <props.preFooter />}
{props.footerMenu && (
<nav class="flex flex-col gap-0.5 px-4">
{props.footerMenu.map(menuItem => <MenuItemButton {...menuItem} />)}
@@ -199,7 +203,10 @@ export const SidenavLayout: ParentComponent<{
<props.sideNav />
</div>
<div class="flex-1 min-h-0 flex flex-col">
<UsageWarningCard organizationId={params.organizationId} />
<div class="flex justify-between px-6 pt-4">
<div class="flex items-center">
@@ -301,6 +308,7 @@ export const SidenavLayout: ParentComponent<{
<div class="flex-1 overflow-auto max-w-screen">
<Suspense>
{props.children}
</Suspense>
</div>
</div>

View File

@@ -21,10 +21,13 @@ import { CreateOrganizationPage } from './modules/organizations/pages/create-org
import { InvitationsListPage } from './modules/organizations/pages/invitations-list.page';
import { InviteMemberPage } from './modules/organizations/pages/invite-member.page';
import { MembersPage } from './modules/organizations/pages/members.page';
import { OrganizationUsagePage } from './modules/organizations/pages/organization-usage.page';
import { OrganizationPage } from './modules/organizations/pages/organization.page';
import { OrganizationsSettingsPage } from './modules/organizations/pages/organizations-settings.page';
import { OrganizationsPage } from './modules/organizations/pages/organizations.page';
import { NotFoundPage } from './modules/shared/pages/not-found.page';
import { CheckoutCancelPage } from './modules/subscriptions/pages/checkout-cancel.page';
import { CheckoutSuccessPage } from './modules/subscriptions/pages/checkout-success.page';
import { CreateTaggingRulePage } from './modules/tagging-rules/pages/create-tagging-rule.page';
import { TaggingRulesPage } from './modules/tagging-rules/pages/tagging-rules.page';
import { UpdateTaggingRulePage } from './modules/tagging-rules/pages/update-tagging-rule.page';
@@ -155,6 +158,10 @@ export const routes: RouteDefinition[] = [
path: '/',
component: OrganizationsSettingsPage,
},
{
path: '/usage',
component: OrganizationUsagePage,
},
{
path: '/webhooks/create',
component: CreateWebhookPage,
@@ -227,6 +234,14 @@ export const routes: RouteDefinition[] = [
path: '/email-validation-required',
component: createProtectedPage({ authType: 'public-only', component: EmailValidationRequiredPage }),
},
{
path: '/checkout-success',
component: CheckoutSuccessPage,
},
{
path: '/checkout-cancel',
component: CheckoutCancelPage,
},
{
path: '*404',
component: NotFoundPage,

View File

@@ -0,0 +1,9 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
env: {
TZ: 'UTC',
},
},
});

View File

@@ -1,5 +1,62 @@
# @papra/app-server
## 0.9.6
### Patch Changes
- [#531](https://github.com/papra-hq/papra/pull/531) [`2e2bb6f`](https://github.com/papra-hq/papra/commit/2e2bb6fbbdd02f6b8352ef2653bef0447948c1f0) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added env variable to configure ip header for rate limit
- [#524](https://github.com/papra-hq/papra/pull/524) [`c84a921`](https://github.com/papra-hq/papra/commit/c84a9219886ecb2a77c67d904cf8c8d15b50747b) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Fixed the api validation of tag colors to make it case incensitive
## 0.9.5
### Patch Changes
- [#521](https://github.com/papra-hq/papra/pull/521) [`b287723`](https://github.com/papra-hq/papra/commit/b28772317c3662555e598755b85597d6cd5aeea1) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Properly handle file names encoding (utf8 instead of latin1) to support non-ASCII characters.
- [#517](https://github.com/papra-hq/papra/pull/517) [`a3f9f05`](https://github.com/papra-hq/papra/commit/a3f9f05c664b4995b62db59f2e9eda8a3bfef0de) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Prevented organization deletion by non-organization owner
## 0.9.4
### Patch Changes
- [#508](https://github.com/papra-hq/papra/pull/508) [`782f70f`](https://github.com/papra-hq/papra/commit/782f70ff663634bf9ff7218edabb9885a7c6f965) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added an option to disable PRAGMA statements from sqlite task service migrations
- [#510](https://github.com/papra-hq/papra/pull/510) [`ab6fd6a`](https://github.com/papra-hq/papra/commit/ab6fd6ad10387f1dcd626936efc195d9d58d40ec) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added fallbacks env variables for the task worker id
- [#512](https://github.com/papra-hq/papra/pull/512) [`cb3ce6b`](https://github.com/papra-hq/papra/commit/cb3ce6b1d8d5dba09cbf0d2964f14b1c93220571) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added organizations permissions for api keys
## 0.9.3
### Patch Changes
- [#506](https://github.com/papra-hq/papra/pull/506) [`6bcb2a7`](https://github.com/papra-hq/papra/commit/6bcb2a71e990d534dd12d84e64a38f2b2baea25a) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added the possibility to define patterns for email intake username generation
- [#504](https://github.com/papra-hq/papra/pull/504) [`936bc2b`](https://github.com/papra-hq/papra/commit/936bc2bd0a788e4fb0bceb6d14810f9f8734097b) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Split the intake-email username generation from the email address creation, some changes regarding the configuration when using the `random` driver.
```env
# Old configuration
INTAKE_EMAILS_DRIVER=random-username
INTAKE_EMAILS_EMAIL_GENERATION_DOMAIN=mydomain.com
# New configuration
INTAKE_EMAILS_DRIVER=catch-all
INTAKE_EMAILS_CATCH_ALL_DOMAIN=mydomain.com
INTAKE_EMAILS_USERNAME_DRIVER=random
```
- [#504](https://github.com/papra-hq/papra/pull/504) [`936bc2b`](https://github.com/papra-hq/papra/commit/936bc2bd0a788e4fb0bceb6d14810f9f8734097b) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added the possibility to configure OwlRelay domain
## 0.9.2
### Patch Changes
- [#493](https://github.com/papra-hq/papra/pull/493) [`ed4d7e4`](https://github.com/papra-hq/papra/commit/ed4d7e4a00b2ca2c7fe808201c322f957d6ed990) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Fix to allow cross docker volume file moving when consumption is done
- [#500](https://github.com/papra-hq/papra/pull/500) [`208a561`](https://github.com/papra-hq/papra/commit/208a561668ed2d1019430a9f4f5c5d3fd4cde603) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added the possibility to define a Libsql/Sqlite driver for the tasks service
- [#499](https://github.com/papra-hq/papra/pull/499) [`40cb1d7`](https://github.com/papra-hq/papra/commit/40cb1d71d5e52c40aab7ea2c6bc222cea6d55b70) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Enhanced security by serving files as attachement and with an octet-stream content type
## 0.9.1
### Patch Changes

View File

@@ -1,7 +1,7 @@
{
"name": "@papra/app-server",
"type": "module",
"version": "0.9.1",
"version": "0.9.6",
"private": true,
"packageManager": "pnpm@10.12.3",
"description": "Papra app server",
@@ -42,6 +42,7 @@
"@aws-sdk/lib-storage": "^3.835.0",
"@azure/storage-blob": "^12.27.0",
"@cadence-mq/core": "^0.2.1",
"@cadence-mq/driver-libsql": "^0.2.4",
"@cadence-mq/driver-memory": "^0.2.0",
"@corentinth/chisels": "^1.3.1",
"@corentinth/friendly-ids": "^0.0.1",
@@ -54,6 +55,7 @@
"@papra/lecture": "workspace:*",
"@papra/webhooks": "workspace:*",
"@paralleldrive/cuid2": "^2.2.2",
"@sindresorhus/slugify": "^3.0.0",
"better-auth": "catalog:",
"busboy": "^1.6.0",
"c12": "^3.0.4",
@@ -61,7 +63,7 @@
"date-fns": "^4.1.0",
"drizzle-kit": "^0.30.6",
"drizzle-orm": "^0.38.4",
"figue": "^2.2.3",
"figue": "^3.1.1",
"hono": "^4.8.2",
"lodash-es": "^4.17.21",
"mime-types": "^3.0.1",

View File

@@ -21,6 +21,8 @@ const { db, client } = setupDatabase(config.database);
const documentsStorageService = createDocumentStorageService({ documentStorageConfig: config.documentsStorage });
const taskServices = createTaskServices({ config });
await taskServices.initialize();
const { app } = await createServer({ config, db, taskServices, documentsStorageService });
const server = serve(

View File

@@ -5,8 +5,15 @@ export const API_KEY_ID_REGEX = createPrefixedIdRegex({ prefix: API_KEY_ID_PREFI
export const API_KEY_PREFIX = 'ppapi';
export const API_KEY_TOKEN_LENGTH = 64;
export const API_KEY_TOKEN_REGEX = new RegExp(`^${API_KEY_PREFIX}_[A-Za-z0-9]{${API_KEY_TOKEN_LENGTH}}$`);
export const API_KEY_PERMISSIONS = {
ORGANIZATIONS: {
CREATE: 'organizations:create',
READ: 'organizations:read',
UPDATE: 'organizations:update',
DELETE: 'organizations:delete',
},
DOCUMENTS: {
CREATE: 'documents:create',
READ: 'documents:read',

View File

@@ -4,6 +4,7 @@ import { createMiddleware } from 'hono/factory';
import { createUnauthorizedError } from '../app/auth/auth.errors';
import { getAuthorizationHeader } from '../shared/headers/headers.models';
import { isNil } from '../shared/utils';
import { looksLikeAnApiKey } from './api-keys.models';
import { createApiKeysRepository } from './api-keys.repository';
import { getApiKey } from './api-keys.usecases';
@@ -31,8 +32,7 @@ export function createApiKeyMiddleware({ db }: { db: Database }) {
throw createUnauthorizedError();
}
if (isNil(token)) {
// For type safety
if (!looksLikeAnApiKey(token)) {
throw createUnauthorizedError();
}

View File

@@ -1,5 +1,6 @@
import { describe, expect, test } from 'vitest';
import { getApiKeyUiPrefix } from './api-keys.models';
import { getApiKeyUiPrefix, looksLikeAnApiKey } from './api-keys.models';
import { generateApiToken } from './api-keys.services';
describe('api-keys models', () => {
describe('getApiKeyUiPrefix', () => {
@@ -11,4 +12,39 @@ describe('api-keys models', () => {
);
});
});
describe('looksLikeAnApiKey', () => {
test(`validate that a token looks like an api key
- it starts with the api key prefix
- it has the correct length
- it only contains alphanumeric characters`, () => {
expect(
looksLikeAnApiKey('ppapi_29qxv9eCbRkQQGhwrVZCEXEFjOYpXZX07G4vDK4HT03Jp7fVHyJx1b0l6e1LIEPD'),
).toBe(true);
expect(
looksLikeAnApiKey(''),
).toBe(false);
expect(
looksLikeAnApiKey('ppapi_'),
).toBe(false);
expect(
looksLikeAnApiKey('ppapi_29qxv9eCbRkQQGhwrVZCEXEFjOYpXZX07G4vDK4HT03Jp7fVHyJx1b0l6e1LIEPD_extra'),
).toBe(false);
expect(
looksLikeAnApiKey('invalidprefix_29qxv9eCbRkQQGhwrVZCEXEFjOYpXZX07G4vDK4HT03Jp7fVHyJx1b0l6e1LIEPD'),
).toBe(false);
});
test('a freshly generated token should always look like an api key', () => {
const { token } = generateApiToken();
expect(
looksLikeAnApiKey(token),
).toBe(true);
});
});
});

View File

@@ -1,5 +1,6 @@
import { sha256 } from '../shared/crypto/hash';
import { API_KEY_PREFIX } from './api-keys.constants';
import { isNil } from '../shared/utils';
import { API_KEY_PREFIX, API_KEY_TOKEN_REGEX } from './api-keys.constants';
export function getApiKeyUiPrefix({ token }: { token: string }) {
return {
@@ -12,3 +13,12 @@ export function getApiKeyHash({ token }: { token: string }) {
keyHash: sha256(token, { digest: 'base64url' }),
};
}
// Positional argument as TS does not like named argument with type guards
export function looksLikeAnApiKey(token?: string | null | undefined): token is string {
if (isNil(token)) {
return false;
}
return API_KEY_TOKEN_REGEX.test(token);
}

View File

@@ -55,6 +55,17 @@ export const authConfig = {
default: false,
env: 'AUTH_SHOW_LEGAL_LINKS',
},
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.
- If behind a standard proxy, you might want to set this to "x-forwarded-for".
- If behind Cloudflare, you might want to set this to "cf-connecting-ip".`,
schema: z.union([
z.string(),
z.array(z.string()),
]).transform(value => (typeof value === 'string' ? value.split(',').map(v => v.trim()) : value)),
default: ['x-forwarded-for'],
env: 'AUTH_IP_ADDRESS_HEADERS',
},
providers: {
email: {
isEnabled: {

View File

@@ -1,7 +1,7 @@
import type { Context, RouteDefinitionContext } from '../server.types';
import type { Session } from './auth.types';
import { get } from 'lodash-es';
import { isDefined } from '../../shared/utils';
import { isDefined, isString } from '../../shared/utils';
export function registerAuthRoutes({ app, auth, config }: RouteDefinitionContext) {
app.on(
@@ -26,7 +26,7 @@ export function registerAuthRoutes({ app, auth, config }: RouteDefinitionContext
app.use('*', async (context: Context, next) => {
const overrideUserId: unknown = get(context.env, 'loggedInUserId');
if (isDefined(overrideUserId) && typeof overrideUserId === 'string') {
if (isDefined(overrideUserId) && isString(overrideUserId)) {
context.set('userId', overrideUserId);
context.set('session', {} as Session);
context.set('authType', 'session');

View File

@@ -37,8 +37,8 @@ export function getAuth({
trustedOrigins,
logger: {
disabled: false,
log: (baseLevel, message) => {
logger[baseLevel ?? 'info'](message);
log: (baseLevel, message, ...args: unknown[]) => {
logger[baseLevel ?? 'info']({ ...args }, message);
},
},
emailAndPassword: {
@@ -85,6 +85,9 @@ export function getAuth({
advanced: {
// Drizzle tables handle the id generation
database: { generateId: false },
ipAddress: {
ipAddressHeaders: config.auth.ipAddressHeaders,
},
},
socialProviders: {
github: {

View File

@@ -46,7 +46,7 @@ export async function createServer(initialDeps: Partial<GlobalDependencies> = {}
const app = new Hono<ServerInstanceGenerics>({ strict: true });
app.use(createLoggerMiddleware());
app.use(createLoggerMiddleware({ config }));
app.use(createCorsMiddleware({ config }));
app.use(createTimeoutMiddleware({ config }));
app.use(secureHeaders());

View File

@@ -15,6 +15,7 @@ import { intakeEmailsConfig } from '../intake-emails/intake-emails.config';
import { organizationsConfig } from '../organizations/organizations.config';
import { organizationPlansConfig } from '../plans/plans.config';
import { createLogger } from '../shared/logger/logger';
import { isString } from '../shared/utils';
import { subscriptionsConfig } from '../subscriptions/subscriptions.config';
import { tasksConfig } from '../tasks/tasks.config';
import { trackingConfig } from '../tracking/tracking.config';
@@ -71,7 +72,7 @@ export const configDefinition = {
schema: z.union([
z.string(),
z.array(z.string()),
]).transform(value => (typeof value === 'string' ? value.split(',') : value)),
]).transform(value => (isString(value) ? value.split(',') : value)),
default: ['http://localhost:3000'],
env: 'SERVER_CORS_ORIGINS',
},

View File

@@ -288,9 +288,13 @@ function setupGetDocumentFileRoute({ app, db, documentsStorageService }: RouteDe
Readable.toWeb(fileStream),
200,
{
'Content-Type': document.mimeType,
'Content-Disposition': `inline; filename*=UTF-8''${encodeURIComponent(document.name)}`,
// Prevent XSS by serving the file as an octet-stream
'Content-Type': 'application/octet-stream',
// Always use attachment for defense in depth - client uses blob API anyway
'Content-Disposition': `attachment; filename*=UTF-8''${encodeURIComponent(document.name)}`,
'Content-Length': String(document.originalSize),
'X-Content-Type-Options': 'nosniff',
'X-Frame-Options': 'DENY',
},
);
},

View File

@@ -127,5 +127,71 @@ describe('documents e2e', () => {
// Ensure no file is saved in the storage
expect(documentsStorageService._getStorage().size).to.eql(0);
});
// https://github.com/papra-hq/papra/issues/519
test('uploading documents with various UTF-8 characters in filenames', 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 } = await createServer({
db,
config: overrideConfig({
env: 'test',
documentsStorage: {
driver: 'in-memory',
},
}),
});
// Various UTF-8 characters that cause encoding issues
const testCases = [
{ filename: 'ΒΕΒΑΙΩΣΗ ΧΑΡΕΣ.txt', content: 'Filename with Greek characters' },
{ filename: 'résumé français.txt', content: 'French document' },
{ filename: 'documento español.txt', content: 'Spanish document' },
{ filename: '日本語ファイル.txt', content: 'Japanese document' },
{ filename: 'файл на русском.txt', content: 'Russian document' },
{ filename: 'émojis 🎉📄.txt', content: 'Document with emojis' },
];
for (const testCase of testCases) {
const formData = new FormData();
formData.append('file', new File([testCase.content], testCase.filename, { 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' },
);
expect(createDocumentResponse.status).to.eql(200);
const { document } = (await createDocumentResponse.json()) as { document: Document };
// Each filename should be preserved correctly
expect(document.name).to.eql(testCase.filename);
expect(document.originalName).to.eql(testCase.filename);
// Retrieve the document
const getDocumentResponse = await app.request(
`/api/organizations/org_222222222222222222222222/documents/${document.id}`,
{ method: 'GET' },
{ loggedInUserId: 'usr_111111111111111111111111' },
);
expect(getDocumentResponse.status).to.eql(200);
const { document: retrievedDocument } = (await getDocumentResponse.json()) as { document: Document };
expect(retrievedDocument).to.eql({ ...document, tags: [] });
}
});
});
});

View File

@@ -3,6 +3,7 @@ import { DeleteObjectCommand, GetObjectCommand, HeadObjectCommand, S3Client } fr
import { Upload } from '@aws-sdk/lib-storage';
import { safely } from '@corentinth/chisels';
import { isString } from '../../../../shared/utils';
import { createFileNotFoundError } from '../../document-storage.errors';
import { defineStorageDriver } from '../drivers.models';
@@ -12,7 +13,7 @@ function isS3NotFoundError(error: Error) {
const codes = ['NoSuchKey', 'NotFound'];
return codes.includes(error.name)
|| ('Code' in error && typeof error.Code === 'string' && codes.includes(error.Code));
|| ('Code' in error && isString(error.Code) && codes.includes(error.Code));
}
export const s3StorageDriverFactory = defineStorageDriver(({ documentStorageConfig }) => {

View File

@@ -1,6 +1,7 @@
import type { ConfigDefinition } from 'figue';
import { z } from 'zod';
import { booleanishSchema } from '../config/config.schemas';
import { isString } from '../shared/utils';
import { defaultIgnoredPatterns } from './ingestion-folders.constants';
export const ingestionFolderConfig = {
@@ -61,7 +62,7 @@ export const ingestionFolderConfig = {
schema: z.union([
z.string(),
z.array(z.string()),
]).transform(value => (typeof value === 'string' ? value.split(',') : value)),
]).transform(value => (isString(value) ? value.split(',') : value)),
default: defaultIgnoredPatterns,
env: 'INGESTION_FOLDER_IGNORED_PATTERNS',
},

View File

@@ -1,11 +1,12 @@
import type { ConfigDefinition } from 'figue';
import { z } from 'zod';
export const randomUsernameIntakeEmailDriverConfig = {
export const catchAllIntakeEmailDriverConfig = {
domain: {
doc: 'The domain to use when generating email addresses for intake emails when using the random username driver',
doc: 'The domain to use when generating email addresses for intake emails when using the `catch-all` driver',
schema: z.string(),
default: 'papra.email',
env: 'INTAKE_EMAILS_EMAIL_GENERATION_DOMAIN',
default: 'papra.local',
env: 'INTAKE_EMAILS_CATCH_ALL_DOMAIN',
},
} as const satisfies ConfigDefinition;

View File

@@ -0,0 +1,20 @@
import { buildEmailAddress } from '../../intake-emails.models';
import { defineIntakeEmailDriver } from '../intake-emails.drivers.models';
export const CATCH_ALL_INTAKE_EMAIL_DRIVER_NAME = 'catch-all';
// This driver is used when no external service is used to manage the email addresses
// like for example when using a catch-all domain
export const catchAllIntakeEmailDriverFactory = defineIntakeEmailDriver(({ config }) => {
const { domain } = config.intakeEmails.drivers.catchAll;
return {
name: CATCH_ALL_INTAKE_EMAIL_DRIVER_NAME,
createEmailAddress: async ({ username }) => {
const emailAddress = buildEmailAddress({ username, domain });
return { emailAddress };
},
deleteEmailAddress: async () => {},
};
});

View File

@@ -2,8 +2,8 @@ import type { Config } from '../../config/config.types';
export type IntakeEmailsServices = {
name: string;
generateEmailAddress: () => Promise<{ emailAddress: string }>;
deleteEmailAddress: ({ emailAddress }: { emailAddress: string }) => Promise<void>;
createEmailAddress: (args: { username: string }) => Promise<{ emailAddress: string }>;
deleteEmailAddress: (args: { emailAddress: string }) => Promise<void>;
};
export type IntakeEmailDriverFactory = (args: { config: Config }) => IntakeEmailsServices;

View File

@@ -1,9 +1,9 @@
import { CATCH_ALL_INTAKE_EMAIL_DRIVER_NAME, catchAllIntakeEmailDriverFactory } from './catch-all/catch-all.intake-email-driver';
import { OWLRELAY_INTAKE_EMAIL_DRIVER_NAME, owlrelayIntakeEmailDriverFactory } from './owlrelay/owlrelay.intake-email-driver';
import { RANDOM_USERNAME_INTAKE_EMAIL_DRIVER_NAME, randomUsernameIntakeEmailDriverFactory } from './random-username/random-username.intake-email-driver';
export const intakeEmailDrivers = {
[RANDOM_USERNAME_INTAKE_EMAIL_DRIVER_NAME]: randomUsernameIntakeEmailDriverFactory,
[OWLRELAY_INTAKE_EMAIL_DRIVER_NAME]: owlrelayIntakeEmailDriverFactory,
[CATCH_ALL_INTAKE_EMAIL_DRIVER_NAME]: catchAllIntakeEmailDriverFactory,
} as const;
export type IntakeEmailDriverName = keyof typeof intakeEmailDrivers;

View File

@@ -15,4 +15,10 @@ export const owlrelayIntakeEmailDriverConfig = {
default: undefined,
env: 'OWLRELAY_WEBHOOK_URL',
},
domain: {
doc: 'The domain to use when generating email addresses for intake emails with OwlRelay, if not provided, the OwlRelay will use their default domain',
schema: z.string().optional(), // TODO: check valid hostname
default: undefined,
env: 'OWLRELAY_DOMAIN',
},
} as const satisfies ConfigDefinition;

View File

@@ -1,7 +1,7 @@
import { buildUrl, safely } from '@corentinth/chisels';
import { generateId as generateHumanReadableId } from '@corentinth/friendly-ids';
import { createClient } from '@owlrelay/api-sdk';
import { getServerBaseUrl } from '../../../config/config.models';
import { createError } from '../../../shared/errors/errors';
import { createLogger } from '../../../shared/logger/logger';
import { INTAKE_EMAILS_INGEST_ROUTE } from '../../intake-emails.constants';
import { buildEmailAddress } from '../../intake-emails.models';
@@ -14,24 +14,35 @@ const logger = createLogger({ namespace: 'intake-emails.drivers.owlrelay' });
export const owlrelayIntakeEmailDriverFactory = defineIntakeEmailDriver(({ config }) => {
const { serverBaseUrl } = getServerBaseUrl({ config });
const { webhookSecret } = config.intakeEmails;
const { owlrelayApiKey, webhookUrl: configuredWebhookUrl } = config.intakeEmails.drivers.owlrelay;
const { owlrelayApiKey, webhookUrl: configuredWebhookUrl, domain } = config.intakeEmails.drivers.owlrelay;
const client = createClient({
apiKey: owlrelayApiKey,
});
const client = createClient({ apiKey: owlrelayApiKey });
const webhookUrl = configuredWebhookUrl ?? buildUrl({ baseUrl: serverBaseUrl, path: INTAKE_EMAILS_INGEST_ROUTE });
return {
name: OWLRELAY_INTAKE_EMAIL_DRIVER_NAME,
generateEmailAddress: async () => {
const { domain, username, id: owlrelayEmailId } = await client.createEmail({
username: generateHumanReadableId(),
createEmailAddress: async ({ username }) => {
const [result, error] = await safely(client.createEmail({
username,
webhookUrl,
webhookSecret,
});
domain,
}));
const emailAddress = buildEmailAddress({ username, domain });
if (error) {
logger.error({ error, username }, 'Failed to create email address in OwlRelay');
throw createError({
code: 'intake_emails.create_email_address_failed',
message: 'Failed to create email address in OwlRelay',
statusCode: 500,
isInternal: true,
});
}
const { id: owlrelayEmailId, username: createdAddressUsername, domain: createdAddressDomain } = result;
const emailAddress = buildEmailAddress({ username: createdAddressUsername, domain: createdAddressDomain });
logger.info({ emailAddress, owlrelayEmailId }, 'Created email address in OwlRelay');

View File

@@ -1,21 +0,0 @@
import { generateId as generateHumanReadableId } from '@corentinth/friendly-ids';
import { defineIntakeEmailDriver } from '../intake-emails.drivers.models';
export const RANDOM_USERNAME_INTAKE_EMAIL_DRIVER_NAME = 'random-username';
export const randomUsernameIntakeEmailDriverFactory = defineIntakeEmailDriver(({ config }) => {
const { domain } = config.intakeEmails.drivers.randomUsername;
return {
name: RANDOM_USERNAME_INTAKE_EMAIL_DRIVER_NAME,
generateEmailAddress: async () => {
const randomUsername = generateHumanReadableId();
return {
emailAddress: `${randomUsername}@${domain}`,
};
},
// Deletion functionality is not required for this driver
deleteEmailAddress: async () => {},
};
});

View File

@@ -1,10 +1,11 @@
import type { ConfigDefinition } from 'figue';
import { z } from 'zod';
import { booleanishSchema } from '../config/config.schemas';
import { CATCH_ALL_INTAKE_EMAIL_DRIVER_NAME } from './drivers/catch-all/catch-all.intake-email-driver';
import { catchAllIntakeEmailDriverConfig } from './drivers/catch-all/catch-all.intake-email-driver.config';
import { intakeEmailDrivers } from './drivers/intake-emails.drivers';
import { owlrelayIntakeEmailDriverConfig } from './drivers/owlrelay/owlrelay.intake-email-driver.config';
import { RANDOM_USERNAME_INTAKE_EMAIL_DRIVER_NAME } from './drivers/random-username/random-username.intake-email-driver';
import { randomUsernameIntakeEmailDriverConfig } from './drivers/random-username/random-username.intake-email-driver.config';
import { intakeEmailUsernameConfig } from './username-drivers/intake-email-username.config';
export const intakeEmailsConfig = {
isEnabled: {
@@ -13,20 +14,21 @@ export const intakeEmailsConfig = {
default: false,
env: 'INTAKE_EMAILS_IS_ENABLED',
},
driver: {
doc: `The driver to use when generating email addresses for intake emails, value can be one of: ${Object.keys(intakeEmailDrivers).map(x => `\`${x}\``).join(', ')}`,
schema: z.enum(Object.keys(intakeEmailDrivers) as [string, ...string[]]),
default: RANDOM_USERNAME_INTAKE_EMAIL_DRIVER_NAME,
env: 'INTAKE_EMAILS_DRIVER',
},
webhookSecret: {
doc: 'The secret to use when verifying webhooks',
schema: z.string(),
default: 'change-me',
env: 'INTAKE_EMAILS_WEBHOOK_SECRET',
},
drivers: {
randomUsername: randomUsernameIntakeEmailDriverConfig,
owlrelay: owlrelayIntakeEmailDriverConfig,
driver: {
doc: `The driver to use when generating email addresses for intake emails, value can be one of: ${Object.keys(intakeEmailDrivers).map(x => `\`${x}\``).join(', ')}.`,
schema: z.enum(Object.keys(intakeEmailDrivers) as [string, ...string[]]),
default: CATCH_ALL_INTAKE_EMAIL_DRIVER_NAME,
env: 'INTAKE_EMAILS_DRIVER',
},
drivers: {
owlrelay: owlrelayIntakeEmailDriverConfig,
catchAll: catchAllIntakeEmailDriverConfig,
},
username: intakeEmailUsernameConfig,
} as const satisfies ConfigDefinition;

View File

@@ -11,3 +11,9 @@ export const createIntakeEmailNotFoundError = createErrorFactory({
code: 'intake_email.not_found',
statusCode: 404,
});
export const createIntakeEmailAlreadyExistsError = createErrorFactory({
message: 'Intake email already exists',
code: 'intake_email.already_exists',
statusCode: 400,
});

View File

@@ -27,6 +27,14 @@ export function parseEmailAddress({ email }: { email: string }) {
const [username, ...plusParts] = fullUsername.split('+');
const plusPart = plusParts.length > 0 ? plusParts.join('+') : undefined;
if (isNil(username)) {
throw createError({
message: 'Badly formatted email address',
code: 'intake_emails.badly_formatted_email_address',
statusCode: 400,
});
}
return { username, domain, plusPart };
}

View File

@@ -1,9 +1,10 @@
import type { Database } from '../app/database/database.types';
import { injectArguments } from '@corentinth/chisels';
import { injectArguments, safely } from '@corentinth/chisels';
import { and, count, eq } from 'drizzle-orm';
import { isUniqueConstraintError } from '../shared/db/constraints.models';
import { createError } from '../shared/errors/errors';
import { omitUndefined } from '../shared/utils';
import { createIntakeEmailNotFoundError } from './intake-emails.errors';
import { createIntakeEmailAlreadyExistsError, createIntakeEmailNotFoundError } from './intake-emails.errors';
import { intakeEmailsTable } from './intake-emails.tables';
export type IntakeEmailsRepository = ReturnType<typeof createIntakeEmailsRepository>;
@@ -24,7 +25,17 @@ export function createIntakeEmailsRepository({ db }: { db: Database }) {
}
async function createIntakeEmail({ organizationId, emailAddress, db }: { organizationId: string; emailAddress: string; db: Database }) {
const [intakeEmail] = await db.insert(intakeEmailsTable).values({ organizationId, emailAddress }).returning();
const [result, error] = await safely(db.insert(intakeEmailsTable).values({ organizationId, emailAddress }).returning());
if (isUniqueConstraintError({ error })) {
throw createIntakeEmailAlreadyExistsError();
}
if (error) {
throw error;
}
const [intakeEmail] = result;
if (!intakeEmail) {
// Very unlikely to happen as the insertion should throw an issue, it's for type safety

View File

@@ -15,11 +15,13 @@ import { createLogger } from '../shared/logger/logger';
import { isNil } from '../shared/utils';
import { validateFormData, validateJsonBody, validateParams } from '../shared/validation/validation';
import { createSubscriptionsRepository } from '../subscriptions/subscriptions.repository';
import { createUsersRepository } from '../users/users.repository';
import { INTAKE_EMAILS_INGEST_ROUTE } from './intake-emails.constants';
import { createIntakeEmailsRepository } from './intake-emails.repository';
import { allowedOriginsSchema, intakeEmailIdSchema, intakeEmailsIngestionMetaSchema, parseJson } from './intake-emails.schemas';
import { createIntakeEmailsServices } from './intake-emails.services';
import { createIntakeEmail, deleteIntakeEmail, processIntakeEmailIngestion } from './intake-emails.usecases';
import { createIntakeEmailUsernameServices } from './username-drivers/intake-email-username.services';
const logger = createLogger({ namespace: 'intake-emails.routes' });
@@ -65,20 +67,24 @@ function setupCreateIntakeEmailRoute({ app, db, config }: RouteDefinitionContext
const { userId } = getUser({ context });
const { organizationId } = context.req.valid('param');
const usersRepository = createUsersRepository({ db });
const organizationsRepository = createOrganizationsRepository({ db });
const intakeEmailsRepository = createIntakeEmailsRepository({ db });
const intakeEmailsServices = createIntakeEmailsServices({ config });
const plansRepository = createPlansRepository({ config });
const subscriptionsRepository = createSubscriptionsRepository({ db });
const intakeEmailUsernameServices = createIntakeEmailUsernameServices({ config, usersRepository, organizationsRepository });
await ensureUserIsInOrganization({ userId, organizationId, organizationsRepository });
const { intakeEmail } = await createIntakeEmail({
userId,
organizationId,
intakeEmailsRepository,
intakeEmailsServices,
plansRepository,
subscriptionsRepository,
intakeEmailUsernameServices,
});
return context.json({ intakeEmail });

View File

@@ -230,7 +230,7 @@ describe('intake-emails usecases', () => {
id: 'os-1',
organizationId: 'org-1',
status: 'active',
seatsCount: 1,
seatsCount: 10,
currentPeriodStart: new Date('2025-03-18T00:00:00.000Z'),
currentPeriodEnd: new Date('2025-04-18T00:00:00.000Z'),
customerId: 'sc_123',

View File

@@ -4,6 +4,7 @@ import type { Logger } from '../shared/logger/logger';
import type { SubscriptionsRepository } from '../subscriptions/subscriptions.repository';
import type { IntakeEmailsServices } from './drivers/intake-emails.drivers.models';
import type { IntakeEmailsRepository } from './intake-emails.repository';
import type { IntakeEmailUsernameServices } from './username-drivers/intake-email-username.services';
import { safely } from '@corentinth/chisels';
import { getOrganizationPlan } from '../plans/plans.usecases';
import { addLogContext, createLogger } from '../shared/logger/logger';
@@ -12,17 +13,21 @@ import { createIntakeEmailLimitReachedError, createIntakeEmailNotFoundError } fr
import { getIsFromAllowedOrigin } from './intake-emails.models';
export async function createIntakeEmail({
userId,
organizationId,
intakeEmailsRepository,
intakeEmailsServices,
plansRepository,
subscriptionsRepository,
intakeEmailUsernameServices,
}: {
userId: string;
organizationId: string;
intakeEmailsRepository: IntakeEmailsRepository;
intakeEmailsServices: IntakeEmailsServices;
plansRepository: PlansRepository;
subscriptionsRepository: SubscriptionsRepository;
intakeEmailUsernameServices: IntakeEmailUsernameServices;
}) {
await checkIfOrganizationCanCreateNewIntakeEmail({
organizationId,
@@ -31,7 +36,9 @@ export async function createIntakeEmail({
intakeEmailsRepository,
});
const { emailAddress } = await intakeEmailsServices.generateEmailAddress();
const { username } = await intakeEmailUsernameServices.generateIntakeEmailUsername({ userId, organizationId });
const { emailAddress } = await intakeEmailsServices.createEmailAddress({ username });
const { intakeEmail } = await intakeEmailsRepository.createIntakeEmail({ organizationId, emailAddress });

View File

@@ -0,0 +1,17 @@
import type { ConfigDefinition } from 'figue';
import { z } from 'zod';
import { intakeEmailUsernameDrivers } from './intake-email-username.drivers';
import { patternIntakeEmailDriverConfig } from './pattern/pattern.intake-email-username-driver.config';
import { RANDOM_INTAKE_EMAIL_ADDRESSES_DRIVER_NAME } from './random/random.intake-email-username-driver';
export const intakeEmailUsernameConfig = {
driver: {
doc: `The driver to use when generating email addresses for intake emails, value can be one of: ${Object.keys(intakeEmailUsernameDrivers).map(x => `\`${x}\``).join(', ')}`,
schema: z.enum(Object.keys(intakeEmailUsernameDrivers) as [string, ...string[]]),
default: RANDOM_INTAKE_EMAIL_ADDRESSES_DRIVER_NAME,
env: 'INTAKE_EMAILS_USERNAME_DRIVER',
},
drivers: {
pattern: patternIntakeEmailDriverConfig,
},
} as const satisfies ConfigDefinition;

View File

@@ -0,0 +1,10 @@
import { patternIntakeEmailUsernameDriverFactory } from './pattern/pattern.intake-email-username-driver';
import { PATTERN_INTAKE_EMAIL_ADDRESSES_DRIVER_NAME } from './pattern/pattern.intake-email-username-driver.config';
import { RANDOM_INTAKE_EMAIL_ADDRESSES_DRIVER_NAME, randomIntakeEmailUsernameDriverFactory } from './random/random.intake-email-username-driver';
export const intakeEmailUsernameDrivers = {
[RANDOM_INTAKE_EMAIL_ADDRESSES_DRIVER_NAME]: randomIntakeEmailUsernameDriverFactory,
[PATTERN_INTAKE_EMAIL_ADDRESSES_DRIVER_NAME]: patternIntakeEmailUsernameDriverFactory,
} as const;
export type IntakeEmailUsernameDriverName = keyof typeof intakeEmailUsernameDrivers;

View File

@@ -0,0 +1,20 @@
import type { Logger } from '@crowlog/logger';
import type { Config } from '../../config/config.types';
import type { OrganizationsRepository } from '../../organizations/organizations.repository';
import type { UsersRepository } from '../../users/users.repository';
export type IntakeEmailUsernameDriver = {
name: string;
generateIntakeEmailUsername: (args: { userId: string; organizationId: string }) => Promise<{ username: string }>;
};
export type IntakeEmailUsernameDriverFactory = (args: {
config: Config;
logger?: Logger;
usersRepository: UsersRepository;
organizationsRepository: OrganizationsRepository;
}) => IntakeEmailUsernameDriver;
export function defineIntakeEmailUsernameDriverFactory(factory: IntakeEmailUsernameDriverFactory) {
return factory;
}

View File

@@ -0,0 +1,35 @@
import type { Config } from '../../config/config.types';
import type { OrganizationsRepository } from '../../organizations/organizations.repository';
import type { UsersRepository } from '../../users/users.repository';
import type { IntakeEmailUsernameDriverName } from './intake-email-username.drivers';
import type { IntakeEmailUsernameDriver, IntakeEmailUsernameDriverFactory } from './intake-email-username.models';
import { createError } from '../../shared/errors/errors';
import { isNil } from '../../shared/utils';
import { intakeEmailUsernameDrivers } from './intake-email-username.drivers';
export type IntakeEmailUsernameServices = IntakeEmailUsernameDriver;
export function createIntakeEmailUsernameServices({
config,
...dependencies
}: {
config: Config;
usersRepository: UsersRepository;
organizationsRepository: OrganizationsRepository;
}) {
const { driver } = config.intakeEmails.username;
const intakeEmailUsernameDriver: IntakeEmailUsernameDriverFactory | undefined = intakeEmailUsernameDrivers[driver as IntakeEmailUsernameDriverName];
if (isNil(intakeEmailUsernameDriver)) {
throw createError({
message: `Invalid intake email addresses driver ${driver}`,
code: 'intake-emails.addresses.invalid_driver',
statusCode: 500,
isInternal: true,
});
}
const intakeEmailUsernameServices = intakeEmailUsernameDriver({ config, ...dependencies });
return intakeEmailUsernameServices;
}

View File

@@ -0,0 +1,15 @@
import type { ConfigDefinition } from 'figue';
import { z } from 'zod';
import { PATTERNS_PLACEHOLDERS } from './pattern.intake-email-username-driver.constants';
export const PATTERN_INTAKE_EMAIL_ADDRESSES_DRIVER_NAME = 'pattern';
export const patternIntakeEmailDriverConfig = {
pattern: {
doc: `The pattern to use when generating email addresses usernames (before the @) for intake emails. Available placeholders are: ${Object.values(PATTERNS_PLACEHOLDERS).join(', ')}. Note: the resulting username will be slugified to remove special characters and spaces.`,
schema: z.string(),
default: `${PATTERNS_PLACEHOLDERS.USER_NAME}-${PATTERNS_PLACEHOLDERS.RANDOM_DIGITS}`,
env: 'INTAKE_EMAILS_USERNAME_DRIVER_PATTERN',
},
} as const satisfies ConfigDefinition;

View File

@@ -0,0 +1,8 @@
export const PATTERNS_PLACEHOLDERS = {
USER_NAME: '{{user.name}}',
USER_ID: '{{user.id}}',
USER_EMAIL_USERNAME: '{{user.email.username}}',
ORGANIZATION_ID: '{{organization.id}}',
ORGANIZATION_NAME: '{{organization.name}}',
RANDOM_DIGITS: '{{random.digits}}',
} as const;

View File

@@ -0,0 +1,52 @@
import slugify from '@sindresorhus/slugify';
import { createError } from '../../../shared/errors/errors';
import { createLogger } from '../../../shared/logger/logger';
import { isNil } from '../../../shared/utils';
import { parseEmailAddress } from '../../intake-emails.models';
import { defineIntakeEmailUsernameDriverFactory } from '../intake-email-username.models';
import { PATTERN_INTAKE_EMAIL_ADDRESSES_DRIVER_NAME } from './pattern.intake-email-username-driver.config';
import { PATTERNS_PLACEHOLDERS } from './pattern.intake-email-username-driver.constants';
export const patternIntakeEmailUsernameDriverFactory = defineIntakeEmailUsernameDriverFactory(({
logger = createLogger({ namespace: 'intake-emails.addresses-drivers.pattern' }),
config,
usersRepository,
organizationsRepository,
}) => {
const { pattern } = config.intakeEmails.username.drivers.pattern;
return {
name: PATTERN_INTAKE_EMAIL_ADDRESSES_DRIVER_NAME,
generateIntakeEmailUsername: async ({ userId, organizationId }) => {
const [{ user }, { organization }] = await Promise.all([
usersRepository.getUserById({ userId }),
organizationsRepository.getOrganizationById({ organizationId }),
]);
if (isNil(user) || isNil(organization)) {
// Should not really happen, there is a check on the routes handlers
throw createError({
message: 'User or organization not found',
code: 'intake-emails.addresses.user_or_organization_not_found',
statusCode: 404,
});
}
const { username: userEmailUsername } = parseEmailAddress({ email: user.email });
const rawUsername = pattern
.replaceAll(PATTERNS_PLACEHOLDERS.USER_NAME, user.name ?? '')
.replaceAll(PATTERNS_PLACEHOLDERS.USER_ID, user.id)
.replaceAll(PATTERNS_PLACEHOLDERS.USER_EMAIL_USERNAME, userEmailUsername)
.replaceAll(PATTERNS_PLACEHOLDERS.ORGANIZATION_ID, organization.id)
.replaceAll(PATTERNS_PLACEHOLDERS.ORGANIZATION_NAME, organization.name)
.replaceAll(PATTERNS_PLACEHOLDERS.RANDOM_DIGITS, () => Math.floor(Math.random() * 10000).toString());
const username = slugify(rawUsername);
logger.debug({ rawUsername, username, pattern, userId, organizationId }, 'Generated email address');
return { username };
},
};
});

View File

@@ -0,0 +1,18 @@
import { generateId as generateHumanReadableId } from '@corentinth/friendly-ids';
import { createLogger } from '../../../shared/logger/logger';
import { defineIntakeEmailUsernameDriverFactory } from '../intake-email-username.models';
export const RANDOM_INTAKE_EMAIL_ADDRESSES_DRIVER_NAME = 'random';
export const randomIntakeEmailUsernameDriverFactory = defineIntakeEmailUsernameDriverFactory(({ logger = createLogger({ namespace: 'intake-emails.addresses-drivers.random' }) }) => {
return {
name: RANDOM_INTAKE_EMAIL_ADDRESSES_DRIVER_NAME,
generateIntakeEmailUsername: async () => {
const username = generateHumanReadableId();
logger.debug({ username }, 'Generated email address');
return { username };
},
};
});

View File

@@ -47,3 +47,9 @@ export const createUserAlreadyInOrganizationError = createErrorFactory({
code: 'user.already_in_organization',
statusCode: 400,
});
export const createMaxOrganizationMembersCountReachedError = createErrorFactory({
message: 'You have reached the maximum number of members in this organization.',
code: 'organization.max_members_count_reached',
statusCode: 403,
});

View File

@@ -42,6 +42,7 @@ export function createOrganizationsRepository({ db }: { db: Database }) {
getOrganizationMemberByEmail,
getOrganizationInvitations,
updateExpiredPendingInvitationsStatus,
getOrganizationPendingInvitationsCount,
},
{ db },
);
@@ -444,3 +445,27 @@ async function updateExpiredPendingInvitationsStatus({ db, now = new Date() }: {
),
);
}
async function getOrganizationPendingInvitationsCount({ organizationId, db }: { organizationId: string; db: Database }) {
const [record] = await db
.select({
pendingInvitationsCount: count(organizationInvitationsTable.id),
})
.from(organizationInvitationsTable)
.where(
and(
eq(organizationInvitationsTable.organizationId, organizationId),
eq(organizationInvitationsTable.status, ORGANIZATION_INVITATION_STATUS.PENDING),
),
);
if (!record) {
throw createOrganizationNotFoundError();
}
const { pendingInvitationsCount } = record;
return {
pendingInvitationsCount,
};
}

View File

@@ -3,12 +3,14 @@ import { z } from 'zod';
import { createForbiddenError } from '../app/auth/auth.errors';
import { requireAuthentication } from '../app/auth/auth.middleware';
import { getUser } from '../app/auth/auth.models';
import { createPlansRepository } from '../plans/plans.repository';
import { validateJsonBody, validateParams } from '../shared/validation/validation';
import { createSubscriptionsRepository } from '../subscriptions/subscriptions.repository';
import { createUsersRepository } from '../users/users.repository';
import { memberIdSchema, organizationIdSchema } from './organization.schemas';
import { ORGANIZATION_ROLES } from './organizations.constants';
import { createOrganizationsRepository } from './organizations.repository';
import { checkIfUserCanCreateNewOrganization, createOrganization, ensureUserIsInOrganization, inviteMemberToOrganization, removeMemberFromOrganization, updateOrganizationMemberRole } from './organizations.usecases';
import { checkIfUserCanCreateNewOrganization, createOrganization, ensureUserIsInOrganization, ensureUserIsOwnerOfOrganization, inviteMemberToOrganization, removeMemberFromOrganization, updateOrganizationMemberRole } from './organizations.usecases';
export function registerOrganizationsRoutes(context: RouteDefinitionContext) {
setupGetOrganizationsRoute(context);
@@ -27,7 +29,7 @@ export function registerOrganizationsRoutes(context: RouteDefinitionContext) {
function setupGetOrganizationsRoute({ app, db }: RouteDefinitionContext) {
app.get(
'/api/organizations',
requireAuthentication(),
requireAuthentication({ apiKeyPermissions: ['organizations:read'] }),
async (context) => {
const { userId } = getUser({ context });
@@ -45,7 +47,7 @@ function setupGetOrganizationsRoute({ app, db }: RouteDefinitionContext) {
function setupCreateOrganizationRoute({ app, db, config }: RouteDefinitionContext) {
app.post(
'/api/organizations',
requireAuthentication(),
requireAuthentication({ apiKeyPermissions: ['organizations:create'] }),
validateJsonBody(z.object({
name: z.string().min(3).max(50),
})),
@@ -70,7 +72,7 @@ function setupCreateOrganizationRoute({ app, db, config }: RouteDefinitionContex
function setupGetOrganizationRoute({ app, db }: RouteDefinitionContext) {
app.get(
'/api/organizations/:organizationId',
requireAuthentication(),
requireAuthentication({ apiKeyPermissions: ['organizations:read'] }),
validateParams(z.object({
organizationId: organizationIdSchema,
})),
@@ -92,7 +94,7 @@ function setupGetOrganizationRoute({ app, db }: RouteDefinitionContext) {
function setupUpdateOrganizationRoute({ app, db }: RouteDefinitionContext) {
app.put(
'/api/organizations/:organizationId',
requireAuthentication(),
requireAuthentication({ apiKeyPermissions: ['organizations:update'] }),
validateJsonBody(z.object({
name: z.string().min(3).max(50),
})),
@@ -120,7 +122,7 @@ function setupUpdateOrganizationRoute({ app, db }: RouteDefinitionContext) {
function setupDeleteOrganizationRoute({ app, db }: RouteDefinitionContext) {
app.delete(
'/api/organizations/:organizationId',
requireAuthentication(),
requireAuthentication({ apiKeyPermissions: ['organizations:delete'] }),
validateParams(z.object({
organizationId: organizationIdSchema,
})),
@@ -130,7 +132,9 @@ function setupDeleteOrganizationRoute({ app, db }: RouteDefinitionContext) {
const organizationsRepository = createOrganizationsRepository({ db });
// No Promise.all as we want to ensure consistency in error handling
await ensureUserIsInOrganization({ userId, organizationId, organizationsRepository });
await ensureUserIsOwnerOfOrganization({ userId, organizationId, organizationsRepository });
await organizationsRepository.deleteOrganization({ organizationId });
@@ -252,6 +256,8 @@ function setupInviteOrganizationMemberRoute({ app, db, config, emailsServices }:
const { email, role } = context.req.valid('json');
const organizationsRepository = createOrganizationsRepository({ db });
const subscriptionsRepository = createSubscriptionsRepository({ db });
const plansRepository = createPlansRepository({ config });
await ensureUserIsInOrganization({ userId, organizationId, organizationsRepository });
@@ -260,6 +266,8 @@ function setupInviteOrganizationMemberRoute({ app, db, config, emailsServices }:
role,
organizationId,
organizationsRepository,
subscriptionsRepository,
plansRepository,
inviterId: userId,
expirationDelayDays: config.organizations.invitationExpirationDelayDays,
maxInvitationsPerDay: config.organizations.maxUserInvitationsPerDay,

View File

@@ -1,3 +1,4 @@
import type { EmailsServices } from '../emails/emails.services';
import type { PlansRepository } from '../plans/plans.repository';
import type { SubscriptionsServices } from '../subscriptions/subscriptions.services';
import { describe, expect, test } from 'vitest';
@@ -10,10 +11,10 @@ import { createTestLogger } from '../shared/logger/logger.test-utils';
import { createSubscriptionsRepository } from '../subscriptions/subscriptions.repository';
import { createUsersRepository } from '../users/users.repository';
import { ORGANIZATION_ROLES } from './organizations.constants';
import { createOrganizationDocumentStorageLimitReachedError, createOrganizationNotFoundError, createUserMaxOrganizationCountReachedError, createUserNotInOrganizationError, createUserNotOrganizationOwnerError } from './organizations.errors';
import { createMaxOrganizationMembersCountReachedError, createOrganizationDocumentStorageLimitReachedError, createOrganizationInvitationAlreadyExistsError, createOrganizationNotFoundError, createUserAlreadyInOrganizationError, createUserMaxOrganizationCountReachedError, createUserNotInOrganizationError, createUserNotOrganizationOwnerError, createUserOrganizationInvitationLimitReachedError } from './organizations.errors';
import { createOrganizationsRepository } from './organizations.repository';
import { organizationMembersTable, organizationsTable } from './organizations.table';
import { checkIfOrganizationCanCreateNewDocument, checkIfUserCanCreateNewOrganization, ensureUserIsInOrganization, ensureUserIsOwnerOfOrganization, getOrCreateOrganizationCustomerId, removeMemberFromOrganization } from './organizations.usecases';
import { organizationInvitationsTable, organizationMembersTable, organizationsTable } from './organizations.table';
import { checkIfOrganizationCanCreateNewDocument, checkIfUserCanCreateNewOrganization, ensureUserIsInOrganization, ensureUserIsOwnerOfOrganization, getOrCreateOrganizationCustomerId, inviteMemberToOrganization, removeMemberFromOrganization } from './organizations.usecases';
describe('organizations usecases', () => {
describe('ensureUserIsInOrganization', () => {
@@ -166,7 +167,7 @@ describe('organizations usecases', () => {
id: 'org_sub_1',
organizationId: 'organization-1',
planId: PLUS_PLAN_ID,
seatsCount: 1,
seatsCount: 10,
customerId: 'cus_123',
status: 'active',
currentPeriodStart: new Date('2025-03-18T00:00:00.000Z'),
@@ -463,4 +464,565 @@ describe('organizations usecases', () => {
]);
});
});
describe('inviteMemberToOrganization', () => {
test('only organization owners and admins can invite members, regular members cannot send invitations', async () => {
const { logger, getLogs } = createTestLogger();
const { db } = await createInMemoryDatabase({
users: [
{ id: 'user-1', email: 'owner@example.com' },
{ id: 'user-2', email: 'admin@example.com' },
{ id: 'user-3', email: 'member@example.com' },
],
organizations: [{ id: 'organization-1', name: 'Organization 1' }],
organizationMembers: [
{ organizationId: 'organization-1', userId: 'user-1', role: ORGANIZATION_ROLES.OWNER },
{ organizationId: 'organization-1', userId: 'user-2', role: ORGANIZATION_ROLES.ADMIN },
{ organizationId: 'organization-1', userId: 'user-3', role: ORGANIZATION_ROLES.MEMBER },
],
});
const organizationsRepository = createOrganizationsRepository({ db });
const subscriptionsRepository = createSubscriptionsRepository({ db });
const config = overrideConfig({ organizations: { invitationExpirationDelayDays: 7, maxUserInvitationsPerDay: 10 } });
const plansRepository = {
getOrganizationPlanById: async () => ({
organizationPlan: {
limits: {
maxOrganizationsMembersCount: 100,
},
},
}),
} as unknown as PlansRepository;
const sentEmails: unknown[] = [];
const emailsServices = {
sendEmail: async (args: unknown) => sentEmails.push(args),
} as unknown as EmailsServices;
// Owner can invite
const { organizationInvitation: ownerInvitation } = await inviteMemberToOrganization({
email: 'new-member-1@example.com',
role: ORGANIZATION_ROLES.MEMBER,
organizationId: 'organization-1',
organizationsRepository,
subscriptionsRepository,
plansRepository,
inviterId: 'user-1',
expirationDelayDays: 7,
maxInvitationsPerDay: 10,
emailsServices,
config,
});
expect(ownerInvitation?.email).toBe('new-member-1@example.com');
// Admin can invite
const { organizationInvitation: adminInvitation } = await inviteMemberToOrganization({
email: 'new-member-2@example.com',
role: ORGANIZATION_ROLES.MEMBER,
organizationId: 'organization-1',
organizationsRepository,
subscriptionsRepository,
plansRepository,
inviterId: 'user-2',
expirationDelayDays: 7,
maxInvitationsPerDay: 10,
emailsServices,
config,
});
expect(adminInvitation?.email).toBe('new-member-2@example.com');
// Member cannot invite
await expect(
inviteMemberToOrganization({
email: 'new-member-3@example.com',
role: ORGANIZATION_ROLES.MEMBER,
organizationId: 'organization-1',
organizationsRepository,
subscriptionsRepository,
plansRepository,
inviterId: 'user-3',
expirationDelayDays: 7,
maxInvitationsPerDay: 10,
logger,
emailsServices,
config,
}),
).rejects.toThrow(createForbiddenError());
expect(getLogs({ excludeTimestampMs: true })).toEqual([
{
level: 'error',
message: 'Inviter does not have permission to invite members to organization',
namespace: 'test',
data: {
inviterId: 'user-3',
organizationId: 'organization-1',
},
},
]);
});
test('it is not possible to create an invitation for the owner role to prevent multiple owners in an organization', async () => {
const { logger, getLogs } = createTestLogger();
const { db } = await createInMemoryDatabase({
users: [{ id: 'user-1', email: 'owner@example.com' }],
organizations: [{ id: 'organization-1', name: 'Organization 1' }],
organizationMembers: [
{ organizationId: 'organization-1', userId: 'user-1', role: ORGANIZATION_ROLES.OWNER },
],
});
const organizationsRepository = createOrganizationsRepository({ db });
const subscriptionsRepository = createSubscriptionsRepository({ db });
const config = overrideConfig({ organizations: { invitationExpirationDelayDays: 7, maxUserInvitationsPerDay: 10 } });
const plansRepository = {
getOrganizationPlanById: async () => ({
organizationPlan: {
limits: {
maxOrganizationsMembersCount: 100,
},
},
}),
} as unknown as PlansRepository;
const emailsServices = {
sendEmail: async () => {},
} as unknown as EmailsServices;
await expect(
inviteMemberToOrganization({
email: 'new-owner@example.com',
role: ORGANIZATION_ROLES.OWNER,
organizationId: 'organization-1',
organizationsRepository,
subscriptionsRepository,
plansRepository,
inviterId: 'user-1',
expirationDelayDays: 7,
maxInvitationsPerDay: 10,
logger,
emailsServices,
config,
}),
).rejects.toThrow(createForbiddenError());
expect(getLogs({ excludeTimestampMs: true })).toEqual([
{
level: 'error',
message: 'Cannot create another owner in organization',
namespace: 'test',
data: {
inviterId: 'user-1',
organizationId: 'organization-1',
},
},
]);
});
test('cannot invite a user who is already a member of the organization to prevent duplicate memberships', async () => {
const { logger, getLogs } = createTestLogger();
const { db } = await createInMemoryDatabase({
users: [
{ id: 'user-1', email: 'owner@example.com' },
{ id: 'user-2', email: 'existing-member@example.com' },
],
organizations: [{ id: 'organization-1', name: 'Organization 1' }],
organizationMembers: [
{ organizationId: 'organization-1', userId: 'user-1', role: ORGANIZATION_ROLES.OWNER },
{ id: 'member-2', organizationId: 'organization-1', userId: 'user-2', role: ORGANIZATION_ROLES.MEMBER },
],
});
const organizationsRepository = createOrganizationsRepository({ db });
const subscriptionsRepository = createSubscriptionsRepository({ db });
const config = overrideConfig({ organizations: { invitationExpirationDelayDays: 7, maxUserInvitationsPerDay: 10 } });
const plansRepository = {
getOrganizationPlanById: async () => ({
organizationPlan: {
limits: {
maxOrganizationsMembersCount: 100,
},
},
}),
} as unknown as PlansRepository;
const emailsServices = {
sendEmail: async () => {},
} as unknown as EmailsServices;
await expect(
inviteMemberToOrganization({
email: 'existing-member@example.com',
role: ORGANIZATION_ROLES.MEMBER,
organizationId: 'organization-1',
organizationsRepository,
subscriptionsRepository,
plansRepository,
inviterId: 'user-1',
expirationDelayDays: 7,
maxInvitationsPerDay: 10,
logger,
emailsServices,
config,
}),
).rejects.toThrow(createUserAlreadyInOrganizationError());
expect(getLogs({ excludeTimestampMs: true })).toEqual([
{
level: 'error',
message: 'User already in organization',
namespace: 'test',
data: {
inviterId: 'user-1',
organizationId: 'organization-1',
email: 'existing-member@example.com',
memberId: 'member-2',
memberUserId: 'user-2',
},
},
]);
});
test('cannot create multiple invitations for the same email address to the same organization to prevent spam and confusion', async () => {
const { logger, getLogs } = createTestLogger();
const { db } = await createInMemoryDatabase({
users: [{ id: 'user-1', email: 'owner@example.com' }],
organizations: [{ id: 'organization-1', name: 'Organization 1' }],
organizationMembers: [
{ organizationId: 'organization-1', userId: 'user-1', role: ORGANIZATION_ROLES.OWNER },
],
organizationInvitations: [
{
id: 'invitation-1',
organizationId: 'organization-1',
email: 'invited@example.com',
role: ORGANIZATION_ROLES.MEMBER,
inviterId: 'user-1',
status: 'pending',
expiresAt: new Date('2025-12-31'),
},
],
});
const organizationsRepository = createOrganizationsRepository({ db });
const subscriptionsRepository = createSubscriptionsRepository({ db });
const config = overrideConfig({ organizations: { invitationExpirationDelayDays: 7, maxUserInvitationsPerDay: 10 } });
const plansRepository = {
getOrganizationPlanById: async () => ({
organizationPlan: {
limits: {
maxOrganizationsMembersCount: 100,
},
},
}),
} as unknown as PlansRepository;
const emailsServices = {
sendEmail: async () => {},
} as unknown as EmailsServices;
await expect(
inviteMemberToOrganization({
email: 'invited@example.com',
role: ORGANIZATION_ROLES.MEMBER,
organizationId: 'organization-1',
organizationsRepository,
subscriptionsRepository,
plansRepository,
inviterId: 'user-1',
expirationDelayDays: 7,
maxInvitationsPerDay: 10,
logger,
emailsServices,
config,
}),
).rejects.toThrow(createOrganizationInvitationAlreadyExistsError());
expect(getLogs({ excludeTimestampMs: true })).toEqual([
{
level: 'error',
message: 'Invitation already exists',
namespace: 'test',
data: {
inviterId: 'user-1',
organizationId: 'organization-1',
email: 'invited@example.com',
invitationId: 'invitation-1',
},
},
]);
});
test('cannot invite new members when the organization has reached its maximum member count (including pending invitations) defined by the plan to enforce subscription limits', async () => {
const { logger, getLogs } = createTestLogger();
const { db } = await createInMemoryDatabase({
users: [
{ id: 'user-1', email: 'owner@example.com' },
],
organizations: [{ id: 'organization-1', name: 'Organization 1' }],
organizationMembers: [
{ organizationId: 'organization-1', userId: 'user-1', role: ORGANIZATION_ROLES.OWNER },
],
organizationInvitations: [
{
organizationId: 'organization-1',
email: 'pending-1@example.com',
role: ORGANIZATION_ROLES.MEMBER,
inviterId: 'user-1',
status: 'pending',
expiresAt: new Date('2025-12-31'),
},
],
});
const organizationsRepository = createOrganizationsRepository({ db });
const subscriptionsRepository = createSubscriptionsRepository({ db });
const config = overrideConfig({ organizations: { invitationExpirationDelayDays: 7, maxUserInvitationsPerDay: 10 } });
const plansRepository = {
getOrganizationPlanById: async () => ({
organizationPlan: {
limits: {
maxOrganizationsMembersCount: 2,
},
},
}),
} as unknown as PlansRepository;
const emailsServices = {
sendEmail: async () => {},
} as unknown as EmailsServices;
await expect(
inviteMemberToOrganization({
email: 'new-member@example.com',
role: ORGANIZATION_ROLES.MEMBER,
organizationId: 'organization-1',
organizationsRepository,
subscriptionsRepository,
plansRepository,
inviterId: 'user-1',
expirationDelayDays: 7,
maxInvitationsPerDay: 10,
logger,
emailsServices,
config,
}),
).rejects.toThrow(createMaxOrganizationMembersCountReachedError());
expect(getLogs({ excludeTimestampMs: true })).toEqual([
{
level: 'error',
message: 'Organization has reached its maximum number of members',
namespace: 'test',
data: {
inviterId: 'user-1',
organizationId: 'organization-1',
membersCount: 1,
maxMembers: 2,
},
},
]);
});
test('users have a daily invitation limit to prevent spam and abuse of the invitation system', async () => {
const { db } = await createInMemoryDatabase({
users: [{ id: 'user-1', email: 'owner@example.com' }],
organizations: [{ id: 'organization-1', name: 'Organization 1' }],
organizationMembers: [
{ organizationId: 'organization-1', userId: 'user-1', role: ORGANIZATION_ROLES.OWNER },
],
organizationInvitations: [
{
organizationId: 'organization-1',
email: 'invited-1@example.com',
role: ORGANIZATION_ROLES.MEMBER,
inviterId: 'user-1',
status: 'pending',
expiresAt: new Date('2025-12-31'),
createdAt: new Date('2025-10-05T10:00:00Z'),
},
{
organizationId: 'organization-1',
email: 'invited-2@example.com',
role: ORGANIZATION_ROLES.MEMBER,
inviterId: 'user-1',
status: 'pending',
expiresAt: new Date('2025-12-31'),
createdAt: new Date('2025-10-05T14:00:00Z'),
},
],
});
const organizationsRepository = createOrganizationsRepository({ db });
const subscriptionsRepository = createSubscriptionsRepository({ db });
const config = overrideConfig({ organizations: { invitationExpirationDelayDays: 7, maxUserInvitationsPerDay: 2 } });
const plansRepository = {
getOrganizationPlanById: async () => ({
organizationPlan: {
limits: {
maxOrganizationsMembersCount: 100,
},
},
}),
} as unknown as PlansRepository;
const emailsServices = {
sendEmail: async () => {},
} as unknown as EmailsServices;
await expect(
inviteMemberToOrganization({
email: 'new-member@example.com',
role: ORGANIZATION_ROLES.MEMBER,
organizationId: 'organization-1',
organizationsRepository,
subscriptionsRepository,
plansRepository,
inviterId: 'user-1',
expirationDelayDays: 7,
maxInvitationsPerDay: 2,
now: new Date('2025-10-05T18:00:00Z'),
emailsServices,
config,
}),
).rejects.toThrow(createUserOrganizationInvitationLimitReachedError());
});
test('invitations are created with the correct expiration date and an email notification is sent to the invited user', async () => {
const { db } = await createInMemoryDatabase({
users: [{ id: 'user-1', email: 'owner@example.com' }],
organizations: [{ id: 'organization-1', name: 'Organization 1' }],
organizationMembers: [
{ organizationId: 'organization-1', userId: 'user-1', role: ORGANIZATION_ROLES.OWNER },
],
});
const organizationsRepository = createOrganizationsRepository({ db });
const subscriptionsRepository = createSubscriptionsRepository({ db });
const config = overrideConfig({
organizations: { invitationExpirationDelayDays: 7, maxUserInvitationsPerDay: 10 },
client: { baseUrl: 'https://app.example.com' },
});
const plansRepository = {
getOrganizationPlanById: async () => ({
organizationPlan: {
limits: {
maxOrganizationsMembersCount: 100,
},
},
}),
} as unknown as PlansRepository;
const sentEmails: unknown[] = [];
const emailsServices = {
sendEmail: async (args: unknown) => sentEmails.push(args),
} as unknown as EmailsServices;
const now = new Date('2025-10-05T12:00:00Z');
const { organizationInvitation } = await inviteMemberToOrganization({
email: 'new-member@example.com',
role: ORGANIZATION_ROLES.ADMIN,
organizationId: 'organization-1',
organizationsRepository,
subscriptionsRepository,
plansRepository,
inviterId: 'user-1',
expirationDelayDays: 7,
maxInvitationsPerDay: 10,
now,
emailsServices,
config,
});
expect(organizationInvitation).toMatchObject({
email: 'new-member@example.com',
role: ORGANIZATION_ROLES.ADMIN,
organizationId: 'organization-1',
inviterId: 'user-1',
status: 'pending',
expiresAt: new Date('2025-10-12T12:00:00Z'),
});
// Verify email was sent
expect(sentEmails).toHaveLength(1);
expect(sentEmails[0]).toMatchObject({
to: 'new-member@example.com',
subject: 'You are invited to join an organization',
});
// Verify invitation was saved in database
const invitations = await db.select().from(organizationInvitationsTable);
expect(invitations).toHaveLength(1);
expect(invitations[0]).toMatchObject({
email: 'new-member@example.com',
role: ORGANIZATION_ROLES.ADMIN,
organizationId: 'organization-1',
inviterId: 'user-1',
status: 'pending',
});
});
test('users who are not members of the organization cannot send invitations to prevent unauthorized access', async () => {
const { logger, getLogs } = createTestLogger();
const { db } = await createInMemoryDatabase({
users: [
{ id: 'user-1', email: 'owner@example.com' },
{ id: 'user-2', email: 'outsider@example.com' },
],
organizations: [{ id: 'organization-1', name: 'Organization 1' }],
organizationMembers: [
{ organizationId: 'organization-1', userId: 'user-1', role: ORGANIZATION_ROLES.OWNER },
],
});
const organizationsRepository = createOrganizationsRepository({ db });
const subscriptionsRepository = createSubscriptionsRepository({ db });
const config = overrideConfig({ organizations: { invitationExpirationDelayDays: 7, maxUserInvitationsPerDay: 10 } });
const plansRepository = {
getOrganizationPlanById: async () => ({
organizationPlan: {
limits: {
maxOrganizationsMembersCount: 100,
},
},
}),
} as unknown as PlansRepository;
const emailsServices = {
sendEmail: async () => {},
} as unknown as EmailsServices;
await expect(
inviteMemberToOrganization({
email: 'new-member@example.com',
role: ORGANIZATION_ROLES.MEMBER,
organizationId: 'organization-1',
organizationsRepository,
subscriptionsRepository,
plansRepository,
inviterId: 'user-2',
expirationDelayDays: 7,
maxInvitationsPerDay: 10,
logger,
emailsServices,
config,
}),
).rejects.toThrow(createUserNotInOrganizationError());
expect(getLogs({ excludeTimestampMs: true })).toEqual([
{
level: 'error',
message: 'Inviter not found in organization',
namespace: 'test',
data: {
inviterId: 'user-2',
organizationId: 'organization-1',
},
},
]);
});
});
});

View File

@@ -18,6 +18,7 @@ import { createLogger } from '../shared/logger/logger';
import { isDefined } from '../shared/utils';
import { ORGANIZATION_INVITATION_STATUS, ORGANIZATION_ROLES } from './organizations.constants';
import {
createMaxOrganizationMembersCountReachedError,
createOrganizationDocumentStorageLimitReachedError,
createOrganizationInvitationAlreadyExistsError,
createOrganizationNotFoundError,
@@ -212,6 +213,8 @@ export async function inviteMemberToOrganization({
role,
organizationId,
organizationsRepository,
subscriptionsRepository,
plansRepository,
inviterId,
expirationDelayDays,
maxInvitationsPerDay,
@@ -224,6 +227,8 @@ export async function inviteMemberToOrganization({
role: OrganizationRole;
organizationId: string;
organizationsRepository: OrganizationsRepository;
subscriptionsRepository: SubscriptionsRepository;
plansRepository: PlansRepository;
inviterId: string;
expirationDelayDays: number;
maxInvitationsPerDay: number;
@@ -263,6 +268,15 @@ export async function inviteMemberToOrganization({
throw createOrganizationInvitationAlreadyExistsError();
}
const { membersCount } = await organizationsRepository.getOrganizationMembersCount({ organizationId });
const { pendingInvitationsCount } = await organizationsRepository.getOrganizationPendingInvitationsCount({ organizationId });
const { organizationPlan } = await getOrganizationPlan({ organizationId, subscriptionsRepository, plansRepository });
if ((membersCount + pendingInvitationsCount) >= organizationPlan.limits.maxOrganizationsMembersCount) {
logger.error({ inviterId, organizationId, membersCount, maxMembers: organizationPlan.limits.maxOrganizationsMembersCount }, 'Organization has reached its maximum number of members');
throw createMaxOrganizationMembersCountReachedError();
}
await checkIfUserHasReachedOrganizationInvitationLimit({
userId: inviterId,
maxInvitationsPerDay,

View File

@@ -9,16 +9,16 @@ export const organizationPlansConfig = {
default: true,
env: 'IS_FREE_PLAN_UNLIMITED',
},
plusPlanPriceId: {
doc: 'The price id of the plus plan (useless for self-hosting)',
plusPlanMonthlyPriceId: {
doc: 'The monthly price id of the plus plan (useless for self-hosting)',
schema: z.string(),
default: 'change-me',
env: 'PLANS_PLUS_PLAN_PRICE_ID',
env: 'PLANS_PLUS_PLAN_MONTHLY_PRICE_ID',
},
familyPlanPriceId: {
doc: 'The price id of the family plan (useless for self-hosting)',
plusPlanAnnualPriceId: {
doc: 'The annual price id of the plus plan (useless for self-hosting)',
schema: z.string(),
default: 'change-me',
env: 'PLANS_FAMILY_PLAN_PRICE_ID',
env: 'PLANS_PLUS_PLAN_ANNUAL_PRICE_ID',
},
} as const satisfies ConfigDefinition;

View File

@@ -1,3 +1,2 @@
export const FREE_PLAN_ID = 'free';
export const PLUS_PLAN_ID = 'plus';
export const FAMILY_PLAN_ID = 'family';

View File

@@ -5,3 +5,10 @@ export const createPlanNotFoundError = createErrorFactory({
message: 'Plan not found',
statusCode: 404,
});
export const createOrganizationPlanPriceIdNotSetError = createErrorFactory({
code: 'plans.organization_plan_price_id_not_set',
message: 'Organization plan price ID is not set',
statusCode: 500,
isInternal: true,
});

View File

@@ -0,0 +1,20 @@
import { isNil } from '../shared/utils';
import { createOrganizationPlanPriceIdNotSetError } from './plans.errors';
export function getPriceIdForBillingInterval({
plan,
billingInterval,
}: {
plan: { monthlyPriceId?: string; annualPriceId?: string };
billingInterval: 'monthly' | 'annual';
}) {
const priceId = billingInterval === 'annual' ? plan.annualPriceId : plan.monthlyPriceId;
if (isNil(priceId)) {
// Very unlikely to happen, as only the free plan does not have a price ID, and we check for the plans in the route validation
// but for type safety, we assert that the price ID is set
throw createOrganizationPlanPriceIdNotSetError();
}
return { priceId };
}

View File

@@ -2,7 +2,7 @@ import type { Config } from '../config/config.types';
import type { OrganizationPlanRecord } from './plans.types';
import { injectArguments } from '@corentinth/chisels';
import { isDocumentSizeLimitEnabled } from '../documents/documents.models';
import { FAMILY_PLAN_ID, FREE_PLAN_ID, PLUS_PLAN_ID } from './plans.constants';
import { FREE_PLAN_ID, PLUS_PLAN_ID } from './plans.constants';
import { createPlanNotFoundError } from './plans.errors';
export type PlansRepository = ReturnType<typeof createPlansRepository>;
@@ -29,36 +29,22 @@ export function getOrganizationPlansRecords({ config }: { config: Config }) {
[FREE_PLAN_ID]: {
id: FREE_PLAN_ID,
name: 'Free',
isPerSeat: true,
limits: {
maxDocumentStorageBytes: isFreePlanUnlimited ? Number.POSITIVE_INFINITY : 1024 * 1024 * 500, // 500 MiB
maxIntakeEmailsCount: isFreePlanUnlimited ? Number.POSITIVE_INFINITY : 1,
maxOrganizationsMembersCount: isFreePlanUnlimited ? Number.POSITIVE_INFINITY : 10,
maxOrganizationsMembersCount: isFreePlanUnlimited ? Number.POSITIVE_INFINITY : 3,
maxFileSize: isDocumentSizeLimitEnabled({ maxUploadSize }) ? maxUploadSize : Number.POSITIVE_INFINITY,
},
},
[PLUS_PLAN_ID]: {
id: PLUS_PLAN_ID,
name: 'Plus',
priceId: config.organizationPlans.plusPlanPriceId,
isPerSeat: true,
monthlyPriceId: config.organizationPlans.plusPlanMonthlyPriceId,
annualPriceId: config.organizationPlans.plusPlanAnnualPriceId,
limits: {
maxDocumentStorageBytes: 1024 * 1024 * 1024 * 5, // 5 GiB
maxIntakeEmailsCount: 10,
maxOrganizationsMembersCount: 100,
maxFileSize: 1024 * 1024 * 100, // 100 MiB
},
},
[FAMILY_PLAN_ID]: {
id: FAMILY_PLAN_ID,
name: 'Family',
priceId: config.organizationPlans.familyPlanPriceId,
isPerSeat: false,
defaultSeatsCount: 6,
limits: {
maxDocumentStorageBytes: 1024 * 1024 * 1024 * 5, // 5 GiB
maxIntakeEmailsCount: 10,
maxOrganizationsMembersCount: 6,
maxOrganizationsMembersCount: 10,
maxFileSize: 1024 * 1024 * 100, // 100 MiB
},
},
@@ -78,7 +64,7 @@ async function getOrganizationPlanById({ planId, organizationPlans }: { planId:
}
async function getOrganizationPlanByPriceId({ priceId, organizationPlans }: { priceId: string; organizationPlans: Record<string, OrganizationPlanRecord> }) {
const organizationPlan = Object.values(organizationPlans).find(plan => plan.priceId === priceId);
const organizationPlan = Object.values(organizationPlans).find(plan => plan.monthlyPriceId === priceId || plan.annualPriceId === priceId);
if (!organizationPlan) {
throw createPlanNotFoundError();

View File

@@ -1,9 +1,8 @@
export type OrganizationPlanRecord = {
id: string;
name: string;
priceId?: string;
defaultSeatsCount?: number;
isPerSeat: boolean;
monthlyPriceId?: string;
annualPriceId?: string;
limits: {
maxDocumentStorageBytes: number;
maxFileSize: number;

View File

@@ -19,7 +19,7 @@ describe('plans usecases', () => {
organizationId: 'organization-1',
planId: PLUS_PLAN_ID,
customerId: 'cus_123',
seatsCount: 1,
seatsCount: 10,
status: 'active',
currentPeriodStart: new Date('2025-03-18T00:00:00.000Z'),
currentPeriodEnd: new Date('2025-04-18T00:00:00.000Z'),
@@ -29,7 +29,8 @@ describe('plans usecases', () => {
const config = overrideConfig({
organizationPlans: {
plusPlanPriceId: 'price_123',
plusPlanAnnualPriceId: 'price_123',
plusPlanMonthlyPriceId: 'price_456',
},
});
@@ -50,7 +51,8 @@ describe('plans usecases', () => {
const config = overrideConfig({
organizationPlans: {
plusPlanPriceId: 'price_123',
plusPlanAnnualPriceId: 'price_123',
plusPlanMonthlyPriceId: 'price_456',
},
});

View File

@@ -3,7 +3,7 @@ import type { FsNative } from './fs.services';
import { memfs } from 'memfs';
import { createFsServices } from './fs.services';
export function createInMemoryFsServices(volume: NestedDirectoryJSON) {
export function buildInMemoryFs(volume: NestedDirectoryJSON) {
const { vol } = memfs(volume);
const fs = {
@@ -12,7 +12,16 @@ export function createInMemoryFsServices(volume: NestedDirectoryJSON) {
} as FsNative;
return {
fs,
getFsState: () => vol.toJSON(),
};
}
export function createInMemoryFsServices(volume: NestedDirectoryJSON) {
const { fs, getFsState } = buildInMemoryFs(volume);
return {
getFsState,
fs: createFsServices({ fs }),
};
}

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