mirror of
https://github.com/papra-hq/papra.git
synced 2025-12-19 03:51:28 -06:00
Compare commits
36 Commits
@papra/app
...
calver
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
32b4129e2c | ||
|
|
9a6e822e71 | ||
|
|
e52bc261db | ||
|
|
624ad62c53 | ||
|
|
630f9cc328 | ||
|
|
9f5be458fe | ||
|
|
1bfdb8aa66 | ||
|
|
2e2bb6fbbd | ||
|
|
d09b9ed70d | ||
|
|
e1571d2b87 | ||
|
|
c9a66e4aa8 | ||
|
|
9fa2df4235 | ||
|
|
c84a921988 | ||
|
|
9b5f3993c3 | ||
|
|
b28772317c | ||
|
|
a3f9f05c66 | ||
|
|
0616635cd6 | ||
|
|
9e7a3ba70b | ||
|
|
04990b986e | ||
|
|
097b6bf2b7 | ||
|
|
cb3ce6b1d8 | ||
|
|
405ba645f6 | ||
|
|
ab6fd6ad10 | ||
|
|
782f70ff66 | ||
|
|
1abbf18e94 | ||
|
|
6bcb2a71e9 | ||
|
|
936bc2bd0a | ||
|
|
2efe7321cd | ||
|
|
947bdf8385 | ||
|
|
b5bf0cca4b | ||
|
|
208a561668 | ||
|
|
40cb1d71d5 | ||
|
|
3da13f7591 | ||
|
|
2a444aad31 | ||
|
|
47d8bbd356 | ||
|
|
ed4d7e4a00 |
7
.changelog/releases/25.10.2/dangerous-spicy-bird.md
Normal file
7
.changelog/releases/25.10.2/dangerous-spicy-bird.md
Normal 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
1
.changelog/version
Normal file
@@ -0,0 +1 @@
|
||||
25.10.2
|
||||
6
.changeset/big-beans-win.md
Normal file
6
.changeset/big-beans-win.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@papra/app-client": patch
|
||||
"@papra/app-server": patch
|
||||
---
|
||||
|
||||
Drop docker armv7 support
|
||||
6
.changeset/fancy-days-fly.md
Normal file
6
.changeset/fancy-days-fly.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@papra/app-client": patch
|
||||
"@papra/app-server": patch
|
||||
---
|
||||
|
||||
Added a page to view organization usage
|
||||
81
.github/workflows/changelog-release.yaml
vendored
Normal file
81
.github/workflows/changelog-release.yaml
vendored
Normal 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 }}
|
||||
4
.github/workflows/release-docker.yaml
vendored
4
.github/workflows/release-docker.yaml
vendored
@@ -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
2
.gitignore
vendored
@@ -35,6 +35,8 @@ cache
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
*.sqlite
|
||||
*.sqlite-shm
|
||||
*.sqlite-wal
|
||||
|
||||
local-documents
|
||||
ingestion
|
||||
|
||||
206
CLAUDE.md
Normal file
206
CLAUDE.md
Normal 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)
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -27,6 +27,7 @@ export const ConfigProvider: ParentComponent = (props) => {
|
||||
const query = useQuery(() => ({
|
||||
queryKey: ['config'],
|
||||
queryFn: fetchPublicConfig,
|
||||
refetchOnWindowFocus: false,
|
||||
}));
|
||||
|
||||
const mergeConfigs = (runtimeConfig: RuntimePublicConfig): Config => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
2
apps/papra-client/src/modules/plans/plans.constants.ts
Normal file
2
apps/papra-client/src/modules/plans/plans.constants.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export const FREE_PLAN_ID = 'free';
|
||||
export const PLUS_PLAN_ID = 'plus';
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 };
|
||||
@@ -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`,
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
9
apps/papra-client/vitest.config.ts
Normal file
9
apps/papra-client/vitest.config.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
env: {
|
||||
TZ: 'UTC',
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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: [] });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
@@ -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 () => {},
|
||||
};
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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 () => {},
|
||||
};
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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 };
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -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 };
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
export const FREE_PLAN_ID = 'free';
|
||||
export const PLUS_PLAN_ID = 'plus';
|
||||
export const FAMILY_PLAN_ID = 'family';
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
20
apps/papra-server/src/modules/plans/plans.models.ts
Normal file
20
apps/papra-server/src/modules/plans/plans.models.ts
Normal 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 };
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user