Compare commits

..

35 Commits

Author SHA1 Message Date
Corentin Thomasset
62b7f0382c chore(release): update versions (#358)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-06-18 22:11:19 +02:00
Corentin Thomasset
57c6a26657 fix(demo): case insensitive dummy search in demo (#367) 2025-06-18 19:03:10 +00:00
Corentin Thomasset
b8c2bd70e3 feat(tags): allow for adding/removing tags to document using api keys (#366) 2025-06-18 20:58:03 +02:00
Marvin Deuschle
0c2cf698d1 feat(i18n): added German translation (#359)
* feat: Add german translation

* fix: Added changeset entry

* Update apps/papra-client/src/locales/de.yml

* Update apps/papra-client/src/locales/de.yml

* Update apps/papra-client/src/locales/de.yml

---------

Co-authored-by: Corentin Thomasset <corentin.thomasset74@gmail.com>
2025-06-15 21:51:13 +02:00
Corentin Thomasset
585c53cd9d chore(changesets): added /llms.txt announcement changesets (#357) 2025-06-14 19:16:28 +02:00
Corentin Thomasset
f035458e16 feat(docs): added descriptions in docs-navigation.json (#354) 2025-06-14 00:37:47 +02:00
Corentin Thomasset
556fd8b167 feat(docs): added navigation json export (#341) 2025-06-10 21:30:56 +02:00
Corentin Thomasset
81e85295ba chore(release): update versions (#334)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-06-07 17:40:28 +02:00
Corentin Thomasset
1c574b8305 feat(script): ensure local database directory exists before running scripts (#337) 2025-06-07 17:26:28 +02:00
Corentin Thomasset
ff830c234a fix(client): corrected version release link (#333) 2025-06-07 15:09:08 +02:00
Corentin Thomasset
451564f354 docs(readme): updated features statuses (#328) 2025-06-07 14:58:21 +02:00
Corentin Thomasset
ecd6af45c8 docs(README): update project status and add sponsorship section (#327) 2025-06-06 22:04:49 +00:00
Corentin Thomasset
cb652c7166 chore(release): update versions (#323)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-06-04 21:32:34 +02:00
Corentin Thomasset
17ca8f8f81 fix(documents): update Content-Disposition header to support UTF-8 encoded filenames (#326) 2025-06-04 21:30:06 +02:00
Corentin Thomasset
f54b8e162a feat(docs): auto compute urls from port in dc generator (#322) 2025-06-04 13:47:52 +02:00
Corentin Thomasset
6b435bba79 chore(release): update versions (#305)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-06-04 00:09:45 +02:00
Corentin Thomasset
8ccdb74834 refactor(docker): added base url override in docker (#320) 2025-06-03 22:04:15 +00:00
Corentin Thomasset
60059c895c feat(invitations): add invitations management page (#319) 2025-06-03 22:13:21 +02:00
Corentin Thomasset
6e22a93dff feat(locales): add fr translations for document activity logging (#318) 2025-05-30 13:58:00 +02:00
Corentin Thomasset
79c1d3206b feat(documents): added document activity logging (#317) 2025-05-30 13:45:25 +02:00
Corentin Thomasset
48a953a584 refactor(client): migrated tanstack createQuery and createMutation to useQuery and useMutation (#316) 2025-05-28 21:51:30 +02:00
Corentin Thomasset
fdb90fa164 feat(tags): add error handling for existing tags (#315) 2025-05-27 21:09:46 +02:00
Corentin Thomasset
e9a205c0a3 feat(documents): added document renaming (#314) 2025-05-27 20:11:05 +02:00
Corentin Thomasset
278db63fc8 chore(deps): updated some dependencies version (#313) 2025-05-27 13:46:43 +02:00
Corentin Thomasset
e5ef40f36c chore(version): added missing changeset for password reset fix (#312) 2025-05-26 20:30:24 +00:00
Corentin Thomasset
27c9e39422 fix(auth): fix deprecated better-auth database id generation conf (#311) 2025-05-26 20:27:32 +00:00
Corentin Thomasset
91d2e236d0 fix(auth): corrected password reset navigation guard (#310) 2025-05-26 22:19:33 +02:00
Corentin Thomasset
d4f72e889a refactor(client): hide manage subscription section (#309) 2025-05-26 21:42:19 +02:00
Corentin Thomasset
759a3ff713 feat(i18n): extracted hard coded text for i18n (#308) 2025-05-26 01:14:43 +02:00
Corentin Thomasset
34862991fb chore(cf): added security headers in docs and papra-client (#307) 2025-05-25 12:05:38 +00:00
Corentin Thomasset
f0876fdc63 feat(server): added smtp client support for emailing (#306) 2025-05-25 11:47:12 +02:00
Corentin Thomasset
cb38d66485 refactor(emails): restructure emails service to support multiple drivers (#304) 2025-05-25 01:26:28 +02:00
Corentin Thomasset
c28af1407f chore(release): update versions (#303)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-05-24 19:58:09 +02:00
Corentin Thomasset
b62ddf2bc4 chore(docker): add EMAILS_DRY_RUN environment variable to Dockerfiles (#302) 2025-05-24 17:55:59 +00:00
Corentin Thomasset
fa7909c62d chore(release): add 'actions' permission to changeset workflow (#301) 2025-05-24 17:38:19 +00:00
138 changed files with 6693 additions and 1660 deletions

View File

@@ -15,6 +15,7 @@ jobs:
contents: write
pull-requests: write
id-token: write
actions: write
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4

View File

@@ -60,7 +60,10 @@ pnpm script:generate-i18n-types
```
- This command will update the file [`locales.types.ts`](./apps/papra-client/src/modules/i18n/locale.types.ts) with the new/removed keys.
- When developing in papra-client (using `pnpm dev`), the i18n types definition will automatically update when you touch the [`en.yml`](./apps/papra-client/src/locales/en.yml) file, so no need to run the command above.
- When developing in papra-client (using `pnpm dev`), **the i18n types definition will automatically update** when you touch the [`en.yml`](./apps/papra-client/src/locales/en.yml) file, so no need to run the command above.
> [!TIP]
> You can use the command `pnpm script:sync-i18n-key-order` to sync the order of the keys in the i18n files, it'll also add the missing keys as comments.
## Development Setup

View File

@@ -39,12 +39,9 @@ A live demo of the platform is available at [demo.papra.app](https://demo.papra.
## Project Status
Papra is currently in **beta**. The core functionality is stable and usable, but you may encounter occasional bugs or limitations. The project is actively developed, with new features being added regularly.
Papra is under active development, the core functionalities are stable and usable. With lots of features and improvements added regularly.
- ✅ Core document management features are stable
- ✅ Self-hosting is fully supported
- 🚧 Some advanced features are still in development
- 📝 Feedback and bug reports are highly appreciated
Feedback and bug reports are highly appreciated to help us improve the platform.
## Features
@@ -63,11 +60,17 @@ Papra is currently in **beta**. The core functionality is stable and usable, but
- **Folder ingestion**: Automatically import documents from a folder.
- **CLI**: Manage your documents from the command line.
- **API, SDK and webhooks**: Build your own applications on top of Papra.
- *In progress:* **i18n**: Support for multiple languages.
- **i18n**: Support for multiple languages.
- *Coming soon:* **Document sharing**: Share documents with others.
- *Coming soon:* **Document requests**: Generate upload links for people to add documents.
- *Coming maybe one day:* **Mobile app**: Access and upload documents on the go.
- *Coming maybe one day:* **Desktop app**: Access and upload documents from your computer.
- *Coming maybe one day:* **Browser extension**: Upload documents from your browser.
- *Coming maybe one day:* **AI**: Use AI to help you manage or tag your documents.
## Sponsors
Papra is a free and open-source project, but it is not free to run and develop. If you want to support the project, you can become a sponsor on [GitHub Sponsors](https://github.com/sponsors/corentinth) or [Buy me a coffee](https://buymeacoffee.com/cthmsst). If you are a company, you can also contact me to discuss a sponsorship.
## Self-hosting

View File

@@ -1,5 +1,33 @@
# @papra/docs
## 0.5.1
### Patch Changes
- [#357](https://github.com/papra-hq/papra/pull/357) [`585c53c`](https://github.com/papra-hq/papra/commit/585c53cd9d0d7dbd517dbb1adddfd9e7b70f9fe5) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added a /llms.txt on main website
## 0.5.0
### Minor Changes
- [#337](https://github.com/papra-hq/papra/pull/337) [`1c574b8`](https://github.com/papra-hq/papra/commit/1c574b8305eb7bde4f1b75ac38a610ca0120a613) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added troubleshooting page
### Patch Changes
- [#337](https://github.com/papra-hq/papra/pull/337) [`1c574b8`](https://github.com/papra-hq/papra/commit/1c574b8305eb7bde4f1b75ac38a610ca0120a613) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added `docker compose up` command in dc generator
## 0.4.2
### Patch Changes
- [#322](https://github.com/papra-hq/papra/pull/322) [`f54b8e1`](https://github.com/papra-hq/papra/commit/f54b8e162acd6cfe92241aaa649847fc03ca5852) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Auto computes urls from the provided port
## 0.4.1
### Patch Changes
- [#320](https://github.com/papra-hq/papra/pull/320) [`8ccdb74`](https://github.com/papra-hq/papra/commit/8ccdb748349a3cacf38f032fd4d3beebce202487) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added base url configuration in docker compose generator
## 0.4.0
### Minor Changes

View File

@@ -1,7 +1,7 @@
{
"name": "@papra/docs",
"type": "module",
"version": "0.4.0",
"version": "0.5.1",
"private": true,
"packageManager": "pnpm@10.9.0",
"description": "Papra documentation website",

View File

@@ -0,0 +1,3 @@
/*
X-Frame-Options: DENY
X-Content-Type-Options: nosniff

View File

@@ -33,11 +33,14 @@ const rows = configDetails
const rawDocumentation = formatDoc(doc);
// The client baseUrl default value is overridden in the Dockerfiles
const defaultOverride = path.join('.') === 'client.baseUrl' ? 'http://localhost:1221' : undefined;
return {
path,
env,
documentation: rawDocumentation,
defaultValue: isEmptyDefaultValue ? undefined : defaultValue,
defaultValue: defaultOverride ?? (isEmptyDefaultValue ? undefined : defaultValue),
};
});

View File

@@ -1,6 +1,7 @@
---
title: Using Docker Compose
slug: self-hosting/using-docker-compose
description: Self-host Papra using Docker Compose.
---
import { Steps } from '@astrojs/starlight/components';

View File

@@ -1,7 +1,7 @@
---
title: Configuration
slug: self-hosting/configuration
description: Configure your self-hosted Papra instance.
---
import { mdSections, fullDotEnv } from '../../../config.data.ts';

View File

@@ -0,0 +1,28 @@
---
title: Troubleshooting
description: Troubleshooting guide for Papra
slug: resources/troubleshooting
---
You can find here some common issues and how to fix them. If you encounter an issue that is not listed here, please [open an issue](https://github.com/papra-hq/papra/issues/new/choose) or [join our Discord](https://papra.app/discord).
## Failed to ensure that the database directory exists
Upon starting the server or a script, you may encounter this error
```
Failed to ensure that the database directory exists, error while creating the directory
Error: EACCES: permission denied, mkdir './app-data/db'
```
Before accessing the DB sqlite file, the server will try to ensure that the database directory exists, and if it doesn't, it try will create it. But in case of insufficient permissions, it will fail.
To fix this, you can either:
- Create the directory manually `mkdir -p <your-app-data-dir>/db`
- Ensure that the directory is owned by the user running the container
- Run the server as root (not recommended)

View File

@@ -1,6 +1,6 @@
---
title: Papra documentation
description: Papra documentation.
description: Documentation for Papra, the minimalistic document archiving platform.
hero:
title: Papra Docs
tagline: Documentation for Papra, the minimalistic document archiving platform.
@@ -55,7 +55,7 @@ In today's digital world, managing countless important documents efficiently and
- **Folder ingestion**: Automatically import documents from a folder.
- **API, SDK and webhooks**: Build your own applications on top of Papra.
- **CLI**: Manage your documents from the command line.
- *In progress:* **i18n**: Support for multiple languages.
- **i18n**: Support for multiple languages.
- *Coming soon:* **Document sharing**: Share documents with others.
- *Coming soon:* **Document requests**: Generate upload links for people to add documents.

View File

@@ -1,6 +1,6 @@
import type { StarlightUserConfig } from '@astrojs/starlight/types';
export const sidebar: StarlightUserConfig['sidebar'] = [
export const sidebar = [
{
label: 'Getting Started',
items: [
@@ -40,6 +40,10 @@ export const sidebar: StarlightUserConfig['sidebar'] = [
{
label: 'Resources',
items: [
{
label: 'Troubleshooting',
slug: 'resources/troubleshooting',
},
{
label: 'CLI Documentation',
slug: 'resources/cli',
@@ -51,6 +55,7 @@ export const sidebar: StarlightUserConfig['sidebar'] = [
target: '_blank',
},
},
],
},
];
] satisfies StarlightUserConfig['sidebar'];

View File

@@ -16,15 +16,17 @@ services:
- 1221:1221
environment:
- AUTH_SECRET=change-me
- CLIENT_BASE_URL=http://localhost:1221
- SERVER_BASE_URL=http://localhost:1221
volumes:
- ./app-data:/app/app-data
user: 1000:1000
`.trim();
const dcHtml = await codeToHtml(defaultDockerCompose, { theme: 'vitesse-black', lang: 'yaml' });
const defaultCommand = `mkdir -p ./app-data/{db,documents} && docker compose up -d`;
---
<p>This tool will help you generate a custom docker-compose.yml file for Papra, tailored to your needs. You can personalize the service name, the port, the auth secret, and the source image.</p>
<h2 class="mt-8 mb-2">General settings</h2>
@@ -33,6 +35,11 @@ const dcHtml = await codeToHtml(defaultDockerCompose, { theme: 'vitesse-black',
<input id="port" class="input-field" value="1221" type="number" min="1024" max="65535" placeholder="eg: 1221" />
</div>
<div class="flex items-center gap-2 mt-1">
<label for="app-base-url" class="min-w-32">App base URL</label>
<input id="app-base-url" class="input-field" type="text" placeholder="eg: https://papra.example.com" value="http://localhost:1221" />
</div>
<div class="flex items-center gap-2 mt-1">
<label for="source" class="min-w-32">Image source</label>
<select class="input-field mt-0" id="source">
@@ -118,7 +125,7 @@ const dcHtml = await codeToHtml(defaultDockerCompose, { theme: 'vitesse-black',
</div>
<div class="flex items-center gap-2 mt-1">
<label for="intake-email-owlrelay-webhook-url" class="min-w-32">Webhook URL</label>
<input id="intake-email-owlrelay-webhook-url" class="input-field" type="text" placeholder="https://your-instance.com/api/intake-emails/ingest" />
<input id="intake-email-owlrelay-webhook-url" class="input-field" type="text" placeholder="https://your-instance.com/api/intake-emails/ingest" value="https://localhost:1221/api/intake-emails/ingest" />
</div>
</div>
@@ -139,9 +146,12 @@ const dcHtml = await codeToHtml(defaultDockerCompose, { theme: 'vitesse-black',
<div id="docker-compose-output" class="mt-12" set:html={dcHtml} />
<pre id="command-output" class="bg-card p-4 rounded-md text-muted-foreground text-sm font-mono overflow-x-auto">{defaultCommand}</pre>
<div class="flex items-center gap-2 mt-4">
<button class="btn bg-muted mt-0" id="download-button">Download docker-compose.yml</button>
<button class="btn bg-muted mt-0" id="copy-button">Copy to clipboard</button>
<button class="btn bg-muted mt-0" id="copy-button">Copy docker compose to clipboard</button>
<button class="btn bg-muted mt-0" id="copy-command-button">Copy command</button>
</div>
@@ -153,6 +163,7 @@ const portInput = document.getElementById('port') as HTMLInputElement;
const sourceSelect = document.getElementById('source') as HTMLSelectElement;
const serviceNameInput = document.getElementById('service-name') as HTMLInputElement;
const authSecretInput = document.getElementById('auth-secret') as HTMLInputElement;
const appBaseUrlInput = document.getElementById('app-base-url') as HTMLInputElement;
const refreshSecretButton = document.getElementById('refresh-secret');
const copyButton = document.getElementById('copy-button');
const dockerComposeOutput = document.getElementById('docker-compose-output');
@@ -171,6 +182,13 @@ const owlrelayWebhookUrlInput = document.getElementById('intake-email-owlrelay-w
const cfEmailDomainInput = document.getElementById('intake-email-cf-email-domain') as HTMLInputElement;
const webhookSecretInput = document.getElementById('intake-email-webhook-secret') as HTMLInputElement;
const refreshWebhookSecretButton = document.getElementById('refresh-webhook-secret');
const commandOutput = document.getElementById('command-output');
const copyCommandButton = document.getElementById('copy-command-button');
// Track whether the app base URL has been customized by the user
let isAppBaseUrlCustomized = false;
// Track whether the webhook URL has been customized by the user
let isWebhookUrlCustomized = false;
function getRandomString() {
const alphabet = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
@@ -181,6 +199,76 @@ function copyToClipboard(text: string) {
navigator.clipboard.writeText(text);
}
function isDefaultAppBaseUrl(url: string, port: string): boolean {
return url === `http://localhost:${port}`;
}
function generateDefaultWebhookUrl(baseUrl: string): string {
// Remove trailing slash if present
const cleanBaseUrl = baseUrl.replace(/\/$/, '');
return `${cleanBaseUrl}/api/intake-emails/ingest`;
}
function isDefaultWebhookUrl(webhookUrl: string, baseUrl: string): boolean {
return webhookUrl === generateDefaultWebhookUrl(baseUrl);
}
function refreshIsWebhookUrlCustomized() {
const currentBaseUrl = appBaseUrlInput.value.trim();
const currentWebhookUrl = owlrelayWebhookUrlInput.value.trim();
if (isDefaultWebhookUrl(currentWebhookUrl, currentBaseUrl)) {
isWebhookUrlCustomized = false;
} else {
isWebhookUrlCustomized = true;
}
}
function refreshIsAppBaseUrlCustomized() {
const currentPort = portInput.value;
const currentUrl = appBaseUrlInput.value.trim();
if (isDefaultAppBaseUrl(currentUrl, currentPort)) {
isAppBaseUrlCustomized = false;
} else {
isAppBaseUrlCustomized = true;
}
}
function updateWebhookUrlFromBaseUrl() {
if (!isWebhookUrlCustomized) {
const baseUrl = appBaseUrlInput.value.trim();
if (baseUrl) {
owlrelayWebhookUrlInput.value = generateDefaultWebhookUrl(baseUrl);
}
}
}
function updateAppBaseUrlFromPort() {
if (!isAppBaseUrlCustomized) {
const port = portInput.value;
appBaseUrlInput.value = `http://localhost:${port}`;
// Also update webhook URL when app base URL changes
updateWebhookUrlFromBaseUrl();
}
}
function handlePortChange() {
updateAppBaseUrlFromPort();
updateDockerCompose();
}
function handleAppBaseUrlChange() {
refreshIsAppBaseUrlCustomized();
updateWebhookUrlFromBaseUrl();
updateDockerCompose();
}
function handleWebhookUrlChange() {
refreshIsWebhookUrlCustomized();
updateDockerCompose();
}
function getDockerComposeYml() {
const serviceName = serviceNameInput.value;
const isRootless = privilegedModeSelect.value === 'false';
@@ -193,12 +281,19 @@ function getDockerComposeYml() {
const intakeEmailEnabled = intakeEmailEnabledSelect.value === 'true';
const intakeDriver = intakeDriverSelect.value;
const webhookSecret = webhookSecretInput.value;
const appBaseUrl = appBaseUrlInput.value.trim();
const version = isRootless ? 'latest' : 'latest-root';
const fullImage = `${image}:${version}`;
// Determine base URLs
const clientBaseUrl = appBaseUrl || `http://localhost:${port}`;
const serverBaseUrl = appBaseUrl || `http://localhost:${port}`;
const environment = [
`AUTH_SECRET=${authSecret}`,
`CLIENT_BASE_URL=${clientBaseUrl}`,
`SERVER_BASE_URL=${serverBaseUrl}`,
isIngestionEnabled && 'INGESTION_FOLDER_IS_ENABLED=true',
intakeEmailEnabled && 'INTAKE_EMAILS_IS_ENABLED=true',
intakeEmailEnabled && `INTAKE_EMAILS_DRIVER=${intakeDriver}`,
@@ -232,14 +327,31 @@ function getDockerComposeYml() {
return stringify(dc);
}
function getStartCommand() {
const volumePath = volumePathInput.value;
const volumePathNormalized = volumePath.replace(/\/$/, '');
const volumeWithSubdirs = `${volumePathNormalized}/{db,documents}`;
const mkdirCommand = `mkdir -p ${volumeWithSubdirs}`;
const dockerCommand = 'docker compose up -d';
return `${mkdirCommand} && ${dockerCommand}`;
}
async function updateDockerCompose() {
const dockerCompose = getDockerComposeYml();
const command = getStartCommand();
const html = await codeToHtml(dockerCompose, { theme: 'vitesse-black', lang: 'yaml' });
if (dockerComposeOutput) {
dockerComposeOutput.innerHTML = html;
}
if (commandOutput) {
commandOutput.textContent = command;
}
}
function handleCopy() {
@@ -331,11 +443,28 @@ function handleRefreshWebhookSecret() {
updateDockerCompose();
}
function handleCopyCommand() {
const command = getStartCommand();
copyToClipboard(command);
if (copyCommandButton) {
copyCommandButton.textContent = 'Copied!';
}
setTimeout(() => {
if (copyCommandButton) {
copyCommandButton.textContent = 'Copy command';
}
}, 1000);
}
// Add event listeners
portInput.addEventListener('input', updateDockerCompose);
portInput.addEventListener('input', handlePortChange);
sourceSelect.addEventListener('change', updateDockerCompose);
serviceNameInput.addEventListener('input', updateDockerCompose);
authSecretInput.addEventListener('input', updateDockerCompose);
appBaseUrlInput.addEventListener('input', handleAppBaseUrlChange);
refreshSecretButton?.addEventListener('click', handleRefreshSecret);
copyButton?.addEventListener('click', handleCopy);
downloadButton?.addEventListener('click', handleDownload);
@@ -346,10 +475,11 @@ ingestionPathInput.addEventListener('input', updateDockerCompose);
intakeEmailEnabledSelect.addEventListener('change', handleIntakeEmailEnabledChange);
intakeDriverSelect.addEventListener('change', handleIntakeDriverChange);
owlrelayApiKeyInput.addEventListener('input', updateDockerCompose);
owlrelayWebhookUrlInput.addEventListener('input', updateDockerCompose);
owlrelayWebhookUrlInput.addEventListener('input', handleWebhookUrlChange);
cfEmailDomainInput.addEventListener('input', updateDockerCompose);
webhookSecretInput.addEventListener('input', updateDockerCompose);
refreshWebhookSecretButton?.addEventListener('click', handleRefreshWebhookSecret);
copyCommandButton?.addEventListener('click', handleCopyCommand);
authSecretInput.value = getRandomString();

View File

@@ -8,8 +8,9 @@ import DockerComposeGeneratorComp from '../docker-compose-generator/dc-generator
title: 'Papra docker-compose.yml generator',
description: 'Generate a custom docker-compose.yml file for Papra, tailored to your needs.',
tableOfContents: false,
}}
>
<p>This tool will help you generate a custom docker-compose.yml file for Papra, tailored to your needs. You can personalize the service name, the port, the auth secret, and the source image.</p>
<p>For more configuration options, you can use the <a href="/self-hosting/configuration">configuration reference</a>.</p>
<DockerComposeGeneratorComp />
</StarlightPage>

View File

@@ -0,0 +1,28 @@
import type { APIRoute } from 'astro';
import { getCollection } from 'astro:content';
import { sidebar } from '../content/navigation';
export const GET: APIRoute = async ({ site }) => {
const docs = await getCollection('docs');
const sections = sidebar.map((section) => {
return {
label: section.label,
items: section
.items
.filter(item => item.slug !== undefined || (item.link && !item.link.startsWith('http')))
.map((item) => {
const slug = item.slug ?? item.link?.replace(/^\//, '');
return {
label: item.label,
slug,
url: new URL(slug, site).toString(),
description: docs.find(doc => (doc.id === slug || (slug === '' && doc.id === 'index')))?.data.description,
};
}),
};
});
return new Response(JSON.stringify(sections));
};

View File

@@ -1,5 +1,39 @@
# @papra/app-client
## 0.6.3
### Patch Changes
- [#357](https://github.com/papra-hq/papra/pull/357) [`585c53c`](https://github.com/papra-hq/papra/commit/585c53cd9d0d7dbd517dbb1adddfd9e7b70f9fe5) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added a /llms.txt on main website
- [#359](https://github.com/papra-hq/papra/pull/359) [`0c2cf69`](https://github.com/papra-hq/papra/commit/0c2cf698d1a9e9a3cea023920b10cfcd5d83be14) Thanks [@Mavv3006](https://github.com/Mavv3006)! - Add German translation
## 0.6.2
### Patch Changes
- [#333](https://github.com/papra-hq/papra/pull/333) [`ff830c2`](https://github.com/papra-hq/papra/commit/ff830c234a02ddb4cbc480cf77ef49b8de35fbae) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Fixed version release link
## 0.6.1
## 0.6.0
### Minor Changes
- [#317](https://github.com/papra-hq/papra/pull/317) [`79c1d32`](https://github.com/papra-hq/papra/commit/79c1d3206b140cf8b3d33ef8bda6098dcf4c9c9c) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added document activity log
- [#319](https://github.com/papra-hq/papra/pull/319) [`60059c8`](https://github.com/papra-hq/papra/commit/60059c895c4860cbfda69d3c989ad00542def65b) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added pending invitation management page
### Patch Changes
- [#309](https://github.com/papra-hq/papra/pull/309) [`d4f72e8`](https://github.com/papra-hq/papra/commit/d4f72e889a4d39214de998942bc0eb88cd5cee3d) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Disable "Manage subscription" from organization setting by default
- [#308](https://github.com/papra-hq/papra/pull/308) [`759a3ff`](https://github.com/papra-hq/papra/commit/759a3ff713db8337061418b9c9b122b957479343) Thanks [@CorentinTh](https://github.com/CorentinTh)! - I18n: full support for French language
- [#312](https://github.com/papra-hq/papra/pull/312) [`e5ef40f`](https://github.com/papra-hq/papra/commit/e5ef40f36c27ea25dc8a79ef2805d673761eec2a) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Fixed an issue with the reset-password page navigation guard that prevented reset
## 0.5.1
## 0.5.0
### Minor Changes

View File

@@ -1,7 +1,7 @@
{
"name": "@papra/app-client",
"type": "module",
"version": "0.5.0",
"version": "0.6.3",
"private": true,
"packageManager": "pnpm@10.9.0",
"description": "Papra frontend client",
@@ -26,51 +26,52 @@
"test:e2e": "playwright test",
"typecheck": "tsc --noEmit",
"script:get-missing-i18n-keys": "tsx src/scripts/get-missing-i18n-keys.script.ts",
"script:generate-i18n-types": "tsx src/scripts/generate-i18n-types.script.ts"
"script:generate-i18n-types": "tsx src/scripts/generate-i18n-types.script.ts",
"script:sync-i18n-key-order": "tsx src/scripts/sync-i18n-key-order.script.ts"
},
"dependencies": {
"@corentinth/chisels": "^1.0.2",
"@kobalte/core": "^0.13.7",
"@corentinth/chisels": "^1.3.1",
"@kobalte/core": "^0.13.9",
"@kobalte/utils": "^0.9.1",
"@modular-forms/solid": "^0.25.0",
"@pdfslick/solid": "^2.0.0",
"@solid-primitives/storage": "^4.2.1",
"@solidjs/router": "^0.14.3",
"@tanstack/solid-query": "^5.61.5",
"@tanstack/solid-table": "^8.20.5",
"@unocss/reset": "^0.64.0",
"@modular-forms/solid": "^0.25.1",
"@pdfslick/solid": "^2.3.0",
"@solid-primitives/storage": "^4.3.2",
"@solidjs/router": "^0.14.10",
"@tanstack/solid-query": "^5.77.2",
"@tanstack/solid-table": "^8.21.3",
"@unocss/reset": "^0.64.1",
"better-auth": "catalog:",
"class-variance-authority": "^0.7.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk-solid": "^1.1.0",
"cmdk-solid": "^1.1.2",
"date-fns": "^4.1.0",
"lodash-es": "^4.17.21",
"ofetch": "^1.4.1",
"posthog-js": "^1.231.0",
"posthog-js": "^1.246.0",
"radix3": "^1.1.2",
"solid-js": "^1.8.11",
"solid-js": "^1.9.7",
"solid-sonner": "^0.2.8",
"tailwind-merge": "^2.6.0",
"ts-pattern": "^5.5.0",
"unocss-preset-animations": "^1.1.0",
"unstorage": "^1.14.4",
"ts-pattern": "^5.7.1",
"unocss-preset-animations": "^1.2.1",
"unstorage": "^1.16.0",
"valibot": "1.0.0-beta.10"
},
"devDependencies": {
"@antfu/eslint-config": "catalog:",
"@iconify-json/tabler": "^1.1.120",
"@playwright/test": "^1.46.1",
"@iconify-json/tabler": "^1.2.18",
"@playwright/test": "^1.52.0",
"@types/lodash-es": "^4.17.12",
"@types/node": "catalog:",
"eslint": "catalog:",
"jsdom": "^25.0.0",
"tinyglobby": "^0.2.13",
"tsx": "^4.19.1",
"jsdom": "^25.0.1",
"tinyglobby": "^0.2.14",
"tsx": "^4.19.4",
"typescript": "catalog:",
"unocss": "0.65.0-beta.2",
"vite": "^5.0.11",
"vite-plugin-solid": "^2.8.2",
"vite": "^5.4.19",
"vite-plugin-solid": "^2.11.6",
"vitest": "catalog:",
"yaml": "^2.7.0"
"yaml": "^2.8.0"
}
}

View File

@@ -0,0 +1,3 @@
/*
X-Frame-Options: DENY
X-Content-Type-Options: nosniff

View File

@@ -9,6 +9,7 @@ import { render, Suspense } from 'solid-js/web';
import { CommandPaletteProvider } from './modules/command-palette/command-palette.provider';
import { ConfigProvider } from './modules/config/config.provider';
import { DemoIndicator } from './modules/demo/demo.provider';
import { RenameDocumentDialogProvider } from './modules/documents/components/rename-document-button.component';
import { I18nProvider } from './modules/i18n/i18n.provider';
import { ConfirmModalProvider } from './modules/shared/confirm';
import { queryClient } from './modules/shared/query/query-client';
@@ -44,9 +45,11 @@ render(
>
<CommandPaletteProvider>
<ConfigProvider>
<div class="min-h-screen font-sans text-sm font-400">
{props.children}
</div>
<RenameDocumentDialogProvider>
<div class="min-h-screen font-sans text-sm font-400">
{props.children}
</div>
</RenameDocumentDialogProvider>
<DemoIndicator />
</ConfigProvider>

View File

@@ -0,0 +1,552 @@
# Authentication
auth.request-password-reset.title: Passwort zurücksetzen
auth.request-password-reset.description: Geben Sie Ihre E-Mail-Adresse ein, um Ihr Passwort zurückzusetzen.
auth.request-password-reset.requested: Wenn ein Konto mit dieser E-Mail-Adresse existiert, haben wir Ihnen eine E-Mail zum Zurücksetzen Ihres Passworts gesendet.
auth.request-password-reset.back-to-login: Zurück zum Login
auth.request-password-reset.form.email.label: E-Mail
auth.request-password-reset.form.email.placeholder: 'Beispiel: ada@papra.app'
auth.request-password-reset.form.email.required: Bitte geben Sie Ihre E-Mail-Adresse ein
auth.request-password-reset.form.email.invalid: Diese E-Mail-Adresse ist ungültig
auth.request-password-reset.form.submit: Passwort zurücksetzen anfordern
auth.reset-password.title: Passwort zurücksetzen
auth.reset-password.description: Geben Sie Ihr neues Passwort ein, um Ihr Passwort zurückzusetzen.
auth.reset-password.reset: Ihr Passwort wurde zurückgesetzt.
auth.reset-password.back-to-login: Zurück zum Login
auth.reset-password.form.new-password.label: Neues Passwort
auth.reset-password.form.new-password.placeholder: 'Beispiel: **********'
auth.reset-password.form.new-password.required: Bitte geben Sie Ihr neues Passwort ein
auth.reset-password.form.new-password.min-length: Das Passwort muss mindestens {{ minLength }} Zeichen lang sein
auth.reset-password.form.new-password.max-length: Das Passwort muss weniger als {{ maxLength }} Zeichen lang sein
auth.reset-password.form.submit: Passwort zurücksetzen
auth.email-provider.open: '{{ provider }} öffnen'
auth.login.title: Bei Papra anmelden
auth.login.description: Geben Sie Ihre E-Mail-Adresse ein oder verwenden Sie die soziale Anmeldung, um auf Ihr Papra-Konto zuzugreifen.
auth.login.login-with-provider: Mit {{ provider }} anmelden
auth.login.no-account: Sie haben noch kein Konto?
auth.login.register: Registrieren
auth.login.form.email.label: E-Mail
auth.login.form.email.placeholder: 'Beispiel: ada@papra.app'
auth.login.form.email.required: Bitte geben Sie Ihre E-Mail-Adresse ein
auth.login.form.email.invalid: Diese E-Mail-Adresse ist ungültig
auth.login.form.password.label: Passwort
auth.login.form.password.placeholder: Passwort festlegen
auth.login.form.password.required: Bitte geben Sie Ihr Passwort ein
auth.login.form.remember-me.label: Angemeldet bleiben
auth.login.form.forgot-password.label: Passwort vergessen?
auth.login.form.submit: Anmelden
auth.register.title: Bei Papra registrieren
auth.register.description: Erstellen Sie ein Konto, um Papra zu nutzen.
auth.register.register-with-email: Mit E-Mail registrieren
auth.register.register-with-provider: Mit {{ provider }} registrieren
auth.register.providers.google: Google
auth.register.providers.github: GitHub
auth.register.have-account: Sie haben bereits ein Konto?
auth.register.login: Anmelden
auth.register.registration-disabled.title: Registrierung ist deaktiviert
auth.register.registration-disabled.description: Die Erstellung neuer Konten ist auf dieser Papra-Instanz derzeit deaktiviert. Nur Benutzer mit bestehenden Konten können sich anmelden. Wenn Sie dies für einen Fehler halten, wenden Sie sich bitte an den Administrator dieser Instanz.
auth.register.form.email.label: E-Mail
auth.register.form.email.placeholder: 'Beispiel: ada@papra.app'
auth.register.form.email.required: Bitte geben Sie Ihre E-Mail-Adresse ein
auth.register.form.email.invalid: Diese E-Mail-Adresse ist ungültig
auth.register.form.password.label: Passwort
auth.register.form.password.placeholder: Passwort festlegen
auth.register.form.password.required: Bitte geben Sie Ihr Passwort ein
auth.register.form.password.min-length: Das Passwort muss mindestens {{ minLength }} Zeichen lang sein
auth.register.form.password.max-length: Das Passwort muss weniger als {{ maxLength }} Zeichen lang sein
auth.register.form.name.label: Name
auth.register.form.name.placeholder: 'Beispiel: Ada Lovelace'
auth.register.form.name.required: Bitte geben Sie Ihren Namen ein
auth.register.form.name.max-length: Der Name muss weniger als {{ maxLength }} Zeichen lang sein
auth.register.form.submit: Registrieren
auth.email-validation-required.title: E-Mail verifizieren
auth.email-validation-required.description: Eine Verifizierungs-E-Mail wurde an Ihre E-Mail-Adresse gesendet. Bitte verifizieren Sie Ihre E-Mail-Adresse, indem Sie auf den Link in der E-Mail klicken.
auth.legal-links.description: Indem Sie fortfahren, bestätigen Sie, dass Sie die {{ terms }} und die {{ privacy }} verstanden haben und ihnen zustimmen.
auth.legal-links.terms: Nutzungsbedingungen
auth.legal-links.privacy: Datenschutzrichtlinie
# User settings
user.settings.title: Benutzereinstellungen
user.settings.description: Verwalten Sie hier Ihre Kontoeinstellungen.
user.settings.email.title: E-Mail-Adresse
user.settings.email.description: Ihre E-Mail-Adresse kann nicht geändert werden.
user.settings.email.label: E-Mail-Adresse
user.settings.name.title: Vollständiger Name
user.settings.name.description: Ihr vollständiger Name wird anderen Organisationsmitgliedern angezeigt.
user.settings.name.label: Vollständiger Name
user.settings.name.placeholder: Z.B. Max Mustermann
user.settings.name.update: Namen aktualisieren
user.settings.name.updated: Ihr vollständiger Name wurde aktualisiert
user.settings.logout.title: Abmelden
user.settings.logout.description: Melden Sie sich von Ihrem Konto ab. Sie können sich später wieder anmelden.
user.settings.logout.button: Abmelden
# Organizations
organizations.list.title: Ihre Organisationen
organizations.list.description: Organisationen sind eine Möglichkeit, Ihre Dokumente zu gruppieren und den Zugriff darauf zu verwalten. Sie können mehrere Organisationen erstellen und Ihre Teammitglieder zur Zusammenarbeit einladen.
organizations.list.create-new: Neue Organisation erstellen
organizations.details.no-documents.title: Keine Dokumente
organizations.details.no-documents.description: Es sind noch keine Dokumente in dieser Organisation vorhanden. Beginnen Sie mit dem Hochladen von Dokumenten.
organizations.details.upload-documents: Dokumente hochladen
organizations.details.documents-count: Dokumente insgesamt
organizations.details.total-size: Gesamtgröße
organizations.details.latest-documents: Neueste importierte Dokumente
organizations.create.title: Eine neue Organisation erstellen
organizations.create.description: Ihre Dokumente werden nach Organisation gruppiert. Sie können mehrere Organisationen erstellen, um Ihre Dokumente zu trennen, z.B. für persönliche und geschäftliche Dokumente.
organizations.create.back: Zurück
organizations.create.error.max-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.
organizations.create.form.name.label: Name der Organisation
organizations.create.form.name.placeholder: Z.B. Acme Inc.
organizations.create.form.name.required: Bitte geben Sie einen Organisationsnamen ein
organizations.create.form.submit: Organisation erstellen
organizations.create.success: Organisation erfolgreich erstellt
organizations.create-first.title: Erstellen Sie Ihre Organisation
organizations.create-first.description: Ihre Dokumente werden nach Organisation gruppiert. Sie können mehrere Organisationen erstellen, um Ihre Dokumente zu trennen, z.B. für persönliche und geschäftliche Dokumente.
organizations.create-first.default-name: Meine Organisation
organizations.create-first.user-name: Organisation von "{{ name }}"
organization.settings.title: Organisationseinstellungen
organization.settings.page.title: Organisationseinstellungen
organization.settings.page.description: Verwalten Sie hier Ihre Organisationseinstellungen.
organization.settings.name.title: Name der Organisation
organization.settings.name.update: Namen aktualisieren
organization.settings.name.placeholder: Z.B. Acme Inc.
organization.settings.name.updated: Organisationsname aktualisiert
organization.settings.subscription.title: Abonnement
organization.settings.subscription.description: Verwalten Sie Ihre Abrechnung, Rechnungen und Zahlungsmethoden.
organization.settings.subscription.manage: Abonnement verwalten
organization.settings.subscription.error: Kundenportal-URL konnte nicht abgerufen werden
organization.settings.delete.title: Organisation löschen
organization.settings.delete.description: Das Löschen dieser Organisation entfernt dauerhaft alle damit verbundenen Daten.
organization.settings.delete.confirm.title: Organisation löschen
organization.settings.delete.confirm.message: Sind Sie sicher, dass Sie diese Organisation löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden und alle mit dieser Organisation verbundenen Daten werden dauerhaft entfernt.
organization.settings.delete.confirm.confirm-button: Organisation löschen
organization.settings.delete.confirm.cancel-button: Abbrechen
organization.settings.delete.success: Organisation gelöscht
organizations.members.title: Mitglieder
organizations.members.description: Verwalten Sie Ihre Organisationsmitglieder
organizations.members.invite-member: Mitglied einladen
organizations.members.invite-member-disabled-tooltip: Nur Administratoren oder Eigentümer können Mitglieder in die Organisation einladen
organizations.members.remove-from-organization: Aus Organisation entfernen
organizations.members.role: Rolle
organizations.members.roles.owner: Eigentümer
organizations.members.roles.admin: Administrator
organizations.members.roles.member: Mitglied
organizations.members.delete.confirm.title: Mitglied entfernen
organizations.members.delete.confirm.message: Sind Sie sicher, dass Sie dieses Mitglied aus der Organisation entfernen möchten?
organizations.members.delete.confirm.confirm-button: Entfernen
organizations.members.delete.confirm.cancel-button: Abbrechen
organizations.members.delete.success: Mitglied aus Organisation entfernt
organizations.members.update-role.success: Mitgliederrolle aktualisiert
organizations.members.table.headers.name: Name
organizations.members.table.headers.email: E-Mail
organizations.members.table.headers.role: Rolle
organizations.members.table.headers.created: Erstellt
organizations.members.table.headers.actions: Aktionen
organizations.invite-member.title: Mitglied einladen
organizations.invite-member.description: Laden Sie ein Mitglied in Ihre Organisation ein
organizations.invite-member.form.email.label: E-Mail
organizations.invite-member.form.email.placeholder: 'Beispiel: ada@papra.app'
organizations.invite-member.form.email.required: Bitte geben Sie eine gültige E-Mail-Adresse ein
organizations.invite-member.form.role.label: Rolle
organizations.invite-member.form.submit: In Organisation einladen
organizations.invite-member.success.message: Mitglied eingeladen
organizations.invite-member.success.description: Die E-Mail wurde in die Organisation eingeladen.
organizations.invite-member.error.message: Mitglied konnte nicht eingeladen werden
organizations.invitations.title: Einladungen
organizations.invitations.description: Verwalten Sie Ihre Organisationseinladungen
organizations.invitations.list.cta: Mitglied einladen
organizations.invitations.list.empty.title: Keine ausstehenden Einladungen
organizations.invitations.list.empty.description: Sie wurden noch nicht zu Organisationen eingeladen.
organizations.invitations.status.pending: Ausstehend
organizations.invitations.status.accepted: Angenommen
organizations.invitations.status.rejected: Abgelehnt
organizations.invitations.status.expired: Abgelaufen
organizations.invitations.status.cancelled: Abgebrochen
organizations.invitations.resend: Einladung erneut senden
organizations.invitations.cancel.title: Einladung abbrechen
organizations.invitations.cancel.description: Sind Sie sicher, dass Sie diese Einladung abbrechen möchten?
organizations.invitations.cancel.confirm: Einladung abbrechen
organizations.invitations.cancel.cancel: Abbrechen
organizations.invitations.resend.title: Einladung erneut senden
organizations.invitations.resend.description: Sind Sie sicher, dass Sie diese Einladung erneut senden möchten? Dadurch wird eine neue E-Mail an den Empfänger gesendet.
organizations.invitations.resend.confirm: Einladung erneut senden
organizations.invitations.resend.cancel: Abbrechen
invitations.list.title: Einladungen
invitations.list.description: Verwalten Sie Ihre Organisationseinladungen
invitations.list.empty.title: Keine ausstehenden Einladungen
invitations.list.empty.description: Sie wurden noch nicht zu Organisationen eingeladen.
invitations.list.headers.organization: Organisation
invitations.list.headers.status: Status
invitations.list.headers.created: Erstellt
invitations.list.headers.actions: Aktionen
invitations.list.actions.accept: Annehmen
invitations.list.actions.reject: Ablehnen
invitations.list.actions.accept.success.message: Einladung angenommen
invitations.list.actions.accept.success.description: Die Einladung wurde angenommen.
invitations.list.actions.reject.success.message: Einladung abgelehnt
invitations.list.actions.reject.success.description: Die Einladung wurde abgelehnt.
# Documents
documents.list.title: Dokumente
documents.list.no-documents.title: Keine Dokumente
documents.list.no-documents.description: Es sind noch keine Dokumente in dieser Organisation vorhanden. Beginnen Sie mit dem Hochladen von Dokumenten.
documents.list.no-results: Keine Dokumente gefunden
documents.tabs.info: Info
documents.tabs.content: Inhalt
documents.tabs.activity: Aktivität
documents.deleted.message: Dieses Dokument wurde gelöscht und wird in {{ days }} Tagen dauerhaft entfernt.
documents.actions.download: Herunterladen
documents.actions.open-in-new-tab: In neuem Tab öffnen
documents.actions.restore: Wiederherstellen
documents.actions.delete: Löschen
documents.actions.edit: Bearbeiten
documents.actions.cancel: Abbrechen
documents.actions.save: Speichern
documents.actions.saving: Speichern...
documents.content.alert: Der Inhalt des Dokuments wird beim Hochladen automatisch aus dem Dokument extrahiert. Er wird nur für Such- und Indexierungszwecke verwendet.
documents.info.id: ID
documents.info.name: Name
documents.info.type: Typ
documents.info.size: Größe
documents.info.created-at: Erstellt am
documents.info.updated-at: Aktualisiert am
documents.info.never: Nie
documents.rename.title: Dokument umbenennen
documents.rename.form.name.label: Name
documents.rename.form.name.placeholder: 'Beispiel: Rechnung 2024'
documents.rename.form.name.required: Bitte geben Sie einen Namen für das Dokument ein
documents.rename.form.name.max-length: Der Name muss weniger als 255 Zeichen lang sein
documents.rename.form.submit: Dokument umbenennen
documents.rename.success: Dokument erfolgreich umbenannt
documents.rename.cancel: Abbrechen
import-documents.title.error: '{{ count }} Dokumente fehlgeschlagen'
import-documents.title.success: '{{ count }} Dokumente importiert'
import-documents.title.pending: '{{ count }} / {{ total }} Dokumente importiert'
import-documents.title.none: Dokumente importieren
import-documents.no-import-in-progress: Kein Dokumentimport im Gange
documents.deleted.title: Gelöschte Dokumente
documents.deleted.empty.title: Keine gelöschten Dokumente
documents.deleted.empty.description: Sie haben keine gelöschten Dokumente. Gelöschte Dokumente werden für {{ days }} Tage in den Papierkorb verschoben.
documents.deleted.retention-notice: Alle gelöschten Dokumente werden für {{ days }} Tage im Papierkorb gespeichert. Nach Ablauf dieser Frist werden die Dokumente dauerhaft gelöscht und Sie können sie nicht wiederherstellen.
documents.deleted.deleted-at: Gelöscht
documents.deleted.restoring: Wiederherstellen...
documents.deleted.deleting: Löschen...
trash.delete-all.button: Alles löschen
trash.delete-all.confirm.title: Alle Dokumente dauerhaft löschen?
trash.delete-all.confirm.description: Sind Sie sicher, dass Sie alle Dokumente aus dem Papierkorb dauerhaft löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.
trash.delete-all.confirm.label: Löschen
trash.delete-all.confirm.cancel: Abbrechen
trash.delete.button: Löschen
trash.delete.confirm.title: Dokument dauerhaft löschen?
trash.delete.confirm.description: Sind Sie sicher, dass Sie dieses Dokument dauerhaft aus dem Papierkorb löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.
trash.delete.confirm.label: Löschen
trash.delete.confirm.cancel: Abbrechen
trash.deleted.success.title: Dokument gelöscht
trash.deleted.success.description: Das Dokument wurde dauerhaft gelöscht.
activity.document.created: Das Dokument wurde erstellt
activity.document.updated.single: Das Feld {{ field }} wurde aktualisiert
activity.document.updated.multiple: Die Felder {{ fields }} wurden aktualisiert
activity.document.updated: Das Dokument wurde aktualisiert
activity.document.deleted: Das Dokument wurde gelöscht
activity.document.restored: Das Dokument wurde wiederhergestellt
activity.document.tagged: Tag {{ tag }} wurde hinzugefügt
activity.document.untagged: Tag {{ tag }} wurde entfernt
activity.document.user.name: von {{ name }}
activity.load-more: Mehr laden
activity.no-more-activities: Keine weiteren Aktivitäten für dieses Dokument
# Tags
tags.no-tags.title: Noch keine Tags
tags.no-tags.description: Diese Organisation hat noch keine Tags. Tags werden zur Kategorisierung von Dokumenten verwendet. Sie können Ihren Dokumenten Tags hinzufügen, um sie leichter zu finden und zu organisieren.
tags.no-tags.create-tag: Tag erstellen
tags.title: Dokumenten-Tags
tags.description: Tags werden zur Kategorisierung von Dokumenten verwendet. Sie können Ihren Dokumenten Tags hinzufügen, um sie leichter zu finden und zu organisieren.
tags.create: Tag erstellen
tags.update: Tag aktualisieren
tags.delete: Tag löschen
tags.delete.confirm.title: Tag löschen
tags.delete.confirm.message: Sind Sie sicher, dass Sie diesen Tag löschen möchten? Das Löschen eines Tags entfernt ihn von allen Dokumenten.
tags.delete.confirm.confirm-button: Löschen
tags.delete.confirm.cancel-button: Abbrechen
tags.delete.success: Tag erfolgreich gelöscht
tags.create.success: Tag "{{ name }}" erfolgreich erstellt.
tags.update.success: Tag "{{ name }}" erfolgreich aktualisiert.
tags.form.name.label: Name
tags.form.name.placeholder: Z.B. Verträge
tags.form.name.required: Bitte geben Sie einen Tag-Namen ein
tags.form.name.max-length: Tag-Name muss weniger als 64 Zeichen lang sein
tags.form.color.label: Farbe
tags.form.color.placeholder: 'Z.B. #FF0000'
tags.form.color.required: Bitte geben Sie eine Farbe ein
tags.form.color.invalid: Die Hex-Farbe ist falsch formatiert.
tags.form.description.label: Beschreibung
tags.form.description.optional: (optional)
tags.form.description.placeholder: Z.B. Alle von der Firma unterzeichneten Verträge
tags.form.description.max-length: Beschreibung muss weniger als 256 Zeichen lang sein
tags.form.no-description: Keine Beschreibung
tags.table.headers.tag: Tag
tags.table.headers.description: Beschreibung
tags.table.headers.documents: Dokumente
tags.table.headers.created: Erstellt
tags.table.headers.actions: Aktionen
# Tagging rules
tagging-rules.field.name: Dokumentenname
tagging-rules.field.content: Dokumenteninhalt
tagging-rules.operator.equals: ist gleich
tagging-rules.operator.not-equals: ist nicht gleich
tagging-rules.operator.contains: enthält
tagging-rules.operator.not-contains: enthält nicht
tagging-rules.operator.starts-with: beginnt mit
tagging-rules.operator.ends-with: endet mit
tagging-rules.list.title: Tagging-Regeln
tagging-rules.list.description: Verwalten Sie die Tagging-Regeln Ihrer Organisation, um Dokumente automatisch basierend auf von Ihnen definierten Bedingungen zu taggen.
tagging-rules.list.demo-warning: 'Hinweis: Da dies eine Demo-Umgebung (ohne Server) ist, werden Tagging-Regeln nicht auf neu hinzugefügte Dokumente angewendet.'
tagging-rules.list.no-tagging-rules.title: Keine Tagging-Regeln
tagging-rules.list.no-tagging-rules.description: Erstellen Sie eine Tagging-Regel, um Ihre hinzugefügten Dokumente automatisch basierend auf von Ihnen definierten Bedingungen zu taggen.
tagging-rules.list.no-tagging-rules.create-tagging-rule: Tagging-Regel erstellen
tagging-rules.list.card.no-conditions: Keine Bedingungen
tagging-rules.list.card.one-condition: 1 Bedingung
tagging-rules.list.card.conditions: '{{ count }} Bedingungen'
tagging-rules.list.card.delete: Regel löschen
tagging-rules.list.card.edit: Regel bearbeiten
tagging-rules.create.title: Tagging-Regel erstellen
tagging-rules.create.success: Tagging-Regel erfolgreich erstellt
tagging-rules.create.error: Tagging-Regel konnte nicht erstellt werden
tagging-rules.create.submit: Regel erstellen
tagging-rules.form.name.label: Name
tagging-rules.form.name.placeholder: 'Beispiel: Rechnungen taggen'
tagging-rules.form.name.min-length: Bitte geben Sie einen Namen für die Regel ein
tagging-rules.form.name.max-length: Der Name muss weniger als 64 Zeichen lang sein
tagging-rules.form.description.label: Beschreibung
tagging-rules.form.description.placeholder: "Beispiel: Dokumente mit 'Rechnung' im Namen taggen"
tagging-rules.form.description.max-length: Die Beschreibung muss weniger als 256 Zeichen lang sein
tagging-rules.form.conditions.label: Bedingungen
tagging-rules.form.conditions.description: Definieren Sie die Bedingungen, die erfüllt sein müssen, damit die Regel angewendet wird. Alle Bedingungen müssen erfüllt sein, damit die Regel angewendet wird.
tagging-rules.form.conditions.add-condition: Bedingung hinzufügen
tagging-rules.form.conditions.no-conditions.title: Keine Bedingungen
tagging-rules.form.conditions.no-conditions.description: Sie haben dieser Regel keine Bedingungen hinzugefügt. Diese Regel wendet ihre Tags auf alle Dokumente an.
tagging-rules.form.conditions.no-conditions.confirm: Regel ohne Bedingungen anwenden
tagging-rules.form.conditions.no-conditions.cancel: Abbrechen
tagging-rules.form.conditions.value.placeholder: 'Beispiel: Rechnung'
tagging-rules.form.conditions.value.min-length: Bitte geben Sie einen Wert für die Bedingung ein
tagging-rules.form.tags.label: Tags
tagging-rules.form.tags.description: Wählen Sie die Tags aus, die auf die hinzugefügten Dokumente angewendet werden sollen, die den Bedingungen entsprechen
tagging-rules.form.tags.min-length: Es ist mindestens ein anzuwendender Tag erforderlich
tagging-rules.form.tags.add-tag: Tag erstellen
tagging-rules.form.submit: Regel erstellen
tagging-rules.update.title: Tagging-Regel aktualisieren
tagging-rules.update.error: Tagging-Regel konnte nicht aktualisiert werden
tagging-rules.update.submit: Regel aktualisieren
tagging-rules.update.cancel: Abbrechen
# Intake emails
intake-emails.title: E-Mail-Eingang
intake-emails.description: E-Mail-Eingangsadressen werden verwendet, um E-Mails automatisch in Papra aufzunehmen. Leiten Sie einfach E-Mails an die Eingangsadresse weiter und deren Anhänge werden zu den Dokumenten Ihrer Organisation hinzugefügt.
intake-emails.disabled.title: E-Mail-Eingang ist deaktiviert
intake-emails.disabled.description: E-Mail-Eingang ist auf dieser Instanz deaktiviert. Bitte kontaktieren Sie Ihren Administrator, um ihn zu aktivieren. Weitere Informationen finden Sie in der {{ documentation }}.
intake-emails.disabled.documentation: Dokumentation
intake-emails.info: Es werden nur aktivierte E-Mails aus zulässigen Ursprüngen verarbeitet. Sie können eine E-Mail-Eingangsadresse jederzeit aktivieren oder deaktivieren.
intake-emails.empty.title: Keine E-Mail-Eingänge
intake-emails.empty.description: Generieren Sie eine Eingangsadresse, um E-Mail-Anhänge einfach aufzunehmen.
intake-emails.empty.generate: E-Mail-Eingang generieren
intake-emails.count: '{{ count }} Eingangse-Mail{{ plural }} für diese Organisation'
intake-emails.new: Neue Eingangse-Mail
intake-emails.disabled-label: (Deaktiviert)
intake-emails.no-origins: Keine zulässigen E-Mail-Ursprünge
intake-emails.allowed-origins: Zulässig von {{ count }} Adresse{{ plural }}
intake-emails.actions.enable: Aktivieren
intake-emails.actions.disable: Deaktivieren
intake-emails.actions.manage-origins: Ursprungsadressen verwalten
intake-emails.actions.delete: Löschen
intake-emails.delete.confirm.title: Eingangse-Mail löschen?
intake-emails.delete.confirm.message: Sind Sie sicher, dass Sie diese Eingangse-Mail löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.
intake-emails.delete.confirm.confirm-button: Eingangse-Mail löschen
intake-emails.delete.confirm.cancel-button: Abbrechen
intake-emails.delete.success: Eingangse-Mail gelöscht
intake-emails.create.success: Eingangse-Mail erstellt
intake-emails.update.success.enabled: Eingangse-Mail aktiviert
intake-emails.update.success.disabled: Eingangse-Mail deaktiviert
intake-emails.allowed-origins.title: Zulässige Ursprünge
intake-emails.allowed-origins.description: Es werden nur E-Mails, die an {{ email }} von diesen Ursprüngen gesendet werden, verarbeitet. Wenn keine Ursprünge angegeben sind, werden alle E-Mails verworfen.
intake-emails.allowed-origins.add.label: Zulässige Ursprungs-E-Mail hinzufügen
intake-emails.allowed-origins.add.placeholder: Z.B. ada@papra.app
intake-emails.allowed-origins.add.button: Hinzufügen
intake-emails.allowed-origins.add.error.exists: Diese E-Mail ist bereits in den zulässigen Ursprüngen für diese Eingangse-Mail vorhanden
# API keys
api-keys.permissions.documents.title: Dokumente
api-keys.permissions.documents.documents:create: Dokumente erstellen
api-keys.permissions.documents.documents:read: Dokumente lesen
api-keys.permissions.documents.documents:update: Dokumente aktualisieren
api-keys.permissions.documents.documents:delete: Dokumente löschen
api-keys.permissions.tags.title: Tags
api-keys.permissions.tags.tags:create: Tags erstellen
api-keys.permissions.tags.tags:read: Tags lesen
api-keys.permissions.tags.tags:update: Tags aktualisieren
api-keys.permissions.tags.tags:delete: Tags löschen
api-keys.create.title: API-Schlüssel erstellen
api-keys.create.description: Erstellen Sie einen neuen API-Schlüssel, um auf die Papra API zuzugreifen.
api-keys.create.success: Der API-Schlüssel wurde erfolgreich erstellt.
api-keys.create.back: Zurück zu den API-Schlüsseln
api-keys.create.form.name.label: Name
api-keys.create.form.name.placeholder: 'Beispiel: Mein API-Schlüssel'
api-keys.create.form.name.required: Bitte geben Sie einen Namen für den API-Schlüssel ein
api-keys.create.form.permissions.label: Berechtigungen
api-keys.create.form.permissions.required: Bitte wählen Sie mindestens eine Berechtigung aus
api-keys.create.form.submit: API-Schlüssel erstellen
api-keys.create.created.title: API-Schlüssel erstellt
api-keys.create.created.description: Der API-Schlüssel wurde erfolgreich erstellt. Speichern Sie ihn an einem sicheren Ort, da er nicht erneut angezeigt wird.
api-keys.list.title: API-Schlüssel
api-keys.list.description: Verwalten Sie hier Ihre API-Schlüssel.
api-keys.list.create: API-Schlüssel erstellen
api-keys.list.empty.title: Keine API-Schlüssel
api-keys.list.empty.description: Erstellen Sie einen API-Schlüssel, um auf die Papra API zuzugreifen.
api-keys.list.card.last-used: Zuletzt verwendet
api-keys.list.card.never: Nie
api-keys.list.card.created: Erstellt
api-keys.delete.success: Der API-Schlüssel wurde erfolgreich gelöscht
api-keys.delete.confirm.title: API-Schlüssel löschen
api-keys.delete.confirm.message: Sind Sie sicher, dass Sie diesen API-Schlüssel löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.
api-keys.delete.confirm.confirm-button: Löschen
api-keys.delete.confirm.cancel-button: Abbrechen
# Webhooks
webhooks.list.title: Webhooks
webhooks.list.description: Verwalten Sie Ihre Organisations-Webhooks
webhooks.list.empty.title: Keine Webhooks
webhooks.list.empty.description: Erstellen Sie Ihren ersten Webhook, um Ereignisse zu empfangen
webhooks.list.create: Webhook erstellen
webhooks.list.card.last-triggered: Zuletzt ausgelöst
webhooks.list.card.never: Nie
webhooks.list.card.created: Erstellt
webhooks.create.title: Webhook erstellen
webhooks.create.description: Erstellen Sie einen neuen Webhook, um Ereignisse zu empfangen
webhooks.create.success: Webhook erfolgreich erstellt
webhooks.create.back: Zurück
webhooks.create.form.submit: Webhook erstellen
webhooks.create.form.name.label: Webhook-Name
webhooks.create.form.name.placeholder: Webhook-Namen eingeben
webhooks.create.form.name.required: Name ist erforderlich
webhooks.create.form.url.label: Webhook-URL
webhooks.create.form.url.placeholder: Webhook-URL eingeben
webhooks.create.form.url.required: URL ist erforderlich
webhooks.create.form.url.invalid: URL ist ungültig
webhooks.create.form.secret.label: Geheimnis
webhooks.create.form.secret.placeholder: Webhook-Geheimnis eingeben
webhooks.create.form.events.label: Ereignisse
webhooks.create.form.events.required: Mindestens ein Ereignis ist erforderlich
webhooks.update.title: Webhook bearbeiten
webhooks.update.description: Aktualisieren Sie Ihre Webhook-Details
webhooks.update.success: Webhook erfolgreich aktualisiert
webhooks.update.submit: Webhook aktualisieren
webhooks.update.cancel: Abbrechen
webhooks.update.form.secret.placeholder: Neues Geheimnis eingeben
webhooks.update.form.secret.placeholder-redacted: '[Geheimnis geschwärzt]'
webhooks.update.form.rotate-secret.button: Geheimnis rotieren
webhooks.delete.success: Webhook erfolgreich gelöscht
webhooks.delete.confirm.title: Webhook löschen
webhooks.delete.confirm.message: Sind Sie sicher, dass Sie diesen Webhook löschen möchten?
webhooks.delete.confirm.confirm-button: Löschen
webhooks.delete.confirm.cancel-button: Abbrechen
webhooks.events.documents.document:created.description: Dokument erstellt
webhooks.events.documents.document:deleted.description: Dokument gelöscht
# Navigation
layout.menu.home: Startseite
layout.menu.documents: Dokumente
layout.menu.tags: Tags
layout.menu.tagging-rules: Tagging-Regeln
layout.menu.deleted-documents: Gelöschte Dokumente
layout.menu.organization-settings: Einstellungen
layout.menu.api-keys: API-Schlüssel
layout.menu.settings: Einstellungen
layout.menu.account: Konto
layout.menu.general-settings: Allgemeine Einstellungen
layout.menu.intake-emails: E-Mail-Eingang
layout.menu.webhooks: Webhooks
layout.menu.members: Mitglieder
layout.menu.invitations: Einladungen
layout.theme.light: Heller Modus
layout.theme.dark: Dunkler Modus
layout.theme.system: Systemmodus
layout.search.placeholder: Suchen...
layout.menu.import-document: Dokument importieren
user-menu.account-settings: Kontoeinstellungen
user-menu.api-keys: API-Schlüssel
user-menu.invitations: Einladungen
user-menu.language: Sprache
user-menu.logout: Abmelden
# Command palette
command-palette.search.placeholder: Befehle oder Dokumente suchen
command-palette.no-results: Keine Ergebnisse gefunden
command-palette.sections.documents: Dokumente
command-palette.sections.theme: Thema
# API errors
api-errors.document.already_exists: Das Dokument existiert bereits
api-errors.document.file_too_big: Die Dokumentdatei 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.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.
api-errors.user.already_in_organization: Dieser Benutzer ist bereits in dieser Organisation.
api-errors.user.organization_invitation_limit_reached: Die maximale Anzahl an Einladungen für heute wurde erreicht. Bitte versuchen Sie es morgen erneut.
api-errors.demo.not_available: Diese Funktion ist in der Demo nicht verfügbar
api-errors.tags.already_exists: Ein Tag mit diesem Namen existiert bereits für diese Organisation
# Not found
not-found.title: 404 - Seite nicht gefunden
not-found.description: Entschuldigung, die gesuchte Seite scheint nicht zu existieren. Bitte überprüfen Sie die URL und versuchen Sie es erneut.
not-found.back-to-home: Zurück zur Startseite
# Demo
demo.popup.description: Dies ist eine Demo-Umgebung, alle Daten werden im lokalen Speicher Ihres Browsers gespeichert.
demo.popup.discord: Treten Sie dem {{ discordLink }} bei, um Support zu erhalten, Funktionen vorzuschlagen oder einfach nur zu chatten.
demo.popup.discord-link-label: Discord-Server
demo.popup.reset: Demo-Daten zurücksetzen
demo.popup.hide: Ausblenden

View File

@@ -1,3 +1,5 @@
# Authentication
auth.request-password-reset.title: Reset your password
auth.request-password-reset.description: Enter your email to reset your password.
auth.request-password-reset.requested: If an account exists for this email, we've sent you an email to reset your password.
@@ -69,24 +71,257 @@ auth.legal-links.description: By continuing, you acknowledge that you understand
auth.legal-links.terms: Terms of Service
auth.legal-links.privacy: Privacy Policy
# User settings
user.settings.title: User settings
user.settings.description: Manage your account settings here.
user.settings.email.title: Email address
user.settings.email.description: Your email address cannot be changed.
user.settings.email.label: Email address
user.settings.name.title: Full name
user.settings.name.description: Your full name is displayed to other organization members.
user.settings.name.label: Full name
user.settings.name.placeholder: Eg. John Doe
user.settings.name.update: Update name
user.settings.name.updated: Your full name has been updated
user.settings.logout.title: Logout
user.settings.logout.description: Logout from your account. You can login again later.
user.settings.logout.button: Logout
# Organizations
organizations.list.title: Your organizations
organizations.list.description: Organizations are a way to group your documents and manage access to them. You can create multiple organizations and invite your team members to collaborate.
organizations.list.create-new: Create new organization
organizations.details.no-documents.title: No documents
organizations.details.no-documents.description: There are no documents in this organization yet. Start by uploading some documents.
organizations.details.upload-documents: Upload documents
organizations.details.documents-count: documents in total
organizations.details.total-size: total size
organizations.details.latest-documents: Latest imported documents
organizations.create.title: Create a new organization
organizations.create.description: Your documents will be grouped by organization. You can create multiple organizations to separate your documents, for example, for personal and work documents.
organizations.create.back: Back
organizations.create.error.max-count-reached: You have reached the maximum number of organizations you can create, if you need to create more, please contact support.
organizations.create.form.name.label: Organization name
organizations.create.form.name.placeholder: Eg. Acme Inc.
organizations.create.form.name.required: Please enter an organization name
organizations.create.form.submit: Create organization
organizations.create.success: Organization created successfully
organizations.create-first.title: Create your organization
organizations.create-first.description: Your documents will be grouped by organization. You can create multiple organizations to separate your documents, for example, for personal and work documents.
organizations.create-first.default-name: My organization
organizations.create-first.user-name: "{{ name }}'s organization"
organization.settings.title: Organization Settings
organization.settings.page.title: Organization settings
organization.settings.page.description: Manage your organization settings here.
organization.settings.name.title: Organization name
organization.settings.name.update: Update name
organization.settings.name.placeholder: Eg. Acme Inc.
organization.settings.name.updated: Organization name updated
organization.settings.subscription.title: Subscription
organization.settings.subscription.description: Manage your billing, invoices and payment methods.
organization.settings.subscription.manage: Manage subscription
organization.settings.subscription.error: Failed to get customer portal URL
organization.settings.delete.title: Delete organization
organization.settings.delete.description: Deleting this organization will permanently remove all data associated with it.
organization.settings.delete.confirm.title: Delete organization
organization.settings.delete.confirm.message: Are you sure you want to delete this organization? This action cannot be undone, and all data associated with this organization will be permanently removed.
organization.settings.delete.confirm.confirm-button: Delete organization
organization.settings.delete.confirm.cancel-button: Cancel
organization.settings.delete.success: Organization deleted
organizations.members.title: Members
organizations.members.description: Manage your organization members
organizations.members.invite-member: Invite member
organizations.members.invite-member-disabled-tooltip: Only admins or owners can invite members to the organization
organizations.members.remove-from-organization: Remove from organization
organizations.members.role: Role
organizations.members.roles.owner: Owner
organizations.members.roles.admin: Admin
organizations.members.roles.member: Member
organizations.members.delete.confirm.title: Remove member
organizations.members.delete.confirm.message: Are you sure you want to remove this member from the organization?
organizations.members.delete.confirm.confirm-button: Remove
organizations.members.delete.confirm.cancel-button: Cancel
organizations.members.delete.success: Member removed from organization
organizations.members.update-role.success: Member role updated
organizations.members.table.headers.name: Name
organizations.members.table.headers.email: Email
organizations.members.table.headers.role: Role
organizations.members.table.headers.created: Created
organizations.members.table.headers.actions: Actions
organizations.invite-member.title: Invite member
organizations.invite-member.description: Invite a member to your organization
organizations.invite-member.form.email.label: Email
organizations.invite-member.form.email.placeholder: 'Example: ada@papra.app'
organizations.invite-member.form.email.required: Please enter a valid email address
organizations.invite-member.form.role.label: Role
organizations.invite-member.form.submit: Invite to organization
organizations.invite-member.success.message: Member invited
organizations.invite-member.success.description: The email has been invited to the organization.
organizations.invite-member.error.message: Failed to invite member
organizations.invitations.title: Invitations
organizations.invitations.description: Manage your organization invitations
organizations.invitations.list.cta: Invite member
organizations.invitations.list.empty.title: No pending invitations
organizations.invitations.list.empty.description: You haven't been invited to any organizations yet.
organizations.invitations.status.pending: Pending
organizations.invitations.status.accepted: Accepted
organizations.invitations.status.rejected: Rejected
organizations.invitations.status.expired: Expired
organizations.invitations.status.cancelled: Cancelled
organizations.invitations.resend: Resend invitation
organizations.invitations.cancel.title: Cancel invitation
organizations.invitations.cancel.description: Are you sure you want to cancel this invitation?
organizations.invitations.cancel.confirm: Cancel invitation
organizations.invitations.cancel.cancel: Cancel
organizations.invitations.resend.title: Resend invitation
organizations.invitations.resend.description: Are you sure you want to resend this invitation? This will send a new email to the recipient.
organizations.invitations.resend.confirm: Resend invitation
organizations.invitations.resend.cancel: Cancel
invitations.list.title: Invitations
invitations.list.description: Manage your organization invitations
invitations.list.empty.title: No pending invitations
invitations.list.empty.description: You haven't been invited to any organizations yet.
invitations.list.headers.organization: Organization
invitations.list.headers.status: Status
invitations.list.headers.created: Created
invitations.list.headers.actions: Actions
invitations.list.actions.accept: Accept
invitations.list.actions.reject: Reject
invitations.list.actions.accept.success.message: Invitation accepted
invitations.list.actions.accept.success.description: The invitation has been accepted.
invitations.list.actions.reject.success.message: Invitation rejected
invitations.list.actions.reject.success.description: The invitation has been rejected.
# Documents
documents.list.title: Documents
documents.list.no-documents.title: No documents
documents.list.no-documents.description: There are no documents in this organization yet. Start by uploading some documents.
documents.list.no-results: No documents found
documents.tabs.info: Info
documents.tabs.content: Content
documents.tabs.activity: Activity
documents.deleted.message: This document has been deleted and will be permanently removed in {{ days }} days.
documents.actions.download: Download
documents.actions.open-in-new-tab: Open in new tab
documents.actions.restore: Restore
documents.actions.delete: Delete
documents.actions.edit: Edit
documents.actions.cancel: Cancel
documents.actions.save: Save
documents.actions.saving: Saving...
documents.content.alert: The content of the document is automatically extracted from the document on upload. It is only used for search and indexing purposes.
documents.info.id: ID
documents.info.name: Name
documents.info.type: Type
documents.info.size: Size
documents.info.created-at: Created At
documents.info.updated-at: Updated At
documents.info.never: Never
documents.rename.title: Rename document
documents.rename.form.name.label: Name
documents.rename.form.name.placeholder: 'Example: Invoice 2024'
documents.rename.form.name.required: Please enter a name for the document
documents.rename.form.name.max-length: The name must be less than 255 characters
documents.rename.form.submit: Rename document
documents.rename.success: Document renamed successfully
documents.rename.cancel: Cancel
import-documents.title.error: '{{ count }} documents failed'
import-documents.title.success: '{{ count }} documents imported'
import-documents.title.pending: '{{ count }} / {{ total }} documents imported'
import-documents.title.none: Import documents
import-documents.no-import-in-progress: No document import in progress
documents.deleted.title: Deleted documents
documents.deleted.empty.title: No deleted documents
documents.deleted.empty.description: You have no deleted documents. Documents that are deleted will be moved to the trash bin for {{ days }} days.
documents.deleted.retention-notice: All deleted documents are stored in the trash bin for {{ days }} days. Passing this delay, the documents will be permanently deleted, and you will not be able to restore them.
documents.deleted.deleted-at: Deleted
documents.deleted.restoring: Restoring...
documents.deleted.deleting: Deleting...
trash.delete-all.button: Delete all
trash.delete-all.confirm.title: Permanently delete all documents?
trash.delete-all.confirm.description: Are you sure you want to permanently delete all documents from the trash? This action cannot be undone.
trash.delete-all.confirm.label: Delete
trash.delete-all.confirm.cancel: Cancel
trash.delete.button: Delete
trash.delete.confirm.title: Permanently delete document?
trash.delete.confirm.description: Are you sure you want to permanently delete this document from the trash? This action cannot be undone.
trash.delete.confirm.label: Delete
trash.delete.confirm.cancel: Cancel
trash.deleted.success.title: Document deleted
trash.deleted.success.description: The document has been permanently deleted.
activity.document.created: The document has been created
activity.document.updated.single: The {{ field }} has been updated
activity.document.updated.multiple: The {{ fields }} have been updated
activity.document.updated: The document has been updated
activity.document.deleted: The document has been deleted
activity.document.restored: The document has been restored
activity.document.tagged: Tag {{ tag }} has been added
activity.document.untagged: Tag {{ tag }} has been removed
activity.document.user.name: by {{ name }}
activity.load-more: Load more
activity.no-more-activities: No more activities for this document
# Tags
tags.no-tags.title: No tags yet
tags.no-tags.description: This organization has no tags yet. Tags are used to categorize documents. You can add tags to your documents to make them easier to find and organize.
tags.no-tags.create-tag: Create tag
layout.menu.home: Home
layout.menu.documents: Documents
layout.menu.tags: Tags
layout.menu.tagging-rules: Tagging rules
layout.menu.deleted-documents: Deleted documents
layout.menu.organization-settings: Settings
layout.menu.api-keys: API keys
layout.menu.settings: Settings
layout.menu.account: Account
layout.menu.general-settings: General settings
layout.menu.intake-emails: Intake emails
layout.menu.webhooks: Webhooks
layout.menu.members: Members
layout.menu.invitations: Invitations
tags.title: Documents Tags
tags.description: Tags are used to categorize documents. You can add tags to your documents to make them easier to find and organize.
tags.create: Create tag
tags.update: Update tag
tags.delete: Delete tag
tags.delete.confirm.title: Delete tag
tags.delete.confirm.message: Are you sure you want to delete this tag? Deleting a tag will remove it from all documents.
tags.delete.confirm.confirm-button: Delete
tags.delete.confirm.cancel-button: Cancel
tags.delete.success: Tag deleted successfully
tags.create.success: Tag "{{ name }}" created successfully.
tags.update.success: Tag "{{ name }}" updated successfully.
tags.form.name.label: Name
tags.form.name.placeholder: Eg. Contracts
tags.form.name.required: Please enter a tag name
tags.form.name.max-length: Tag name must be less than 64 characters
tags.form.color.label: Color
tags.form.color.placeholder: 'Eg. #FF0000'
tags.form.color.required: Please enter a color
tags.form.color.invalid: The hex color is badly formatted.
tags.form.description.label: Description
tags.form.description.optional: (optional)
tags.form.description.placeholder: Eg. All the contracts signed by the company
tags.form.description.max-length: Description must be less than 256 characters
tags.form.no-description: No description
tags.table.headers.tag: Tag
tags.table.headers.description: Description
tags.table.headers.documents: Documents
tags.table.headers.created: Created
tags.table.headers.actions: Actions
# Tagging rules
tagging-rules.field.name: document name
tagging-rules.field.content: document content
tagging-rules.operator.equals: equals
@@ -136,40 +371,42 @@ tagging-rules.update.error: Failed to update tagging rule
tagging-rules.update.submit: Update rule
tagging-rules.update.cancel: Cancel
demo.popup.description: This is a demo environment, all data is save to your browser local storage.
demo.popup.discord: Join the {{ discordLink }} to get support, propose features or just chat.
demo.popup.discord-link-label: Discord server
demo.popup.reset: Reset demo data
demo.popup.hide: Hide
# Intake emails
trash.delete-all.button: Delete all
trash.delete-all.confirm.title: Permanently delete all documents?
trash.delete-all.confirm.description: Are you sure you want to permanently delete all documents from the trash? This action cannot be undone.
trash.delete-all.confirm.label: Delete
trash.delete-all.confirm.cancel: Cancel
trash.delete.button: Delete
trash.delete.confirm.title: Permanently delete document?
trash.delete.confirm.description: Are you sure you want to permanently delete this document from the trash? This action cannot be undone.
trash.delete.confirm.label: Delete
trash.delete.confirm.cancel: Cancel
trash.deleted.success.title: Document deleted
trash.deleted.success.description: The document has been permanently deleted.
intake-emails.title: Intake Emails
intake-emails.description: Intake emails address are used to automatically ingest emails into Papra. Just forward emails to the intake email address and their attachments will be added to your organization's documents.
intake-emails.disabled.title: Intake Emails are disabled
intake-emails.disabled.description: Intake emails are disabled on this instance. Please contact your administrator to enable them. See the {{ documentation }} for more information.
intake-emails.disabled.documentation: documentation
intake-emails.info: Only enabled intake emails from allowed origins will be processed. You can enable or disable an intake email at any time.
intake-emails.empty.title: No intake emails
intake-emails.empty.description: Generate an intake address to easily ingest emails attachments.
intake-emails.empty.generate: Generate intake email
intake-emails.count: '{{ count }} intake email{{ plural }} for this organization'
intake-emails.new: New intake email
intake-emails.disabled-label: (Disabled)
intake-emails.no-origins: No allowed email origins
intake-emails.allowed-origins: Allowed from {{ count }} address{{ plural }}
intake-emails.actions.enable: Enable
intake-emails.actions.disable: Disable
intake-emails.actions.manage-origins: Manage origins addresses
intake-emails.actions.delete: Delete
intake-emails.delete.confirm.title: Delete intake email?
intake-emails.delete.confirm.message: Are you sure you want to delete this intake email? This action cannot be undone.
intake-emails.delete.confirm.confirm-button: Delete intake email
intake-emails.delete.confirm.cancel-button: Cancel
intake-emails.delete.success: Intake email deleted
intake-emails.create.success: Intake email created
intake-emails.update.success.enabled: Intake email enabled
intake-emails.update.success.disabled: Intake email disabled
intake-emails.allowed-origins.title: Allowed origins
intake-emails.allowed-origins.description: Only emails sent to {{ email }} from these origins will be processed. If no origins are specified, all emails will be discarded.
intake-emails.allowed-origins.add.label: Add allowed origin email
intake-emails.allowed-origins.add.placeholder: Eg. ada@papra.app
intake-emails.allowed-origins.add.button: Add
intake-emails.allowed-origins.add.error.exists: This email is already in the allowed origins for this intake email
import-documents.title.error: '{{ count }} documents failed'
import-documents.title.success: '{{ count }} documents imported'
import-documents.title.pending: '{{ count }} / {{ total }} documents imported'
import-documents.title.none: Import documents
import-documents.no-import-in-progress: No document import in progress
api-errors.document.already_exists: The document already exists
api-errors.document.file_too_big: The document file is too big
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.
api-errors.organization.invitation_already_exists: An invitation for this email already exists in this organization.
api-errors.user.already_in_organization: This user is already in this organization.
api-errors.user.organization_invitation_limit_reached: The maximum number of invitations has been reached for today. Please try again tomorrow.
api-errors.demo.not_available: This feature is not available in demo
# API keys
api-keys.permissions.documents.title: Documents
api-keys.permissions.documents.documents:create: Create documents
@@ -207,6 +444,8 @@ api-keys.delete.confirm.message: Are you sure you want to delete this API key? T
api-keys.delete.confirm.confirm-button: Delete
api-keys.delete.confirm.cancel-button: Cancel
# Webhooks
webhooks.list.title: Webhooks
webhooks.list.description: Manage your organization webhooks
webhooks.list.empty.title: No webhooks
@@ -215,7 +454,6 @@ webhooks.list.create: Create webhook
webhooks.list.card.last-triggered: Last triggered
webhooks.list.card.never: Never
webhooks.list.card.created: Created
webhooks.create.title: Create webhook
webhooks.create.description: Create a new webhook to receive events
webhooks.create.success: Webhook created successfully
@@ -249,43 +487,66 @@ webhooks.delete.confirm.cancel-button: Cancel
webhooks.events.documents.document:created.description: Document created
webhooks.events.documents.document:deleted.description: Document deleted
organizations.members.title: Members
organizations.members.description: Manage your organization members
organizations.members.invite-member: Invite member
organizations.members.invite-member-disabled-tooltip: Only admins or owners can invite members to the organization
organizations.members.remove-from-organization: Remove from organization
organizations.members.role: Role
organizations.members.roles.owner: Owner
organizations.members.roles.admin: Admin
organizations.members.roles.member: Member
organizations.members.delete.confirm.title: Remove member
organizations.members.delete.confirm.message: Are you sure you want to remove this member from the organization?
organizations.members.delete.confirm.confirm-button: Remove
organizations.members.delete.confirm.cancel-button: Cancel
organizations.members.delete.success: Member removed from organization
organizations.members.update-role.success: Member role updated
# Navigation
organizations.invite-member.title: Invite member
organizations.invite-member.description: Invite a member to your organization
organizations.invite-member.form.email.label: Email
organizations.invite-member.form.email.placeholder: 'Example: ada@papra.app'
organizations.invite-member.form.email.required: Please enter a valid email address
organizations.invite-member.form.role.label: Role
organizations.invite-member.form.submit: Invite to organization
organizations.invite-member.success.message: Member invited
organizations.invite-member.success.description: The email has been invited to the organization.
organizations.invite-member.error.message: Failed to invite member
layout.menu.home: Home
layout.menu.documents: Documents
layout.menu.tags: Tags
layout.menu.tagging-rules: Tagging rules
layout.menu.deleted-documents: Deleted documents
layout.menu.organization-settings: Settings
layout.menu.api-keys: API keys
layout.menu.settings: Settings
layout.menu.account: Account
layout.menu.general-settings: General settings
layout.menu.intake-emails: Intake emails
layout.menu.webhooks: Webhooks
layout.menu.members: Members
layout.menu.invitations: Invitations
invitations.list.title: Invitations
invitations.list.description: Manage your organization invitations
invitations.list.empty.title: No pending invitations
invitations.list.empty.description: You haven't been invited to any organizations yet.
invitations.list.headers.organization: Organization
invitations.list.headers.created: Created
invitations.list.headers.actions: Actions
invitations.list.actions.accept: Accept
invitations.list.actions.reject: Reject
invitations.list.actions.accept.success.message: Invitation accepted
invitations.list.actions.accept.success.description: The invitation has been accepted.
invitations.list.actions.reject.success.message: Invitation rejected
invitations.list.actions.reject.success.description: The invitation has been rejected.
layout.theme.light: Light mode
layout.theme.dark: Dark mode
layout.theme.system: System mode
layout.search.placeholder: Search...
layout.menu.import-document: Import a document
user-menu.account-settings: Account settings
user-menu.api-keys: API keys
user-menu.invitations: Invitations
user-menu.language: Language
user-menu.logout: Logout
# Command palette
command-palette.search.placeholder: Search commands or documents
command-palette.no-results: No results found
command-palette.sections.documents: Documents
command-palette.sections.theme: Theme
# API errors
api-errors.document.already_exists: The document already exists
api-errors.document.file_too_big: The document file is too big
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.
api-errors.organization.invitation_already_exists: An invitation for this email already exists in this organization.
api-errors.user.already_in_organization: This user is already in this organization.
api-errors.user.organization_invitation_limit_reached: The maximum number of invitations has been reached for today. Please try again tomorrow.
api-errors.demo.not_available: This feature is not available in demo
api-errors.tags.already_exists: A tag with this name already exists for this organization
# Not found
not-found.title: 404 - Not Found
not-found.description: Sorry, the page you are looking for does not seem to exist. Please check the URL and try again.
not-found.back-to-home: Go back to home
# Demo
demo.popup.description: This is a demo environment, all data is save to your browser local storage.
demo.popup.discord: Join the {{ discordLink }} to get support, propose features or just chat.
demo.popup.discord-link-label: Discord server
demo.popup.reset: Reset demo data
demo.popup.hide: Hide

View File

@@ -1,3 +1,5 @@
# Authentication
auth.request-password-reset.title: Réinitialiser votre mot de passe
auth.request-password-reset.description: Entrez votre email pour réinitialiser votre mot de passe.
auth.request-password-reset.requested: Si un compte existe pour cet email, nous vous avons envoyé un email pour réinitialiser votre mot de passe.
@@ -69,30 +71,265 @@ auth.legal-links.description: En continuant, vous reconnaissez que vous comprene
auth.legal-links.terms: Conditions d'utilisation
auth.legal-links.privacy: Politique de confidentialité
# User settings
user.settings.title: Paramètres de l'utilisateur
user.settings.description: Gérez vos paramètres de compte ici.
user.settings.email.title: Adresse email
user.settings.email.description: Votre adresse email ne peut pas être modifiée.
user.settings.email.label: Adresse email
user.settings.name.title: Nom complet
user.settings.name.description: Votre nom complet est affiché aux autres membres de l'organisation.
user.settings.name.label: Nom complet
user.settings.name.placeholder: 'Exemple: John Doe'
user.settings.name.update: Mettre à jour le nom
user.settings.name.updated: Votre nom complet a été mis à jour
user.settings.logout.title: Déconnexion
user.settings.logout.description: Déconnectez-vous de votre compte. Vous pouvez vous reconnecter plus tard.
user.settings.logout.button: Déconnexion
# Organizations
organizations.list.title: Vos organisations
organizations.list.description: Les organisations sont un moyen de grouper vos documents et de gérer l'accès à eux. Vous pouvez créer plusieurs organisations et inviter vos membres de l'équipe à collaborer.
organizations.list.create-new: Créer une nouvelle organisation
organizations.details.no-documents.title: Aucun document
organizations.details.no-documents.description: Il n'y a pas de documents dans cette organisation. Commencez par télécharger des documents.
organizations.details.upload-documents: Télécharger des documents
organizations.details.documents-count: documents en total
organizations.details.total-size: taille totale
organizations.details.latest-documents: Derniers documents importés
organizations.create.title: Créer une nouvelle organisation
organizations.create.description: Vos documents seront regroupés par organisation. Vous pouvez créer plusieurs organisations pour séparer vos documents, par exemple, pour les documents personnels et professionnels.
organizations.create.back: Retour
organizations.create.error.max-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.
organizations.create.form.name.label: Nom de l'organisation
organizations.create.form.name.placeholder: 'Exemple: Acme Inc.'
organizations.create.form.name.required: Veuillez entrer un nom pour l'organisation
organizations.create.form.submit: Créer l'organisation
organizations.create.success: Organisation créée avec succès
organizations.create-first.title: Créer votre organisation
organizations.create-first.description: Vos documents seront regroupés par organisation. Vous pouvez créer plusieurs organisations pour séparer vos documents, par exemple, pour les documents personnels et professionnels.
organizations.create-first.default-name: Mon organisation
organizations.create-first.user-name: "{{ name }}'s organisation"
organization.settings.title: Paramètres de l'organisation
organization.settings.page.title: Paramètres de l'organisation
organization.settings.page.description: Gérez les paramètres de votre organisation ici.
organization.settings.name.title: Nom de l'organisation
organization.settings.name.update: Modifier le nom
organization.settings.name.placeholder: 'Exemple: Acme Inc.'
organization.settings.name.updated: Nom de l'organisation mis à jour
organization.settings.subscription.title: Subscription
organization.settings.subscription.description: Gérez votre facturation, vos factures et vos méthodes de paiement.
organization.settings.subscription.manage: Gérer la souscription
organization.settings.subscription.error: Échec de la récupération de l'URL du portail client
organization.settings.delete.title: Supprimer l'organisation
organization.settings.delete.description: Supprimer cette organisation supprimera définitivement toutes les données associées à elle.
organization.settings.delete.confirm.title: Supprimer l'organisation
organization.settings.delete.confirm.message: Êtes-vous sûr de vouloir supprimer cette organisation ? Cette action est irréversible, et toutes les données associées à cette organisation seront supprimées définitivement.
organization.settings.delete.confirm.confirm-button: Supprimer l'organisation
organization.settings.delete.confirm.cancel-button: Annuler
organization.settings.delete.success: Organisation supprimée
organizations.members.title: Membres
organizations.members.description: Gérez les membres de votre organisation.
organizations.members.invite-member: Inviter un membre
organizations.members.invite-member-disabled-tooltip: Seuls les administrateurs ou les propriétaires peuvent inviter des membres à l'organisation
organizations.members.remove-from-organization: Retirer de l'organisation
organizations.members.role: Rôle
organizations.members.roles.owner: Propriétaire
organizations.members.roles.admin: Admin
organizations.members.roles.member: Membre
organizations.members.delete.confirm.title: Retirer un membre
organizations.members.delete.confirm.message: Êtes-vous sûr de vouloir retirer ce membre de l'organisation ?
organizations.members.delete.confirm.confirm-button: Retirer
organizations.members.delete.confirm.cancel-button: Annuler
organizations.members.delete.success: Membre retiré de l'organisation
organizations.members.update-role.success: Rôle du membre mis à jour
organizations.members.table.headers.name: Nom
organizations.members.table.headers.email: Email
organizations.members.table.headers.role: Rôle
# organizations.members.table.headers.created: Created
organizations.members.table.headers.actions: Actions
organizations.invite-member.title: Inviter un membre
organizations.invite-member.description: Invite un membre à votre organisation
organizations.invite-member.form.email.label: Email
organizations.invite-member.form.email.placeholder: 'Exemple: ada@papra.app'
organizations.invite-member.form.email.required: Veuillez entrer une adresse email valide
organizations.invite-member.form.role.label: Rôle
organizations.invite-member.form.submit: Inviter à l'organisation
organizations.invite-member.success.message: Membre invité
organizations.invite-member.success.description: L'email a été invité à l'organisation.
organizations.invite-member.error.message: Échec de l'invitation du membre
organizations.invitations.title: Invitations
organizations.invitations.description: Gérez les invitations de votre organisation.
organizations.invitations.list.cta: Inviter un membre
organizations.invitations.list.empty.title: Aucune invitation en attente
organizations.invitations.list.empty.description: Vous n'avez pas été invité à aucune organisation.
organizations.invitations.status.pending: En attente
organizations.invitations.status.accepted: Accepté
organizations.invitations.status.rejected: Refusé
organizations.invitations.status.expired: Expiré
organizations.invitations.status.cancelled: Annulé
organizations.invitations.resend: Renvoyer l'invitation
organizations.invitations.cancel.title: Annuler l'invitation
organizations.invitations.cancel.description: Êtes-vous sûr de vouloir annuler cette invitation ?
organizations.invitations.cancel.confirm: Annuler l'invitation
organizations.invitations.cancel.cancel: Annuler
organizations.invitations.resend.title: Renvoyer l'invitation
organizations.invitations.resend.description: Êtes-vous sûr de vouloir renvoyer cette invitation ? Cela enverra un nouvel email à l'invité.
organizations.invitations.resend.confirm: Renvoyer l'invitation
organizations.invitations.resend.cancel: Annuler
invitations.list.title: Invitations
invitations.list.description: Gérez les invitations de votre organisation.
invitations.list.empty.title: Aucune invitation en attente
invitations.list.empty.description: Vous n'avez pas été invité à aucune organisation.
invitations.list.headers.organization: Organisation
# invitations.list.headers.status: Status
invitations.list.headers.created: Créé
invitations.list.headers.actions: Actions
invitations.list.actions.accept: Accepter
invitations.list.actions.reject: Refuser
invitations.list.actions.accept.success.message: Invitation acceptée
invitations.list.actions.accept.success.description: L'invitation a été acceptée.
invitations.list.actions.reject.success.message: Invitation refusée
invitations.list.actions.reject.success.description: L'invitation a été refusée.
# Documents
documents.list.title: Documents
documents.list.no-documents.title: Aucun document
documents.list.no-documents.description: Il n'y a pas de documents dans cette organisation. Commencez par télécharger des documents.
documents.list.no-results: Aucun document trouvé
documents.tabs.info: Info
documents.tabs.content: Contenu
documents.tabs.activity: Activité
documents.deleted.message: Ce document a été supprimé et sera supprimé définitivement dans {{ days }} jours.
documents.actions.download: Télécharger
documents.actions.open-in-new-tab: Ouvrir dans un nouvel onglet
documents.actions.restore: Restaurer
documents.actions.delete: Supprimer
documents.actions.edit: Modifier
documents.actions.cancel: Annuler
documents.actions.save: Enregistrer
documents.actions.saving: Enregistrement...
documents.content.alert: Le contenu du document est automatiquement extrait du document lors de l'import. Il est uniquement utilisé pour la recherche et l'indexation.
documents.info.id: ID
documents.info.name: Nom
documents.info.type: Type
documents.info.size: Taille
documents.info.created-at: Créé le
documents.info.updated-at: Mis à jour le
documents.info.never: Jamais
documents.rename.title: Renommer le document
documents.rename.form.name.label: Nom
documents.rename.form.name.placeholder: 'Exemple: Facture 2024'
documents.rename.form.name.required: Veuillez entrer un nom pour le document
documents.rename.form.name.max-length: Le nom doit contenir moins de 255 caractères
documents.rename.form.submit: Renommer
documents.rename.success: Document renommé avec succès
documents.rename.cancel: Annuler
import-documents.title.error: '{{ count }} documents ont échoué'
import-documents.title.success: '{{ count }} documents ont été importés'
import-documents.title.pending: '{{ count }} / {{ total }} documents importés'
import-documents.title.none: Importer des documents
import-documents.no-import-in-progress: Aucune importation de documents en cours
documents.deleted.title: Documents supprimés
documents.deleted.empty.title: Aucun document supprimé
documents.deleted.empty.description: Vous n'avez pas de documents supprimés. Les documents supprimés seront déplacés dans la corbeille pour {{ days }} jours.
documents.deleted.retention-notice: Tous les documents supprimés sont stockés dans la corbeille pour {{ days }} jours. Passé ce délai, les documents seront supprimés définitivement, et vous ne pourrez plus les restaurer.
documents.deleted.deleted-at: Supprimé
documents.deleted.restoring: Restauration...
documents.deleted.deleting: Suppression...
trash.delete-all.button: Supprimer tous les documents
trash.delete-all.confirm.title: Supprimer définitivement tous les documents ?
trash.delete-all.confirm.description: Êtes-vous sûr de vouloir supprimer définitivement tous les documents de la corbeille ? Cette action est irréversible.
trash.delete-all.confirm.label: Supprimer
trash.delete-all.confirm.cancel: Annuler
trash.delete.button: Supprimer
trash.delete.confirm.title: Supprimer définitivement le document ?
trash.delete.confirm.description: Êtes-vous sûr de vouloir supprimer définitivement ce document de la corbeille ? Cette action est irréversible.
trash.delete.confirm.label: Supprimer
trash.delete.confirm.cancel: Annuler
trash.deleted.success.title: Document supprimé
trash.deleted.success.description: Le document a été supprimé définitivement.
activity.document.created: Le document a été créé
activity.document.updated.single: Le {{ field }} a été mis à jour
activity.document.updated.multiple: Les {{ fields }} ont été mis à jour
activity.document.updated: Le document a été mis à jour
activity.document.deleted: Le document a été supprimé
activity.document.restored: Le document a été restauré
activity.document.tagged: Le tag {{ tag }} a été ajouté
activity.document.untagged: Le tag {{ tag }} a été supprimé
activity.document.user.name: par {{ name }}
activity.load-more: Charger plus
activity.no-more-activities: Aucune activité pour ce document
# Tags
tags.no-tags.title: Aucun tag
tags.no-tags.description: Cette organisation n'a pas de tags. Les tags sont utilisés pour catégoriser les documents. Vous pouvez ajouter des tags à vos documents pour les rendre plus faciles à trouver et à organiser.
tags.no-tags.create-tag: Créer un tag
layout.menu.home: Accueil
layout.menu.documents: Documents
layout.menu.tags: Tags
layout.menu.tagging-rules: Règles de catégorisation
layout.menu.deleted-documents: Documents supprimés
layout.menu.organization-settings: Paramètres
layout.menu.api-keys: API keys
layout.menu.settings: Paramètres
layout.menu.account: Compte
tags.title: Tags de documents
tags.description: Les tags sont utilisés pour catégoriser les documents. Vous pouvez ajouter des tags à vos documents pour les rendre plus faciles à trouver et à organiser.
tags.create: Créer un tag
tags.update: Mettre à jour un tag
tags.delete: Supprimer un tag
tags.delete.confirm.title: Supprimer un tag
tags.delete.confirm.message: Êtes-vous sûr de vouloir supprimer ce tag ? Supprimer un tag supprimera toutes les règles de catégorisation qui l'utilisent.
tags.delete.confirm.confirm-button: Supprimer
tags.delete.confirm.cancel-button: Annuler
tags.delete.success: Tag supprimé avec succès
tags.create.success: Tag "{{ name }}" créé avec succès.
tags.update.success: Tag "{{ name }}" mis à jour avec succès.
tags.form.name.label: Nom
tags.form.name.placeholder: 'Exemple: Contrats'
tags.form.name.required: Veuillez entrer un nom pour le tag
tags.form.name.max-length: Le nom du tag doit contenir moins de 64 caractères
tags.form.color.label: Couleur
tags.form.color.placeholder: 'Exemple: #FF0000'
tags.form.color.required: Veuillez entrer une couleur
tags.form.color.invalid: La couleur hexadécimale est mal formatée.
tags.form.description.label: Description
tags.form.description.optional: (optionnel)
tags.form.description.placeholder: "Exemple: Tous les contrats signés par l'entreprise"
tags.form.description.max-length: La description doit contenir moins de 256 caractères
tags.form.no-description: Aucune description
tags.table.headers.tag: Tag
tags.table.headers.description: Description
tags.table.headers.documents: Documents
tags.table.headers.created: Date de création
tags.table.headers.actions: Actions
# Tagging rules
tagging-rules.field.name: nom du document
tagging-rules.field.content: contenu du document
tagging-rules.operator.equals: égal à
tagging-rules.operator.not-equals: différent de
tagging-rules.operator.contains: contient
tagging-rules.operator.not-contains: ne contient pas
tagging-rules.operator.starts-with: commence par
tagging-rules.operator.ends-with: finit par
tagging-rules.list.title: Règles de catégorisation
tagging-rules.list.description: Gérez vos règles de catégorisation, pour catégoriser automatiquement les documents en fonction de conditions que vous définissez.
tagging-rules.list.demo-warning: 'Note: Cette instance est une démo, les règles de catégorisation ne seront pas appliquées aux documents ajoutés.'
@@ -134,36 +371,42 @@ tagging-rules.update.error: Échec de la mise à jour de la règle de catégoris
tagging-rules.update.submit: Mettre à jour la règle
tagging-rules.update.cancel: Annuler
demo.popup.description: Cette instance est une démo, toutes les données sont sauvegardées dans le stockage local de votre navigateur.
demo.popup.discord: Rejoignez le {{ discordLink }} pour obtenir de l'aide, proposer des fonctionnalités ou simplement discuter.
demo.popup.discord-link-label: Serveur Discord
demo.popup.reset: Réinitialiser les données de la démo
demo.popup.hide: Masquer
# Intake emails
trash.delete-all.button: Supprimer tous les documents
trash.delete-all.confirm.title: Supprimer définitivement tous les documents ?
trash.delete-all.confirm.description: Êtes-vous sûr de vouloir supprimer définitivement tous les documents de la corbeille ? Cette action est irréversible.
trash.delete-all.confirm.label: Supprimer
trash.delete-all.confirm.cancel: Annuler
trash.delete.button: Supprimer
trash.delete.confirm.title: Supprimer définitivement le document ?
trash.delete.confirm.description: Êtes-vous sûr de vouloir supprimer définitivement ce document de la corbeille ? Cette action est irréversible.
trash.delete.confirm.label: Supprimer
trash.delete.confirm.cancel: Annuler
trash.deleted.success.title: Document supprimé
trash.deleted.success.description: Le document a été supprimé définitivement.
intake-emails.title: Adresses de réception
intake-emails.description: Les adresses de réception sont utilisées pour ingérer automatiquement les emails dans Papra. Il suffit de les envoyer à l'adresse de réception et leurs pièces jointes seront ajoutées à vos documents.
intake-emails.disabled.title: Les adresses de réception sont désactivées
intake-emails.disabled.description: Les adresses de réception sont désactivées sur cette instance. Veuillez contacter votre administrateur pour les activer. Voir la {{ documentation }} pour plus d'informations.
intake-emails.disabled.documentation: documentation
intake-emails.info: Seules les adresses de réception activées depuis les origines autorisées seront traitées. Vous pouvez activer ou désactiver une adresse de réception à tout moment.
intake-emails.empty.title: Aucune adresse de réception
intake-emails.empty.description: Générez une adresse de réception pour ingérer facilement les pièces jointes des emails.
intake-emails.empty.generate: Générer une adresse de réception
intake-emails.count: '{{ count }} intake email{{ plural }} for this organization'
intake-emails.new: Nouvelle adresse de réception
intake-emails.disabled-label: (Désactivé)
intake-emails.no-origins: Aucune adresse de réception autorisée
intake-emails.allowed-origins: Autorisées depuis {{ count }} adresse{{ plural }}
intake-emails.actions.enable: Activer
intake-emails.actions.disable: Désactiver
intake-emails.actions.manage-origins: Gérer les adresses d'origine
intake-emails.actions.delete: Supprimer
intake-emails.delete.confirm.title: Supprimer l'adresse de réception ?
intake-emails.delete.confirm.message: Êtes-vous sûr de vouloir supprimer cette adresse de réception ? Cette action est irréversible.
intake-emails.delete.confirm.confirm-button: Supprimer l'adresse de réception
intake-emails.delete.confirm.cancel-button: Annuler
intake-emails.delete.success: Adresse de réception supprimée
intake-emails.create.success: Adresse de réception créée
intake-emails.update.success.enabled: Adresse de réception activée
intake-emails.update.success.disabled: Adresse de réception désactivée
intake-emails.allowed-origins.title: Adresses d'origine autorisées
intake-emails.allowed-origins.description: Seuls les emails envoyés à {{ email }} depuis ces adresses d'origine seront traités. Si aucune adresse d'origine n'est spécifiée, tous les emails seront rejetés.
intake-emails.allowed-origins.add.label: Ajouter une adresse d'origine autorisée
intake-emails.allowed-origins.add.placeholder: 'Exemple: ada@papra.app'
intake-emails.allowed-origins.add.button: Ajouter
intake-emails.allowed-origins.add.error.exists: Cette adresse email est déjà dans les adresses d'origine autorisées pour cette adresse de réception
import-documents.title.error: '{{ count }} documents ont échoué'
import-documents.title.success: '{{ count }} documents ont été importés'
import-documents.title.pending: '{{ count }} / {{ total }} documents importés'
import-documents.title.none: Importer des documents
import-documents.no-import-in-progress: Aucune importation de documents en cours
api-errors.document.already_exists: Le document existe déjà
api-errors.document.file_too_big: Le fichier du document est trop grand
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.
# API keys
api-keys.permissions.documents.title: Documents
api-keys.permissions.documents.documents:create: Créer des documents
@@ -200,3 +443,110 @@ api-keys.delete.confirm.title: Supprimer la clé API
api-keys.delete.confirm.message: Êtes-vous sûr de vouloir supprimer cette clé API ? Cette action est irréversible.
api-keys.delete.confirm.confirm-button: Supprimer
api-keys.delete.confirm.cancel-button: Annuler
# Webhooks
webhooks.list.title: Webhooks
webhooks.list.description: Gérez vos webhooks ici.
webhooks.list.empty.title: Aucun webhook
webhooks.list.empty.description: Créez votre premier webhook pour commencer à recevoir des événements.
webhooks.list.create: Créer un webhook
webhooks.list.card.last-triggered: Dernière invocation
webhooks.list.card.never: Jamais
webhooks.list.card.created: Créée
webhooks.create.title: Créer un webhook
webhooks.create.description: Créez un webhook pour recevoir des événements lorsque des documents sont ajoutés à votre organisation.
webhooks.create.success: Le webhook a été créé avec succès.
webhooks.create.back: Retour aux webhooks
webhooks.create.form.submit: Créer le webhook
webhooks.create.form.name.label: Nom du webhook
webhooks.create.form.name.placeholder: Entrez le nom du webhook
webhooks.create.form.name.required: Le nom est requis
webhooks.create.form.url.label: URL du webhook
webhooks.create.form.url.placeholder: Entrez l'URL du webhook
webhooks.create.form.url.required: L'URL est requise
webhooks.create.form.url.invalid: L'URL est invalide
webhooks.create.form.secret.label: Secret
webhooks.create.form.secret.placeholder: Entrez le secret du webhook
webhooks.create.form.events.label: Événements
webhooks.create.form.events.required: Au moins un événement est requis
webhooks.update.title: Modifier le webhook
webhooks.update.description: Mettez à jour les détails de votre webhook
webhooks.update.success: Le webhook a été mis à jour avec succès
webhooks.update.submit: Mettre à jour le webhook
webhooks.update.cancel: Annuler
webhooks.update.form.secret.placeholder: Entrez un nouveau secret
webhooks.update.form.secret.placeholder-redacted: '[Secret masqué]'
webhooks.update.form.rotate-secret.button: Rotation du secret
webhooks.delete.success: Le webhook a été supprimé avec succès
webhooks.delete.confirm.title: Supprimer le webhook
webhooks.delete.confirm.message: Êtes-vous sûr de vouloir supprimer ce webhook ? Cette action est irréversible.
webhooks.delete.confirm.confirm-button: Supprimer
webhooks.delete.confirm.cancel-button: Annuler
webhooks.events.documents.document:created.description: Document créé
webhooks.events.documents.document:deleted.description: Document supprimé
# Navigation
layout.menu.home: Accueil
layout.menu.documents: Documents
layout.menu.tags: Tags
layout.menu.tagging-rules: Règles de catégorisation
layout.menu.deleted-documents: Documents supprimés
layout.menu.organization-settings: Paramètres
layout.menu.api-keys: API keys
layout.menu.settings: Paramètres
layout.menu.account: Compte
layout.menu.general-settings: Paramètres généraux
layout.menu.intake-emails: Adresses de réception
layout.menu.webhooks: Webhooks
layout.menu.members: Membres
layout.menu.invitations: Invitations
layout.theme.light: Mode clair
layout.theme.dark: Mode sombre
layout.theme.system: Mode système
layout.search.placeholder: Rechercher...
layout.menu.import-document: Importer un document
user-menu.account-settings: Paramètres du compte
user-menu.api-keys: Clés d'API
user-menu.invitations: Invitations
user-menu.language: Langue
user-menu.logout: Déconnexion
# Command palette
command-palette.search.placeholder: Rechercher des commandes ou des documents
command-palette.no-results: Aucun résultat trouvé
command-palette.sections.documents: Documents
command-palette.sections.theme: Thème
# API errors
api-errors.document.already_exists: Le document existe déjà
api-errors.document.file_too_big: Le fichier du document est trop grand
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.
api-errors.organization.invitation_already_exists: Une invitation pour cet email existe déjà dans cette organisation.
api-errors.user.already_in_organization: Cet utilisateur est déjà dans cette organisation.
api-errors.user.organization_invitation_limit_reached: Le nombre maximum d'invitations a été atteint pour aujourd'hui. Veuillez réessayer demain.
api-errors.demo.not_available: Cette fonctionnalité n'est pas disponible dans la démo
api-errors.tags.already_exists: Un tag avec ce nom existe déjà pour cette organisation
# Not found
not-found.title: 404 - Not Found
not-found.description: Désolé, la page que vous cherchez n'existe pas. Veuillez vérifier l'URL et réessayer.
not-found.back-to-home: Retour à l'accueil
# Demo
demo.popup.description: Cette instance est une démo, toutes les données sont sauvegardées dans le stockage local de votre navigateur.
demo.popup.discord: Rejoignez le {{ discordLink }} pour obtenir de l'aide, proposer des fonctionnalités ou simplement discuter.
demo.popup.discord-link-label: Serveur Discord
demo.popup.reset: Réinitialiser la démo
demo.popup.hide: Masquer

View File

@@ -1,7 +1,7 @@
import type { Component } from 'solid-js';
import type { ApiKey } from '../api-keys.types';
import { A } from '@solidjs/router';
import { createMutation, createQuery } from '@tanstack/solid-query';
import { useMutation, useQuery } from '@tanstack/solid-query';
import { format } from 'date-fns';
import { For, Match, Show, Suspense, Switch } from 'solid-js';
import { useI18n } from '@/modules/i18n/i18n.provider';
@@ -16,7 +16,7 @@ export const ApiKeyCard: Component<{ apiKey: ApiKey }> = ({ apiKey }) => {
const { t } = useI18n();
const { confirm } = useConfirmModal();
const deleteApiKeyMutation = createMutation(() => ({
const deleteApiKeyMutation = useMutation(() => ({
mutationFn: deleteApiKey,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['api-keys'] });
@@ -85,7 +85,7 @@ export const ApiKeyCard: Component<{ apiKey: ApiKey }> = ({ apiKey }) => {
export const ApiKeysPage: Component = () => {
const { t } = useI18n();
const query = createQuery(() => ({
const query = useQuery(() => ({
queryKey: ['api-keys'],
queryFn: () => fetchApiKeys(),
}));

View File

@@ -86,9 +86,11 @@ export const EmailLoginForm: Component = () => {
)}
</Field>
<Button variant="link" as={A} class="inline p-0! h-auto" href="/request-password-reset">
{t('auth.login.form.forgot-password.label')}
</Button>
<Show when={config.auth.isPasswordResetEnabled}>
<Button variant="link" as={A} class="inline p-0! h-auto" href="/request-password-reset">
{t('auth.login.form.forgot-password.label')}
</Button>
</Show>
</div>
<Button type="submit" class="w-full">{t('auth.login.form.submit')}</Button>

View File

@@ -58,7 +58,7 @@ export const RequestPasswordResetPage: Component = () => {
const navigate = useNavigate();
onMount(() => {
if (config.auth.isPasswordResetEnabled) {
if (!config.auth.isPasswordResetEnabled) {
navigate('/login');
}
});

View File

@@ -62,7 +62,7 @@ export const ResetPasswordPage: Component = () => {
const navigate = useNavigate();
onMount(() => {
if (config.auth.isPasswordResetEnabled) {
if (!config.auth.isPasswordResetEnabled) {
navigate('/login');
}
});

View File

@@ -5,6 +5,7 @@ import { debounce } from 'lodash-es';
import { createContext, createEffect, createSignal, For, on, onCleanup, onMount, Show, useContext } from 'solid-js';
import { getDocumentIcon } from '../documents/document.models';
import { searchDocuments } from '../documents/documents.services';
import { useI18n } from '../i18n/i18n.provider';
import { cn } from '../shared/style/cn';
import { useThemeStore } from '../theme/theme.store';
import { CommandDialog, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandLoading } from '../ui/components/command';
@@ -29,9 +30,11 @@ export const CommandPaletteProvider: ParentComponent = (props) => {
const [getIsCommandPaletteOpen, setIsCommandPaletteOpen] = createSignal(false);
const [getMatchingDocuments, setMatchingDocuments] = createSignal<Document[]>([]);
const [getSearchQuery, setSearchQuery] = createSignal('');
const params = useParams();
const [getIsLoading, setIsLoading] = createSignal(false);
const params = useParams();
const { t } = useI18n();
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
@@ -82,7 +85,7 @@ export const CommandPaletteProvider: ParentComponent = (props) => {
options: { label: string; icon: string; action: () => void; forceMatch?: boolean }[];
}[] => [
{
label: 'Documents',
label: t('command-palette.sections.documents'),
forceMatch: true,
options: getMatchingDocuments().map(document => ({
label: document.name,
@@ -92,20 +95,20 @@ export const CommandPaletteProvider: ParentComponent = (props) => {
})),
},
{
label: `Theme`,
label: t('command-palette.sections.theme'),
options: [
{
label: 'Switch to light mode',
label: t('layout.theme.light'),
icon: 'i-tabler-sun',
action: () => setColorMode({ mode: 'light' }),
},
{
label: 'Switch to dark mode',
label: t('layout.theme.dark'),
icon: 'i-tabler-moon',
action: () => setColorMode({ mode: 'dark' }),
},
{
label: 'Switch to system',
label: t('layout.theme.system'),
icon: 'i-tabler-device-laptop',
action: () => setColorMode({ mode: 'system' }),
},
@@ -132,7 +135,7 @@ export const CommandPaletteProvider: ParentComponent = (props) => {
onOpenChange={setIsCommandPaletteOpen}
>
<CommandInput onValueChange={setSearchQuery} placeholder="Search commands or documents" />
<CommandInput onValueChange={setSearchQuery} placeholder={t('command-palette.search.placeholder')} />
<CommandList>
<Show when={getIsLoading()}>
<CommandLoading>
@@ -142,7 +145,7 @@ export const CommandPaletteProvider: ParentComponent = (props) => {
<Show when={!getIsLoading()}>
<Show when={getMatchingDocuments().length === 0}>
<CommandEmpty>
No results found.
{t('command-palette.no-results')}
</CommandEmpty>
</Show>

View File

@@ -1,6 +1,6 @@
import type { ParentComponent } from 'solid-js';
import type { Config, RuntimePublicConfig } from './config';
import { createQuery } from '@tanstack/solid-query';
import { useQuery } from '@tanstack/solid-query';
import { merge } from 'lodash-es';
import { createContext, Match, Switch, useContext } from 'solid-js';
import { Button } from '../ui/components/button';
@@ -24,7 +24,7 @@ export function useConfig() {
}
export const ConfigProvider: ParentComponent = (props) => {
const query = createQuery(() => ({
const query = useQuery(() => ({
queryKey: ['config'],
queryFn: fetchPublicConfig,
}));

View File

@@ -5,7 +5,7 @@ const asString = <T extends string | undefined>(value: string | undefined, defau
const asNumber = <T extends number | undefined>(value: string | undefined, defaultValue?: T): T extends undefined ? number | undefined : number => (value === undefined ? defaultValue : Number(value)) as T extends undefined ? number | undefined : number;
export const buildTimeConfig = {
papraVersion: asString(import.meta.env.VITE_PAPRA_VERSION),
papraVersion: asString(import.meta.env.VITE_PAPRA_VERSION, '0.0.0'),
baseUrl: asString(import.meta.env.VITE_BASE_URL, window.location.origin),
baseApiUrl: asString(import.meta.env.VITE_BASE_API_URL, window.location.origin),
vitrineBaseUrl: asString(import.meta.env.VITE_VITRINE_BASE_URL, 'http://localhost:3000/'),
@@ -37,6 +37,7 @@ export const buildTimeConfig = {
isEnabled: asBoolean(import.meta.env.VITE_INTAKE_EMAILS_IS_ENABLED, false),
emailGenerationDomain: asString(import.meta.env.VITE_INTAKE_EMAILS_EMAIL_GENERATION_DOMAIN),
},
isSubscriptionsEnabled: asBoolean(import.meta.env.VITE_IS_SUBSCRIPTIONS_ENABLED, false),
} as const;
export type Config = typeof buildTimeConfig;

View File

@@ -193,7 +193,7 @@ const inMemoryApiMock: Record<string, { handler: any }> = {
const {
pageIndex = 0,
pageSize = 5,
searchQuery = '',
searchQuery: rawSearchQuery = '',
} = query ?? {};
const organization = organizationStorage.getItem(organizationId);
@@ -201,7 +201,9 @@ const inMemoryApiMock: Record<string, { handler: any }> = {
const documents = await findMany(documentStorage, document => document?.organizationId === organizationId);
const filteredDocuments = documents.filter(document => document?.name.includes(searchQuery) && !document?.deletedAt);
const searchQuery = rawSearchQuery.trim().toLowerCase();
const filteredDocuments = documents.filter(document => document?.name.toLowerCase().includes(searchQuery) && !document?.deletedAt);
return {
documents: filteredDocuments.slice(pageIndex * pageSize, (pageIndex + 1) * pageSize),

View File

@@ -5,9 +5,11 @@ import { A } from '@solidjs/router';
import { Button } from '@/modules/ui/components/button';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/modules/ui/components/dropdown-menu';
import { useDeleteDocument } from '../documents.composables';
import { useRenameDocumentDialog } from './rename-document-button.component';
export const DocumentManagementDropdown: Component<{ document: Document }> = (props) => {
const { deleteDocument } = useDeleteDocument();
const { openRenameDialog } = useRenameDocumentDialog();
const deleteDoc = () => deleteDocument({
documentId: props.document.id,
@@ -16,6 +18,7 @@ export const DocumentManagementDropdown: Component<{ document: Document }> = (pr
});
return (
<DropdownMenu>
<DropdownMenuTrigger
as={(props: DropdownMenuSubTriggerProps) => (
@@ -34,6 +37,18 @@ export const DocumentManagementDropdown: Component<{ document: Document }> = (pr
<span>Document details</span>
</DropdownMenuItem>
<DropdownMenuItem
class="cursor-pointer"
onClick={() => openRenameDialog({
documentId: props.document.id,
organizationId: props.document.organizationId,
documentName: props.document.name,
})}
>
<div class="i-tabler-pencil size-4 mr-2"></div>
<span>Rename document</span>
</DropdownMenuItem>
<DropdownMenuItem
class="cursor-pointer text-red"
onClick={() => deleteDoc()}
@@ -43,5 +58,6 @@ export const DocumentManagementDropdown: Component<{ document: Document }> = (pr
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
};

View File

@@ -1,6 +1,6 @@
import type { Component } from 'solid-js';
import type { Document } from '../documents.types';
import { createQuery } from '@tanstack/solid-query';
import { useQuery } from '@tanstack/solid-query';
import { createResource, Match, Suspense, Switch } from 'solid-js';
import { Card } from '@/modules/ui/components/card';
import { fetchDocumentFile } from '../documents.services';
@@ -35,7 +35,7 @@ export const DocumentPreview: Component<{ document: Document }> = (props) => {
const getIsImage = () => imageMimeType.includes(props.document.mimeType);
const getIsPdf = () => pdfMimeType.includes(props.document.mimeType);
const query = createQuery(() => ({
const query = useQuery(() => ({
queryKey: ['organizations', props.document.organizationId, 'documents', props.document.id, 'file'],
queryFn: () => fetchDocumentFile({ documentId: props.document.id, organizationId: props.document.organizationId }),
}));

View File

@@ -0,0 +1,136 @@
import type { Component, ParentComponent } from 'solid-js';
import { setValue } from '@modular-forms/solid';
import { useMutation } from '@tanstack/solid-query';
import { createContext, createEffect, createSignal, useContext } from 'solid-js';
import * as v from 'valibot';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { createForm } from '@/modules/shared/form/form';
import { Button } from '@/modules/ui/components/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/modules/ui/components/dialog';
import { createToast } from '@/modules/ui/components/sonner';
import { TextField, TextFieldLabel, TextFieldRoot } from '@/modules/ui/components/textfield';
import { getDocumentNameExtension, getDocumentNameWithoutExtension } from '../document.models';
import { invalidateOrganizationDocumentsQuery } from '../documents.composables';
import { updateDocument } from '../documents.services';
export const RenameDocumentDialog: Component<{
documentId: string;
organizationId: string;
documentName: string;
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
}> = (props) => {
const { t } = useI18n();
const renameDocumentMutation = useMutation(() => ({
mutationFn: ({ name }: { name: string }) => updateDocument({ documentId: props.documentId, organizationId: props.organizationId, name }),
onSuccess: async () => {
createToast({
message: t('documents.rename.success'),
type: 'success',
});
props.setIsOpen(false);
await invalidateOrganizationDocumentsQuery({ organizationId: props.organizationId });
},
}));
const { Form, Field, form } = createForm({
schema: v.object({
name: v.pipe(
v.string(),
v.trim(),
v.maxLength(255, t('documents.rename.form.name.max-length')),
v.minLength(1, t('documents.rename.form.name.required')),
),
}),
initialValues: {
name: getDocumentNameWithoutExtension({ name: props.documentName }),
},
onSubmit: async ({ name }) => {
const extension = getDocumentNameExtension({ name: props.documentName });
const newName = extension ? `${name}.${extension}` : name;
await renameDocumentMutation.mutateAsync({ name: newName });
},
});
createEffect(() => {
setValue(form, 'name', getDocumentNameWithoutExtension({ name: props.documentName }));
});
return (
<Dialog onOpenChange={props.setIsOpen} open={props.isOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('documents.rename.title')}</DialogTitle>
</DialogHeader>
<Form>
<Field name="name">
{(field, inputProps) => (
<TextFieldRoot>
<TextFieldLabel class="sr-only" for="name">{t('documents.rename.form.name.label')}</TextFieldLabel>
<TextField {...inputProps} value={field.value} id="name" placeholder={t('documents.rename.form.name.placeholder')} />
{field.error && <div class="text-red-500 text-sm">{field.error}</div>}
</TextFieldRoot>
)}
</Field>
<div class="flex justify-end mt-4 gap-2">
<Button type="button" variant="secondary" onClick={() => props.setIsOpen(false)}>
{t('documents.rename.cancel')}
</Button>
<Button type="submit">{t('documents.rename.form.submit')}</Button>
</div>
</Form>
</DialogContent>
</Dialog>
);
};
const context = createContext<{
openRenameDialog: (args: { documentId: string; organizationId: string; documentName: string }) => void;
}>();
export function useRenameDocumentDialog() {
const renameDialogContext = useContext(context);
if (!renameDialogContext) {
throw new Error('useRenameDocumentDialog must be used within a RenameDocumentDialogProvider');
}
return renameDialogContext;
}
export const RenameDocumentDialogProvider: ParentComponent = (props) => {
const [getIsRenameDialogOpen, setIsRenameDialogOpen] = createSignal(false);
const [getDocumentId, setDocumentId] = createSignal<string | undefined>(undefined);
const [getOrganizationId, setOrganizationId] = createSignal<string | undefined>(undefined);
const [getDocumentName, setDocumentName] = createSignal<string | undefined>(undefined);
return (
<context.Provider
value={{
openRenameDialog: ({ documentId, organizationId, documentName }) => {
setIsRenameDialogOpen(true);
setDocumentId(documentId);
setOrganizationId(organizationId);
setDocumentName(documentName);
},
}}
>
<RenameDocumentDialog
documentId={getDocumentId() ?? ''}
organizationId={getOrganizationId() ?? ''}
documentName={getDocumentName() ?? ''}
isOpen={getIsRenameDialogOpen()}
setIsOpen={setIsRenameDialogOpen}
/>
{props.children}
</context.Provider>
);
};

View File

@@ -1,3 +1,4 @@
import type { DocumentActivityEvent } from './documents.types';
import { addDays, differenceInDays } from 'date-fns';
export const iconByFileType = {
@@ -79,3 +80,16 @@ export function getDocumentNameExtension({ name }: { name: string }) {
return dotSplittedName[dotCount];
}
export const documentActivityIcon: Record<DocumentActivityEvent, string> = {
created: 'i-tabler-file-plus',
updated: 'i-tabler-file-diff',
deleted: 'i-tabler-file-x',
restored: 'i-tabler-file-check',
tagged: 'i-tabler-tag',
untagged: 'i-tabler-tag-off',
} as const;
export function getDocumentActivityIcon({ event }: { event: DocumentActivityEvent }) {
return documentActivityIcon[event] ?? 'i-tabler-file';
}

View File

@@ -0,0 +1,10 @@
export const DOCUMENT_ACTIVITY_EVENTS = {
CREATED: 'created',
UPDATED: 'updated',
DELETED: 'deleted',
RESTORED: 'restored',
TAGGED: 'tagged',
UNTAGGED: 'untagged',
} as const;
export const DOCUMENT_ACTIVITY_EVENT_LIST = Object.values(DOCUMENT_ACTIVITY_EVENTS);

View File

@@ -1,5 +1,5 @@
import type { AsDto } from '../shared/http/http-client.types';
import type { Document } from './documents.types';
import type { Document, DocumentActivity } from './documents.types';
import { apiClient } from '../shared/http/api-client';
import { coerceDates, getFormData } from '../shared/http/http-client.models';
@@ -194,18 +194,45 @@ export async function updateDocument({
documentId,
organizationId,
content,
name,
}: {
documentId: string;
organizationId: string;
content: string;
content?: string;
name?: string;
}) {
const { document } = await apiClient<{ document: AsDto<Document> }>({
method: 'PATCH',
path: `/api/organizations/${organizationId}/documents/${documentId}`,
body: { content },
body: { content, name },
});
return {
document: coerceDates(document),
};
}
export async function fetchDocumentActivities({
documentId,
organizationId,
pageIndex,
pageSize,
}: {
documentId: string;
organizationId: string;
pageIndex: number;
pageSize: number;
}) {
const { activities } = await apiClient<{ activities: AsDto<DocumentActivity>[] }>({
method: 'GET',
path: `/api/organizations/${organizationId}/documents/${documentId}/activity`,
query: {
pageIndex,
pageSize,
},
});
return {
activities: activities.map(coerceDates),
};
}

View File

@@ -1,4 +1,6 @@
import type { Tag } from '../tags/tags.types';
import type { User } from '../users/users.types';
import type { DOCUMENT_ACTIVITY_EVENTS } from './documents.constants';
export type Document = {
id: string;
@@ -14,3 +16,17 @@ export type Document = {
content: string;
tags: Tag[];
};
export type DocumentActivityEvent = (typeof DOCUMENT_ACTIVITY_EVENTS)[keyof typeof DOCUMENT_ACTIVITY_EVENTS];
export type DocumentActivity = {
id: string;
documentId: string;
event: DocumentActivityEvent;
eventData: Record<string, unknown>;
userId?: string;
createdAt: Date;
updatedAt?: Date;
tag?: Pick<Tag, 'id' | 'name' | 'color' | 'description'>;
user?: Pick<User, 'id' | 'name'>;
};

View File

@@ -1,7 +1,7 @@
import type { Component } from 'solid-js';
import type { Document } from '../documents.types';
import { useParams } from '@solidjs/router';
import { createMutation, createQuery, keepPreviousData } from '@tanstack/solid-query';
import { keepPreviousData, useMutation, useQuery } from '@tanstack/solid-query';
import { createSignal, Show, Suspense } from 'solid-js';
import { useConfig } from '@/modules/config/config.provider';
import { useI18n } from '@/modules/i18n/i18n.provider';
@@ -17,6 +17,7 @@ import { deleteAllTrashDocuments, deleteTrashDocument, fetchOrganizationDeletedD
const RestoreDocumentButton: Component<{ document: Document }> = (props) => {
const { getIsRestoring, restore } = useRestoreDocument();
const { t } = useI18n();
return (
<Button
@@ -26,11 +27,11 @@ const RestoreDocumentButton: Component<{ document: Document }> = (props) => {
isLoading={getIsRestoring()}
>
{ getIsRestoring()
? (<>Restoring...</>)
? (<>{t('documents.deleted.restoring')}</>)
: (
<>
<div class="i-tabler-refresh size-4 mr-2" />
Restore
{t('documents.actions.restore')}
</>
)}
</Button>
@@ -41,7 +42,7 @@ const PermanentlyDeleteTrashDocumentButton: Component<{ document: Document; orga
const { confirm } = useConfirmModal();
const { t } = useI18n();
const deleteMutation = createMutation(() => ({
const deleteMutation = useMutation(() => ({
mutationFn: async () => {
await deleteTrashDocument({ documentId: props.document.id, organizationId: props.organizationId });
},
@@ -82,7 +83,7 @@ const PermanentlyDeleteTrashDocumentButton: Component<{ document: Document; orga
class="text-red-500 hover:text-red-600"
>
{deleteMutation.isPending
? (<>Deleting...</>)
? (<>{t('documents.deleted.deleting')}</>)
: (
<>
<div class="i-tabler-trash size-4 mr-2" />
@@ -97,7 +98,7 @@ const DeleteAllTrashDocumentsButton: Component<{ organizationId: string }> = (pr
const { confirm } = useConfirmModal();
const { t } = useI18n();
const deleteAllMutation = createMutation(() => ({
const deleteAllMutation = useMutation(() => ({
mutationFn: async () => {
await deleteAllTrashDocuments({ organizationId: props.organizationId });
},
@@ -133,7 +134,7 @@ const DeleteAllTrashDocumentsButton: Component<{ organizationId: string }> = (pr
class="text-red-500 hover:text-red-600"
>
{deleteAllMutation.isPending
? (<>Deleting...</>)
? (<>{t('documents.deleted.deleting')}</>)
: (
<>
<div class="i-tabler-trash size-4 mr-2" />
@@ -148,8 +149,9 @@ export const DeletedDocumentsPage: Component = () => {
const [getPagination, setPagination] = createSignal({ pageIndex: 0, pageSize: 100 });
const params = useParams();
const { config } = useConfig();
const { t } = useI18n();
const query = createQuery(() => ({
const query = useQuery(() => ({
queryKey: ['organizations', params.organizationId, 'documents', 'deleted', getPagination()],
queryFn: () => fetchOrganizationDeletedDocuments({
organizationId: params.organizationId,
@@ -160,16 +162,12 @@ export const DeletedDocumentsPage: Component = () => {
return (
<div class="p-6 mt-4 pb-32">
<h1 class="text-2xl font-bold">Deleted documents</h1>
<h1 class="text-2xl font-bold">{t('documents.deleted.title')}</h1>
<Alert variant="muted" class="my-4 flex items-center gap-6 xl:gap-4">
<div class="i-tabler-info-circle size-10 xl:size-8 text-primary flex-shrink-0 hidden sm:block" />
<AlertDescription>
All deleted documents are stored in the trash bin for
{' '}
{config.documents.deletedDocumentsRetentionDays}
{' '}
days. Passing this delay, the documents will be permanently deleted, and you will not be able to restore them.
{t('documents.deleted.retention-notice', { days: config.documents.deletedDocumentsRetentionDays })}
</AlertDescription>
</Alert>
@@ -177,13 +175,9 @@ export const DeletedDocumentsPage: Component = () => {
<Show when={query.data?.documents.length === 0}>
<div class="flex flex-col items-center justify-center gap-2 pt-24 mx-auto max-w-md text-center">
<div class="i-tabler-trash text-primary size-12" aria-hidden="true" />
<div class="text-xl font-medium">No deleted documents</div>
<div class="text-sm text-muted-foreground">
You have no deleted documents. Documents that are deleted will be moved to the trash bin for
{' '}
{config.documents.deletedDocumentsRetentionDays}
{' '}
days.
<div class="text-xl font-medium">{t('documents.deleted.empty.title')}</div>
<div class="text-sm text-muted-foreground">
{t('documents.deleted.empty.description', { days: config.documents.deletedDocumentsRetentionDays })}
</div>
</div>
</Show>
@@ -203,7 +197,7 @@ export const DeletedDocumentsPage: Component = () => {
id: 'deletion',
cell: data => (
<div class="text-muted-foreground hidden sm:block">
Deleted
{t('documents.deleted.deleted-at')}
{' '}
<span class="text-foreground font-bold" title={data.row.original.deletedAt?.toLocaleString()}>{timeAgo({ date: data.row.original.deletedAt! })}</span>
</div>

View File

@@ -1,14 +1,17 @@
import type { Component, JSX } from 'solid-js';
import type { DocumentActivity } from '../documents.types';
import { formatBytes, safely } from '@corentinth/chisels';
import { useNavigate, useParams } from '@solidjs/router';
import { createQueries } from '@tanstack/solid-query';
import { createSignal, For, Show, Suspense } from 'solid-js';
import { A, useNavigate, useParams, useSearchParams } from '@solidjs/router';
import { createQueries, useInfiniteQuery } from '@tanstack/solid-query';
import { createEffect, createSignal, For, Match, Show, Suspense, Switch } from 'solid-js';
import { useConfig } from '@/modules/config/config.provider';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { timeAgo } from '@/modules/shared/date/time-ago';
import { downloadFile } from '@/modules/shared/files/download';
import { queryClient } from '@/modules/shared/query/query-client';
import { cn } from '@/modules/shared/style/cn';
import { DocumentTagPicker } from '@/modules/tags/components/tag-picker.component';
import { TagLink } from '@/modules/tags/components/tag.component';
import { CreateTagModal } from '@/modules/tags/pages/tags.page';
import { addTagToDocument, removeTagFromDocument } from '@/modules/tags/tags.services';
import { Alert, AlertDescription } from '@/modules/ui/components/alert';
@@ -19,9 +22,10 @@ import { Tabs, TabsContent, TabsIndicator, TabsList, TabsTrigger } from '@/modul
import { TextArea } from '@/modules/ui/components/textarea';
import { TextFieldRoot } from '@/modules/ui/components/textfield';
import { DocumentPreview } from '../components/document-preview.component';
import { getDaysBeforePermanentDeletion } from '../document.models';
import { useRenameDocumentDialog } from '../components/rename-document-button.component';
import { getDaysBeforePermanentDeletion, getDocumentActivityIcon } from '../document.models';
import { useDeleteDocument, useRestoreDocument } from '../documents.composables';
import { fetchDocument, fetchDocumentFile, updateDocument } from '../documents.services';
import { fetchDocument, fetchDocumentActivities, fetchDocumentFile, updateDocument } from '../documents.services';
import '@pdfslick/solid/dist/pdf_viewer.css';
type KeyValueItem = {
@@ -50,13 +54,78 @@ const KeyValues: Component<{ data?: KeyValueItem[] }> = (props) => {
);
};
const ActivityItem: Component<{ activity: DocumentActivity }> = (props) => {
const { t, te } = useI18n();
const params = useParams();
return (
<div class="border-b py-3 flex items-center gap-2">
<div>
<div class={cn(getDocumentActivityIcon({ event: props.activity.event }), 'size-6 text-muted-foreground')} />
</div>
<div>
<Switch fallback={<span class="text-sm">{t(`activity.document.${props.activity.event}`)}</span>}>
<Match when={['tagged', 'untagged'].includes(props.activity.event)}>
<span class="text-sm flex items-baseline gap-1">
{te(`activity.document.${props.activity.event}`, { tag: props.activity.tag ? <TagLink {...props.activity.tag} organizationId={params.organizationId} class="text-xs" /> : undefined })}
</span>
</Match>
<Match when={props.activity.event === 'updated' && (props.activity.eventData.updatedFields as string[]).length === 1}>
<span class="text-sm flex items-baseline gap-1">
{te(`activity.document.updated.single`, {
field: <span class="font-bold">{(props.activity.eventData.updatedFields as string[])[0]}</span>,
})}
</span>
</Match>
<Match when={props.activity.event === 'updated' && (props.activity.eventData.updatedFields as string[]).length > 1}>
<span class="text-sm flex items-baseline gap-1">
{te(`activity.document.updated.multiple`, { fields: (props.activity.eventData.updatedFields as string[]).join(', ') })}
</span>
</Match>
</Switch>
<div class="flex items-center gap-1 text-xs text-muted-foreground">
<span title={props.activity.createdAt.toLocaleString()}>{timeAgo({ date: props.activity.createdAt })}</span>
<Show when={props.activity.user}>
{getUser => (
<span>{te('activity.document.user.name', { name: <A href={`/organizations/${params.organizationId}/members`} class="underline hover:text-primary transition">{getUser().name}</A> })}</span>
)}
</Show>
</div>
</div>
</div>
);
};
const tabs = ['info', 'content', 'activity'] as const;
type Tab = typeof tabs[number];
export const DocumentPage: Component = () => {
const { t } = useI18n();
const params = useParams();
const [searchParams, setSearchParams] = useSearchParams();
const { deleteDocument } = useDeleteDocument();
const { restore, getIsRestoring } = useRestoreDocument();
const navigate = useNavigate();
const { config } = useConfig();
const { openRenameDialog } = useRenameDocumentDialog();
const getInitialTab = (): Tab => {
const tab = searchParams.tab;
if (tab && typeof tab === 'string' && tabs.includes(tab as Tab)) {
return tab as Tab;
}
return 'info';
};
const [getTab, setTab] = createSignal<Tab>(getInitialTab());
createEffect(() => {
setSearchParams({ tab: getTab() }, { replace: true });
});
const queries = createQueries(() => ({
queries: [
@@ -71,6 +140,30 @@ export const DocumentPage: Component = () => {
],
}));
const activityPageSize = 20;
const activityQuery = useInfiniteQuery(() => ({
enabled: getTab() === 'activity',
queryKey: ['organizations', params.organizationId, 'documents', params.documentId, 'activity'],
queryFn: async ({ pageParam }) => {
const { activities } = await fetchDocumentActivities({
documentId: params.documentId,
organizationId: params.organizationId,
pageIndex: pageParam,
pageSize: activityPageSize,
});
return activities;
},
getNextPageParam: (lastPage, _pages, lastPageParam) => {
if (lastPage.length < activityPageSize) {
return undefined;
}
return lastPageParam + 1;
},
initialPageParam: 0,
}));
const deleteDoc = async () => {
if (!queries[0].data) {
return;
@@ -137,7 +230,21 @@ export const DocumentPage: Component = () => {
{getDocument => (
<div class="flex gap-4 md:pr-6">
<div class="flex-1">
<h1 class="text-xl font-semibold">{getDocument().name}</h1>
<Button
variant="ghost"
class="flex items-center gap-2 group bg-transparent! px-0"
onClick={() => openRenameDialog({
documentId: getDocument().id,
organizationId: params.organizationId,
documentName: getDocument().name,
})}
>
<h1 class="text-xl font-semibold">
{getDocument().name}
</h1>
<div class="i-tabler-pencil size-4 text-muted-foreground group-hover:text-foreground transition-colors"></div>
</Button>
<p class="text-sm text-muted-foreground mb-6">{getDocument().id}</p>
<div class="flex gap-2 mb-2">
@@ -147,7 +254,7 @@ export const DocumentPage: Component = () => {
size="sm"
>
<div class="i-tabler-download size-4 mr-2"></div>
Download
{t('documents.actions.download')}
</Button>
<Button
@@ -156,7 +263,7 @@ export const DocumentPage: Component = () => {
size="sm"
>
<div class="i-tabler-eye size-4 mr-2"></div>
Open in new tab
{t('documents.actions.open-in-new-tab')}
</Button>
{getDocument().isDeleted
@@ -168,7 +275,7 @@ export const DocumentPage: Component = () => {
isLoading={getIsRestoring()}
>
<div class="i-tabler-refresh size-4 mr-2"></div>
Restore
{t('documents.actions.restore')}
</Button>
)
: (
@@ -178,7 +285,7 @@ export const DocumentPage: Component = () => {
onClick={deleteDoc}
>
<div class="i-tabler-trash size-4 mr-2"></div>
Delete
{t('documents.actions.delete')}
</Button>
)}
</div>
@@ -218,56 +325,67 @@ export const DocumentPage: Component = () => {
{getDocument().isDeleted && (
<Alert variant="destructive" class="mt-6">
This document has been deleted and will be permanently removed in
{' '}
{getDaysBeforePermanentDeletion({
{t('documents.deleted.message', { days: getDaysBeforePermanentDeletion({
document: getDocument(),
deletedDocumentsRetentionDays: config.documents.deletedDocumentsRetentionDays,
})}
{' '}
days.
}) ?? 0 })}
</Alert>
)}
<Separator class="my-3" />
<Tabs defaultValue="info" class="w-full">
<Tabs value={getTab()} onChange={setTab} class="w-full">
<TabsList class="w-full h-8">
<TabsTrigger value="info">Info</TabsTrigger>
<TabsTrigger value="content">Content</TabsTrigger>
<TabsTrigger value="info">{t('documents.tabs.info')}</TabsTrigger>
<TabsTrigger value="content">{t('documents.tabs.content')}</TabsTrigger>
<TabsTrigger value="activity">{t('documents.tabs.activity')}</TabsTrigger>
<TabsIndicator />
</TabsList>
<TabsContent value="info">
<KeyValues data={[
{
label: 'ID',
label: t('documents.info.id'),
value: getDocument().id,
icon: 'i-tabler-id',
},
{
label: 'Name',
value: getDocument().name,
label: t('documents.info.name'),
value: (
<Button
variant="ghost"
class="flex items-center gap-2 group bg-transparent! p-0 h-auto"
onClick={() => openRenameDialog({
documentId: getDocument().id,
organizationId: params.organizationId,
documentName: getDocument().name,
})}
>
{getDocument().name}
<div class="i-tabler-pencil size-4 text-muted-foreground group-hover:text-foreground transition-colors"></div>
</Button>
),
icon: 'i-tabler-file-text',
},
{
label: 'Type',
label: t('documents.info.type'),
value: getDocument().mimeType,
icon: 'i-tabler-file-unknown',
},
{
label: 'Size',
label: t('documents.info.size'),
value: formatBytes({ bytes: getDocument().originalSize, base: 1000 }),
icon: 'i-tabler-weight',
},
{
label: 'Created At',
label: t('documents.info.created-at'),
value: timeAgo({ date: getDocument().createdAt }),
icon: 'i-tabler-calendar',
},
{
label: 'Updated At',
value: getDocument().updatedAt ? timeAgo({ date: getDocument().updatedAt! }) : <span class="text-muted-foreground">Never</span>,
label: t('documents.info.updated-at'),
value: getDocument().updatedAt ? timeAgo({ date: getDocument().updatedAt! }) : <span class="text-muted-foreground">{t('documents.info.never')}</span>,
icon: 'i-tabler-calendar',
},
]}
@@ -284,14 +402,14 @@ export const DocumentPage: Component = () => {
<div class="flex justify-end">
<Button variant="outline" onClick={handleEdit}>
<div class="i-tabler-edit size-4 mr-2" />
Edit
{t('documents.actions.edit')}
</Button>
</div>
<Alert variant="muted" class="my-4 flex items-center gap-2">
<div class="i-tabler-info-circle size-8 flex-shrink-0" />
<AlertDescription>
The content of the document is automatically extracted from the document on upload. It is only used for search and indexing purposes.
{t('documents.content.alert')}
</AlertDescription>
</Alert>
</div>
@@ -307,15 +425,49 @@ export const DocumentPage: Component = () => {
</TextFieldRoot>
<div class="flex justify-end gap-2">
<Button variant="outline" onClick={handleCancel} disabled={isSaving()}>
Cancel
{t('documents.actions.cancel')}
</Button>
<Button onClick={handleSave} disabled={isSaving()}>
{isSaving() ? 'Saving...' : 'Save'}
{isSaving() ? t('documents.actions.saving') : t('documents.actions.save')}
</Button>
</div>
</div>
</Show>
</TabsContent>
<TabsContent value="activity">
<Show when={activityQuery.data?.pages}>
{getActivitiesPages => (
<div class="flex flex-col">
<For each={getActivitiesPages() ?? []}>
{activities => (
<For each={activities}>
{activity => (
<ActivityItem activity={activity} />
)}
</For>
)}
</For>
<Show
when={activityQuery.hasNextPage}
fallback={(
<div class="text-sm text-muted-foreground text-center py-4">
{t('activity.no-more-activities')}
</div>
)}
>
<Button
variant="outline"
onClick={() => activityQuery.fetchNextPage()}
isLoading={activityQuery.isFetchingNextPage}
>
{t('activity.load-more')}
</Button>
</Show>
</div>
)}
</Show>
</TabsContent>
</Tabs>
</div>

View File

@@ -3,6 +3,7 @@ import { useParams, useSearchParams } from '@solidjs/router';
import { createQueries, keepPreviousData } from '@tanstack/solid-query';
import { castArray } from 'lodash-es';
import { createSignal, For, Show, Suspense } from 'solid-js';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { fetchOrganization } from '@/modules/organizations/organizations.services';
import { Tag } from '@/modules/tags/components/tag.component';
import { fetchTags } from '@/modules/tags/tags.services';
@@ -12,6 +13,7 @@ import { fetchOrganizationDocuments } from '../documents.services';
export const DocumentsPage: Component = () => {
const params = useParams();
const { t } = useI18n();
const [searchParams, setSearchParams] = useSearchParams();
const [getPagination, setPagination] = createSignal({ pageIndex: 0, pageSize: 100 });
@@ -51,11 +53,11 @@ export const DocumentsPage: Component = () => {
? (
<>
<h2 class="text-xl font-bold ">
No documents
{t('documents.list.no-documents.title')}
</h2>
<p class="text-muted-foreground mt-1 mb-6">
There are no documents in this organization yet. Start by uploading some documents.
{t('documents.list.no-documents.description')}
</p>
<DocumentUploadArea />
@@ -65,7 +67,7 @@ export const DocumentsPage: Component = () => {
: (
<>
<h2 class="text-lg font-semibold mb-4">
Documents
{t('documents.list.title')}
</h2>
<Show when={hasFilters()}>
<div class="flex flex-wrap gap-2 mb-4">
@@ -83,7 +85,7 @@ export const DocumentsPage: Component = () => {
<Show when={hasFilters() && query[0].data?.documentsCount === 0}>
<p class="text-muted-foreground mt-1 mb-6">
No documents found
{t('documents.list.no-results')}
</p>
</Show>

View File

@@ -1,4 +1,5 @@
export const locales = [
{ key: 'en', name: 'English' },
{ key: 'fr', name: 'Français' },
{ key: 'de', name: 'Deutsch' },
] as const;

View File

@@ -41,6 +41,8 @@ describe('locales', () => {
/^webhooks\.events\.documents\.[a-z0-9:]+.description$/, // webhooks.events.organization.organization:created
/^api-keys\.permissions\.[a-z0-9:]+\.[a-z0-9:]+$/, // api-keys.permissions.documents.documents:delete
/^organizations\.members\.roles\.[a-z0-9]+$/, // organizations.members.roles.admin
/^activity\.document\.[a-z0-9:]+$/, // activity.document.created
/^organizations\.invitations\.status\.[a-z0-9:]+$/, // organizations.invitations.status.pending
];
const keys = new Set(

View File

@@ -68,23 +68,223 @@ export type LocaleKeys =
| 'auth.legal-links.description'
| 'auth.legal-links.terms'
| 'auth.legal-links.privacy'
| 'user.settings.title'
| 'user.settings.description'
| 'user.settings.email.title'
| 'user.settings.email.description'
| 'user.settings.email.label'
| 'user.settings.name.title'
| 'user.settings.name.description'
| 'user.settings.name.label'
| 'user.settings.name.placeholder'
| 'user.settings.name.update'
| 'user.settings.name.updated'
| 'user.settings.logout.title'
| 'user.settings.logout.description'
| 'user.settings.logout.button'
| 'organizations.list.title'
| 'organizations.list.description'
| 'organizations.list.create-new'
| 'organizations.details.no-documents.title'
| 'organizations.details.no-documents.description'
| 'organizations.details.upload-documents'
| 'organizations.details.documents-count'
| 'organizations.details.total-size'
| 'organizations.details.latest-documents'
| 'organizations.create.title'
| 'organizations.create.description'
| 'organizations.create.back'
| 'organizations.create.error.max-count-reached'
| 'organizations.create.form.name.label'
| 'organizations.create.form.name.placeholder'
| 'organizations.create.form.name.required'
| 'organizations.create.form.submit'
| 'organizations.create.success'
| 'organizations.create-first.title'
| 'organizations.create-first.description'
| 'organizations.create-first.default-name'
| 'organizations.create-first.user-name'
| 'organization.settings.title'
| 'organization.settings.page.title'
| 'organization.settings.page.description'
| 'organization.settings.name.title'
| 'organization.settings.name.update'
| 'organization.settings.name.placeholder'
| 'organization.settings.name.updated'
| 'organization.settings.subscription.title'
| 'organization.settings.subscription.description'
| 'organization.settings.subscription.manage'
| 'organization.settings.subscription.error'
| 'organization.settings.delete.title'
| 'organization.settings.delete.description'
| 'organization.settings.delete.confirm.title'
| 'organization.settings.delete.confirm.message'
| 'organization.settings.delete.confirm.confirm-button'
| 'organization.settings.delete.confirm.cancel-button'
| 'organization.settings.delete.success'
| 'organizations.members.title'
| 'organizations.members.description'
| 'organizations.members.invite-member'
| 'organizations.members.invite-member-disabled-tooltip'
| 'organizations.members.remove-from-organization'
| 'organizations.members.role'
| 'organizations.members.roles.owner'
| 'organizations.members.roles.admin'
| 'organizations.members.roles.member'
| 'organizations.members.delete.confirm.title'
| 'organizations.members.delete.confirm.message'
| 'organizations.members.delete.confirm.confirm-button'
| 'organizations.members.delete.confirm.cancel-button'
| 'organizations.members.delete.success'
| 'organizations.members.update-role.success'
| 'organizations.members.table.headers.name'
| 'organizations.members.table.headers.email'
| 'organizations.members.table.headers.role'
| 'organizations.members.table.headers.created'
| 'organizations.members.table.headers.actions'
| 'organizations.invite-member.title'
| 'organizations.invite-member.description'
| 'organizations.invite-member.form.email.label'
| 'organizations.invite-member.form.email.placeholder'
| 'organizations.invite-member.form.email.required'
| 'organizations.invite-member.form.role.label'
| 'organizations.invite-member.form.submit'
| 'organizations.invite-member.success.message'
| 'organizations.invite-member.success.description'
| 'organizations.invite-member.error.message'
| 'organizations.invitations.title'
| 'organizations.invitations.description'
| 'organizations.invitations.list.cta'
| 'organizations.invitations.list.empty.title'
| 'organizations.invitations.list.empty.description'
| 'organizations.invitations.status.pending'
| 'organizations.invitations.status.accepted'
| 'organizations.invitations.status.rejected'
| 'organizations.invitations.status.expired'
| 'organizations.invitations.status.cancelled'
| 'organizations.invitations.resend'
| 'organizations.invitations.cancel.title'
| 'organizations.invitations.cancel.description'
| 'organizations.invitations.cancel.confirm'
| 'organizations.invitations.cancel.cancel'
| 'organizations.invitations.resend.title'
| 'organizations.invitations.resend.description'
| 'organizations.invitations.resend.confirm'
| 'organizations.invitations.resend.cancel'
| 'invitations.list.title'
| 'invitations.list.description'
| 'invitations.list.empty.title'
| 'invitations.list.empty.description'
| 'invitations.list.headers.organization'
| 'invitations.list.headers.status'
| 'invitations.list.headers.created'
| 'invitations.list.headers.actions'
| 'invitations.list.actions.accept'
| 'invitations.list.actions.reject'
| 'invitations.list.actions.accept.success.message'
| 'invitations.list.actions.accept.success.description'
| 'invitations.list.actions.reject.success.message'
| 'invitations.list.actions.reject.success.description'
| 'documents.list.title'
| 'documents.list.no-documents.title'
| 'documents.list.no-documents.description'
| 'documents.list.no-results'
| 'documents.tabs.info'
| 'documents.tabs.content'
| 'documents.tabs.activity'
| 'documents.deleted.message'
| 'documents.actions.download'
| 'documents.actions.open-in-new-tab'
| 'documents.actions.restore'
| 'documents.actions.delete'
| 'documents.actions.edit'
| 'documents.actions.cancel'
| 'documents.actions.save'
| 'documents.actions.saving'
| 'documents.content.alert'
| 'documents.info.id'
| 'documents.info.name'
| 'documents.info.type'
| 'documents.info.size'
| 'documents.info.created-at'
| 'documents.info.updated-at'
| 'documents.info.never'
| 'documents.rename.title'
| 'documents.rename.form.name.label'
| 'documents.rename.form.name.placeholder'
| 'documents.rename.form.name.required'
| 'documents.rename.form.name.max-length'
| 'documents.rename.form.submit'
| 'documents.rename.success'
| 'documents.rename.cancel'
| 'import-documents.title.error'
| 'import-documents.title.success'
| 'import-documents.title.pending'
| 'import-documents.title.none'
| 'import-documents.no-import-in-progress'
| 'documents.deleted.title'
| 'documents.deleted.empty.title'
| 'documents.deleted.empty.description'
| 'documents.deleted.retention-notice'
| 'documents.deleted.deleted-at'
| 'documents.deleted.restoring'
| 'documents.deleted.deleting'
| 'trash.delete-all.button'
| 'trash.delete-all.confirm.title'
| 'trash.delete-all.confirm.description'
| 'trash.delete-all.confirm.label'
| 'trash.delete-all.confirm.cancel'
| 'trash.delete.button'
| 'trash.delete.confirm.title'
| 'trash.delete.confirm.description'
| 'trash.delete.confirm.label'
| 'trash.delete.confirm.cancel'
| 'trash.deleted.success.title'
| 'trash.deleted.success.description'
| 'activity.document.created'
| 'activity.document.updated.single'
| 'activity.document.updated.multiple'
| 'activity.document.updated'
| 'activity.document.deleted'
| 'activity.document.restored'
| 'activity.document.tagged'
| 'activity.document.untagged'
| 'activity.document.user.name'
| 'activity.load-more'
| 'activity.no-more-activities'
| 'tags.no-tags.title'
| 'tags.no-tags.description'
| 'tags.no-tags.create-tag'
| 'layout.menu.home'
| 'layout.menu.documents'
| 'layout.menu.tags'
| 'layout.menu.tagging-rules'
| 'layout.menu.deleted-documents'
| 'layout.menu.organization-settings'
| 'layout.menu.api-keys'
| 'layout.menu.settings'
| 'layout.menu.account'
| 'layout.menu.general-settings'
| 'layout.menu.intake-emails'
| 'layout.menu.webhooks'
| 'layout.menu.members'
| 'layout.menu.invitations'
| 'tags.title'
| 'tags.description'
| 'tags.create'
| 'tags.update'
| 'tags.delete'
| 'tags.delete.confirm.title'
| 'tags.delete.confirm.message'
| 'tags.delete.confirm.confirm-button'
| 'tags.delete.confirm.cancel-button'
| 'tags.delete.success'
| 'tags.create.success'
| 'tags.update.success'
| 'tags.form.name.label'
| 'tags.form.name.placeholder'
| 'tags.form.name.required'
| 'tags.form.name.max-length'
| 'tags.form.color.label'
| 'tags.form.color.placeholder'
| 'tags.form.color.required'
| 'tags.form.color.invalid'
| 'tags.form.description.label'
| 'tags.form.description.optional'
| 'tags.form.description.placeholder'
| 'tags.form.description.max-length'
| 'tags.form.no-description'
| 'tags.table.headers.tag'
| 'tags.table.headers.description'
| 'tags.table.headers.documents'
| 'tags.table.headers.created'
| 'tags.table.headers.actions'
| 'tagging-rules.field.name'
| 'tagging-rules.field.content'
| 'tagging-rules.operator.equals'
@@ -133,37 +333,38 @@ export type LocaleKeys =
| 'tagging-rules.update.error'
| 'tagging-rules.update.submit'
| 'tagging-rules.update.cancel'
| 'demo.popup.description'
| 'demo.popup.discord'
| 'demo.popup.discord-link-label'
| 'demo.popup.reset'
| 'demo.popup.hide'
| 'trash.delete-all.button'
| 'trash.delete-all.confirm.title'
| 'trash.delete-all.confirm.description'
| 'trash.delete-all.confirm.label'
| 'trash.delete-all.confirm.cancel'
| 'trash.delete.button'
| 'trash.delete.confirm.title'
| 'trash.delete.confirm.description'
| 'trash.delete.confirm.label'
| 'trash.delete.confirm.cancel'
| 'trash.deleted.success.title'
| 'trash.deleted.success.description'
| 'import-documents.title.error'
| 'import-documents.title.success'
| 'import-documents.title.pending'
| 'import-documents.title.none'
| 'import-documents.no-import-in-progress'
| 'api-errors.document.already_exists'
| 'api-errors.document.file_too_big'
| 'api-errors.intake_email.limit_reached'
| 'api-errors.user.max_organization_count_reached'
| 'api-errors.default'
| 'api-errors.organization.invitation_already_exists'
| 'api-errors.user.already_in_organization'
| 'api-errors.user.organization_invitation_limit_reached'
| 'api-errors.demo.not_available'
| 'intake-emails.title'
| 'intake-emails.description'
| 'intake-emails.disabled.title'
| 'intake-emails.disabled.description'
| 'intake-emails.disabled.documentation'
| 'intake-emails.info'
| 'intake-emails.empty.title'
| 'intake-emails.empty.description'
| 'intake-emails.empty.generate'
| 'intake-emails.count'
| 'intake-emails.new'
| 'intake-emails.disabled-label'
| 'intake-emails.no-origins'
| 'intake-emails.allowed-origins'
| 'intake-emails.actions.enable'
| 'intake-emails.actions.disable'
| 'intake-emails.actions.manage-origins'
| 'intake-emails.actions.delete'
| 'intake-emails.delete.confirm.title'
| 'intake-emails.delete.confirm.message'
| 'intake-emails.delete.confirm.confirm-button'
| 'intake-emails.delete.confirm.cancel-button'
| 'intake-emails.delete.success'
| 'intake-emails.create.success'
| 'intake-emails.update.success.enabled'
| 'intake-emails.update.success.disabled'
| 'intake-emails.allowed-origins.title'
| 'intake-emails.allowed-origins.description'
| 'intake-emails.allowed-origins.add.label'
| 'intake-emails.allowed-origins.add.placeholder'
| 'intake-emails.allowed-origins.add.button'
| 'intake-emails.allowed-origins.add.error.exists'
| 'api-keys.permissions.documents.title'
| 'api-keys.permissions.documents.documents:create'
| 'api-keys.permissions.documents.documents:read'
@@ -238,41 +439,49 @@ export type LocaleKeys =
| 'webhooks.delete.confirm.cancel-button'
| 'webhooks.events.documents.document:created.description'
| 'webhooks.events.documents.document:deleted.description'
| 'organizations.members.title'
| 'organizations.members.description'
| 'organizations.members.invite-member'
| 'organizations.members.invite-member-disabled-tooltip'
| 'organizations.members.remove-from-organization'
| 'organizations.members.role'
| 'organizations.members.roles.owner'
| 'organizations.members.roles.admin'
| 'organizations.members.roles.member'
| 'organizations.members.delete.confirm.title'
| 'organizations.members.delete.confirm.message'
| 'organizations.members.delete.confirm.confirm-button'
| 'organizations.members.delete.confirm.cancel-button'
| 'organizations.members.delete.success'
| 'organizations.members.update-role.success'
| 'organizations.invite-member.title'
| 'organizations.invite-member.description'
| 'organizations.invite-member.form.email.label'
| 'organizations.invite-member.form.email.placeholder'
| 'organizations.invite-member.form.email.required'
| 'organizations.invite-member.form.role.label'
| 'organizations.invite-member.form.submit'
| 'organizations.invite-member.success.message'
| 'organizations.invite-member.success.description'
| 'organizations.invite-member.error.message'
| 'invitations.list.title'
| 'invitations.list.description'
| 'invitations.list.empty.title'
| 'invitations.list.empty.description'
| 'invitations.list.headers.organization'
| 'invitations.list.headers.created'
| 'invitations.list.headers.actions'
| 'invitations.list.actions.accept'
| 'invitations.list.actions.reject'
| 'invitations.list.actions.accept.success.message'
| 'invitations.list.actions.accept.success.description'
| 'invitations.list.actions.reject.success.message'
| 'invitations.list.actions.reject.success.description';
| 'layout.menu.home'
| 'layout.menu.documents'
| 'layout.menu.tags'
| 'layout.menu.tagging-rules'
| 'layout.menu.deleted-documents'
| 'layout.menu.organization-settings'
| 'layout.menu.api-keys'
| 'layout.menu.settings'
| 'layout.menu.account'
| 'layout.menu.general-settings'
| 'layout.menu.intake-emails'
| 'layout.menu.webhooks'
| 'layout.menu.members'
| 'layout.menu.invitations'
| 'layout.theme.light'
| 'layout.theme.dark'
| 'layout.theme.system'
| 'layout.search.placeholder'
| 'layout.menu.import-document'
| 'user-menu.account-settings'
| 'user-menu.api-keys'
| 'user-menu.invitations'
| 'user-menu.language'
| 'user-menu.logout'
| 'command-palette.search.placeholder'
| 'command-palette.no-results'
| 'command-palette.sections.documents'
| 'command-palette.sections.theme'
| 'api-errors.document.already_exists'
| 'api-errors.document.file_too_big'
| 'api-errors.intake_email.limit_reached'
| 'api-errors.user.max_organization_count_reached'
| 'api-errors.default'
| 'api-errors.organization.invitation_already_exists'
| 'api-errors.user.already_in_organization'
| 'api-errors.user.organization_invitation_limit_reached'
| 'api-errors.demo.not_available'
| 'api-errors.tags.already_exists'
| 'not-found.title'
| 'not-found.description'
| 'not-found.back-to-home'
| 'demo.popup.description'
| 'demo.popup.discord'
| 'demo.popup.discord-link-label'
| 'demo.popup.reset'
| 'demo.popup.hide';

View File

@@ -3,10 +3,11 @@ import type { Component, JSX } from 'solid-js';
import type { IntakeEmail } from '../intake-emails.types';
import { safely } from '@corentinth/chisels';
import { useParams } from '@solidjs/router';
import { createQuery } from '@tanstack/solid-query';
import { useQuery } from '@tanstack/solid-query';
import { createSignal, For, Show, Suspense } from 'solid-js';
import * as v from 'valibot';
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';
@@ -23,6 +24,7 @@ import { createIntakeEmail, deleteIntakeEmail, fetchIntakeEmails, updateIntakeEm
const AllowedOriginsDialog: Component<{ children: (props: DialogTriggerProps) => JSX.Element; intakeEmails: IntakeEmail }> = (props) => {
const [getAllowedOrigins, setAllowedOrigins] = createSignal([...props.intakeEmails.allowedOrigins]);
const { t } = useI18n();
const update = async () => {
await updateIntakeEmail({
@@ -47,7 +49,7 @@ const AllowedOriginsDialog: Component<{ children: (props: DialogTriggerProps) =>
}),
onSubmit: async ({ email }) => {
if (getAllowedOrigins().includes(email)) {
throw new Error('This email is already in the allowed origins for this intake email');
throw new Error(t('intake-emails.allowed-origins.add.error.exists'));
}
setAllowedOrigins(origins => [...origins, email]);
@@ -67,13 +69,9 @@ const AllowedOriginsDialog: Component<{ children: (props: DialogTriggerProps) =>
<DialogContent>
<DialogHeader>
<DialogTitle>Allowed origins</DialogTitle>
<DialogTitle>{t('intake-emails.allowed-origins.title')}</DialogTitle>
<DialogDescription>
Only emails sent to
{' '}
<span class="font-medium text-primary">{props.intakeEmails.emailAddress}</span>
{' '}
from these origins will be processed. If no origins are specified, all emails will be discarded.
{t('intake-emails.allowed-origins.description', { email: props.intakeEmails.emailAddress })}
</DialogDescription>
</DialogHeader>
@@ -81,13 +79,13 @@ const AllowedOriginsDialog: Component<{ children: (props: DialogTriggerProps) =>
<Field name="email">
{(field, inputProps) => (
<TextFieldRoot class="flex flex-col gap-1 mb-4 mt-4">
<TextFieldLabel for="email">Add allowed origin email</TextFieldLabel>
<TextFieldLabel for="email">{t('intake-emails.allowed-origins.add.label')}</TextFieldLabel>
<div class="flex items-center gap-2">
<TextField type="email" id="email" placeholder="Eg. ada@papra.app" {...inputProps} autoFocus value={field.value} aria-invalid={Boolean(field.error)} />
<TextField type="email" id="email" placeholder={t('intake-emails.allowed-origins.add.placeholder')} {...inputProps} autoFocus value={field.value} aria-invalid={Boolean(field.error)} />
<Button type="submit">
<div class="i-tabler-plus size-4 mr-2" />
Add
{t('intake-emails.allowed-origins.add.button')}
</Button>
</div>
@@ -130,26 +128,28 @@ const AllowedOriginsDialog: Component<{ children: (props: DialogTriggerProps) =>
export const IntakeEmailsPage: Component = () => {
const { config } = useConfig();
const { t, te } = useI18n();
if (!config.intakeEmails.isEnabled) {
return (
<div class="p-6 max-w-screen-md mx-auto mt-10">
<h1 class="text-xl font-semibold">Intake Emails</h1>
<h1 class="text-xl font-semibold">{t('intake-emails.title')}</h1>
<p class="text-muted-foreground mt-1">
Intake emails address are used to automatically ingest emails into Papra. Just forward emails to the intake email address and their attachments will be added to your organization's documents.
{t('intake-emails.description')}
</p>
<Card class="px-6 py-4 mt-4 flex items-center gap-4">
<div class="i-tabler-mail-off size-12 text-muted-foreground flex-shrink-0" />
<div>
<h2 class="text-base font-bold text-muted-foreground">Intake Emails are disabled</h2>
<h2 class="text-base font-bold text-muted-foreground">{t('intake-emails.disabled.title')}</h2>
<p class="text-muted-foreground mt-1">
Intake emails are disabled on this instance. Please contact your administrator to enable them. See the
{' '}
<a href="https://docs.papra.app/guides/intake-emails-with-owlrelay/" target="_blank" class="text-primary">documentation</a>
{' '}
for more information.
{te('intake-emails.disabled.description', {
documentation: (
<a href="https://docs.papra.app/guides/intake-emails-with-owlrelay/" target="_blank" class="text-primary">
{t('intake-emails.disabled.documentation')}
</a>
),
})}
</p>
</div>
</Card>
@@ -160,7 +160,7 @@ export const IntakeEmailsPage: Component = () => {
const params = useParams();
const { confirm } = useConfirmModal();
const query = createQuery(() => ({
const query = useQuery(() => ({
queryKey: ['organizations', params.organizationId, 'intake-emails'],
queryFn: () => fetchIntakeEmails({ organizationId: params.organizationId }),
}));
@@ -170,7 +170,7 @@ export const IntakeEmailsPage: Component = () => {
if (isHttpErrorWithCode({ error, code: 'intake_email.limit_reached' })) {
createToast({
message: 'The maximum number of intake emails for this organization has been reached. Please upgrade your plan to create more intake emails.',
message: t('api-errors.intake_email.limit_reached'),
type: 'error',
});
@@ -184,20 +184,20 @@ export const IntakeEmailsPage: Component = () => {
await query.refetch();
createToast({
message: 'Intake email created',
message: t('intake-emails.create.success'),
type: 'success',
});
};
const deleteEmail = async ({ intakeEmailId }: { intakeEmailId: string }) => {
const confirmed = await confirm({
title: 'Delete intake email?',
message: 'Are you sure you want to delete this intake email? This action cannot be undone.',
title: t('intake-emails.delete.confirm.title'),
message: t('intake-emails.delete.confirm.message'),
cancelButton: {
text: 'Cancel',
text: t('intake-emails.delete.confirm.cancel-button'),
},
confirmButton: {
text: 'Delete intake email',
text: t('intake-emails.delete.confirm.confirm-button'),
variant: 'destructive',
},
});
@@ -210,7 +210,7 @@ export const IntakeEmailsPage: Component = () => {
await query.refetch();
createToast({
message: 'Intake email deleted',
message: t('intake-emails.delete.success'),
type: 'success',
});
};
@@ -220,27 +220,25 @@ export const IntakeEmailsPage: Component = () => {
await query.refetch();
createToast({
message: `Intake email ${isEnabled ? 'enabled' : 'disabled'}`,
message: isEnabled ? t('intake-emails.update.success.enabled') : t('intake-emails.update.success.disabled'),
type: 'success',
});
};
return (
<div class="p-6 max-w-screen-md mx-auto mt-10">
<h1 class="text-xl font-semibold">Intake Emails</h1>
<h1 class="text-xl font-semibold">{t('intake-emails.title')}</h1>
<p class="text-muted-foreground mt-1">
Intake emails address are used to automatically ingest emails into Papra. Just forward emails to the intake email address and their attachments will be added to your organization's documents.
{t('intake-emails.description')}
</p>
<Alert variant="default" class="mt-4 flex items-center gap-4 xl:gap-4 text-muted-foreground">
<div class="i-tabler-info-circle size-10 xl:size-8 text-primary flex-shrink-0 " />
<AlertDescription>
Only enabled intake emails from allowed origins will be processed. You can enable or disable an intake email at any time.
{t('intake-emails.info')}
</AlertDescription>
</Alert>
<Suspense>
@@ -251,14 +249,14 @@ export const IntakeEmailsPage: Component = () => {
fallback={(
<div class="mt-4 py-8 border-2 border-dashed rounded-lg text-center">
<EmptyState
title="No intake emails"
description="Generate an intake address to easily ingest emails attachments."
title={t('intake-emails.empty.title')}
description={t('intake-emails.empty.description')}
class="pt-0"
icon="i-tabler-mail"
cta={(
<Button variant="secondary" onClick={createEmail}>
<div class="i-tabler-plus size-4 mr-2" />
Generate intake email
{t('intake-emails.empty.generate')}
</Button>
)}
/>
@@ -267,12 +265,15 @@ export const IntakeEmailsPage: Component = () => {
>
<div class="mt-4 mb-4 flex items-center justify-between">
<div class="text-muted-foreground">
{`${intakeEmails().length} intake email${intakeEmails().length > 1 ? 's' : ''} for this organization`}
{t('intake-emails.count', {
count: intakeEmails().length,
plural: intakeEmails().length > 1 ? 's' : '',
})}
</div>
<Button onClick={createEmail}>
<div class="i-tabler-plus size-4 mr-2" />
New intake email
{t('intake-emails.new')}
</Button>
</div>
@@ -290,9 +291,8 @@ export const IntakeEmailsPage: Component = () => {
{intakeEmail.emailAddress}
<Show when={!intakeEmail.isEnabled}>
<span class="text-muted-foreground text-xs ml-2">(Disabled)</span>
<span class="text-muted-foreground text-xs ml-2">{t('intake-emails.disabled-label')}</span>
</Show>
</div>
<Show
@@ -300,14 +300,16 @@ export const IntakeEmailsPage: Component = () => {
fallback={(
<div class="text-xs text-warning flex items-center gap-1.5">
<div class="i-tabler-alert-triangle size-3.75" />
No allowed email origins
{t('intake-emails.no-origins')}
</div>
)}
>
<div class="text-xs text-muted-foreground flex items-center gap-2">
{`Allowed from ${intakeEmail.allowedOrigins.length} address${intakeEmail.allowedOrigins.length > 1 ? 'es' : ''}`}
{t('intake-emails.allowed-origins', {
count: intakeEmail.allowedOrigins.length,
plural: intakeEmail.allowedOrigins.length > 1 ? 'es' : '',
})}
</div>
</Show>
</div>
</div>
@@ -318,7 +320,7 @@ export const IntakeEmailsPage: Component = () => {
onClick={() => updateEmail({ intakeEmailId: intakeEmail.id, isEnabled: !intakeEmail.isEnabled })}
>
<div class="i-tabler-power size-4 mr-2" />
{intakeEmail.isEnabled ? 'Disable' : 'Enable'}
{intakeEmail.isEnabled ? t('intake-emails.actions.disable') : t('intake-emails.actions.enable')}
</Button>
<AllowedOriginsDialog intakeEmails={intakeEmail}>
@@ -330,7 +332,7 @@ export const IntakeEmailsPage: Component = () => {
class="flex items-center gap-2 leading-none"
>
<div class="i-tabler-edit size-4" />
Manage origins addresses
{t('intake-emails.actions.manage-origins')}
</Button>
)}
</AllowedOriginsDialog>
@@ -342,18 +344,14 @@ export const IntakeEmailsPage: Component = () => {
class="text-red"
>
<div class="i-tabler-trash size-4 mr-2" />
Delete
{t('intake-emails.actions.delete')}
</Button>
</div>
</div>
)}
</For>
</div>
</Show>
)}
</Show>
</Suspense>

View File

@@ -25,13 +25,6 @@ export async function fetchPendingInvitationsCount() {
return { pendingInvitationsCount };
}
export async function cancelInvitation({ invitationId }: { invitationId: string }) {
await apiClient({
path: `/api/invitations/${invitationId}`,
method: 'DELETE',
});
}
export async function acceptInvitation({ invitationId }: { invitationId: string }) {
await apiClient({
path: `/api/invitations/${invitationId}/accept`,
@@ -45,3 +38,17 @@ export async function rejectInvitation({ invitationId }: { invitationId: string
method: 'POST',
});
}
export async function resendInvitation({ invitationId }: { invitationId: string }) {
await apiClient({
path: `/api/invitations/${invitationId}/resend`,
method: 'POST',
});
}
export async function cancelInvitation({ invitationId }: { invitationId: string }) {
await apiClient({
path: `/api/invitations/${invitationId}/cancel`,
method: 'POST',
});
}

View File

@@ -1,6 +1,7 @@
import type { Component } from 'solid-js';
import { safely } from '@corentinth/chisels';
import * as v from 'valibot';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { createForm } from '@/modules/shared/form/form';
import { isHttpErrorWithCode } from '@/modules/shared/http/http-errors';
import { Button } from '@/modules/ui/components/button';
@@ -11,18 +12,22 @@ export const CreateOrganizationForm: Component<{
onSubmit: (args: { organizationName: string }) => Promise<void>;
initialOrganizationName?: string;
}> = (props) => {
const { t } = useI18n();
const { form, Form, Field } = createForm({
onSubmit: async ({ organizationName }) => {
const [, error] = await safely(props.onSubmit({ organizationName }));
if (isHttpErrorWithCode({ error, code: 'user.max_organization_count_reached' })) {
throw new Error('You have reached the maximum number of organizations you can create, if you need to create more, please contact support.');
throw new Error(t('organizations.create.error.max-count-reached'));
}
throw error;
},
schema: v.object({
organizationName: organizationNameSchema,
organizationName: v.pipe(
organizationNameSchema,
v.nonEmpty(t('organizations.create.form.name.required')),
),
}),
initialValues: {
organizationName: props.initialOrganizationName,
@@ -35,8 +40,8 @@ export const CreateOrganizationForm: Component<{
<Field name="organizationName">
{(field, inputProps) => (
<TextFieldRoot class="flex flex-col gap-1 mb-6">
<TextFieldLabel for="organizationName">Organization name</TextFieldLabel>
<TextField type="text" id="organizationName" placeholder="Eg. Acme Inc." {...inputProps} autoFocus value={field.value} aria-invalid={Boolean(field.error)} />
<TextFieldLabel for="organizationName">{t('organizations.create.form.name.label')}</TextFieldLabel>
<TextField type="text" id="organizationName" placeholder={t('organizations.create.form.name.placeholder')} {...inputProps} autoFocus value={field.value} aria-invalid={Boolean(field.error)} />
{field.error && <div class="text-red-500 text-sm">{field.error}</div>}
</TextFieldRoot>
)}
@@ -44,7 +49,7 @@ export const CreateOrganizationForm: Component<{
<div class="flex justify-end">
<Button type="submit" isLoading={form.submitting} class="w-full">
Create organization
{t('organizations.create.form.submit')}
</Button>
</div>

View File

@@ -1,7 +1,7 @@
import type { ParentComponent } from 'solid-js';
import type { Organization } from '../organizations.types';
import { makePersisted } from '@solid-primitives/storage';
import { createQuery } from '@tanstack/solid-query';
import { useQuery } from '@tanstack/solid-query';
import { createContext, createSignal, Show, useContext } from 'solid-js';
import { fetchOrganizations } from '../organizations.services';
@@ -24,7 +24,7 @@ export function useCurrentOrganization() {
export const CurrentOrganizationProvider: ParentComponent = (props) => {
const [getCurrentOrganizationId, setCurrentOrganizationId] = makePersisted(createSignal<string | null>(null), { name: 'papra_current_organization_id', storage: localStorage });
const query = createQuery(() => ({
const query = useQuery(() => ({
queryKey: ['organizations'],
queryFn: fetchOrganizations,
}));

View File

@@ -1,5 +1,6 @@
import { useNavigate, useParams } from '@solidjs/router';
import { useQuery } from '@tanstack/solid-query';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { queryClient } from '@/modules/shared/query/query-client';
import { createToast } from '@/modules/ui/components/sonner';
import { ORGANIZATION_ROLES } from './organizations.constants';
@@ -7,12 +8,13 @@ import { createOrganization, deleteOrganization, getMembership, updateOrganizati
export function useCreateOrganization() {
const navigate = useNavigate();
const { t } = useI18n();
return {
createOrganization: async ({ organizationName }: { organizationName: string }) => {
const { organization } = await createOrganization({ name: organizationName });
createToast({ type: 'success', message: 'Organization created' });
createToast({ type: 'success', message: t('organizations.create.success') });
await queryClient.invalidateQueries({
queryKey: ['organizations'],

View File

@@ -5,3 +5,13 @@ export const ORGANIZATION_ROLES = {
} as const;
export const ORGANIZATION_ROLES_LIST = Object.values(ORGANIZATION_ROLES);
export const ORGANIZATION_INVITATION_STATUS = {
PENDING: 'pending',
ACCEPTED: 'accepted',
REJECTED: 'rejected',
EXPIRED: 'expired',
CANCELLED: 'cancelled',
} as const;
export const ORGANIZATION_INVITATION_STATUS_LIST = Object.values(ORGANIZATION_INVITATION_STATUS);

View File

@@ -1,5 +1,5 @@
import type { AsDto } from '../shared/http/http-client.types';
import type { Organization, OrganizationMember, OrganizationMemberRole } from './organizations.types';
import type { Organization, OrganizationInvitation, OrganizationMember, OrganizationMemberRole } from './organizations.types';
import { apiClient } from '../shared/http/api-client';
import { coerceDates } from '../shared/http/http-client.models';
@@ -75,6 +75,17 @@ export async function fetchOrganizationMembers({ organizationId }: { organizatio
};
}
export async function fetchOrganizationInvitations({ organizationId }: { organizationId: string }) {
const { invitations } = await apiClient<{ invitations: AsDto<OrganizationInvitation>[] }>({
path: `/api/organizations/${organizationId}/members/invitations`,
method: 'GET',
});
return {
invitations: invitations.map(coerceDates),
};
}
export async function removeOrganizationMember({ organizationId, memberId }: { organizationId: string; memberId: string }) {
await apiClient({
path: `/api/organizations/${organizationId}/members/${memberId}`,

View File

@@ -1,4 +1,5 @@
import type { User } from 'better-auth/types';
import type { ORGANIZATION_INVITATION_STATUS_LIST } from './organizations.constants';
export type Organization = {
id: string;
@@ -15,3 +16,14 @@ export type OrganizationMember = {
};
export type OrganizationMemberRole = 'owner' | 'admin' | 'member';
export type OrganizationInvitationStatus = typeof ORGANIZATION_INVITATION_STATUS_LIST[number];
export type OrganizationInvitation = {
id: string;
organizationId: string;
email: string;
status: OrganizationInvitationStatus;
role: OrganizationMemberRole;
createdAt: Date;
};

View File

@@ -1,7 +1,8 @@
import type { Component } from 'solid-js';
import { useNavigate } from '@solidjs/router';
import { createQuery } from '@tanstack/solid-query';
import { useQuery } from '@tanstack/solid-query';
import { createEffect, on } from 'solid-js';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { useCurrentUser } from '@/modules/users/composables/useCurrentUser';
import { CreateOrganizationForm } from '../components/create-organization-form.component';
import { useCreateOrganization } from '../organizations.composables';
@@ -11,24 +12,25 @@ export const CreateFirstOrganizationPage: Component = () => {
const { createOrganization } = useCreateOrganization();
const { user } = useCurrentUser();
const navigate = useNavigate();
const { t } = useI18n();
const getOrganizationName = () => {
const { name } = user;
if (name && name.length > 0) {
return `${name}'s organization`;
return t('organizations.create-first.user-name', { name });
}
return `My organization`;
return t('organizations.create-first.default-name');
};
const queries = createQuery(() => ({
const query = useQuery(() => ({
queryKey: ['organizations'],
queryFn: fetchOrganizations,
}));
createEffect(on(
() => queries.data?.organizations,
() => query.data?.organizations,
(orgs) => {
if (orgs && orgs.length > 0) {
navigate('/organizations/create');
@@ -40,11 +42,11 @@ export const CreateFirstOrganizationPage: Component = () => {
<div>
<div class="max-w-md mx-auto pt-12 sm:pt-24 px-6">
<h1 class="text-xl font-bold">
Create your organization
{t('organizations.create-first.title')}
</h1>
<p class="text-muted-foreground mb-6">
Your documents will be grouped by organization. You can create multiple organizations to separate your documents, for example, for personal and work documents.
{t('organizations.create-first.description')}
</p>
<CreateOrganizationForm onSubmit={createOrganization} initialOrganizationName={getOrganizationName()} />

View File

@@ -1,27 +1,28 @@
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';
import { CreateOrganizationForm } from '../components/create-organization-form.component';
import { useCreateOrganization } from '../organizations.composables';
export const CreateOrganizationPage: Component = () => {
const { t } = useI18n();
const { createOrganization } = useCreateOrganization();
return (
<div>
<div class="max-w-md mx-auto pt-12 sm:pt-24 px-6">
<Button as={A} href="/" class="mb-4" variant="outline">
<div class="i-tabler-arrow-left mr-2"></div>
Back
{t('organizations.create.back')}
</Button>
<h1 class="text-xl font-bold">
Create a new organization
{t('organizations.create.title')}
</h1>
<p class="text-muted-foreground mb-6">
Your documents will be grouped by organization. You can create multiple organizations to separate your documents, for example, for personal and work documents.
{t('organizations.create.description')}
</p>
<CreateOrganizationForm onSubmit={createOrganization} />

View File

@@ -0,0 +1,239 @@
import type { Component } from 'solid-js';
import type { OrganizationInvitation, OrganizationInvitationStatus, OrganizationMemberRole } from '../organizations.types';
import { A, useNavigate, useParams } from '@solidjs/router';
import { useMutation, useQuery } from '@tanstack/solid-query';
import { createSolidTable, flexRender, getCoreRowModel, getPaginationRowModel } from '@tanstack/solid-table';
import { For, Match, onMount, Show, Switch } from 'solid-js';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { cancelInvitation, resendInvitation } from '@/modules/invitations/invitations.services';
import { useConfirmModal } from '@/modules/shared/confirm';
import { timeAgo } from '@/modules/shared/date/time-ago';
import { queryClient } from '@/modules/shared/query/query-client';
import { Badge } from '@/modules/ui/components/badge';
import { Button } from '@/modules/ui/components/button';
import { EmptyState } from '@/modules/ui/components/empty';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/modules/ui/components/table';
import { useCurrentUserRole } from '../organizations.composables';
import { ORGANIZATION_INVITATION_STATUS } from '../organizations.constants';
import { fetchOrganizationInvitations } from '../organizations.services';
const InvitationStatusBadge: Component<{ status: OrganizationInvitationStatus }> = (props) => {
const { t } = useI18n();
const getStatus = () => t(`organizations.invitations.status.${props.status}`);
const getVariant = () => ({
[ORGANIZATION_INVITATION_STATUS.PENDING]: 'default',
[ORGANIZATION_INVITATION_STATUS.ACCEPTED]: 'default',
[ORGANIZATION_INVITATION_STATUS.REJECTED]: 'destructive',
[ORGANIZATION_INVITATION_STATUS.EXPIRED]: 'destructive',
[ORGANIZATION_INVITATION_STATUS.CANCELLED]: 'destructive',
} as const)[props.status] ?? 'default';
return <Badge variant={getVariant()}>{getStatus()}</Badge>;
};
const InvitationActions: Component<{ invitation: OrganizationInvitation }> = (props) => {
const { t } = useI18n();
const { confirm } = useConfirmModal();
const cancelMutation = useMutation(() => ({
mutationFn: (invitationId: string) => cancelInvitation({ invitationId }),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ['organizations', props.invitation.organizationId, 'invitations'] });
},
}));
const resendMutation = useMutation(() => ({
mutationFn: (invitationId: string) => resendInvitation({ invitationId }),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ['organizations', props.invitation.organizationId, 'invitations'] });
},
}));
const handleCancel = async () => {
const isConfirmed = await confirm({
title: t('organizations.invitations.cancel.title'),
message: t('organizations.invitations.cancel.description'),
confirmButton: {
text: t('organizations.invitations.cancel.confirm'),
variant: 'destructive',
},
cancelButton: {
text: t('organizations.invitations.cancel.cancel'),
},
});
if (!isConfirmed) {
return;
}
cancelMutation.mutate(props.invitation.id);
};
const handleResend = async () => {
const isConfirmed = await confirm({
title: t('organizations.invitations.resend.title'),
message: t('organizations.invitations.resend.description'),
confirmButton: {
text: t('organizations.invitations.resend.confirm'),
variant: 'default',
},
cancelButton: {
text: t('organizations.invitations.resend.cancel'),
},
});
if (!isConfirmed) {
return;
}
resendMutation.mutate(props.invitation.id);
};
return (
<Switch>
<Match when={props.invitation.status === ORGANIZATION_INVITATION_STATUS.PENDING}>
<Button
variant="destructive"
size="sm"
onClick={handleCancel}
disabled={cancelMutation.isPending}
>
<div class="i-tabler-x size-4 mr-2" />
{t('organizations.invitations.cancel.confirm')}
</Button>
</Match>
<Match when={([
ORGANIZATION_INVITATION_STATUS.REJECTED,
ORGANIZATION_INVITATION_STATUS.EXPIRED,
ORGANIZATION_INVITATION_STATUS.CANCELLED,
] as OrganizationInvitationStatus[]).includes(props.invitation.status)}
>
<Button
size="sm"
variant="outline"
onClick={handleResend}
disabled={resendMutation.isPending}
>
<div class="i-tabler-refresh size-4 mr-2" />
{t('organizations.invitations.resend')}
</Button>
</Match>
</Switch>
);
};
const InvitationsList: Component = () => {
const params = useParams();
const { t } = useI18n();
const query = useQuery(() => ({
queryKey: ['organizations', params.organizationId, 'invitations'],
queryFn: () => fetchOrganizationInvitations({ organizationId: params.organizationId }),
}));
const table = createSolidTable({
get data() {
return query.data?.invitations.filter(invitation => !([ORGANIZATION_INVITATION_STATUS.ACCEPTED] as OrganizationInvitationStatus[]).includes(invitation.status)) ?? [];
},
columns: [
{ header: t('organizations.members.table.headers.email'), accessorKey: 'email' },
{ header: t('organizations.members.table.headers.role'), accessorKey: 'role', cell: data => t(`organizations.members.roles.${data.getValue<OrganizationMemberRole>()}`) },
{
header: t('invitations.list.headers.status'),
accessorKey: 'status',
cell: data => <InvitationStatusBadge status={data.getValue()} />,
},
{
header: t('organizations.members.table.headers.created'),
accessorKey: 'createdAt',
cell: data => <span title={data.getValue<Date>().toLocaleString()} class="text-muted-foreground">{timeAgo({ date: data.getValue<Date>() })}</span>,
},
{
header: '',
id: 'actions',
cell: data => (
<div class="flex items-center justify-end">
<InvitationActions invitation={data.row.original} />
</div>
),
},
],
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
});
return (
<div>
<Show when={query.data?.invitations.length === 0}>
<EmptyState
title={t('organizations.invitations.list.empty.title')}
description={t('organizations.invitations.list.empty.description')}
icon="i-tabler-mail"
cta={(
<Button as={A} href={`/organizations/${params.organizationId}/invite`} variant="outline">
<div class="i-tabler-plus size-4 mr-2" />
{t('organizations.invitations.list.cta')}
</Button>
)}
/>
</Show>
<Show when={query.data?.invitations.length}>
<Table>
<TableHeader>
<For each={table.getHeaderGroups()}>
{headerGroup => (
<TableRow>
<For each={headerGroup.headers}>{header => <TableHead>{flexRender(header.column.columnDef.header, header.getContext())}</TableHead>}</For>
</TableRow>
)}
</For>
</TableHeader>
<TableBody>
<For each={table.getRowModel().rows}>
{row => <TableRow>{row.getVisibleCells().map(cell => <TableCell>{flexRender(cell.column.columnDef.cell, cell.getContext())}</TableCell>)}</TableRow>}
</For>
</TableBody>
</Table>
</Show>
</div>
);
};
export const InvitationsListPage: Component = () => {
const { t } = useI18n();
const params = useParams();
const navigate = useNavigate();
const { getIsAtLeastAdmin } = useCurrentUserRole({ organizationId: params.organizationId });
onMount(() => {
if (!getIsAtLeastAdmin()) {
navigate(`/organizations/${params.organizationId}/members`);
}
});
return (
<div class="p-6 max-w-screen-md mx-auto mt-4 ">
<div class="border-b mb-6 pb-4">
<div>
<Button as={A} href={`/organizations/${params.organizationId}/members`} variant="ghost" class="ml--4 text-muted-foreground">
<div class="i-tabler-arrow-left size-4 mr-2" />
{t('organizations.members.title')}
</Button>
</div>
<h1 class="text-xl font-bold">
{t('organizations.invitations.title')}
</h1>
<p class="text-sm text-muted-foreground">
{t('organizations.invitations.description')}
</p>
</div>
<InvitationsList />
</div>
);
};

View File

@@ -57,7 +57,9 @@ export const InviteMemberPage: Component = () => {
createToast({
message: t('organizations.invite-member.success.message'),
description: t('organizations.invite-member.success.description'),
type: 'success',
});
navigate(`/organizations/${params.organizationId}/members`);
},
onError: (error) => {
createToast({
@@ -153,7 +155,7 @@ export const InviteMemberPage: Component = () => {
)}
</Field>
<Button type="submit" class="w-full mt-6">
<Button type="submit" class="w-full mt-6" isLoading={inviteMemberMutation.isPending}>
{t('organizations.invite-member.form.submit')}
<div class="i-tabler-send size-4 ml-1" />
</Button>

View File

@@ -1,7 +1,7 @@
import type { Component } from 'solid-js';
import type { OrganizationMemberRole } from '../organizations.types';
import { A, useParams } from '@solidjs/router';
import { createMutation, createQuery } from '@tanstack/solid-query';
import { useMutation, useQuery } from '@tanstack/solid-query';
import { createSolidTable, flexRender, getCoreRowModel, getPaginationRowModel } from '@tanstack/solid-table';
import { For, Show } from 'solid-js';
import { useI18n } from '@/modules/i18n/i18n.provider';
@@ -22,7 +22,7 @@ const MemberList: Component = () => {
const params = useParams();
const { t } = useI18n();
const { confirm } = useConfirmModal();
const query = createQuery(() => ({
const query = useQuery(() => ({
queryKey: ['organizations', params.organizationId, 'members'],
queryFn: () => fetchOrganizationMembers({ organizationId: params.organizationId }),
}));
@@ -30,7 +30,7 @@ const MemberList: Component = () => {
const { getIsAtLeastAdmin, getRole } = useCurrentUserRole({ organizationId: params.organizationId });
const removeMemberMutation = createMutation(() => ({
const removeMemberMutation = useMutation(() => ({
mutationFn: ({ memberId }: { memberId: string }) => removeOrganizationMember({ organizationId: params.organizationId, memberId }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['organizations', params.organizationId, 'members'] });
@@ -41,7 +41,7 @@ const MemberList: Component = () => {
},
}));
const updateMemberRoleMutation = createMutation(() => ({
const updateMemberRoleMutation = useMutation(() => ({
mutationFn: ({ memberId, role }: { memberId: string; role: OrganizationMemberRole }) => updateOrganizationMemberRole({ organizationId: params.organizationId, memberId, role }),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ['organizations', params.organizationId, 'members'] });
@@ -87,10 +87,10 @@ const MemberList: Component = () => {
return query.data?.members ?? [];
},
columns: [
{ header: 'Name', accessorKey: 'user.name' },
{ header: 'Email', accessorKey: 'user.email' },
{ header: 'Role', accessorKey: 'role', cell: data => t(`organizations.members.roles.${data.getValue<OrganizationMemberRole>()}`) },
{ header: 'Actions', id: 'actions', cell: data => (
{ header: t('organizations.members.table.headers.name'), accessorKey: 'user.name' },
{ header: t('organizations.members.table.headers.email'), accessorKey: 'user.email' },
{ header: t('organizations.members.table.headers.role'), accessorKey: 'role', cell: data => t(`organizations.members.roles.${data.getValue<OrganizationMemberRole>()}`) },
{ header: t('organizations.members.table.headers.actions'), id: 'actions', cell: data => (
<div class="flex items-center justify-end">
<DropdownMenu>
<DropdownMenuTrigger as={Button} variant="ghost" size="icon">
@@ -192,10 +192,18 @@ export const MembersPage: Component = () => {
</Tooltip>
)}
>
<Button as={A} href={`/organizations/${params.organizationId}/invite`}>
<div class="i-tabler-plus size-4 mr-2" />
{t('organizations.members.invite-member')}
</Button>
<div class="flex items-center gap-2">
<Button as={A} href={`/organizations/${params.organizationId}/invitations`} variant="outline">
<div class="i-tabler-mail size-4 mr-2" />
{t('organizations.invitations.title')}
</Button>
<Button as={A} href={`/organizations/${params.organizationId}/invite`}>
<div class="i-tabler-plus size-4 mr-2" />
{t('organizations.members.invite-member')}
</Button>
</div>
</Show>
</div>

View File

@@ -7,10 +7,12 @@ import { DocumentUploadArea } from '@/modules/documents/components/document-uplo
import { createdAtColumn, DocumentsPaginatedList, standardActionsColumn, tagsColumn } from '@/modules/documents/components/documents-list.component';
import { useUploadDocuments } from '@/modules/documents/documents.composables';
import { fetchOrganizationDocuments, getOrganizationDocumentsStats } from '@/modules/documents/documents.services';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { Button } from '@/modules/ui/components/button';
export const OrganizationPage: Component = () => {
const params = useParams();
const { t } = useI18n();
const [getPagination, setPagination] = createSignal({ pageIndex: 0, pageSize: 100 });
const query = createQueries(() => ({
@@ -39,11 +41,11 @@ export const OrganizationPage: Component = () => {
? (
<>
<h2 class="text-xl font-bold ">
No documents
{t('organizations.details.no-documents.title')}
</h2>
<p class="text-muted-foreground mt-1 mb-6">
There are no documents in this organization yet. Start by uploading some documents.
{t('organizations.details.no-documents.description')}
</p>
<DocumentUploadArea />
@@ -57,7 +59,7 @@ export const OrganizationPage: Component = () => {
<Button onClick={promptImport} class="h-auto items-start flex-col gap-4 py-4 px-6">
<div class="i-tabler-upload size-6"></div>
Upload documents
{t('organizations.details.upload-documents')}
</Button>
<Show when={query[1].data?.organizationStats}>
@@ -69,7 +71,7 @@ export const OrganizationPage: Component = () => {
{organizationStats().documentsCount}
</span>
<span class="text-muted-foreground">
documents in total
{t('organizations.details.documents-count')}
</span>
</div>
</div>
@@ -80,7 +82,7 @@ export const OrganizationPage: Component = () => {
{formatBytes({ bytes: organizationStats().documentsSize, base: 1000 })}
</span>
<span class="text-muted-foreground">
total size
{t('organizations.details.total-size')}
</span>
</div>
</div>
@@ -90,7 +92,7 @@ export const OrganizationPage: Component = () => {
</div>
<h2 class="text-lg font-semibold mb-4">
Latest imported documents
{t('organizations.details.latest-documents')}
</h2>
<DocumentsPaginatedList

View File

@@ -2,10 +2,12 @@ import type { Component } from 'solid-js';
import type { Organization } from '../organizations.types';
import { safely } from '@corentinth/chisels';
import { useParams } from '@solidjs/router';
import { createQuery } from '@tanstack/solid-query';
import { useQuery } from '@tanstack/solid-query';
import { createSignal, Show, Suspense } from 'solid-js';
import * as v from 'valibot';
import { buildTimeConfig } from '@/modules/config/config';
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 { getCustomerPortalUrl } from '@/modules/subscriptions/subscriptions.services';
@@ -20,24 +22,25 @@ import { fetchOrganization } from '../organizations.services';
const DeleteOrganizationCard: Component<{ organization: Organization }> = (props) => {
const { deleteOrganization } = useDeleteOrganization();
const { confirm } = useConfirmModal();
const { t } = useI18n();
const handleDelete = async () => {
const confirmed = await confirm({
title: 'Delete organization',
message: 'Are you sure you want to delete this organization? This action cannot be undone, and all data associated with this organization will be permanently removed.',
title: t('organization.settings.delete.confirm.title'),
message: t('organization.settings.delete.confirm.message'),
confirmButton: {
text: 'Delete organization',
text: t('organization.settings.delete.confirm.confirm-button'),
variant: 'destructive',
},
cancelButton: {
text: 'Cancel',
text: t('organization.settings.delete.confirm.cancel-button'),
},
});
if (confirmed) {
await deleteOrganization({ organizationId: props.organization.id });
createToast({ type: 'success', message: 'Organization deleted' });
createToast({ type: 'success', message: t('organization.settings.delete.success') });
}
};
@@ -45,15 +48,15 @@ const DeleteOrganizationCard: Component<{ organization: Organization }> = (props
<div>
<Card class="border-destructive">
<CardHeader class="border-b">
<CardTitle>Delete organization</CardTitle>
<CardTitle>{t('organization.settings.delete.title')}</CardTitle>
<CardDescription>
Deleting this organization will permanently remove all data associated with it.
{t('organization.settings.delete.description')}
</CardDescription>
</CardHeader>
<CardFooter class="pt-6">
<Button onClick={handleDelete} variant="destructive">
Delete organization
{t('organization.settings.delete.confirm.confirm-button')}
</Button>
</CardFooter>
</Card>
@@ -62,7 +65,14 @@ const DeleteOrganizationCard: Component<{ organization: Organization }> = (props
};
export const SubscriptionCard: Component<{ organization: Organization }> = (props) => {
const { config } = useConfig();
if (!config.isSubscriptionsEnabled) {
return null;
}
const [getIsLoading, setIsLoading] = createSignal(false);
const { t } = useI18n();
const goToCustomerPortal = async () => {
setIsLoading(true);
@@ -70,7 +80,7 @@ export const SubscriptionCard: Component<{ organization: Organization }> = (prop
const [result, error] = await safely(getCustomerPortalUrl({ organizationId: props.organization.id }));
if (error) {
createToast({ type: 'error', message: 'Failed to get customer portal URL' });
createToast({ type: 'error', message: t('organization.settings.subscription.error') });
setIsLoading(false);
return;
@@ -86,13 +96,13 @@ export const SubscriptionCard: Component<{ organization: Organization }> = (prop
return (
<Card class="flex flex-col sm:flex-row justify-between gap-4 sm:items-center p-6 ">
<div>
<div class="font-semibold">Subscription</div>
<div class="font-semibold">{t('organization.settings.subscription.title')}</div>
<div class="text-sm text-muted-foreground">
Manage your billing, invoices and payment methods.
{t('organization.settings.subscription.description')}
</div>
</div>
<Button onClick={goToCustomerPortal} isLoading={getIsLoading()} class="flex-shrink-0" disabled={buildTimeConfig.isDemoMode}>
Manage subscription
{t('organization.settings.subscription.manage')}
</Button>
</Card>
);
@@ -100,6 +110,7 @@ export const SubscriptionCard: Component<{ organization: Organization }> = (prop
const UpdateOrganizationNameCard: Component<{ organization: Organization }> = (props) => {
const { updateOrganization } = useUpdateOrganization();
const { t } = useI18n();
const { form, Form, Field } = createForm({
schema: v.object({
@@ -114,7 +125,7 @@ const UpdateOrganizationNameCard: Component<{ organization: Organization }> = (p
organizationName: organizationName.trim(),
});
createToast({ type: 'success', message: 'Organization name updated' });
createToast({ type: 'success', message: t('organization.settings.name.updated') });
},
});
@@ -122,24 +133,22 @@ const UpdateOrganizationNameCard: Component<{ organization: Organization }> = (p
<div>
<Card>
<CardHeader class="border-b">
<CardTitle>Organization name</CardTitle>
<CardTitle>{t('organization.settings.name.title')}</CardTitle>
</CardHeader>
<Form>
<CardContent class="pt-6 ">
<Field name="organizationName">
{(field, inputProps) => (
<TextFieldRoot class="flex flex-col gap-1">
<TextFieldLabel for="organizationName" class="sr-only">
Organization name
{t('organization.settings.name.title')}
</TextFieldLabel>
<div class="flex gap-2 flex-col sm:flex-row">
<TextField type="text" id="organizationName" placeholder="Eg. Acme Inc." {...inputProps} autoFocus value={field.value} aria-invalid={Boolean(field.error)} />
<TextField type="text" id="organizationName" placeholder={t('organization.settings.name.placeholder')} {...inputProps} autoFocus value={field.value} aria-invalid={Boolean(field.error)} />
<Button type="submit" isLoading={form.submitting} class="flex-shrink-0" disabled={field.value?.trim() === props.organization.name}>
Update name
{t('organization.settings.name.update')}
</Button>
</div>
{field.error && <div class="text-red-500 text-sm">{field.error}</div>}
@@ -149,7 +158,6 @@ const UpdateOrganizationNameCard: Component<{ organization: Organization }> = (p
<div class="text-red-500 text-sm">{form.response.message}</div>
</CardContent>
</Form>
</Card>
</div>
@@ -158,8 +166,9 @@ const UpdateOrganizationNameCard: Component<{ organization: Organization }> = (p
export const OrganizationsSettingsPage: Component = () => {
const params = useParams();
const { t } = useI18n();
const query = createQuery(() => ({
const query = useQuery(() => ({
queryKey: ['organizations', params.organizationId],
queryFn: () => fetchOrganization({ organizationId: params.organizationId }),
}));
@@ -171,11 +180,11 @@ export const OrganizationsSettingsPage: Component = () => {
{ getOrganization => (
<>
<h1 class="text-xl font-semibold mb-2">
Organization settings
{t('organization.settings.page.title')}
</h1>
<p class="text-muted-foreground">
Manage your organization settings here.
{t('organization.settings.page.description')}
</p>
<div class="mt-6 flex flex-col gap-6">

View File

@@ -1,19 +1,21 @@
import type { Component } from 'solid-js';
import { A, useNavigate } from '@solidjs/router';
import { createQuery } from '@tanstack/solid-query';
import { useQuery } from '@tanstack/solid-query';
import { createEffect, For, on } from 'solid-js';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { fetchOrganizations } from '../organizations.services';
export const OrganizationsPage: Component = () => {
const navigate = useNavigate();
const { t } = useI18n();
const queries = createQuery(() => ({
const query = useQuery(() => ({
queryKey: ['organizations'],
queryFn: fetchOrganizations,
}));
createEffect(on(
() => queries.data?.organizations,
() => query.data?.organizations,
(orgs) => {
if (orgs && orgs.length === 0) {
navigate('/organizations/first');
@@ -24,15 +26,15 @@ export const OrganizationsPage: Component = () => {
return (
<div class="p-6 mt-4 pb-32 max-w-5xl mx-auto">
<h2 class="text-xl font-bold mb-2">
Your organizations
{t('organizations.list.title')}
</h2>
<p class="text-muted-foreground mb-6">
Organizations are a way to group your documents and manage access to them. You can create multiple organizations and invite your team members to collaborate.
{t('organizations.list.description')}
</p>
<div class="grid gap-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
<For each={queries.data?.organizations}>
<For each={query.data?.organizations}>
{organization => (
<A
href={`/organizations/${organization.id}`}
@@ -43,7 +45,6 @@ export const OrganizationsPage: Component = () => {
</div>
<div class="p-4">
<div class="w-full text-left font-bold truncate block">
{organization.name}
</div>
@@ -56,7 +57,7 @@ export const OrganizationsPage: Component = () => {
<div class="i-tabler-plus size-16 text-muted-foreground op-50 group-hover:(text-primary op-100) transition" />
<div class="font-bold block text-muted-foreground">
Create new organization
{t('organizations.list.create-new')}
</div>
</A>
</div>

View File

@@ -1,20 +1,22 @@
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 NotFoundPage: Component = () => {
const { t } = useI18n();
return (
<div class="h-screen flex flex-col items-center justify-center p-6">
<div class="flex items-center flex-row sm:gap-24">
<div class="max-w-350px">
<h1 class="text-xl mr-4 py-2">404 - Not Found</h1>
<h1 class="text-xl mr-4 py-2">{t('not-found.title')}</h1>
<p class="text-muted-foreground">
Sorry, the page you are looking for does seem to exist. Please check the URL and try again.
{t('not-found.description')}
</p>
<Button as={A} href="/" class="mt-4" variant="default">
<div class="i-tabler-arrow-left mr-2"></div>
Go back to home
{t('not-found.back-to-home')}
</Button>
</div>

View File

@@ -1,7 +1,7 @@
import type { Component } from 'solid-js';
import type { TaggingRuleForCreation } from '../tagging-rules.types';
import { useNavigate, useParams } from '@solidjs/router';
import { createMutation } from '@tanstack/solid-query';
import { useMutation } from '@tanstack/solid-query';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { createToast } from '@/modules/ui/components/sonner';
import { TaggingRuleForm } from '../components/tagging-rule-form.component';
@@ -12,7 +12,7 @@ export const CreateTaggingRulePage: Component = () => {
const params = useParams();
const navigate = useNavigate();
const createTaggingRuleMutation = createMutation(() => ({
const createTaggingRuleMutation = useMutation(() => ({
mutationFn: async ({ taggingRule }: { taggingRule: TaggingRuleForCreation }) => {
await createTaggingRule({ taggingRule, organizationId: params.organizationId });
},

View File

@@ -1,7 +1,7 @@
import type { Component } from 'solid-js';
import type { TaggingRule } from '../tagging-rules.types';
import { A, useParams } from '@solidjs/router';
import { createMutation, createQuery } from '@tanstack/solid-query';
import { useMutation, useQuery } from '@tanstack/solid-query';
import { For, Match, Show, Switch } from 'solid-js';
import { useConfig } from '@/modules/config/config.provider';
import { useI18n } from '@/modules/i18n/i18n.provider';
@@ -28,7 +28,7 @@ const TaggingRuleCard: Component<{ taggingRule: TaggingRule }> = (props) => {
return t('tagging-rules.list.card.conditions', { count });
};
const deleteTaggingRuleMutation = createMutation(() => ({
const deleteTaggingRuleMutation = useMutation(() => ({
mutationFn: async () => {
await deleteTaggingRule({ organizationId: props.taggingRule.organizationId, taggingRuleId: props.taggingRule.id });
},
@@ -82,7 +82,7 @@ export const TaggingRulesPage: Component = () => {
const { config } = useConfig();
const params = useParams();
const query = createQuery(() => ({
const query = useQuery(() => ({
queryKey: ['organizations', params.organizationId, 'tagging-rules'],
queryFn: () => fetchTaggingRules({ organizationId: params.organizationId }),
}));

View File

@@ -1,7 +1,7 @@
import type { Component } from 'solid-js';
import type { TaggingRuleForCreation } from '../tagging-rules.types';
import { useNavigate, useParams } from '@solidjs/router';
import { createMutation, createQuery } from '@tanstack/solid-query';
import { useMutation, useQuery } from '@tanstack/solid-query';
import { Show } from 'solid-js';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { queryClient } from '@/modules/shared/query/query-client';
@@ -14,12 +14,12 @@ export const UpdateTaggingRulePage: Component = () => {
const params = useParams();
const navigate = useNavigate();
const query = createQuery(() => ({
const query = useQuery(() => ({
queryKey: ['organizations', params.organizationId, 'tagging-rules', params.taggingRuleId],
queryFn: () => getTaggingRule({ organizationId: params.organizationId, taggingRuleId: params.taggingRuleId }),
}));
const updateTaggingRuleMutation = createMutation(() => ({
const updateTaggingRuleMutation = useMutation(() => ({
mutationFn: async ({ taggingRule }: { taggingRule: TaggingRuleForCreation }) => {
await updateTaggingRule({ organizationId: params.organizationId, taggingRuleId: params.taggingRuleId, taggingRule });
},

View File

@@ -1,6 +1,6 @@
import type { Component } from 'solid-js';
import type { Tag } from '../tags.types';
import { createQuery } from '@tanstack/solid-query';
import { useQuery } from '@tanstack/solid-query';
import { createSignal, For } from 'solid-js';
import { Combobox, ComboboxContent, ComboboxInput, ComboboxItem, ComboboxTrigger } from '@/modules/ui/components/combobox';
import { fetchTags } from '../tags.services';
@@ -15,7 +15,7 @@ export const DocumentTagPicker: Component<{
}> = (props) => {
const [getSelectedTagIds, setSelectedTagIds] = createSignal<string[]>(props.tagIds);
const query = createQuery(() => ({
const query = useQuery(() => ({
queryKey: ['organizations', props.organizationId, 'tags'],
queryFn: () => fetchTags({ organizationId: props.organizationId }),
}));

View File

@@ -1,15 +1,17 @@
import type { DialogTriggerProps } from '@kobalte/core/dialog';
import type { Component, JSX } from 'solid-js';
import type { Tag as TagType } from '../tags.types';
import { safely } from '@corentinth/chisels';
import { getValues } from '@modular-forms/solid';
import { A, useParams } from '@solidjs/router';
import { createQuery } from '@tanstack/solid-query';
import { useQuery } from '@tanstack/solid-query';
import { createSignal, For, Show, Suspense } from 'solid-js';
import * as v from 'valibot';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { useConfirmModal } from '@/modules/shared/confirm';
import { timeAgo } from '@/modules/shared/date/time-ago';
import { createForm } from '@/modules/shared/form/form';
import { useI18nApiErrors } from '@/modules/shared/http/composables/i18n-api-errors';
import { queryClient } from '@/modules/shared/query/query-client';
import { Button } from '@/modules/ui/components/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/modules/ui/components/dialog';
@@ -26,25 +28,26 @@ const TagForm: Component<{
initialValues?: { name?: string; color?: string; description?: string | null };
submitLabel?: string;
}> = (props) => {
const { t } = useI18n();
const { form, Form, Field } = createForm({
onSubmit: props.onSubmit,
schema: v.object({
name: v.pipe(
v.string(),
v.trim(),
v.nonEmpty('Please enter a tag name'),
v.maxLength(64, 'Tag name must be less than 64 characters'),
v.nonEmpty(t('tags.form.name.required')),
v.maxLength(64, t('tags.form.name.max-length')),
),
color: v.pipe(
v.string(),
v.trim(),
v.nonEmpty('Please enter a color'),
v.hexColor('The hex color is badly formatted.'),
v.nonEmpty(t('tags.form.color.required')),
v.hexColor(t('tags.form.color.invalid')),
),
description: v.pipe(
v.string(),
v.trim(),
v.maxLength(256, 'Description must be less than 256 characters'),
v.maxLength(256, t('tags.form.description.max-length')),
),
}),
initialValues: {
@@ -60,8 +63,8 @@ const TagForm: Component<{
<Field name="name">
{(field, inputProps) => (
<TextFieldRoot class="flex flex-col gap-1 mb-4">
<TextFieldLabel for="name">Name</TextFieldLabel>
<TextField type="text" id="name" {...inputProps} autoFocus value={field.value} aria-invalid={Boolean(field.error)} placeholder="Eg. Contracts" />
<TextFieldLabel for="name">{t('tags.form.name.label')}</TextFieldLabel>
<TextField type="text" id="name" {...inputProps} autoFocus value={field.value} aria-invalid={Boolean(field.error)} placeholder={t('tags.form.name.placeholder')} />
{field.error && <div class="text-red-500 text-sm">{field.error}</div>}
</TextFieldRoot>
)}
@@ -70,8 +73,8 @@ const TagForm: Component<{
<Field name="color">
{(field, inputProps) => (
<TextFieldRoot class="flex flex-col gap-1 mb-4">
<TextFieldLabel for="color">Color</TextFieldLabel>
<TextField id="color" {...inputProps} autoFocus value={field.value} aria-invalid={Boolean(field.error)} placeholder="Eg. #FF0000" />
<TextFieldLabel for="color">{t('tags.form.color.label')}</TextFieldLabel>
<TextField id="color" {...inputProps} autoFocus value={field.value} aria-invalid={Boolean(field.error)} placeholder={t('tags.form.color.placeholder')} />
{field.error && <div class="text-red-500 text-sm">{field.error}</div>}
</TextFieldRoot>
)}
@@ -81,10 +84,10 @@ const TagForm: Component<{
{(field, inputProps) => (
<TextFieldRoot class="flex flex-col gap-1 mb-4">
<TextFieldLabel for="description">
Description
<span class="font-normal ml-1 text-muted-foreground">(optional)</span>
{t('tags.form.description.label')}
<span class="font-normal ml-1 text-muted-foreground">{t('tags.form.description.optional')}</span>
</TextFieldLabel>
<TextArea id="description" {...inputProps} autoFocus value={field.value} aria-invalid={Boolean(field.error)} placeholder="Eg. All the contracts signed by the company" />
<TextArea id="description" {...inputProps} autoFocus value={field.value} aria-invalid={Boolean(field.error)} placeholder={t('tags.form.description.placeholder')} />
{field.error && <div class="text-red-500 text-sm">{field.error}</div>}
</TextFieldRoot>
)}
@@ -92,7 +95,7 @@ const TagForm: Component<{
<div class="flex flex-row-reverse justify-between items-center mt-6">
<Button type="submit">
{props.submitLabel ?? 'Create tag'}
{props.submitLabel ?? t('tags.create')}
</Button>
{getFormValues().name && (
@@ -110,14 +113,24 @@ export const CreateTagModal: Component<{
organizationId: string;
}> = (props) => {
const [getIsModalOpen, setIsModalOpen] = createSignal(false);
const { t } = useI18n();
const { getErrorMessage } = useI18nApiErrors({ t });
const onSubmit = async ({ name, color, description }: { name: string; color: string; description: string }) => {
await createTag({
const [,error] = await safely(createTag({
name,
color,
description,
organizationId: props.organizationId,
});
}));
if (error) {
createToast({
message: getErrorMessage({ error }),
type: 'error',
});
return;
}
await queryClient.invalidateQueries({
queryKey: ['organizations', props.organizationId],
@@ -125,7 +138,7 @@ export const CreateTagModal: Component<{
});
createToast({
message: `Tag "${name}" created successfully.`,
message: t('tags.create.success', { name }),
type: 'success',
});
@@ -137,7 +150,7 @@ export const CreateTagModal: Component<{
<DialogTrigger as={props.children} />
<DialogContent>
<DialogHeader>
<DialogTitle>Create a new tag</DialogTitle>
<DialogTitle>{t('tags.create')}</DialogTitle>
</DialogHeader>
<TagForm onSubmit={onSubmit} initialValues={{ color: '#d8ff75' }} />
@@ -152,6 +165,7 @@ const UpdateTagModal: Component<{
tag: TagType;
}> = (props) => {
const [getIsModalOpen, setIsModalOpen] = createSignal(false);
const { t } = useI18n();
const onSubmit = async ({ name, color, description }: { name: string; color: string; description: string }) => {
await updateTag({
@@ -168,7 +182,7 @@ const UpdateTagModal: Component<{
});
createToast({
message: `Tag "${name}" updated successfully.`,
message: t('tags.update.success', { name }),
type: 'success',
});
@@ -180,10 +194,10 @@ const UpdateTagModal: Component<{
<DialogTrigger as={props.children} />
<DialogContent>
<DialogHeader>
<DialogTitle>Update tag</DialogTitle>
<DialogTitle>{t('tags.update')}</DialogTitle>
</DialogHeader>
<TagForm onSubmit={onSubmit} initialValues={props.tag} submitLabel="Update tag" />
<TagForm onSubmit={onSubmit} initialValues={props.tag} submitLabel={t('tags.update')} />
</DialogContent>
</Dialog>
);
@@ -194,21 +208,21 @@ export const TagsPage: Component = () => {
const { confirm } = useConfirmModal();
const { t } = useI18n();
const query = createQuery(() => ({
const query = useQuery(() => ({
queryKey: ['organizations', params.organizationId, 'tags'],
queryFn: () => fetchTags({ organizationId: params.organizationId }),
}));
const del = async ({ tag }: { tag: TagType }) => {
const confirmed = await confirm({
title: 'Delete tag',
message: 'Are you sure you want to delete this tag? Deleting a tag will remove it from all documents.',
title: t('tags.delete.confirm.title'),
message: t('tags.delete.confirm.message'),
cancelButton: {
text: 'Cancel',
text: t('tags.delete.confirm.cancel-button'),
variant: 'secondary',
},
confirmButton: {
text: 'Delete',
text: t('tags.delete.confirm.confirm-button'),
variant: 'destructive',
},
});
@@ -228,7 +242,7 @@ export const TagsPage: Component = () => {
});
createToast({
message: `Tag deleted successfully.`,
message: t('tags.delete.success'),
type: 'success',
});
};
@@ -261,11 +275,11 @@ export const TagsPage: Component = () => {
<div class="flex justify-between sm:items-center pb-6 gap-4 flex-col sm:flex-row">
<div>
<h2 class="text-xl font-bold ">
Documents Tags
{t('tags.title')}
</h2>
<p class="text-muted-foreground mt-1">
Tags are used to categorize documents. You can add tags to your documents to make them easier to find and organize.
{t('tags.description')}
</p>
</div>
@@ -274,7 +288,7 @@ export const TagsPage: Component = () => {
{props => (
<Button class="w-full" {...props}>
<div class="i-tabler-plus size-4 mr-2" />
Create tag
{t('tags.create')}
</Button>
)}
</CreateTagModal>
@@ -284,12 +298,12 @@ export const TagsPage: Component = () => {
<Table>
<TableHeader>
<TableRow>
<TableHead>Tag</TableHead>
<TableHead>Description</TableHead>
<TableHead>Documents</TableHead>
<TableHead>Created</TableHead>
<TableHead>{t('tags.table.headers.tag')}</TableHead>
<TableHead>{t('tags.table.headers.description')}</TableHead>
<TableHead>{t('tags.table.headers.documents')}</TableHead>
<TableHead>{t('tags.table.headers.created')}</TableHead>
<TableHead class="text-right">
Actions
{t('tags.table.headers.actions')}
</TableHead>
</TableRow>
</TableHeader>
@@ -302,7 +316,7 @@ export const TagsPage: Component = () => {
<Tag name={tag.name} color={tag.color} />
</div>
</TableCell>
<TableCell>{tag.description || <span class="text-muted-foreground">No description</span>}</TableCell>
<TableCell>{tag.description || <span class="text-muted-foreground">{t('tags.form.no-description')}</span>}</TableCell>
<TableCell>
<A href={`/organizations/${params.organizationId}/documents?tags=${tag.id}`} class="inline-flex items-center gap-1 hover:underline">
<div class="i-tabler-file-text size-5 text-muted-foreground" />

View File

@@ -38,7 +38,7 @@ export const OrganizationSettingsLayout: ParentComponent = (props) => {
<div class="i-tabler-arrow-left size-5"></div>
</Button>
<h1 class="text-base font-bold">
Organization Settings
{t('organization.settings.title')}
</h1>
</div>
)}

View File

@@ -3,7 +3,7 @@ import type { Component, ParentComponent } from 'solid-js';
import type { Organization } from '@/modules/organizations/organizations.types';
import { useNavigate, useParams } from '@solidjs/router';
import { createQueries, createQuery } from '@tanstack/solid-query';
import { createQueries, useQuery } from '@tanstack/solid-query';
import { get } from 'lodash-es';
import { createEffect, on } from 'solid-js';
import { DocumentUploadProvider } from '@/modules/documents/components/document-import-status.component';
@@ -148,7 +148,7 @@ export const OrganizationLayout: ParentComponent = (props) => {
const params = useParams();
const navigate = useNavigate();
const query = createQuery(() => ({
const query = useQuery(() => ({
queryKey: ['organizations', params.organizationId],
queryFn: () => fetchOrganization({ organizationId: params.organizationId }),
}));

View File

@@ -14,7 +14,7 @@ import { usePendingInvitationsCount } from '@/modules/invitations/composables/us
import { cn } from '@/modules/shared/style/cn';
import { useThemeStore } from '@/modules/theme/theme.store';
import { Button } from '@/modules/ui/components/button';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger } from '../components/dropdown-menu';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger } from '../components/dropdown-menu';
import { Sheet, SheetContent, SheetTrigger } from '../components/sheet';
import { Tooltip, TooltipContent, TooltipTrigger } from '../components/tooltip';
@@ -36,6 +36,12 @@ const MenuItemButton: Component<MenuItem> = (props) => {
);
};
function getReleaseUrl({ version, packageName = '@papra/app-server' }: { version: string; packageName?: string }) {
const encodedVersion = encodeURIComponent(`${packageName}@${version}`);
return `https://github.com/papra-hq/papra/releases/tag/${encodedVersion}`;
}
export const SideNav: Component<{
mainMenu?: MenuItem[];
footerMenu?: MenuItem[];
@@ -95,7 +101,7 @@ export const SideNav: Component<{
))}
</div>
<a class="text-xs text-muted-foreground text-center mt-auto transition-colors hover:(text-primary underline)" href={`https://github.com/papra-hq/papra/releases/tag/${version}`} target="_blank" rel="noopener noreferrer">
<a class="text-xs text-muted-foreground text-center mt-auto transition-colors hover:(text-primary underline)" href={getReleaseUrl({ version: config.papraVersion })} target="_blank" rel="noopener noreferrer">
{version}
</a>
@@ -127,20 +133,21 @@ export const SideNav: Component<{
export const ThemeSwitcher: Component = () => {
const themeStore = useThemeStore();
const { t } = useI18n();
return (
<>
<DropdownMenuItem onClick={() => themeStore.setColorMode({ mode: 'light' })} class="flex items-center gap-2 cursor-pointer">
<div class="i-tabler-sun text-lg"></div>
Light Mode
{t('layout.theme.light')}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => themeStore.setColorMode({ mode: 'dark' })} class="flex items-center gap-2 cursor-pointer">
<div class="i-tabler-moon text-lg"></div>
Dark Mode
{t('layout.theme.dark')}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => themeStore.setColorMode({ mode: 'system' })} class="flex items-center gap-2 cursor-pointer">
<div class="i-tabler-device-laptop text-lg"></div>
System Mode
{t('layout.theme.system')}
</DropdownMenuItem>
</>
);
@@ -154,9 +161,9 @@ export const LanguageSwitcher: Component = () => {
});
return (
<>
<DropdownMenuRadioGroup value={getLocale()} onChange={setLocale}>
{locales.map(locale => (
<DropdownMenuItem onClick={() => setLocale(locale.key)} class={cn('cursor-pointer', { 'font-bold': getLocale() === locale.key })}>
<DropdownMenuRadioItem value={locale.key} disabled={getLocale() === locale.key}>
<span translate="no" lang={getLocale() === locale.key ? undefined : locale.key}>
{locale.name}
</span>
@@ -167,9 +174,9 @@ export const LanguageSwitcher: Component = () => {
)
</span>
</Show>
</DropdownMenuItem>
</DropdownMenuRadioItem>
))}
</>
</DropdownMenuRadioGroup>
);
};
@@ -210,7 +217,7 @@ export const SidenavLayout: ParentComponent<{
{(props.showSearch ?? true) && (
<Button variant="outline" class="lg:min-w-64 justify-start" onClick={openCommandPalette}>
<div class="i-tabler-search size-4 mr-2"></div>
Search...
{t('layout.search.placeholder')}
</Button>
)}
</div>
@@ -220,7 +227,7 @@ export const SidenavLayout: ParentComponent<{
<Button onClick={promptImport}>
<div class="i-tabler-upload size-4"></div>
<span class="hidden sm:inline ml-2">
Import a document
{t('layout.menu.import-document')}
</span>
</Button>
@@ -243,20 +250,20 @@ export const SidenavLayout: ParentComponent<{
</div>
</Show>
</DropdownMenuTrigger>
<DropdownMenuContent class="w-42">
<DropdownMenuContent class="min-w-48">
<DropdownMenuItem class="flex items-center gap-2 cursor-pointer" as={A} href="/settings">
<div class="i-tabler-settings size-4 text-muted-foreground"></div>
Account settings
{t('user-menu.account-settings')}
</DropdownMenuItem>
<DropdownMenuItem class="flex items-center gap-2 cursor-pointer" as={A} href="/api-keys">
<div class="i-tabler-key size-4 text-muted-foreground"></div>
API keys
{t('user-menu.api-keys')}
</DropdownMenuItem>
<DropdownMenuItem class="flex items-center gap-2 cursor-pointer" as={A} href="/invitations">
<div class="i-tabler-mail-plus size-4 text-muted-foreground"></div>
{t('layout.menu.invitations')}
{t('user-menu.invitations')}
<Show when={getPendingInvitationsCount() > 0}>
<div class="ml-auto bg-primary text-primary-foreground rounded-xl text-xs px-1.5 py-0.8 font-bold leading-none">
{getPendingInvitationsCount() }
@@ -265,12 +272,13 @@ export const SidenavLayout: ParentComponent<{
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger class="flex items-center gap-2 cursor-pointer">
<div class="i-tabler-language size-4 text-muted-foreground"></div>
Language
{t('user-menu.language')}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuSubContent class="min-w-48">
<LanguageSwitcher />
</DropdownMenuSubContent>
</DropdownMenuSub>
@@ -283,7 +291,7 @@ export const SidenavLayout: ParentComponent<{
class="flex items-center gap-2 cursor-pointer"
>
<div class="i-tabler-logout size-4 text-muted-foreground"></div>
Logout
{t('user-menu.logout')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>

View File

@@ -1,9 +1,10 @@
import type { Component } from 'solid-js';
import { useNavigate } from '@solidjs/router';
import { createQuery } from '@tanstack/solid-query';
import { useQuery } from '@tanstack/solid-query';
import { createSignal, Show, Suspense } from 'solid-js';
import * as v from 'valibot';
import { signOut } from '@/modules/auth/auth.services';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { createForm } from '@/modules/shared/form/form';
import { Button } from '@/modules/ui/components/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/modules/ui/components/card';
@@ -16,6 +17,7 @@ import { fetchCurrentUser } from '../users.services';
const LogoutCard: Component = () => {
const [getIsLoading, setIsLoading] = createSignal(false);
const navigate = useNavigate();
const { t } = useI18n();
const handleLogout = async () => {
setIsLoading(true);
@@ -26,29 +28,31 @@ const LogoutCard: Component = () => {
return (
<Card class="flex flex-row justify-between items-center p-6 border-destructive">
<div class="flex flex-col gap-1.5">
<CardTitle>Logout</CardTitle>
<CardTitle>{t('user.settings.logout.title')}</CardTitle>
<CardDescription>
Logout from your account. You can login again later.
{t('user.settings.logout.description')}
</CardDescription>
</div>
<Button onClick={handleLogout} variant="destructive" isLoading={getIsLoading()}>
Logout
{t('user.settings.logout.button')}
</Button>
</Card>
);
};
const UserEmailCard: Component<{ email: string }> = (props) => {
const { t } = useI18n();
return (
<Card>
<CardHeader class="border-b">
<CardTitle>Email address</CardTitle>
<CardDescription>Your email address cannot be changed.</CardDescription>
<CardTitle>{t('user.settings.email.title')}</CardTitle>
<CardDescription>{t('user.settings.email.description')}</CardDescription>
</CardHeader>
<CardContent class="pt-6">
<TextFieldRoot>
<TextFieldLabel for="email" class="sr-only">
Email address
{t('user.settings.email.label')}
</TextFieldLabel>
<TextField id="email" value={props.email} disabled readOnly />
</TextFieldRoot>
@@ -59,6 +63,7 @@ const UserEmailCard: Component<{ email: string }> = (props) => {
const UpdateFullNameCard: Component<{ name: string }> = (props) => {
const { updateCurrentUser } = useUpdateCurrentUser();
const { t } = useI18n();
const { form, Form, Field } = createForm({
schema: v.object({
@@ -72,15 +77,15 @@ const UpdateFullNameCard: Component<{ name: string }> = (props) => {
name: name.trim(),
});
createToast({ type: 'success', message: 'Your full name has been updated' });
createToast({ type: 'success', message: t('user.settings.name.updated') });
},
});
return (
<Card>
<CardHeader class="border-b">
<CardTitle>Full name</CardTitle>
<CardDescription>Your full name is displayed to other organization members.</CardDescription>
<CardTitle>{t('user.settings.name.title')}</CardTitle>
<CardDescription>{t('user.settings.name.description')}</CardDescription>
</CardHeader>
<Form>
@@ -89,13 +94,13 @@ const UpdateFullNameCard: Component<{ name: string }> = (props) => {
{(field, inputProps) => (
<TextFieldRoot class="flex flex-col gap-1">
<TextFieldLabel for="name" class="sr-only">
Full name
{t('user.settings.name.label')}
</TextFieldLabel>
<div class="flex gap-2 flex-col sm:flex-row">
<TextField
type="text"
id="name"
placeholder="Eg. John Doe"
placeholder={t('user.settings.name.placeholder')}
{...inputProps}
value={field.value}
aria-invalid={Boolean(field.error)}
@@ -106,7 +111,7 @@ const UpdateFullNameCard: Component<{ name: string }> = (props) => {
class="flex-shrink-0"
disabled={field.value?.trim() === props.name}
>
Update name
{t('user.settings.name.update')}
</Button>
</div>
{field.error && <div class="text-red-500 text-sm">{field.error}</div>}
@@ -122,7 +127,8 @@ const UpdateFullNameCard: Component<{ name: string }> = (props) => {
};
export const UserSettingsPage: Component = () => {
const query = createQuery(() => ({
const { t } = useI18n();
const query = useQuery(() => ({
queryKey: ['users', 'me'],
queryFn: fetchCurrentUser,
}));
@@ -134,8 +140,8 @@ export const UserSettingsPage: Component = () => {
{getUser => (
<>
<div class="border-b pb-4">
<h1 class="text-2xl font-semibold mb-1">User settings</h1>
<p class="text-muted-foreground">Manage your account settings here.</p>
<h1 class="text-2xl font-semibold mb-1">{t('user.settings.title')}</h1>
<p class="text-muted-foreground">{t('user.settings.description')}</p>
</div>
<div class="mt-6 flex flex-col gap-6">

View File

@@ -9,6 +9,7 @@ export type UserMe = {
export type User = {
id: string;
email: string;
name: string;
createdAt: Date;
updatedAt: Date;
provider: string;

View File

@@ -2,7 +2,7 @@ import type { Component } from 'solid-js';
import type { Webhook } from '../webhooks.types';
import { setValue } from '@modular-forms/solid';
import { A, useNavigate, useParams } from '@solidjs/router';
import { createQuery } from '@tanstack/solid-query';
import { useQuery } from '@tanstack/solid-query';
import { createSignal, Show, Suspense } from 'solid-js';
import * as v from 'valibot';
import { useI18n } from '@/modules/i18n/i18n.provider';
@@ -171,7 +171,7 @@ export const EditWebhookPage: Component = () => {
const { t } = useI18n();
const params = useParams();
const webhookQuery = createQuery(() => ({
const webhookQuery = useQuery(() => ({
queryKey: ['webhook', params.organizationId, params.webhookId],
queryFn: () => fetchWebhook({
organizationId: params.organizationId,

View File

@@ -1,7 +1,7 @@
import type { Component } from 'solid-js';
import type { Webhook } from '../webhooks.types';
import { A, useParams } from '@solidjs/router';
import { createMutation, createQuery } from '@tanstack/solid-query';
import { useMutation, useQuery } from '@tanstack/solid-query';
import { format } from 'date-fns';
import { For, Match, Show, Suspense, Switch } from 'solid-js';
import { useI18n } from '@/modules/i18n/i18n.provider';
@@ -17,7 +17,7 @@ export const WebhookCard: Component<{ webhook: Webhook }> = ({ webhook }) => {
const { confirm } = useConfirmModal();
const params = useParams();
const deleteWebhookMutation = createMutation(() => ({
const deleteWebhookMutation = useMutation(() => ({
mutationFn: deleteWebhook,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['webhooks', params.organizationId] });
@@ -95,7 +95,7 @@ export const WebhookCard: Component<{ webhook: Webhook }> = ({ webhook }) => {
export const WebhooksPage: Component = () => {
const { t } = useI18n();
const params = useParams();
const query = createQuery(() => ({
const query = useQuery(() => ({
queryKey: ['webhooks', params.organizationId],
queryFn: () => fetchWebhooks({ organizationId: params.organizationId }),
}));

View File

@@ -1,6 +1,6 @@
import type { RouteDefinition } from '@solidjs/router';
import { Navigate, useParams } from '@solidjs/router';
import { createQuery } from '@tanstack/solid-query';
import { useQuery } from '@tanstack/solid-query';
import { Match, Show, Suspense, Switch } from 'solid-js';
import { ApiKeysPage } from './modules/api-keys/pages/api-keys.page';
import { CreateApiKeyPage } from './modules/api-keys/pages/create-api-key.page';
@@ -18,6 +18,7 @@ import { InvitationsPage } from './modules/invitations/pages/invitations.page';
import { fetchOrganizations } from './modules/organizations/organizations.services';
import { CreateFirstOrganizationPage } from './modules/organizations/pages/create-first-organization.page';
import { CreateOrganizationPage } from './modules/organizations/pages/create-organization.page';
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 { OrganizationPage } from './modules/organizations/pages/organization.page';
@@ -47,7 +48,7 @@ export const routes: RouteDefinition[] = [
component: () => {
const { getLatestOrganizationId } = useCurrentUser();
const query = createQuery(() => ({
const query = useQuery(() => ({
queryKey: ['organizations'],
queryFn: fetchOrganizations,
}));
@@ -139,6 +140,10 @@ export const routes: RouteDefinition[] = [
path: '/invite',
component: InviteMemberPage,
},
{
path: '/invitations',
component: InvitationsListPage,
},
],
},

View File

@@ -0,0 +1,73 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
const dirname = path.dirname(new URL(import.meta.url).pathname);
function getLineKey({ line }: { line: string }) {
return line.split(': ')[0].trim();
}
function indexLinesByKeys(yaml: string) {
const lines = yaml.split('\n');
const indexedLines: Record<string, string> = {};
for (const line of lines) {
const trimmedLine = line.trim();
if (trimmedLine.startsWith('#') || trimmedLine === '') {
continue;
}
const key = getLineKey({ line });
indexedLines[key] = trimmedLine;
}
return indexedLines;
}
function syncLocaleFiles() {
const localesDir = path.join(dirname, '../locales');
const enFile = path.join(localesDir, 'en.yml');
const enContent = fs.readFileSync(enFile, 'utf8');
const enLines = enContent.split('\n');
const files = fs
.readdirSync(localesDir)
.filter(file => file.endsWith('.yml') && file !== 'en.yml');
for (const file of files) {
const targetFile = path.join(localesDir, file);
console.log(`Syncing ${file} with en.yml`);
const targetContent = fs.readFileSync(targetFile, 'utf8');
const targetYaml = indexLinesByKeys(targetContent);
const newContent = enLines
.map((enLine) => {
// Reflect empty lines from en.yml
if (enLine.trim() === '') {
return '';
}
// Reflect comments from en.yml
if (enLine.trim().startsWith('#')) {
return enLine;
}
const targetLine = targetYaml[getLineKey({ line: enLine })];
// If a translation key exists in the target file, use it
if (targetLine) {
return targetLine;
}
// If the translation key does not exist in the target file, add a comment with the one from en.yml
return `# ${enLine}`;
})
.join('\n');
fs.writeFileSync(targetFile, newContent);
}
}
syncLocaleFiles();

View File

@@ -9,7 +9,7 @@ import {
} from 'unocss';
import { presetAnimations } from 'unocss-preset-animations';
import { ssoProviders } from './src/modules/auth/auth.constants';
import { iconByFileType } from './src/modules/documents/document.models';
import { documentActivityIcon, iconByFileType } from './src/modules/documents/document.models';
export default defineConfig({
presets: [
@@ -113,7 +113,10 @@ export default defineConfig({
},
},
safelist: [
...uniq(values(iconByFileType)),
...(ssoProviders.map(p => p.icon)),
...uniq([
...values(iconByFileType),
...values(documentActivityIcon),
...(ssoProviders.map(p => p.icon)),
]),
],
});

View File

@@ -1,5 +1,46 @@
# @papra/app-server
## 0.6.3
### Patch Changes
- [#357](https://github.com/papra-hq/papra/pull/357) [`585c53c`](https://github.com/papra-hq/papra/commit/585c53cd9d0d7dbd517dbb1adddfd9e7b70f9fe5) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added a /llms.txt on main website
- [#366](https://github.com/papra-hq/papra/pull/366) [`b8c2bd7`](https://github.com/papra-hq/papra/commit/b8c2bd70e3d0c215da34efcdcdf1b75da1ed96a1) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Allow for adding/removing tags to document using api keys
## 0.6.2
### Patch Changes
- [#337](https://github.com/papra-hq/papra/pull/337) [`1c574b8`](https://github.com/papra-hq/papra/commit/1c574b8305eb7bde4f1b75ac38a610ca0120a613) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Ensure database directory exists when running scripts (like migrations)
## 0.6.1
### Patch Changes
- [#326](https://github.com/papra-hq/papra/pull/326) [`17ca8f8`](https://github.com/papra-hq/papra/commit/17ca8f8f8110c3ffb550f67bfba817872370171c) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Fix content disposition header to support non-ascii filenames
## 0.6.0
### Minor Changes
- [#320](https://github.com/papra-hq/papra/pull/320) [`8ccdb74`](https://github.com/papra-hq/papra/commit/8ccdb748349a3cacf38f032fd4d3beebce202487) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Set CLIENT_BASE_URL default value to http://localhost:1221 in Dockerfiles
- [#317](https://github.com/papra-hq/papra/pull/317) [`79c1d32`](https://github.com/papra-hq/papra/commit/79c1d3206b140cf8b3d33ef8bda6098dcf4c9c9c) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added document activity log
- [#319](https://github.com/papra-hq/papra/pull/319) [`60059c8`](https://github.com/papra-hq/papra/commit/60059c895c4860cbfda69d3c989ad00542def65b) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added pending invitation management page
- [#306](https://github.com/papra-hq/papra/pull/306) [`f0876fd`](https://github.com/papra-hq/papra/commit/f0876fdc638d596c5b7f5eeb2e6cd9beecab328f) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added support for classic SMTP client for email sending
- [#304](https://github.com/papra-hq/papra/pull/304) [`cb38d66`](https://github.com/papra-hq/papra/commit/cb38d66485368429027826d7a1630e75fbe52e65) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Reworked the email sending system to be more flexible and allow for different drivers to be used.
`EMAILS_DRY_RUN` has been removed and you can now use `EMAILS_DRIVER=logger` config option to log emails instead of sending them.
## 0.5.1
### Patch Changes
- [#302](https://github.com/papra-hq/papra/pull/302) [`b62ddf2`](https://github.com/papra-hq/papra/commit/b62ddf2bc4d1b134b14c847ffa30b65cb29489af) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Set email setting to dry-run by default in docker
## 0.5.0
### Minor Changes

View File

@@ -0,0 +1,12 @@
CREATE TABLE `document_activity_log` (
`id` text PRIMARY KEY NOT NULL,
`created_at` integer NOT NULL,
`document_id` text NOT NULL,
`event` text NOT NULL,
`event_data` text,
`user_id` text,
`tag_id` text,
FOREIGN KEY (`document_id`) REFERENCES `documents`(`id`) ON UPDATE cascade ON DELETE cascade,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE cascade ON DELETE no action,
FOREIGN KEY (`tag_id`) REFERENCES `tags`(`id`) ON UPDATE cascade ON DELETE no action
);

File diff suppressed because it is too large Load Diff

View File

@@ -43,6 +43,13 @@
"when": 1747575029264,
"tag": "0005_organizations-invitations-improvement",
"breakpoints": true
},
{
"idx": 6,
"version": "6",
"when": 1748554484124,
"tag": "0006_document-activity-log",
"breakpoints": true
}
]
}

View File

@@ -1,7 +1,7 @@
{
"name": "@papra/app-server",
"type": "module",
"version": "0.5.0",
"version": "0.6.3",
"private": true,
"packageManager": "pnpm@10.9.0",
"description": "Papra app server",
@@ -30,14 +30,14 @@
"stripe:webhook": "stripe listen --forward-to localhost:1221/api/stripe/webhook"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.722.0",
"@aws-sdk/lib-storage": "^3.722.0",
"@aws-sdk/client-s3": "^3.817.0",
"@aws-sdk/lib-storage": "^3.817.0",
"@azure/storage-blob": "^12.27.0",
"@corentinth/chisels": "^1.1.0",
"@corentinth/chisels": "^1.3.1",
"@corentinth/friendly-ids": "^0.0.1",
"@crowlog/async-context-plugin": "^1.0.0",
"@crowlog/logger": "^1.1.0",
"@hono/node-server": "^1.13.7",
"@crowlog/async-context-plugin": "^1.2.1",
"@crowlog/logger": "^1.2.1",
"@hono/node-server": "^1.14.3",
"@libsql/client": "^0.14.0",
"@owlrelay/api-sdk": "^0.0.2",
"@owlrelay/webhook": "^0.0.3",
@@ -46,42 +46,44 @@
"@paralleldrive/cuid2": "^2.2.2",
"backblaze-b2": "^1.7.0",
"better-auth": "catalog:",
"c12": "^3.0.2",
"c12": "^3.0.4",
"chokidar": "^4.0.3",
"date-fns": "^4.1.0",
"drizzle-kit": "^0.30.6",
"drizzle-orm": "^0.38.3",
"drizzle-orm": "^0.38.4",
"figue": "^2.2.3",
"hono": "^4.6.15",
"hono": "^4.7.10",
"lodash-es": "^4.17.21",
"mime-types": "^3.0.1",
"nanoid": "^5.1.5",
"node-cron": "^3.0.3",
"nodemailer": "^7.0.3",
"p-limit": "^6.2.0",
"p-queue": "^8.1.0",
"picomatch": "^4.0.2",
"posthog-node": "^4.11.1",
"resend": "^4.1.2",
"posthog-node": "^4.17.2",
"resend": "^4.5.1",
"sanitize-html": "^2.17.0",
"stripe": "^17.7.0",
"tsx": "^4.19.2",
"zod": "^3.24.1"
"tsx": "^4.19.4",
"zod": "^3.25.28"
},
"devDependencies": {
"@antfu/eslint-config": "catalog:",
"@crowlog/pretty": "^1.1.1",
"@crowlog/pretty": "^1.2.1",
"@total-typescript/ts-reset": "^0.6.1",
"@types/backblaze-b2": "^1.5.6",
"@types/lodash-es": "^4.17.12",
"@types/mime-types": "^2.1.4",
"@types/node": "^22.10.2",
"@types/node": "catalog:",
"@types/node-cron": "^3.0.11",
"@types/nodemailer": "^6.4.17",
"@types/picomatch": "^4.0.0",
"@types/sanitize-html": "^2.16.0",
"@vitest/coverage-v8": "catalog:",
"esbuild": "^0.24.2",
"eslint": "catalog:",
"memfs": "^4.17.0",
"memfs": "^4.17.2",
"typescript": "catalog:",
"vitest": "catalog:"
}

View File

@@ -105,7 +105,7 @@ describe('auth models', () => {
});
});
test('when the auth type is api-key, at least one permission must match', () => {
test('when the auth type is api-key, all permissions must match', () => {
expect(isAuthenticationValid({
authType: 'api-key',
apiKey: {
@@ -136,6 +136,22 @@ describe('auth models', () => {
permissions: ['documents:create'],
} as ApiKey,
requiredApiKeyPermissions: ['documents:create', 'documents:read'],
})).to.eql(false);
expect(isAuthenticationValid({
authType: 'api-key',
apiKey: {
permissions: ['documents:create', 'documents:read'],
} as ApiKey,
requiredApiKeyPermissions: ['documents:create', 'documents:read'],
})).to.eql(true);
expect(isAuthenticationValid({
authType: 'api-key',
apiKey: {
permissions: ['documents:create', 'documents:read', 'documents:update'],
} as ApiKey,
requiredApiKeyPermissions: ['documents:create', 'documents:read'],
})).to.eql(true);
});

View File

@@ -71,9 +71,9 @@ export function isAuthenticationValid({
return false;
}
const atLeastOnePermissionMatches = apiKey.permissions.some(permission => requiredApiKeyPermissions.includes(permission));
const allPermissionsMatch = requiredApiKeyPermissions.every(permission => apiKey.permissions.includes(permission));
return atLeastOnePermissionMatches;
return allPermissionsMatch;
}
if (authType === 'session' && session) {

View File

@@ -84,7 +84,7 @@ export function getAuth({
advanced: {
// Drizzle tables handle the id generation
generateId: false,
database: { generateId: false },
},
socialProviders: {
github: {

View File

@@ -31,6 +31,6 @@ export async function ensureLocalDatabaseDirectoryExists({ config }: { config: C
logger.info({ dbUrl: url, dbDir, dbPath }, 'Database directory missing, created it');
}
} catch (error) {
logger.error({ error, dbDir, dbPath }, 'Failed to ensure that the database directory exists, error while creating the directory');
logger.error({ error, dbDir, dbPath }, 'Failed to ensure that the database directory exists, error while creating the directory. Please see https://docs.papra.app/resources/troubleshooting/#failed-to-ensure-that-the-database-directory-exists for more information.');
}
}

View File

@@ -2,7 +2,7 @@ import type { Database } from './database.types';
import { apiKeyOrganizationsTable, apiKeysTable } from '../../api-keys/api-keys.tables';
import { documentsTable } from '../../documents/documents.table';
import { intakeEmailsTable } from '../../intake-emails/intake-emails.tables';
import { organizationMembersTable, organizationsTable } from '../../organizations/organizations.table';
import { organizationInvitationsTable, organizationMembersTable, organizationsTable } from '../../organizations/organizations.table';
import { organizationSubscriptionsTable } from '../../subscriptions/subscriptions.tables';
import { taggingRuleActionsTable, taggingRuleConditionsTable, taggingRulesTable } from '../../tagging-rules/tagging-rules.tables';
import { documentsTagsTable, tagsTable } from '../../tags/tags.table';
@@ -42,6 +42,7 @@ const seedTables = {
webhooks: webhooksTable,
webhookEvents: webhookEventsTable,
webhookDeliveries: webhookDeliveriesTable,
organizationInvitations: organizationInvitationsTable,
} as const;
type SeedTablesRows = {

View File

@@ -1,6 +1,7 @@
import type { RouteDefinitionContext } from './server.types';
import { registerApiKeysRoutes } from '../api-keys/api-keys.routes';
import { registerConfigRoutes } from '../config/config.routes';
import { registerDocumentActivityRoutes } from '../documents/document-activity/document-activity.routes';
import { registerDocumentsRoutes } from '../documents/documents.routes';
import { registerIntakeEmailsRoutes } from '../intake-emails/intake-emails.routes';
import { registerInvitationsRoutes } from '../invitations/invitations.routes';
@@ -27,4 +28,5 @@ export function registerRoutes(context: RouteDefinitionContext) {
registerApiKeysRoutes(context);
registerWebhooksRoutes(context);
registerInvitationsRoutes(context);
registerDocumentActivityRoutes(context);
}

View File

@@ -29,21 +29,15 @@ export const configDefinition = {
},
client: {
baseUrl: {
doc: 'The URL of the client',
doc: 'The URL of the client, when using docker, it should be the same as the server baseUrl',
schema: z.string().url(),
default: 'http://localhost:3000',
env: 'CLIENT_BASE_URL',
},
oauthRedirectUrl: {
doc: 'The URL to redirect to after OAuth',
schema: z.string().url(),
default: 'http://localhost:3000/confirm',
env: 'CLIENT_OAUTH_REDIRECT_URL',
},
},
server: {
baseUrl: {
doc: 'The base URL of the server',
doc: 'The base URL of the server, when using docker, it should be the same as the client baseUrl',
schema: z.string().url(),
default: 'http://localhost:1221',
env: 'SERVER_BASE_URL',

View File

@@ -0,0 +1,10 @@
export const DOCUMENT_ACTIVITY_EVENTS = {
CREATED: 'created',
UPDATED: 'updated',
DELETED: 'deleted',
RESTORED: 'restored',
TAGGED: 'tagged',
UNTAGGED: 'untagged',
} as const;
export const DOCUMENT_ACTIVITY_EVENT_LIST = Object.values(DOCUMENT_ACTIVITY_EVENTS);

View File

@@ -0,0 +1,101 @@
import type { Database } from '../../app/database/database.types';
import type { DocumentActivityEvent } from './document-activity.types';
import { injectArguments } from '@corentinth/chisels';
import { and, desc, eq, getTableColumns } from 'drizzle-orm';
import { withPagination } from '../../shared/db/pagination';
import { tagsTable } from '../../tags/tags.table';
import { usersTable } from '../../users/users.table';
import { documentsTable } from '../documents.table';
import { documentActivityLogTable } from './document-activity.table';
export type DocumentActivityRepository = ReturnType<typeof createDocumentActivityRepository>;
export function createDocumentActivityRepository({ db }: { db: Database }) {
return injectArguments(
{
saveDocumentActivity,
getOrganizationDocumentActivities,
},
{ db },
);
}
async function saveDocumentActivity({
documentId,
event,
eventData,
userId,
tagId,
db,
}: {
documentId: string;
event: DocumentActivityEvent;
eventData?: Record<string, unknown>;
userId?: string;
tagId?: string;
db: Database;
}) {
const [activity] = await db
.insert(documentActivityLogTable)
.values({
documentId,
event,
eventData,
userId,
tagId,
})
.returning();
return { activity };
}
async function getOrganizationDocumentActivities({
organizationId,
documentId,
pageIndex,
pageSize,
db,
}: {
organizationId: string;
documentId: string;
pageIndex: number;
pageSize: number;
db: Database;
}) {
const query = db
.select({
...getTableColumns(documentActivityLogTable),
user: {
id: usersTable.id,
name: usersTable.name,
},
tag: {
id: tagsTable.id,
name: tagsTable.name,
color: tagsTable.color,
description: tagsTable.description,
},
})
.from(documentActivityLogTable)
// Join with documents table to ensure the document exists in the organization
.innerJoin(documentsTable, eq(documentActivityLogTable.documentId, documentsTable.id))
.leftJoin(usersTable, eq(documentActivityLogTable.userId, usersTable.id))
.leftJoin(tagsTable, eq(documentActivityLogTable.tagId, tagsTable.id))
.where(
and(
eq(documentsTable.organizationId, organizationId),
eq(documentActivityLogTable.documentId, documentId),
),
);
const activities = await withPagination(
query.$dynamic(),
{
orderByColumn: desc(documentActivityLogTable.createdAt),
pageIndex,
pageSize,
},
);
return { activities };
}

View File

@@ -0,0 +1,51 @@
import type { RouteDefinitionContext } from '../../app/server.types';
import { z } from 'zod';
import { requireAuthentication } from '../../app/auth/auth.middleware';
import { getUser } from '../../app/auth/auth.models';
import { organizationIdSchema } from '../../organizations/organization.schemas';
import { createOrganizationsRepository } from '../../organizations/organizations.repository';
import { ensureUserIsInOrganization } from '../../organizations/organizations.usecases';
import { validateParams, validateQuery } from '../../shared/validation/validation';
import { documentIdSchema } from '../documents.schemas';
import { createDocumentActivityRepository } from './document-activity.repository';
export function registerDocumentActivityRoutes(context: RouteDefinitionContext) {
setupGetOrganizationDocumentActivitiesRoute(context);
}
function setupGetOrganizationDocumentActivitiesRoute({ app, db }: RouteDefinitionContext) {
app.get(
'/api/organizations/:organizationId/documents/:documentId/activity',
requireAuthentication({ apiKeyPermissions: ['documents:read'] }),
validateParams(z.object({
organizationId: organizationIdSchema,
documentId: documentIdSchema,
})),
validateQuery(
z.object({
pageIndex: z.coerce.number().min(0).int().optional().default(0),
pageSize: z.coerce.number().min(1).max(100).int().optional().default(100),
}),
),
async (context) => {
const { userId } = getUser({ context });
const { organizationId, documentId } = context.req.valid('param');
const { pageIndex, pageSize } = context.req.valid('query');
const organizationsRepository = createOrganizationsRepository({ db });
const documentActivityRepository = createDocumentActivityRepository({ db });
await ensureUserIsInOrganization({ userId, organizationId, organizationsRepository });
const { activities } = await documentActivityRepository.getOrganizationDocumentActivities({
organizationId,
documentId,
pageIndex,
pageSize,
});
return context.json({ activities });
},
);
}

View File

@@ -0,0 +1,20 @@
import type { NonEmptyArray } from '../../shared/types';
import type { DocumentActivityEvent } from './document-activity.types';
import { sqliteTable, text } from 'drizzle-orm/sqlite-core';
import { createCreatedAtField, createPrimaryKeyField } from '../../shared/db/columns.helpers';
import { tagsTable } from '../../tags/tags.table';
import { usersTable } from '../../users/users.table';
import { documentsTable } from '../documents.table';
import { DOCUMENT_ACTIVITY_EVENT_LIST } from './document-activity.constants';
export const documentActivityLogTable = sqliteTable('document_activity_log', {
...createPrimaryKeyField({ prefix: 'doc_act' }),
...createCreatedAtField(),
documentId: text('document_id').notNull().references(() => documentsTable.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
event: text('event', { enum: DOCUMENT_ACTIVITY_EVENT_LIST as NonEmptyArray<DocumentActivityEvent> }).notNull(),
eventData: text('event_data', { mode: 'json' }).$type<Record<string, unknown>>(),
userId: text('user_id').references(() => usersTable.id, { onDelete: 'no action', onUpdate: 'cascade' }),
tagId: text('tag_id').references(() => tagsTable.id, { onDelete: 'no action', onUpdate: 'cascade' }),
});

View File

@@ -0,0 +1,3 @@
import type { DOCUMENT_ACTIVITY_EVENTS } from './document-activity.constants';
export type DocumentActivityEvent = (typeof DOCUMENT_ACTIVITY_EVENTS)[keyof typeof DOCUMENT_ACTIVITY_EVENTS];

View File

@@ -0,0 +1,41 @@
import type { Logger } from '@crowlog/logger';
import type { DocumentActivityRepository } from './document-activity.repository';
import type { DocumentActivityEvent } from './document-activity.types';
import { createDeferable } from '../../shared/async/defer';
import { createLogger } from '../../shared/logger/logger';
export async function registerDocumentActivityLog({
documentId,
event,
eventData,
userId,
tagId,
documentActivityRepository,
logger = createLogger({ namespace: 'document-activity-log' }),
}: {
documentId: string;
event: DocumentActivityEvent;
eventData?: Record<string, unknown>;
userId?: string;
tagId?: string;
documentActivityRepository: DocumentActivityRepository;
logger?: Logger;
}) {
await documentActivityRepository.saveDocumentActivity({
documentId,
event,
eventData,
userId,
tagId,
});
logger.info({
documentId,
event,
eventData,
userId,
tagId,
}, 'Document activity log registered');
}
export const deferRegisterDocumentActivityLog = createDeferable(registerDocumentActivityLog);

View File

@@ -6,19 +6,17 @@ import { getUser } from '../app/auth/auth.models';
import { organizationIdSchema } from '../organizations/organization.schemas';
import { createOrganizationsRepository } from '../organizations/organizations.repository';
import { ensureUserIsInOrganization } from '../organizations/organizations.usecases';
import { createPlansRepository } from '../plans/plans.repository';
import { createError } from '../shared/errors/errors';
import { validateFormData, validateJsonBody, validateParams, validateQuery } from '../shared/validation/validation';
import { createSubscriptionsRepository } from '../subscriptions/subscriptions.repository';
import { createTaggingRulesRepository } from '../tagging-rules/tagging-rules.repository';
import { createTagsRepository } from '../tags/tags.repository';
import { createWebhookRepository } from '../webhooks/webhook.repository';
import { triggerWebhooks } from '../webhooks/webhook.usecases';
import { createDocumentActivityRepository } from './document-activity/document-activity.repository';
import { deferRegisterDocumentActivityLog } from './document-activity/document-activity.usecases';
import { createDocumentIsNotDeletedError } from './documents.errors';
import { isDocumentSizeLimitEnabled } from './documents.models';
import { createDocumentsRepository } from './documents.repository';
import { documentIdSchema } from './documents.schemas';
import { createDocument, deleteAllTrashDocuments, deleteTrashDocument, ensureDocumentExists, getDocumentOrThrow } from './documents.usecases';
import { createDocumentCreationUsecase, deleteAllTrashDocuments, deleteTrashDocument, ensureDocumentExists, getDocumentOrThrow } from './documents.usecases';
import { createDocumentStorageService } from './storage/documents.storage.services';
export function registerDocumentsRoutes(context: RouteDefinitionContext) {
@@ -89,26 +87,16 @@ function setupCreateDocumentRoute({ app, config, db, trackingServices }: RouteDe
});
}
const documentsRepository = createDocumentsRepository({ db });
const documentsStorageService = await createDocumentStorageService({ config });
const plansRepository = createPlansRepository({ config });
const subscriptionsRepository = createSubscriptionsRepository({ db });
const taggingRulesRepository = createTaggingRulesRepository({ db });
const tagsRepository = createTagsRepository({ db });
const webhookRepository = createWebhookRepository({ db });
const createDocument = await createDocumentCreationUsecase({
db,
config,
trackingServices,
});
const { document } = await createDocument({
file,
userId,
organizationId,
documentsRepository,
documentsStorageService,
plansRepository,
subscriptionsRepository,
trackingServices,
taggingRulesRepository,
tagsRepository,
webhookRepository,
});
return context.json({
@@ -245,6 +233,8 @@ function setupDeleteDocumentRoute({ app, db }: RouteDefinitionContext) {
const documentsRepository = createDocumentsRepository({ db });
const organizationsRepository = createOrganizationsRepository({ db });
const webhookRepository = createWebhookRepository({ db });
const documentActivityRepository = createDocumentActivityRepository({ db });
await ensureUserIsInOrganization({ userId, organizationId, organizationsRepository });
await ensureDocumentExists({ documentId, organizationId, documentsRepository });
@@ -257,6 +247,13 @@ function setupDeleteDocumentRoute({ app, db }: RouteDefinitionContext) {
payload: { documentId, organizationId },
});
deferRegisterDocumentActivityLog({
documentId,
event: 'deleted',
userId,
documentActivityRepository,
});
return context.json({
success: true,
});
@@ -279,6 +276,7 @@ function setupRestoreDocumentRoute({ app, db }: RouteDefinitionContext) {
const documentsRepository = createDocumentsRepository({ db });
const organizationsRepository = createOrganizationsRepository({ db });
const documentActivityRepository = createDocumentActivityRepository({ db });
await ensureUserIsInOrganization({ userId, organizationId, organizationsRepository });
@@ -290,6 +288,13 @@ function setupRestoreDocumentRoute({ app, db }: RouteDefinitionContext) {
await documentsRepository.restoreDocument({ documentId, organizationId });
deferRegisterDocumentActivityLog({
documentId,
event: 'restored',
userId,
documentActivityRepository,
});
return context.body(null, 204);
},
);
@@ -324,7 +329,7 @@ function setupGetDocumentFileRoute({ app, config, db }: RouteDefinitionContext)
200,
{
'Content-Type': document.mimeType,
'Content-Disposition': `inline; filename="${document.name}"`,
'Content-Disposition': `inline; filename*=UTF-8''${encodeURIComponent(document.name)}`,
'Content-Length': String(document.originalSize),
},
);
@@ -457,7 +462,7 @@ function setupUpdateDocumentRoute({ app, db }: RouteDefinitionContext) {
documentId: documentIdSchema,
})),
validateJsonBody(z.object({
name: z.string().min(1).optional(),
name: z.string().min(1).max(255).optional(),
content: z.string().min(1).optional(),
}).refine(data => data.name !== undefined || data.content !== undefined, {
message: 'At least one of \'name\' or \'content\' must be provided',
@@ -469,6 +474,7 @@ function setupUpdateDocumentRoute({ app, db }: RouteDefinitionContext) {
const documentsRepository = createDocumentsRepository({ db });
const organizationsRepository = createOrganizationsRepository({ db });
const documentActivityRepository = createDocumentActivityRepository({ db });
await ensureUserIsInOrganization({ userId, organizationId, organizationsRepository });
await ensureDocumentExists({ documentId, organizationId, documentsRepository });
@@ -479,6 +485,16 @@ function setupUpdateDocumentRoute({ app, db }: RouteDefinitionContext) {
...updateData,
});
deferRegisterDocumentActivityLog({
documentId,
event: 'updated',
userId,
documentActivityRepository,
eventData: {
updatedFields: Object.entries(updateData).filter(([_, value]) => value !== undefined).map(([key]) => key),
},
});
return context.json({ document });
},
);

View File

@@ -1,19 +1,15 @@
import type { Config } from '../config/config.types';
import { describe, expect, test } from 'vitest';
import { createInMemoryDatabase } from '../app/database/database.test-utils';
import { overrideConfig } from '../config/config.test-utils';
import { ORGANIZATION_ROLES } from '../organizations/organizations.constants';
import { createPlansRepository } from '../plans/plans.repository';
import { nextTick } from '../shared/async/defer.test-utils';
import { collectReadableStreamToString } from '../shared/streams/readable-stream';
import { createSubscriptionsRepository } from '../subscriptions/subscriptions.repository';
import { createTaggingRulesRepository } from '../tagging-rules/tagging-rules.repository';
import { createTagsRepository } from '../tags/tags.repository';
import { documentsTagsTable } from '../tags/tags.table';
import { createDummyTrackingServices } from '../tracking/tracking.services';
import { createWebhookRepository } from '../webhooks/webhook.repository';
import { documentActivityLogTable } from './document-activity/document-activity.table';
import { createDocumentAlreadyExistsError } from './documents.errors';
import { createDocumentsRepository } from './documents.repository';
import { documentsTable } from './documents.table';
import { createDocument } from './documents.usecases';
import { createDocumentCreationUsecase } from './documents.usecases';
import { createDocumentStorageService } from './storage/documents.storage.services';
describe('documents usecases', () => {
@@ -25,15 +21,18 @@ describe('documents usecases', () => {
organizationMembers: [{ organizationId: 'organization-1', userId: 'user-1', role: ORGANIZATION_ROLES.OWNER }],
});
const documentsRepository = createDocumentsRepository({ db });
const documentsStorageService = await createDocumentStorageService({ config: { documentsStorage: { driver: 'in-memory' } } as Config });
const plansRepository = createPlansRepository({ config: { organizationPlans: { isFreePlanUnlimited: true } } as Config });
const subscriptionsRepository = createSubscriptionsRepository({ db });
const trackingServices = createDummyTrackingServices();
const taggingRulesRepository = createTaggingRulesRepository({ db });
const tagsRepository = createTagsRepository({ db });
const webhookRepository = createWebhookRepository({ db });
const generateDocumentId = () => 'doc_1';
const config = overrideConfig({
organizationPlans: { isFreePlanUnlimited: true },
documentsStorage: { driver: 'in-memory' },
});
const documentsStorageService = await createDocumentStorageService({ config });
const createDocument = await createDocumentCreationUsecase({
db,
config,
generateDocumentId: () => 'doc_1',
documentsStorageService,
});
const file = new File(['content'], 'file.txt', { type: 'text/plain' });
const userId = 'user-1';
@@ -43,15 +42,6 @@ describe('documents usecases', () => {
file,
userId,
organizationId,
documentsRepository,
documentsStorageService,
generateDocumentId,
plansRepository,
subscriptionsRepository,
trackingServices,
taggingRulesRepository,
tagsRepository,
webhookRepository,
});
expect(document).to.include({
@@ -86,16 +76,20 @@ describe('documents usecases', () => {
organizationMembers: [{ organizationId: 'organization-1', userId: 'user-1', role: ORGANIZATION_ROLES.OWNER }],
});
const documentsRepository = createDocumentsRepository({ db });
const documentsStorageService = await createDocumentStorageService({ config: { documentsStorage: { driver: 'in-memory' } } as Config });
const plansRepository = createPlansRepository({ config: { organizationPlans: { isFreePlanUnlimited: true } } as Config });
const subscriptionsRepository = createSubscriptionsRepository({ db });
const trackingServices = createDummyTrackingServices();
const taggingRulesRepository = createTaggingRulesRepository({ db });
const tagsRepository = createTagsRepository({ db });
const webhookRepository = createWebhookRepository({ db });
const config = overrideConfig({
organizationPlans: { isFreePlanUnlimited: true },
documentsStorage: { driver: 'in-memory' },
});
const documentsStorageService = await createDocumentStorageService({ config });
let documentIdIndex = 1;
const generateDocumentId = () => `doc_${documentIdIndex++}`;
const createDocument = await createDocumentCreationUsecase({
db,
config,
generateDocumentId: () => `doc_${documentIdIndex++}`,
documentsStorageService,
});
const file = new File(['content'], 'file.txt', { type: 'text/plain' });
const userId = 'user-1';
@@ -105,15 +99,6 @@ describe('documents usecases', () => {
file,
userId,
organizationId,
documentsRepository,
documentsStorageService,
plansRepository,
subscriptionsRepository,
generateDocumentId,
trackingServices,
taggingRulesRepository,
tagsRepository,
webhookRepository,
});
expect(document1).to.include({
@@ -134,15 +119,6 @@ describe('documents usecases', () => {
file,
userId,
organizationId,
documentsRepository,
documentsStorageService,
plansRepository,
subscriptionsRepository,
generateDocumentId,
trackingServices,
taggingRulesRepository,
tagsRepository,
webhookRepository,
}),
).rejects.toThrow(
createDocumentAlreadyExistsError(),
@@ -202,26 +178,20 @@ describe('documents usecases', () => {
],
});
const documentsRepository = createDocumentsRepository({ db });
const documentsStorageService = await createDocumentStorageService({ config: { documentsStorage: { driver: 'in-memory' } } as Config });
const plansRepository = createPlansRepository({ config: { organizationPlans: { isFreePlanUnlimited: true } } as Config });
const subscriptionsRepository = createSubscriptionsRepository({ db });
const trackingServices = createDummyTrackingServices();
const taggingRulesRepository = createTaggingRulesRepository({ db });
const tagsRepository = createTagsRepository({ db });
const webhookRepository = createWebhookRepository({ db });
const config = overrideConfig({
organizationPlans: { isFreePlanUnlimited: true },
documentsStorage: { driver: 'in-memory' },
});
const createDocument = await createDocumentCreationUsecase({
db,
config,
});
// 3. Re-create the document
const { document: documentRestored } = await createDocument({
file: new File(['hello world'], 'file-2.txt', { type: 'text/plain' }),
organizationId: 'organization-1',
documentsRepository,
documentsStorageService,
plansRepository,
subscriptionsRepository,
trackingServices,
taggingRulesRepository,
tagsRepository,
webhookRepository,
});
expect(documentRestored).to.deep.include({
@@ -255,15 +225,25 @@ describe('documents usecases', () => {
organizationMembers: [{ organizationId: 'organization-1', userId: 'user-1', role: ORGANIZATION_ROLES.OWNER }],
});
const config = overrideConfig({
organizationPlans: { isFreePlanUnlimited: true },
documentsStorage: { driver: 'in-memory' },
});
const documentsRepository = createDocumentsRepository({ db });
const documentsStorageService = await createDocumentStorageService({ config: { documentsStorage: { driver: 'in-memory' } } as Config });
const plansRepository = createPlansRepository({ config: { organizationPlans: { isFreePlanUnlimited: true } } as Config });
const subscriptionsRepository = createSubscriptionsRepository({ db });
const trackingServices = createDummyTrackingServices();
const taggingRulesRepository = createTaggingRulesRepository({ db });
const tagsRepository = createTagsRepository({ db });
const webhookRepository = createWebhookRepository({ db });
const generateDocumentId = () => 'doc_1';
const documentsStorageService = await createDocumentStorageService({ config });
const createDocument = await createDocumentCreationUsecase({
db,
config,
generateDocumentId: () => 'doc_1',
documentsRepository: {
...documentsRepository,
saveOrganizationDocument: async () => {
throw new Error('Macron, explosion!');
},
},
});
const file = new File(['content'], 'file.txt', { type: 'text/plain' });
const userId = 'user-1';
@@ -274,20 +254,6 @@ describe('documents usecases', () => {
file,
userId,
organizationId,
documentsRepository: {
...documentsRepository,
saveOrganizationDocument: async () => {
throw new Error('Macron, explosion!');
},
},
documentsStorageService,
plansRepository,
subscriptionsRepository,
generateDocumentId,
trackingServices,
taggingRulesRepository,
tagsRepository,
webhookRepository,
}),
).rejects.toThrow(new Error('Macron, explosion!'));
@@ -299,5 +265,56 @@ describe('documents usecases', () => {
documentsStorageService.getFileStream({ storageKey: 'organization-1/originals/doc_1.txt' }),
).rejects.toThrow('File not found');
});
test('when a document is created by a user, a document activity log is registered with the user id', async () => {
const { db } = await createInMemoryDatabase({
users: [{ id: 'user-1', email: 'user-1@example.com' }],
organizations: [{ id: 'organization-1', name: 'Organization 1' }],
organizationMembers: [{ organizationId: 'organization-1', userId: 'user-1', role: ORGANIZATION_ROLES.OWNER }],
});
const config = overrideConfig({
organizationPlans: { isFreePlanUnlimited: true },
documentsStorage: { driver: 'in-memory' },
});
let documentIdIndex = 1;
const createDocument = await createDocumentCreationUsecase({
db,
config,
generateDocumentId: () => `doc_${documentIdIndex++}`,
});
await createDocument({
file: new File(['content-1'], 'file.txt', { type: 'text/plain' }),
userId: 'user-1',
organizationId: 'organization-1',
});
await createDocument({
file: new File(['content-2'], 'file.txt', { type: 'text/plain' }),
organizationId: 'organization-1',
});
await nextTick();
const documentActivityLogRecords = await db.select().from(documentActivityLogTable);
expect(documentActivityLogRecords.length).to.eql(2);
expect(documentActivityLogRecords[0]).to.deep.include({
event: 'created',
eventData: null,
userId: 'user-1',
documentId: 'doc_1',
});
expect(documentActivityLogRecords[1]).to.deep.include({
event: 'created',
eventData: null,
userId: null,
documentId: 'doc_2',
});
});
});
});

View File

@@ -7,6 +7,7 @@ import type { TaggingRulesRepository } from '../tagging-rules/tagging-rules.repo
import type { TagsRepository } from '../tags/tags.repository';
import type { TrackingServices } from '../tracking/tracking.services';
import type { WebhookRepository } from '../webhooks/webhook.repository';
import type { DocumentActivityRepository } from './document-activity/document-activity.repository';
import type { DocumentsRepository } from './documents.repository';
import type { Document } from './documents.types';
import type { DocumentStorageService } from './storage/documents.storage.services';
@@ -23,6 +24,8 @@ import { createTagsRepository } from '../tags/tags.repository';
import { createTrackingServices } from '../tracking/tracking.services';
import { createWebhookRepository } from '../webhooks/webhook.repository';
import { triggerWebhooks } from '../webhooks/webhook.usecases';
import { createDocumentActivityRepository } from './document-activity/document-activity.repository';
import { deferRegisterDocumentActivityLog } from './document-activity/document-activity.usecases';
import { createDocumentAlreadyExistsError, createDocumentNotDeletedError, createDocumentNotFoundError } from './documents.errors';
import { buildOriginalDocumentKey, generateDocumentId as generateDocumentIdImpl } from './documents.models';
import { createDocumentsRepository } from './documents.repository';
@@ -56,6 +59,7 @@ export async function createDocument({
taggingRulesRepository,
tagsRepository,
webhookRepository,
documentActivityRepository,
logger = createLogger({ namespace: 'documents:usecases' }),
}: {
file: File;
@@ -70,6 +74,7 @@ export async function createDocument({
taggingRulesRepository: TaggingRulesRepository;
tagsRepository: TagsRepository;
webhookRepository: WebhookRepository;
documentActivityRepository: DocumentActivityRepository;
logger?: Logger;
}) {
const {
@@ -115,6 +120,13 @@ export async function createDocument({
logger,
});
deferRegisterDocumentActivityLog({
documentId: document.id,
event: 'created',
userId,
documentActivityRepository,
});
await applyTaggingRules({ document, taggingRulesRepository, tagsRepository });
await triggerWebhooks({
@@ -134,38 +146,32 @@ export async function createDocument({
}
export type CreateDocumentUsecase = Awaited<ReturnType<typeof createDocumentCreationUsecase>>;
export type DocumentUsecaseDependencies = Omit<Parameters<typeof createDocument>[0], 'file' | 'userId' | 'organizationId'>;
export async function createDocumentCreationUsecase({
db,
config,
logger = createLogger({ namespace: 'documents:usecases' }),
generateDocumentId = generateDocumentIdImpl,
documentsStorageService,
...initialDeps
}: {
db: Database;
config: Config;
logger?: Logger;
documentsStorageService?: DocumentStorageService;
generateDocumentId?: () => string;
}) {
} & Partial<DocumentUsecaseDependencies>) {
const deps = {
documentsRepository: createDocumentsRepository({ db }),
documentsStorageService: documentsStorageService ?? await createDocumentStorageService({ config }),
plansRepository: createPlansRepository({ config }),
subscriptionsRepository: createSubscriptionsRepository({ db }),
trackingServices: createTrackingServices({ config }),
taggingRulesRepository: createTaggingRulesRepository({ db }),
tagsRepository: createTagsRepository({ db }),
webhookRepository: createWebhookRepository({ db }),
generateDocumentId,
logger,
documentsRepository: initialDeps.documentsRepository ?? createDocumentsRepository({ db }),
documentsStorageService: initialDeps.documentsStorageService ?? await createDocumentStorageService({ config }),
plansRepository: initialDeps.plansRepository ?? createPlansRepository({ config }),
subscriptionsRepository: initialDeps.subscriptionsRepository ?? createSubscriptionsRepository({ db }),
trackingServices: initialDeps.trackingServices ?? createTrackingServices({ config }),
taggingRulesRepository: initialDeps.taggingRulesRepository ?? createTaggingRulesRepository({ db }),
tagsRepository: initialDeps.tagsRepository ?? createTagsRepository({ db }),
webhookRepository: initialDeps.webhookRepository ?? createWebhookRepository({ db }),
documentActivityRepository: initialDeps.documentActivityRepository ?? createDocumentActivityRepository({ db }),
generateDocumentId: initialDeps.generateDocumentId,
logger: initialDeps.logger,
};
return async ({ file, userId, organizationId }: { file: File; userId?: string; organizationId: string }) => createDocument({
file,
userId,
organizationId,
...deps,
});
return async (args: { file: File; userId?: string; organizationId: string }) => createDocument({ ...args, ...deps });
}
async function handleExistingDocument({

View File

@@ -0,0 +1,5 @@
import type { EmailDriverFactory } from '../emails.types';
export function defineEmailDriverFactory(factory: EmailDriverFactory) {
return factory;
}

View File

@@ -0,0 +1,12 @@
import { LOGGER_EMAIL_DRIVER_NAME, loggerEmailDriverFactory } from './logger/logger.email-driver';
import { RESEND_EMAIL_DRIVER_NAME, resendEmailDriverFactory } from './resend/resend.email-driver';
import { SMTP_EMAIL_DRIVER_NAME, smtpEmailDriverFactory } from './smtp/smtp.email-driver';
export const emailDrivers = {
[RESEND_EMAIL_DRIVER_NAME]: resendEmailDriverFactory,
[LOGGER_EMAIL_DRIVER_NAME]: loggerEmailDriverFactory,
[SMTP_EMAIL_DRIVER_NAME]: smtpEmailDriverFactory,
} as const;
export const emailDriverFactoryNames = Object.keys(emailDrivers);
export type EmailDriverName = keyof typeof emailDrivers;

View File

@@ -0,0 +1,11 @@
import type { ConfigDefinition } from 'figue';
import { z } from 'zod';
export const loggerEmailDriverConfig = {
level: {
doc: 'When using the logger email driver, the level to log emails at',
schema: z.enum(['info', 'debug', 'warn', 'error']),
default: 'info',
env: 'LOGGER_EMAIL_DRIVER_LOG_LEVEL',
},
} as const satisfies ConfigDefinition;

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