mirror of
https://github.com/papra-hq/papra.git
synced 2025-12-18 12:25:44 -06:00
Compare commits
20 Commits
@papra/app
...
@papra/app
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b435bba79 | ||
|
|
8ccdb74834 | ||
|
|
60059c895c | ||
|
|
6e22a93dff | ||
|
|
79c1d3206b | ||
|
|
48a953a584 | ||
|
|
fdb90fa164 | ||
|
|
e9a205c0a3 | ||
|
|
278db63fc8 | ||
|
|
e5ef40f36c | ||
|
|
27c9e39422 | ||
|
|
91d2e236d0 | ||
|
|
d4f72e889a | ||
|
|
759a3ff713 | ||
|
|
34862991fb | ||
|
|
f0876fdc63 | ||
|
|
cb38d66485 | ||
|
|
c28af1407f | ||
|
|
b62ddf2bc4 | ||
|
|
fa7909c62d |
1
.github/workflows/release.yml
vendored
1
.github/workflows/release.yml
vendored
@@ -15,6 +15,7 @@ jobs:
|
|||||||
contents: write
|
contents: write
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
id-token: write
|
id-token: write
|
||||||
|
actions: write
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
- 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
|
## Development Setup
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
# @papra/docs
|
# @papra/docs
|
||||||
|
|
||||||
|
## 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
|
## 0.4.0
|
||||||
|
|
||||||
### Minor Changes
|
### Minor Changes
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@papra/docs",
|
"name": "@papra/docs",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"version": "0.4.0",
|
"version": "0.4.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"packageManager": "pnpm@10.9.0",
|
"packageManager": "pnpm@10.9.0",
|
||||||
"description": "Papra documentation website",
|
"description": "Papra documentation website",
|
||||||
|
|||||||
3
apps/docs/public/_headers
Normal file
3
apps/docs/public/_headers
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
/*
|
||||||
|
X-Frame-Options: DENY
|
||||||
|
X-Content-Type-Options: nosniff
|
||||||
@@ -33,11 +33,14 @@ const rows = configDetails
|
|||||||
|
|
||||||
const rawDocumentation = formatDoc(doc);
|
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 {
|
return {
|
||||||
path,
|
path,
|
||||||
env,
|
env,
|
||||||
documentation: rawDocumentation,
|
documentation: rawDocumentation,
|
||||||
defaultValue: isEmptyDefaultValue ? undefined : defaultValue,
|
defaultValue: defaultOverride ?? (isEmptyDefaultValue ? undefined : defaultValue),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ services:
|
|||||||
- 1221:1221
|
- 1221:1221
|
||||||
environment:
|
environment:
|
||||||
- AUTH_SECRET=change-me
|
- AUTH_SECRET=change-me
|
||||||
|
- CLIENT_BASE_URL=http://localhost:1221
|
||||||
|
- SERVER_BASE_URL=http://localhost:1221
|
||||||
volumes:
|
volumes:
|
||||||
- ./app-data:/app/app-data
|
- ./app-data:/app/app-data
|
||||||
user: 1000:1000
|
user: 1000:1000
|
||||||
@@ -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" />
|
<input id="port" class="input-field" value="1221" type="number" min="1024" max="65535" placeholder="eg: 1221" />
|
||||||
</div>
|
</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">
|
<div class="flex items-center gap-2 mt-1">
|
||||||
<label for="source" class="min-w-32">Image source</label>
|
<label for="source" class="min-w-32">Image source</label>
|
||||||
<select class="input-field mt-0" id="source">
|
<select class="input-field mt-0" id="source">
|
||||||
@@ -153,6 +160,7 @@ const portInput = document.getElementById('port') as HTMLInputElement;
|
|||||||
const sourceSelect = document.getElementById('source') as HTMLSelectElement;
|
const sourceSelect = document.getElementById('source') as HTMLSelectElement;
|
||||||
const serviceNameInput = document.getElementById('service-name') as HTMLInputElement;
|
const serviceNameInput = document.getElementById('service-name') as HTMLInputElement;
|
||||||
const authSecretInput = document.getElementById('auth-secret') 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 refreshSecretButton = document.getElementById('refresh-secret');
|
||||||
const copyButton = document.getElementById('copy-button');
|
const copyButton = document.getElementById('copy-button');
|
||||||
const dockerComposeOutput = document.getElementById('docker-compose-output');
|
const dockerComposeOutput = document.getElementById('docker-compose-output');
|
||||||
@@ -193,12 +201,19 @@ function getDockerComposeYml() {
|
|||||||
const intakeEmailEnabled = intakeEmailEnabledSelect.value === 'true';
|
const intakeEmailEnabled = intakeEmailEnabledSelect.value === 'true';
|
||||||
const intakeDriver = intakeDriverSelect.value;
|
const intakeDriver = intakeDriverSelect.value;
|
||||||
const webhookSecret = webhookSecretInput.value;
|
const webhookSecret = webhookSecretInput.value;
|
||||||
|
const appBaseUrl = appBaseUrlInput.value.trim();
|
||||||
|
|
||||||
const version = isRootless ? 'latest' : 'latest-root';
|
const version = isRootless ? 'latest' : 'latest-root';
|
||||||
const fullImage = `${image}:${version}`;
|
const fullImage = `${image}:${version}`;
|
||||||
|
|
||||||
|
// Determine base URLs
|
||||||
|
const clientBaseUrl = appBaseUrl || `http://localhost:${port}`;
|
||||||
|
const serverBaseUrl = appBaseUrl || `http://localhost:${port}`;
|
||||||
|
|
||||||
const environment = [
|
const environment = [
|
||||||
`AUTH_SECRET=${authSecret}`,
|
`AUTH_SECRET=${authSecret}`,
|
||||||
|
`CLIENT_BASE_URL=${clientBaseUrl}`,
|
||||||
|
`SERVER_BASE_URL=${serverBaseUrl}`,
|
||||||
isIngestionEnabled && 'INGESTION_FOLDER_IS_ENABLED=true',
|
isIngestionEnabled && 'INGESTION_FOLDER_IS_ENABLED=true',
|
||||||
intakeEmailEnabled && 'INTAKE_EMAILS_IS_ENABLED=true',
|
intakeEmailEnabled && 'INTAKE_EMAILS_IS_ENABLED=true',
|
||||||
intakeEmailEnabled && `INTAKE_EMAILS_DRIVER=${intakeDriver}`,
|
intakeEmailEnabled && `INTAKE_EMAILS_DRIVER=${intakeDriver}`,
|
||||||
@@ -336,6 +351,7 @@ portInput.addEventListener('input', updateDockerCompose);
|
|||||||
sourceSelect.addEventListener('change', updateDockerCompose);
|
sourceSelect.addEventListener('change', updateDockerCompose);
|
||||||
serviceNameInput.addEventListener('input', updateDockerCompose);
|
serviceNameInput.addEventListener('input', updateDockerCompose);
|
||||||
authSecretInput.addEventListener('input', updateDockerCompose);
|
authSecretInput.addEventListener('input', updateDockerCompose);
|
||||||
|
appBaseUrlInput.addEventListener('input', updateDockerCompose);
|
||||||
refreshSecretButton?.addEventListener('click', handleRefreshSecret);
|
refreshSecretButton?.addEventListener('click', handleRefreshSecret);
|
||||||
copyButton?.addEventListener('click', handleCopy);
|
copyButton?.addEventListener('click', handleCopy);
|
||||||
downloadButton?.addEventListener('click', handleDownload);
|
downloadButton?.addEventListener('click', handleDownload);
|
||||||
|
|||||||
@@ -1,5 +1,23 @@
|
|||||||
# @papra/app-client
|
# @papra/app-client
|
||||||
|
|
||||||
|
## 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
|
## 0.5.0
|
||||||
|
|
||||||
### Minor Changes
|
### Minor Changes
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@papra/app-client",
|
"name": "@papra/app-client",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"version": "0.5.0",
|
"version": "0.6.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"packageManager": "pnpm@10.9.0",
|
"packageManager": "pnpm@10.9.0",
|
||||||
"description": "Papra frontend client",
|
"description": "Papra frontend client",
|
||||||
@@ -26,51 +26,52 @@
|
|||||||
"test:e2e": "playwright test",
|
"test:e2e": "playwright test",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"script:get-missing-i18n-keys": "tsx src/scripts/get-missing-i18n-keys.script.ts",
|
"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": {
|
"dependencies": {
|
||||||
"@corentinth/chisels": "^1.0.2",
|
"@corentinth/chisels": "^1.3.1",
|
||||||
"@kobalte/core": "^0.13.7",
|
"@kobalte/core": "^0.13.9",
|
||||||
"@kobalte/utils": "^0.9.1",
|
"@kobalte/utils": "^0.9.1",
|
||||||
"@modular-forms/solid": "^0.25.0",
|
"@modular-forms/solid": "^0.25.1",
|
||||||
"@pdfslick/solid": "^2.0.0",
|
"@pdfslick/solid": "^2.3.0",
|
||||||
"@solid-primitives/storage": "^4.2.1",
|
"@solid-primitives/storage": "^4.3.2",
|
||||||
"@solidjs/router": "^0.14.3",
|
"@solidjs/router": "^0.14.10",
|
||||||
"@tanstack/solid-query": "^5.61.5",
|
"@tanstack/solid-query": "^5.77.2",
|
||||||
"@tanstack/solid-table": "^8.20.5",
|
"@tanstack/solid-table": "^8.21.3",
|
||||||
"@unocss/reset": "^0.64.0",
|
"@unocss/reset": "^0.64.1",
|
||||||
"better-auth": "catalog:",
|
"better-auth": "catalog:",
|
||||||
"class-variance-authority": "^0.7.0",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk-solid": "^1.1.0",
|
"cmdk-solid": "^1.1.2",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"lodash-es": "^4.17.21",
|
"lodash-es": "^4.17.21",
|
||||||
"ofetch": "^1.4.1",
|
"ofetch": "^1.4.1",
|
||||||
"posthog-js": "^1.231.0",
|
"posthog-js": "^1.246.0",
|
||||||
"radix3": "^1.1.2",
|
"radix3": "^1.1.2",
|
||||||
"solid-js": "^1.8.11",
|
"solid-js": "^1.9.7",
|
||||||
"solid-sonner": "^0.2.8",
|
"solid-sonner": "^0.2.8",
|
||||||
"tailwind-merge": "^2.6.0",
|
"tailwind-merge": "^2.6.0",
|
||||||
"ts-pattern": "^5.5.0",
|
"ts-pattern": "^5.7.1",
|
||||||
"unocss-preset-animations": "^1.1.0",
|
"unocss-preset-animations": "^1.2.1",
|
||||||
"unstorage": "^1.14.4",
|
"unstorage": "^1.16.0",
|
||||||
"valibot": "1.0.0-beta.10"
|
"valibot": "1.0.0-beta.10"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@antfu/eslint-config": "catalog:",
|
"@antfu/eslint-config": "catalog:",
|
||||||
"@iconify-json/tabler": "^1.1.120",
|
"@iconify-json/tabler": "^1.2.18",
|
||||||
"@playwright/test": "^1.46.1",
|
"@playwright/test": "^1.52.0",
|
||||||
"@types/lodash-es": "^4.17.12",
|
"@types/lodash-es": "^4.17.12",
|
||||||
"@types/node": "catalog:",
|
"@types/node": "catalog:",
|
||||||
"eslint": "catalog:",
|
"eslint": "catalog:",
|
||||||
"jsdom": "^25.0.0",
|
"jsdom": "^25.0.1",
|
||||||
"tinyglobby": "^0.2.13",
|
"tinyglobby": "^0.2.14",
|
||||||
"tsx": "^4.19.1",
|
"tsx": "^4.19.4",
|
||||||
"typescript": "catalog:",
|
"typescript": "catalog:",
|
||||||
"unocss": "0.65.0-beta.2",
|
"unocss": "0.65.0-beta.2",
|
||||||
"vite": "^5.0.11",
|
"vite": "^5.4.19",
|
||||||
"vite-plugin-solid": "^2.8.2",
|
"vite-plugin-solid": "^2.11.6",
|
||||||
"vitest": "catalog:",
|
"vitest": "catalog:",
|
||||||
"yaml": "^2.7.0"
|
"yaml": "^2.8.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
3
apps/papra-client/public/_headers
Normal file
3
apps/papra-client/public/_headers
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
/*
|
||||||
|
X-Frame-Options: DENY
|
||||||
|
X-Content-Type-Options: nosniff
|
||||||
@@ -9,6 +9,7 @@ import { render, Suspense } from 'solid-js/web';
|
|||||||
import { CommandPaletteProvider } from './modules/command-palette/command-palette.provider';
|
import { CommandPaletteProvider } from './modules/command-palette/command-palette.provider';
|
||||||
import { ConfigProvider } from './modules/config/config.provider';
|
import { ConfigProvider } from './modules/config/config.provider';
|
||||||
import { DemoIndicator } from './modules/demo/demo.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 { I18nProvider } from './modules/i18n/i18n.provider';
|
||||||
import { ConfirmModalProvider } from './modules/shared/confirm';
|
import { ConfirmModalProvider } from './modules/shared/confirm';
|
||||||
import { queryClient } from './modules/shared/query/query-client';
|
import { queryClient } from './modules/shared/query/query-client';
|
||||||
@@ -44,9 +45,11 @@ render(
|
|||||||
>
|
>
|
||||||
<CommandPaletteProvider>
|
<CommandPaletteProvider>
|
||||||
<ConfigProvider>
|
<ConfigProvider>
|
||||||
<div class="min-h-screen font-sans text-sm font-400">
|
<RenameDocumentDialogProvider>
|
||||||
{props.children}
|
<div class="min-h-screen font-sans text-sm font-400">
|
||||||
</div>
|
{props.children}
|
||||||
|
</div>
|
||||||
|
</RenameDocumentDialogProvider>
|
||||||
<DemoIndicator />
|
<DemoIndicator />
|
||||||
</ConfigProvider>
|
</ConfigProvider>
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
# Authentication
|
||||||
|
|
||||||
auth.request-password-reset.title: Reset your password
|
auth.request-password-reset.title: Reset your password
|
||||||
auth.request-password-reset.description: Enter your email to 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.
|
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.terms: Terms of Service
|
||||||
auth.legal-links.privacy: Privacy Policy
|
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.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.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
|
tags.no-tags.create-tag: Create tag
|
||||||
|
|
||||||
layout.menu.home: Home
|
tags.title: Documents Tags
|
||||||
layout.menu.documents: Documents
|
tags.description: Tags are used to categorize documents. You can add tags to your documents to make them easier to find and organize.
|
||||||
layout.menu.tags: Tags
|
tags.create: Create tag
|
||||||
layout.menu.tagging-rules: Tagging rules
|
tags.update: Update tag
|
||||||
layout.menu.deleted-documents: Deleted documents
|
tags.delete: Delete tag
|
||||||
layout.menu.organization-settings: Settings
|
tags.delete.confirm.title: Delete tag
|
||||||
layout.menu.api-keys: API keys
|
tags.delete.confirm.message: Are you sure you want to delete this tag? Deleting a tag will remove it from all documents.
|
||||||
layout.menu.settings: Settings
|
tags.delete.confirm.confirm-button: Delete
|
||||||
layout.menu.account: Account
|
tags.delete.confirm.cancel-button: Cancel
|
||||||
layout.menu.general-settings: General settings
|
tags.delete.success: Tag deleted successfully
|
||||||
layout.menu.intake-emails: Intake emails
|
tags.create.success: Tag "{{ name }}" created successfully.
|
||||||
layout.menu.webhooks: Webhooks
|
tags.update.success: Tag "{{ name }}" updated successfully.
|
||||||
layout.menu.members: Members
|
tags.form.name.label: Name
|
||||||
layout.menu.invitations: Invitations
|
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.name: document name
|
||||||
tagging-rules.field.content: document content
|
tagging-rules.field.content: document content
|
||||||
tagging-rules.operator.equals: equals
|
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.submit: Update rule
|
||||||
tagging-rules.update.cancel: Cancel
|
tagging-rules.update.cancel: Cancel
|
||||||
|
|
||||||
demo.popup.description: This is a demo environment, all data is save to your browser local storage.
|
# Intake emails
|
||||||
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
|
|
||||||
|
|
||||||
trash.delete-all.button: Delete all
|
intake-emails.title: Intake Emails
|
||||||
trash.delete-all.confirm.title: Permanently delete all documents?
|
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.
|
||||||
trash.delete-all.confirm.description: Are you sure you want to permanently delete all documents from the trash? This action cannot be undone.
|
intake-emails.disabled.title: Intake Emails are disabled
|
||||||
trash.delete-all.confirm.label: Delete
|
intake-emails.disabled.description: Intake emails are disabled on this instance. Please contact your administrator to enable them. See the {{ documentation }} for more information.
|
||||||
trash.delete-all.confirm.cancel: Cancel
|
intake-emails.disabled.documentation: documentation
|
||||||
trash.delete.button: Delete
|
intake-emails.info: Only enabled intake emails from allowed origins will be processed. You can enable or disable an intake email at any time.
|
||||||
trash.delete.confirm.title: Permanently delete document?
|
intake-emails.empty.title: No intake emails
|
||||||
trash.delete.confirm.description: Are you sure you want to permanently delete this document from the trash? This action cannot be undone.
|
intake-emails.empty.description: Generate an intake address to easily ingest emails attachments.
|
||||||
trash.delete.confirm.label: Delete
|
intake-emails.empty.generate: Generate intake email
|
||||||
trash.delete.confirm.cancel: Cancel
|
intake-emails.count: '{{ count }} intake email{{ plural }} for this organization'
|
||||||
trash.deleted.success.title: Document deleted
|
intake-emails.new: New intake email
|
||||||
trash.deleted.success.description: The document has been permanently deleted.
|
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'
|
# API keys
|
||||||
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.permissions.documents.title: Documents
|
api-keys.permissions.documents.title: Documents
|
||||||
api-keys.permissions.documents.documents:create: Create 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.confirm-button: Delete
|
||||||
api-keys.delete.confirm.cancel-button: Cancel
|
api-keys.delete.confirm.cancel-button: Cancel
|
||||||
|
|
||||||
|
# Webhooks
|
||||||
|
|
||||||
webhooks.list.title: Webhooks
|
webhooks.list.title: Webhooks
|
||||||
webhooks.list.description: Manage your organization webhooks
|
webhooks.list.description: Manage your organization webhooks
|
||||||
webhooks.list.empty.title: No 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.last-triggered: Last triggered
|
||||||
webhooks.list.card.never: Never
|
webhooks.list.card.never: Never
|
||||||
webhooks.list.card.created: Created
|
webhooks.list.card.created: Created
|
||||||
|
|
||||||
webhooks.create.title: Create webhook
|
webhooks.create.title: Create webhook
|
||||||
webhooks.create.description: Create a new webhook to receive events
|
webhooks.create.description: Create a new webhook to receive events
|
||||||
webhooks.create.success: Webhook created successfully
|
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:created.description: Document created
|
||||||
webhooks.events.documents.document:deleted.description: Document deleted
|
webhooks.events.documents.document:deleted.description: Document deleted
|
||||||
|
|
||||||
organizations.members.title: Members
|
# Navigation
|
||||||
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.invite-member.title: Invite member
|
layout.menu.home: Home
|
||||||
organizations.invite-member.description: Invite a member to your organization
|
layout.menu.documents: Documents
|
||||||
organizations.invite-member.form.email.label: Email
|
layout.menu.tags: Tags
|
||||||
organizations.invite-member.form.email.placeholder: 'Example: ada@papra.app'
|
layout.menu.tagging-rules: Tagging rules
|
||||||
organizations.invite-member.form.email.required: Please enter a valid email address
|
layout.menu.deleted-documents: Deleted documents
|
||||||
organizations.invite-member.form.role.label: Role
|
layout.menu.organization-settings: Settings
|
||||||
organizations.invite-member.form.submit: Invite to organization
|
layout.menu.api-keys: API keys
|
||||||
organizations.invite-member.success.message: Member invited
|
layout.menu.settings: Settings
|
||||||
organizations.invite-member.success.description: The email has been invited to the organization.
|
layout.menu.account: Account
|
||||||
organizations.invite-member.error.message: Failed to invite member
|
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
|
layout.theme.light: Light mode
|
||||||
invitations.list.description: Manage your organization invitations
|
layout.theme.dark: Dark mode
|
||||||
invitations.list.empty.title: No pending invitations
|
layout.theme.system: System mode
|
||||||
invitations.list.empty.description: You haven't been invited to any organizations yet.
|
|
||||||
invitations.list.headers.organization: Organization
|
layout.search.placeholder: Search...
|
||||||
invitations.list.headers.created: Created
|
layout.menu.import-document: Import a document
|
||||||
invitations.list.headers.actions: Actions
|
|
||||||
invitations.list.actions.accept: Accept
|
user-menu.account-settings: Account settings
|
||||||
invitations.list.actions.reject: Reject
|
user-menu.api-keys: API keys
|
||||||
invitations.list.actions.accept.success.message: Invitation accepted
|
user-menu.invitations: Invitations
|
||||||
invitations.list.actions.accept.success.description: The invitation has been accepted.
|
user-menu.language: Language
|
||||||
invitations.list.actions.reject.success.message: Invitation rejected
|
user-menu.logout: Logout
|
||||||
invitations.list.actions.reject.success.description: The invitation has been rejected.
|
|
||||||
|
# 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
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
# Authentication
|
||||||
|
|
||||||
auth.request-password-reset.title: Réinitialiser votre mot de passe
|
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.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.
|
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.terms: Conditions d'utilisation
|
||||||
auth.legal-links.privacy: Politique de confidentialité
|
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.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.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
|
tags.no-tags.create-tag: Créer un tag
|
||||||
|
|
||||||
layout.menu.home: Accueil
|
tags.title: Tags de documents
|
||||||
layout.menu.documents: 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.
|
||||||
layout.menu.tags: Tags
|
tags.create: Créer un tag
|
||||||
layout.menu.tagging-rules: Règles de catégorisation
|
tags.update: Mettre à jour un tag
|
||||||
layout.menu.deleted-documents: Documents supprimés
|
tags.delete: Supprimer un tag
|
||||||
layout.menu.organization-settings: Paramètres
|
tags.delete.confirm.title: Supprimer un tag
|
||||||
layout.menu.api-keys: API keys
|
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.
|
||||||
layout.menu.settings: Paramètres
|
tags.delete.confirm.confirm-button: Supprimer
|
||||||
layout.menu.account: Compte
|
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.name: nom du document
|
||||||
tagging-rules.field.content: contenu du document
|
tagging-rules.field.content: contenu du document
|
||||||
|
|
||||||
tagging-rules.operator.equals: égal à
|
tagging-rules.operator.equals: égal à
|
||||||
tagging-rules.operator.not-equals: différent de
|
tagging-rules.operator.not-equals: différent de
|
||||||
tagging-rules.operator.contains: contient
|
tagging-rules.operator.contains: contient
|
||||||
tagging-rules.operator.not-contains: ne contient pas
|
tagging-rules.operator.not-contains: ne contient pas
|
||||||
tagging-rules.operator.starts-with: commence par
|
tagging-rules.operator.starts-with: commence par
|
||||||
tagging-rules.operator.ends-with: finit par
|
tagging-rules.operator.ends-with: finit par
|
||||||
|
|
||||||
tagging-rules.list.title: Règles de catégorisation
|
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.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.'
|
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.submit: Mettre à jour la règle
|
||||||
tagging-rules.update.cancel: Annuler
|
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.
|
# Intake emails
|
||||||
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
|
|
||||||
|
|
||||||
trash.delete-all.button: Supprimer tous les documents
|
intake-emails.title: Adresses de réception
|
||||||
trash.delete-all.confirm.title: Supprimer définitivement tous les documents ?
|
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.
|
||||||
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.
|
intake-emails.disabled.title: Les adresses de réception sont désactivées
|
||||||
trash.delete-all.confirm.label: Supprimer
|
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.
|
||||||
trash.delete-all.confirm.cancel: Annuler
|
intake-emails.disabled.documentation: documentation
|
||||||
trash.delete.button: Supprimer
|
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.
|
||||||
trash.delete.confirm.title: Supprimer définitivement le document ?
|
intake-emails.empty.title: Aucune adresse de réception
|
||||||
trash.delete.confirm.description: Êtes-vous sûr de vouloir supprimer définitivement ce document de la corbeille ? Cette action est irréversible.
|
intake-emails.empty.description: Générez une adresse de réception pour ingérer facilement les pièces jointes des emails.
|
||||||
trash.delete.confirm.label: Supprimer
|
intake-emails.empty.generate: Générer une adresse de réception
|
||||||
trash.delete.confirm.cancel: Annuler
|
intake-emails.count: '{{ count }} intake email{{ plural }} for this organization'
|
||||||
trash.deleted.success.title: Document supprimé
|
intake-emails.new: Nouvelle adresse de réception
|
||||||
trash.deleted.success.description: Le document a été supprimé définitivement.
|
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é'
|
# API keys
|
||||||
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.permissions.documents.title: Documents
|
api-keys.permissions.documents.title: Documents
|
||||||
api-keys.permissions.documents.documents:create: Créer des 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.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.confirm-button: Supprimer
|
||||||
api-keys.delete.confirm.cancel-button: Annuler
|
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
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { Component } from 'solid-js';
|
import type { Component } from 'solid-js';
|
||||||
import type { ApiKey } from '../api-keys.types';
|
import type { ApiKey } from '../api-keys.types';
|
||||||
import { A } from '@solidjs/router';
|
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 { format } from 'date-fns';
|
||||||
import { For, Match, Show, Suspense, Switch } from 'solid-js';
|
import { For, Match, Show, Suspense, Switch } from 'solid-js';
|
||||||
import { useI18n } from '@/modules/i18n/i18n.provider';
|
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||||
@@ -16,7 +16,7 @@ export const ApiKeyCard: Component<{ apiKey: ApiKey }> = ({ apiKey }) => {
|
|||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { confirm } = useConfirmModal();
|
const { confirm } = useConfirmModal();
|
||||||
|
|
||||||
const deleteApiKeyMutation = createMutation(() => ({
|
const deleteApiKeyMutation = useMutation(() => ({
|
||||||
mutationFn: deleteApiKey,
|
mutationFn: deleteApiKey,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['api-keys'] });
|
queryClient.invalidateQueries({ queryKey: ['api-keys'] });
|
||||||
@@ -85,7 +85,7 @@ export const ApiKeyCard: Component<{ apiKey: ApiKey }> = ({ apiKey }) => {
|
|||||||
|
|
||||||
export const ApiKeysPage: Component = () => {
|
export const ApiKeysPage: Component = () => {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const query = createQuery(() => ({
|
const query = useQuery(() => ({
|
||||||
queryKey: ['api-keys'],
|
queryKey: ['api-keys'],
|
||||||
queryFn: () => fetchApiKeys(),
|
queryFn: () => fetchApiKeys(),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -86,9 +86,11 @@ export const EmailLoginForm: Component = () => {
|
|||||||
)}
|
)}
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<Button variant="link" as={A} class="inline p-0! h-auto" href="/request-password-reset">
|
<Show when={config.auth.isPasswordResetEnabled}>
|
||||||
{t('auth.login.form.forgot-password.label')}
|
<Button variant="link" as={A} class="inline p-0! h-auto" href="/request-password-reset">
|
||||||
</Button>
|
{t('auth.login.form.forgot-password.label')}
|
||||||
|
</Button>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button type="submit" class="w-full">{t('auth.login.form.submit')}</Button>
|
<Button type="submit" class="w-full">{t('auth.login.form.submit')}</Button>
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ export const RequestPasswordResetPage: Component = () => {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
if (config.auth.isPasswordResetEnabled) {
|
if (!config.auth.isPasswordResetEnabled) {
|
||||||
navigate('/login');
|
navigate('/login');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ export const ResetPasswordPage: Component = () => {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
if (config.auth.isPasswordResetEnabled) {
|
if (!config.auth.isPasswordResetEnabled) {
|
||||||
navigate('/login');
|
navigate('/login');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { debounce } from 'lodash-es';
|
|||||||
import { createContext, createEffect, createSignal, For, on, onCleanup, onMount, Show, useContext } from 'solid-js';
|
import { createContext, createEffect, createSignal, For, on, onCleanup, onMount, Show, useContext } from 'solid-js';
|
||||||
import { getDocumentIcon } from '../documents/document.models';
|
import { getDocumentIcon } from '../documents/document.models';
|
||||||
import { searchDocuments } from '../documents/documents.services';
|
import { searchDocuments } from '../documents/documents.services';
|
||||||
|
import { useI18n } from '../i18n/i18n.provider';
|
||||||
import { cn } from '../shared/style/cn';
|
import { cn } from '../shared/style/cn';
|
||||||
import { useThemeStore } from '../theme/theme.store';
|
import { useThemeStore } from '../theme/theme.store';
|
||||||
import { CommandDialog, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandLoading } from '../ui/components/command';
|
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 [getIsCommandPaletteOpen, setIsCommandPaletteOpen] = createSignal(false);
|
||||||
const [getMatchingDocuments, setMatchingDocuments] = createSignal<Document[]>([]);
|
const [getMatchingDocuments, setMatchingDocuments] = createSignal<Document[]>([]);
|
||||||
const [getSearchQuery, setSearchQuery] = createSignal('');
|
const [getSearchQuery, setSearchQuery] = createSignal('');
|
||||||
const params = useParams();
|
|
||||||
const [getIsLoading, setIsLoading] = createSignal(false);
|
const [getIsLoading, setIsLoading] = createSignal(false);
|
||||||
|
|
||||||
|
const params = useParams();
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
|
if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -82,7 +85,7 @@ export const CommandPaletteProvider: ParentComponent = (props) => {
|
|||||||
options: { label: string; icon: string; action: () => void; forceMatch?: boolean }[];
|
options: { label: string; icon: string; action: () => void; forceMatch?: boolean }[];
|
||||||
}[] => [
|
}[] => [
|
||||||
{
|
{
|
||||||
label: 'Documents',
|
label: t('command-palette.sections.documents'),
|
||||||
forceMatch: true,
|
forceMatch: true,
|
||||||
options: getMatchingDocuments().map(document => ({
|
options: getMatchingDocuments().map(document => ({
|
||||||
label: document.name,
|
label: document.name,
|
||||||
@@ -92,20 +95,20 @@ export const CommandPaletteProvider: ParentComponent = (props) => {
|
|||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: `Theme`,
|
label: t('command-palette.sections.theme'),
|
||||||
options: [
|
options: [
|
||||||
{
|
{
|
||||||
label: 'Switch to light mode',
|
label: t('layout.theme.light'),
|
||||||
icon: 'i-tabler-sun',
|
icon: 'i-tabler-sun',
|
||||||
action: () => setColorMode({ mode: 'light' }),
|
action: () => setColorMode({ mode: 'light' }),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Switch to dark mode',
|
label: t('layout.theme.dark'),
|
||||||
icon: 'i-tabler-moon',
|
icon: 'i-tabler-moon',
|
||||||
action: () => setColorMode({ mode: 'dark' }),
|
action: () => setColorMode({ mode: 'dark' }),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Switch to system',
|
label: t('layout.theme.system'),
|
||||||
icon: 'i-tabler-device-laptop',
|
icon: 'i-tabler-device-laptop',
|
||||||
action: () => setColorMode({ mode: 'system' }),
|
action: () => setColorMode({ mode: 'system' }),
|
||||||
},
|
},
|
||||||
@@ -132,7 +135,7 @@ export const CommandPaletteProvider: ParentComponent = (props) => {
|
|||||||
onOpenChange={setIsCommandPaletteOpen}
|
onOpenChange={setIsCommandPaletteOpen}
|
||||||
>
|
>
|
||||||
|
|
||||||
<CommandInput onValueChange={setSearchQuery} placeholder="Search commands or documents" />
|
<CommandInput onValueChange={setSearchQuery} placeholder={t('command-palette.search.placeholder')} />
|
||||||
<CommandList>
|
<CommandList>
|
||||||
<Show when={getIsLoading()}>
|
<Show when={getIsLoading()}>
|
||||||
<CommandLoading>
|
<CommandLoading>
|
||||||
@@ -142,7 +145,7 @@ export const CommandPaletteProvider: ParentComponent = (props) => {
|
|||||||
<Show when={!getIsLoading()}>
|
<Show when={!getIsLoading()}>
|
||||||
<Show when={getMatchingDocuments().length === 0}>
|
<Show when={getMatchingDocuments().length === 0}>
|
||||||
<CommandEmpty>
|
<CommandEmpty>
|
||||||
No results found.
|
{t('command-palette.no-results')}
|
||||||
</CommandEmpty>
|
</CommandEmpty>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { ParentComponent } from 'solid-js';
|
import type { ParentComponent } from 'solid-js';
|
||||||
import type { Config, RuntimePublicConfig } from './config';
|
import type { Config, RuntimePublicConfig } from './config';
|
||||||
import { createQuery } from '@tanstack/solid-query';
|
import { useQuery } from '@tanstack/solid-query';
|
||||||
import { merge } from 'lodash-es';
|
import { merge } from 'lodash-es';
|
||||||
import { createContext, Match, Switch, useContext } from 'solid-js';
|
import { createContext, Match, Switch, useContext } from 'solid-js';
|
||||||
import { Button } from '../ui/components/button';
|
import { Button } from '../ui/components/button';
|
||||||
@@ -24,7 +24,7 @@ export function useConfig() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const ConfigProvider: ParentComponent = (props) => {
|
export const ConfigProvider: ParentComponent = (props) => {
|
||||||
const query = createQuery(() => ({
|
const query = useQuery(() => ({
|
||||||
queryKey: ['config'],
|
queryKey: ['config'],
|
||||||
queryFn: fetchPublicConfig,
|
queryFn: fetchPublicConfig,
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export const buildTimeConfig = {
|
|||||||
isEnabled: asBoolean(import.meta.env.VITE_INTAKE_EMAILS_IS_ENABLED, false),
|
isEnabled: asBoolean(import.meta.env.VITE_INTAKE_EMAILS_IS_ENABLED, false),
|
||||||
emailGenerationDomain: asString(import.meta.env.VITE_INTAKE_EMAILS_EMAIL_GENERATION_DOMAIN),
|
emailGenerationDomain: asString(import.meta.env.VITE_INTAKE_EMAILS_EMAIL_GENERATION_DOMAIN),
|
||||||
},
|
},
|
||||||
|
isSubscriptionsEnabled: asBoolean(import.meta.env.VITE_IS_SUBSCRIPTIONS_ENABLED, false),
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type Config = typeof buildTimeConfig;
|
export type Config = typeof buildTimeConfig;
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ import { A } from '@solidjs/router';
|
|||||||
import { Button } from '@/modules/ui/components/button';
|
import { Button } from '@/modules/ui/components/button';
|
||||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/modules/ui/components/dropdown-menu';
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/modules/ui/components/dropdown-menu';
|
||||||
import { useDeleteDocument } from '../documents.composables';
|
import { useDeleteDocument } from '../documents.composables';
|
||||||
|
import { useRenameDocumentDialog } from './rename-document-button.component';
|
||||||
|
|
||||||
export const DocumentManagementDropdown: Component<{ document: Document }> = (props) => {
|
export const DocumentManagementDropdown: Component<{ document: Document }> = (props) => {
|
||||||
const { deleteDocument } = useDeleteDocument();
|
const { deleteDocument } = useDeleteDocument();
|
||||||
|
const { openRenameDialog } = useRenameDocumentDialog();
|
||||||
|
|
||||||
const deleteDoc = () => deleteDocument({
|
const deleteDoc = () => deleteDocument({
|
||||||
documentId: props.document.id,
|
documentId: props.document.id,
|
||||||
@@ -16,6 +18,7 @@ export const DocumentManagementDropdown: Component<{ document: Document }> = (pr
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger
|
<DropdownMenuTrigger
|
||||||
as={(props: DropdownMenuSubTriggerProps) => (
|
as={(props: DropdownMenuSubTriggerProps) => (
|
||||||
@@ -34,6 +37,18 @@ export const DocumentManagementDropdown: Component<{ document: Document }> = (pr
|
|||||||
<span>Document details</span>
|
<span>Document details</span>
|
||||||
</DropdownMenuItem>
|
</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
|
<DropdownMenuItem
|
||||||
class="cursor-pointer text-red"
|
class="cursor-pointer text-red"
|
||||||
onClick={() => deleteDoc()}
|
onClick={() => deleteDoc()}
|
||||||
@@ -43,5 +58,6 @@ export const DocumentManagementDropdown: Component<{ document: Document }> = (pr
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { Component } from 'solid-js';
|
import type { Component } from 'solid-js';
|
||||||
import type { Document } from '../documents.types';
|
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 { createResource, Match, Suspense, Switch } from 'solid-js';
|
||||||
import { Card } from '@/modules/ui/components/card';
|
import { Card } from '@/modules/ui/components/card';
|
||||||
import { fetchDocumentFile } from '../documents.services';
|
import { fetchDocumentFile } from '../documents.services';
|
||||||
@@ -35,7 +35,7 @@ export const DocumentPreview: Component<{ document: Document }> = (props) => {
|
|||||||
const getIsImage = () => imageMimeType.includes(props.document.mimeType);
|
const getIsImage = () => imageMimeType.includes(props.document.mimeType);
|
||||||
const getIsPdf = () => pdfMimeType.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'],
|
queryKey: ['organizations', props.document.organizationId, 'documents', props.document.id, 'file'],
|
||||||
queryFn: () => fetchDocumentFile({ documentId: props.document.id, organizationId: props.document.organizationId }),
|
queryFn: () => fetchDocumentFile({ documentId: props.document.id, organizationId: props.document.organizationId }),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { DocumentActivityEvent } from './documents.types';
|
||||||
import { addDays, differenceInDays } from 'date-fns';
|
import { addDays, differenceInDays } from 'date-fns';
|
||||||
|
|
||||||
export const iconByFileType = {
|
export const iconByFileType = {
|
||||||
@@ -79,3 +80,16 @@ export function getDocumentNameExtension({ name }: { name: string }) {
|
|||||||
|
|
||||||
return dotSplittedName[dotCount];
|
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';
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { AsDto } from '../shared/http/http-client.types';
|
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 { apiClient } from '../shared/http/api-client';
|
||||||
import { coerceDates, getFormData } from '../shared/http/http-client.models';
|
import { coerceDates, getFormData } from '../shared/http/http-client.models';
|
||||||
|
|
||||||
@@ -194,18 +194,45 @@ export async function updateDocument({
|
|||||||
documentId,
|
documentId,
|
||||||
organizationId,
|
organizationId,
|
||||||
content,
|
content,
|
||||||
|
name,
|
||||||
}: {
|
}: {
|
||||||
documentId: string;
|
documentId: string;
|
||||||
organizationId: string;
|
organizationId: string;
|
||||||
content: string;
|
content?: string;
|
||||||
|
name?: string;
|
||||||
}) {
|
}) {
|
||||||
const { document } = await apiClient<{ document: AsDto<Document> }>({
|
const { document } = await apiClient<{ document: AsDto<Document> }>({
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
path: `/api/organizations/${organizationId}/documents/${documentId}`,
|
path: `/api/organizations/${organizationId}/documents/${documentId}`,
|
||||||
body: { content },
|
body: { content, name },
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
document: coerceDates(document),
|
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),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import type { Tag } from '../tags/tags.types';
|
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 = {
|
export type Document = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -14,3 +16,17 @@ export type Document = {
|
|||||||
content: string;
|
content: string;
|
||||||
tags: Tag[];
|
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'>;
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { Component } from 'solid-js';
|
import type { Component } from 'solid-js';
|
||||||
import type { Document } from '../documents.types';
|
import type { Document } from '../documents.types';
|
||||||
import { useParams } from '@solidjs/router';
|
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 { createSignal, Show, Suspense } from 'solid-js';
|
||||||
import { useConfig } from '@/modules/config/config.provider';
|
import { useConfig } from '@/modules/config/config.provider';
|
||||||
import { useI18n } from '@/modules/i18n/i18n.provider';
|
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||||
@@ -17,6 +17,7 @@ import { deleteAllTrashDocuments, deleteTrashDocument, fetchOrganizationDeletedD
|
|||||||
|
|
||||||
const RestoreDocumentButton: Component<{ document: Document }> = (props) => {
|
const RestoreDocumentButton: Component<{ document: Document }> = (props) => {
|
||||||
const { getIsRestoring, restore } = useRestoreDocument();
|
const { getIsRestoring, restore } = useRestoreDocument();
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
@@ -26,11 +27,11 @@ const RestoreDocumentButton: Component<{ document: Document }> = (props) => {
|
|||||||
isLoading={getIsRestoring()}
|
isLoading={getIsRestoring()}
|
||||||
>
|
>
|
||||||
{ getIsRestoring()
|
{ getIsRestoring()
|
||||||
? (<>Restoring...</>)
|
? (<>{t('documents.deleted.restoring')}</>)
|
||||||
: (
|
: (
|
||||||
<>
|
<>
|
||||||
<div class="i-tabler-refresh size-4 mr-2" />
|
<div class="i-tabler-refresh size-4 mr-2" />
|
||||||
Restore
|
{t('documents.actions.restore')}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -41,7 +42,7 @@ const PermanentlyDeleteTrashDocumentButton: Component<{ document: Document; orga
|
|||||||
const { confirm } = useConfirmModal();
|
const { confirm } = useConfirmModal();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
const deleteMutation = createMutation(() => ({
|
const deleteMutation = useMutation(() => ({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
await deleteTrashDocument({ documentId: props.document.id, organizationId: props.organizationId });
|
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"
|
class="text-red-500 hover:text-red-600"
|
||||||
>
|
>
|
||||||
{deleteMutation.isPending
|
{deleteMutation.isPending
|
||||||
? (<>Deleting...</>)
|
? (<>{t('documents.deleted.deleting')}</>)
|
||||||
: (
|
: (
|
||||||
<>
|
<>
|
||||||
<div class="i-tabler-trash size-4 mr-2" />
|
<div class="i-tabler-trash size-4 mr-2" />
|
||||||
@@ -97,7 +98,7 @@ const DeleteAllTrashDocumentsButton: Component<{ organizationId: string }> = (pr
|
|||||||
const { confirm } = useConfirmModal();
|
const { confirm } = useConfirmModal();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
const deleteAllMutation = createMutation(() => ({
|
const deleteAllMutation = useMutation(() => ({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
await deleteAllTrashDocuments({ organizationId: props.organizationId });
|
await deleteAllTrashDocuments({ organizationId: props.organizationId });
|
||||||
},
|
},
|
||||||
@@ -133,7 +134,7 @@ const DeleteAllTrashDocumentsButton: Component<{ organizationId: string }> = (pr
|
|||||||
class="text-red-500 hover:text-red-600"
|
class="text-red-500 hover:text-red-600"
|
||||||
>
|
>
|
||||||
{deleteAllMutation.isPending
|
{deleteAllMutation.isPending
|
||||||
? (<>Deleting...</>)
|
? (<>{t('documents.deleted.deleting')}</>)
|
||||||
: (
|
: (
|
||||||
<>
|
<>
|
||||||
<div class="i-tabler-trash size-4 mr-2" />
|
<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 [getPagination, setPagination] = createSignal({ pageIndex: 0, pageSize: 100 });
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const { config } = useConfig();
|
const { config } = useConfig();
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
const query = createQuery(() => ({
|
const query = useQuery(() => ({
|
||||||
queryKey: ['organizations', params.organizationId, 'documents', 'deleted', getPagination()],
|
queryKey: ['organizations', params.organizationId, 'documents', 'deleted', getPagination()],
|
||||||
queryFn: () => fetchOrganizationDeletedDocuments({
|
queryFn: () => fetchOrganizationDeletedDocuments({
|
||||||
organizationId: params.organizationId,
|
organizationId: params.organizationId,
|
||||||
@@ -160,16 +162,12 @@ export const DeletedDocumentsPage: Component = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="p-6 mt-4 pb-32">
|
<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">
|
<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" />
|
<div class="i-tabler-info-circle size-10 xl:size-8 text-primary flex-shrink-0 hidden sm:block" />
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
All deleted documents are stored in the trash bin for
|
{t('documents.deleted.retention-notice', { days: config.documents.deletedDocumentsRetentionDays })}
|
||||||
{' '}
|
|
||||||
{config.documents.deletedDocumentsRetentionDays}
|
|
||||||
{' '}
|
|
||||||
days. Passing this delay, the documents will be permanently deleted, and you will not be able to restore them.
|
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|
||||||
@@ -177,13 +175,9 @@ export const DeletedDocumentsPage: Component = () => {
|
|||||||
<Show when={query.data?.documents.length === 0}>
|
<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="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="i-tabler-trash text-primary size-12" aria-hidden="true" />
|
||||||
<div class="text-xl font-medium">No deleted documents</div>
|
<div class="text-xl font-medium">{t('documents.deleted.empty.title')}</div>
|
||||||
<div class="text-sm text-muted-foreground">
|
<div class="text-sm text-muted-foreground">
|
||||||
You have no deleted documents. Documents that are deleted will be moved to the trash bin for
|
{t('documents.deleted.empty.description', { days: config.documents.deletedDocumentsRetentionDays })}
|
||||||
{' '}
|
|
||||||
{config.documents.deletedDocumentsRetentionDays}
|
|
||||||
{' '}
|
|
||||||
days.
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
@@ -203,7 +197,7 @@ export const DeletedDocumentsPage: Component = () => {
|
|||||||
id: 'deletion',
|
id: 'deletion',
|
||||||
cell: data => (
|
cell: data => (
|
||||||
<div class="text-muted-foreground hidden sm:block">
|
<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>
|
<span class="text-foreground font-bold" title={data.row.original.deletedAt?.toLocaleString()}>{timeAgo({ date: data.row.original.deletedAt! })}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
import type { Component, JSX } from 'solid-js';
|
import type { Component, JSX } from 'solid-js';
|
||||||
|
import type { DocumentActivity } from '../documents.types';
|
||||||
import { formatBytes, safely } from '@corentinth/chisels';
|
import { formatBytes, safely } from '@corentinth/chisels';
|
||||||
import { useNavigate, useParams } from '@solidjs/router';
|
import { A, useNavigate, useParams, useSearchParams } from '@solidjs/router';
|
||||||
import { createQueries } from '@tanstack/solid-query';
|
import { createQueries, useInfiniteQuery } from '@tanstack/solid-query';
|
||||||
import { createSignal, For, Show, Suspense } from 'solid-js';
|
import { createEffect, createSignal, For, Match, Show, Suspense, Switch } from 'solid-js';
|
||||||
import { useConfig } from '@/modules/config/config.provider';
|
import { useConfig } from '@/modules/config/config.provider';
|
||||||
import { useI18n } from '@/modules/i18n/i18n.provider';
|
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||||
import { timeAgo } from '@/modules/shared/date/time-ago';
|
import { timeAgo } from '@/modules/shared/date/time-ago';
|
||||||
import { downloadFile } from '@/modules/shared/files/download';
|
import { downloadFile } from '@/modules/shared/files/download';
|
||||||
import { queryClient } from '@/modules/shared/query/query-client';
|
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 { 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 { CreateTagModal } from '@/modules/tags/pages/tags.page';
|
||||||
import { addTagToDocument, removeTagFromDocument } from '@/modules/tags/tags.services';
|
import { addTagToDocument, removeTagFromDocument } from '@/modules/tags/tags.services';
|
||||||
import { Alert, AlertDescription } from '@/modules/ui/components/alert';
|
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 { TextArea } from '@/modules/ui/components/textarea';
|
||||||
import { TextFieldRoot } from '@/modules/ui/components/textfield';
|
import { TextFieldRoot } from '@/modules/ui/components/textfield';
|
||||||
import { DocumentPreview } from '../components/document-preview.component';
|
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 { 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';
|
import '@pdfslick/solid/dist/pdf_viewer.css';
|
||||||
|
|
||||||
type KeyValueItem = {
|
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 = () => {
|
export const DocumentPage: Component = () => {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const { deleteDocument } = useDeleteDocument();
|
const { deleteDocument } = useDeleteDocument();
|
||||||
const { restore, getIsRestoring } = useRestoreDocument();
|
const { restore, getIsRestoring } = useRestoreDocument();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { config } = useConfig();
|
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(() => ({
|
const queries = createQueries(() => ({
|
||||||
queries: [
|
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 () => {
|
const deleteDoc = async () => {
|
||||||
if (!queries[0].data) {
|
if (!queries[0].data) {
|
||||||
return;
|
return;
|
||||||
@@ -137,7 +230,21 @@ export const DocumentPage: Component = () => {
|
|||||||
{getDocument => (
|
{getDocument => (
|
||||||
<div class="flex gap-4 md:pr-6">
|
<div class="flex gap-4 md:pr-6">
|
||||||
<div class="flex-1">
|
<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>
|
<p class="text-sm text-muted-foreground mb-6">{getDocument().id}</p>
|
||||||
|
|
||||||
<div class="flex gap-2 mb-2">
|
<div class="flex gap-2 mb-2">
|
||||||
@@ -147,7 +254,7 @@ export const DocumentPage: Component = () => {
|
|||||||
size="sm"
|
size="sm"
|
||||||
>
|
>
|
||||||
<div class="i-tabler-download size-4 mr-2"></div>
|
<div class="i-tabler-download size-4 mr-2"></div>
|
||||||
Download
|
{t('documents.actions.download')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
@@ -156,7 +263,7 @@ export const DocumentPage: Component = () => {
|
|||||||
size="sm"
|
size="sm"
|
||||||
>
|
>
|
||||||
<div class="i-tabler-eye size-4 mr-2"></div>
|
<div class="i-tabler-eye size-4 mr-2"></div>
|
||||||
Open in new tab
|
{t('documents.actions.open-in-new-tab')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{getDocument().isDeleted
|
{getDocument().isDeleted
|
||||||
@@ -168,7 +275,7 @@ export const DocumentPage: Component = () => {
|
|||||||
isLoading={getIsRestoring()}
|
isLoading={getIsRestoring()}
|
||||||
>
|
>
|
||||||
<div class="i-tabler-refresh size-4 mr-2"></div>
|
<div class="i-tabler-refresh size-4 mr-2"></div>
|
||||||
Restore
|
{t('documents.actions.restore')}
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
: (
|
: (
|
||||||
@@ -178,7 +285,7 @@ export const DocumentPage: Component = () => {
|
|||||||
onClick={deleteDoc}
|
onClick={deleteDoc}
|
||||||
>
|
>
|
||||||
<div class="i-tabler-trash size-4 mr-2"></div>
|
<div class="i-tabler-trash size-4 mr-2"></div>
|
||||||
Delete
|
{t('documents.actions.delete')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -218,56 +325,67 @@ export const DocumentPage: Component = () => {
|
|||||||
|
|
||||||
{getDocument().isDeleted && (
|
{getDocument().isDeleted && (
|
||||||
<Alert variant="destructive" class="mt-6">
|
<Alert variant="destructive" class="mt-6">
|
||||||
This document has been deleted and will be permanently removed in
|
{t('documents.deleted.message', { days: getDaysBeforePermanentDeletion({
|
||||||
{' '}
|
|
||||||
{getDaysBeforePermanentDeletion({
|
|
||||||
document: getDocument(),
|
document: getDocument(),
|
||||||
deletedDocumentsRetentionDays: config.documents.deletedDocumentsRetentionDays,
|
deletedDocumentsRetentionDays: config.documents.deletedDocumentsRetentionDays,
|
||||||
})}
|
}) ?? 0 })}
|
||||||
{' '}
|
|
||||||
days.
|
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Separator class="my-3" />
|
<Separator class="my-3" />
|
||||||
|
|
||||||
<Tabs defaultValue="info" class="w-full">
|
<Tabs value={getTab()} onChange={setTab} class="w-full">
|
||||||
<TabsList class="w-full h-8">
|
<TabsList class="w-full h-8">
|
||||||
<TabsTrigger value="info">Info</TabsTrigger>
|
<TabsTrigger value="info">{t('documents.tabs.info')}</TabsTrigger>
|
||||||
<TabsTrigger value="content">Content</TabsTrigger>
|
<TabsTrigger value="content">{t('documents.tabs.content')}</TabsTrigger>
|
||||||
|
<TabsTrigger value="activity">{t('documents.tabs.activity')}</TabsTrigger>
|
||||||
<TabsIndicator />
|
<TabsIndicator />
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="info">
|
<TabsContent value="info">
|
||||||
<KeyValues data={[
|
<KeyValues data={[
|
||||||
{
|
{
|
||||||
label: 'ID',
|
label: t('documents.info.id'),
|
||||||
value: getDocument().id,
|
value: getDocument().id,
|
||||||
icon: 'i-tabler-id',
|
icon: 'i-tabler-id',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Name',
|
label: t('documents.info.name'),
|
||||||
value: getDocument().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',
|
icon: 'i-tabler-file-text',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Type',
|
label: t('documents.info.type'),
|
||||||
value: getDocument().mimeType,
|
value: getDocument().mimeType,
|
||||||
icon: 'i-tabler-file-unknown',
|
icon: 'i-tabler-file-unknown',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Size',
|
label: t('documents.info.size'),
|
||||||
value: formatBytes({ bytes: getDocument().originalSize, base: 1000 }),
|
value: formatBytes({ bytes: getDocument().originalSize, base: 1000 }),
|
||||||
icon: 'i-tabler-weight',
|
icon: 'i-tabler-weight',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Created At',
|
label: t('documents.info.created-at'),
|
||||||
value: timeAgo({ date: getDocument().createdAt }),
|
value: timeAgo({ date: getDocument().createdAt }),
|
||||||
icon: 'i-tabler-calendar',
|
icon: 'i-tabler-calendar',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Updated At',
|
label: t('documents.info.updated-at'),
|
||||||
value: getDocument().updatedAt ? timeAgo({ date: getDocument().updatedAt! }) : <span class="text-muted-foreground">Never</span>,
|
value: getDocument().updatedAt ? timeAgo({ date: getDocument().updatedAt! }) : <span class="text-muted-foreground">{t('documents.info.never')}</span>,
|
||||||
icon: 'i-tabler-calendar',
|
icon: 'i-tabler-calendar',
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
@@ -284,14 +402,14 @@ export const DocumentPage: Component = () => {
|
|||||||
<div class="flex justify-end">
|
<div class="flex justify-end">
|
||||||
<Button variant="outline" onClick={handleEdit}>
|
<Button variant="outline" onClick={handleEdit}>
|
||||||
<div class="i-tabler-edit size-4 mr-2" />
|
<div class="i-tabler-edit size-4 mr-2" />
|
||||||
Edit
|
{t('documents.actions.edit')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Alert variant="muted" class="my-4 flex items-center gap-2">
|
<Alert variant="muted" class="my-4 flex items-center gap-2">
|
||||||
<div class="i-tabler-info-circle size-8 flex-shrink-0" />
|
<div class="i-tabler-info-circle size-8 flex-shrink-0" />
|
||||||
<AlertDescription>
|
<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>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
</div>
|
</div>
|
||||||
@@ -307,15 +425,49 @@ export const DocumentPage: Component = () => {
|
|||||||
</TextFieldRoot>
|
</TextFieldRoot>
|
||||||
<div class="flex justify-end gap-2">
|
<div class="flex justify-end gap-2">
|
||||||
<Button variant="outline" onClick={handleCancel} disabled={isSaving()}>
|
<Button variant="outline" onClick={handleCancel} disabled={isSaving()}>
|
||||||
Cancel
|
{t('documents.actions.cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleSave} disabled={isSaving()}>
|
<Button onClick={handleSave} disabled={isSaving()}>
|
||||||
{isSaving() ? 'Saving...' : 'Save'}
|
{isSaving() ? t('documents.actions.saving') : t('documents.actions.save')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</TabsContent>
|
</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>
|
</Tabs>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useParams, useSearchParams } from '@solidjs/router';
|
|||||||
import { createQueries, keepPreviousData } from '@tanstack/solid-query';
|
import { createQueries, keepPreviousData } from '@tanstack/solid-query';
|
||||||
import { castArray } from 'lodash-es';
|
import { castArray } from 'lodash-es';
|
||||||
import { createSignal, For, Show, Suspense } from 'solid-js';
|
import { createSignal, For, Show, Suspense } from 'solid-js';
|
||||||
|
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||||
import { fetchOrganization } from '@/modules/organizations/organizations.services';
|
import { fetchOrganization } from '@/modules/organizations/organizations.services';
|
||||||
import { Tag } from '@/modules/tags/components/tag.component';
|
import { Tag } from '@/modules/tags/components/tag.component';
|
||||||
import { fetchTags } from '@/modules/tags/tags.services';
|
import { fetchTags } from '@/modules/tags/tags.services';
|
||||||
@@ -12,6 +13,7 @@ import { fetchOrganizationDocuments } from '../documents.services';
|
|||||||
|
|
||||||
export const DocumentsPage: Component = () => {
|
export const DocumentsPage: Component = () => {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
|
const { t } = useI18n();
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const [getPagination, setPagination] = createSignal({ pageIndex: 0, pageSize: 100 });
|
const [getPagination, setPagination] = createSignal({ pageIndex: 0, pageSize: 100 });
|
||||||
|
|
||||||
@@ -51,11 +53,11 @@ export const DocumentsPage: Component = () => {
|
|||||||
? (
|
? (
|
||||||
<>
|
<>
|
||||||
<h2 class="text-xl font-bold ">
|
<h2 class="text-xl font-bold ">
|
||||||
No documents
|
{t('documents.list.no-documents.title')}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<p class="text-muted-foreground mt-1 mb-6">
|
<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>
|
</p>
|
||||||
|
|
||||||
<DocumentUploadArea />
|
<DocumentUploadArea />
|
||||||
@@ -65,7 +67,7 @@ export const DocumentsPage: Component = () => {
|
|||||||
: (
|
: (
|
||||||
<>
|
<>
|
||||||
<h2 class="text-lg font-semibold mb-4">
|
<h2 class="text-lg font-semibold mb-4">
|
||||||
Documents
|
{t('documents.list.title')}
|
||||||
</h2>
|
</h2>
|
||||||
<Show when={hasFilters()}>
|
<Show when={hasFilters()}>
|
||||||
<div class="flex flex-wrap gap-2 mb-4">
|
<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}>
|
<Show when={hasFilters() && query[0].data?.documentsCount === 0}>
|
||||||
<p class="text-muted-foreground mt-1 mb-6">
|
<p class="text-muted-foreground mt-1 mb-6">
|
||||||
No documents found
|
{t('documents.list.no-results')}
|
||||||
</p>
|
</p>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ describe('locales', () => {
|
|||||||
/^webhooks\.events\.documents\.[a-z0-9:]+.description$/, // webhooks.events.organization.organization:created
|
/^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
|
/^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
|
/^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(
|
const keys = new Set(
|
||||||
|
|||||||
@@ -68,23 +68,223 @@ export type LocaleKeys =
|
|||||||
| 'auth.legal-links.description'
|
| 'auth.legal-links.description'
|
||||||
| 'auth.legal-links.terms'
|
| 'auth.legal-links.terms'
|
||||||
| 'auth.legal-links.privacy'
|
| '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.title'
|
||||||
| 'tags.no-tags.description'
|
| 'tags.no-tags.description'
|
||||||
| 'tags.no-tags.create-tag'
|
| 'tags.no-tags.create-tag'
|
||||||
| 'layout.menu.home'
|
| 'tags.title'
|
||||||
| 'layout.menu.documents'
|
| 'tags.description'
|
||||||
| 'layout.menu.tags'
|
| 'tags.create'
|
||||||
| 'layout.menu.tagging-rules'
|
| 'tags.update'
|
||||||
| 'layout.menu.deleted-documents'
|
| 'tags.delete'
|
||||||
| 'layout.menu.organization-settings'
|
| 'tags.delete.confirm.title'
|
||||||
| 'layout.menu.api-keys'
|
| 'tags.delete.confirm.message'
|
||||||
| 'layout.menu.settings'
|
| 'tags.delete.confirm.confirm-button'
|
||||||
| 'layout.menu.account'
|
| 'tags.delete.confirm.cancel-button'
|
||||||
| 'layout.menu.general-settings'
|
| 'tags.delete.success'
|
||||||
| 'layout.menu.intake-emails'
|
| 'tags.create.success'
|
||||||
| 'layout.menu.webhooks'
|
| 'tags.update.success'
|
||||||
| 'layout.menu.members'
|
| 'tags.form.name.label'
|
||||||
| 'layout.menu.invitations'
|
| '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.name'
|
||||||
| 'tagging-rules.field.content'
|
| 'tagging-rules.field.content'
|
||||||
| 'tagging-rules.operator.equals'
|
| 'tagging-rules.operator.equals'
|
||||||
@@ -133,37 +333,38 @@ export type LocaleKeys =
|
|||||||
| 'tagging-rules.update.error'
|
| 'tagging-rules.update.error'
|
||||||
| 'tagging-rules.update.submit'
|
| 'tagging-rules.update.submit'
|
||||||
| 'tagging-rules.update.cancel'
|
| 'tagging-rules.update.cancel'
|
||||||
| 'demo.popup.description'
|
| 'intake-emails.title'
|
||||||
| 'demo.popup.discord'
|
| 'intake-emails.description'
|
||||||
| 'demo.popup.discord-link-label'
|
| 'intake-emails.disabled.title'
|
||||||
| 'demo.popup.reset'
|
| 'intake-emails.disabled.description'
|
||||||
| 'demo.popup.hide'
|
| 'intake-emails.disabled.documentation'
|
||||||
| 'trash.delete-all.button'
|
| 'intake-emails.info'
|
||||||
| 'trash.delete-all.confirm.title'
|
| 'intake-emails.empty.title'
|
||||||
| 'trash.delete-all.confirm.description'
|
| 'intake-emails.empty.description'
|
||||||
| 'trash.delete-all.confirm.label'
|
| 'intake-emails.empty.generate'
|
||||||
| 'trash.delete-all.confirm.cancel'
|
| 'intake-emails.count'
|
||||||
| 'trash.delete.button'
|
| 'intake-emails.new'
|
||||||
| 'trash.delete.confirm.title'
|
| 'intake-emails.disabled-label'
|
||||||
| 'trash.delete.confirm.description'
|
| 'intake-emails.no-origins'
|
||||||
| 'trash.delete.confirm.label'
|
| 'intake-emails.allowed-origins'
|
||||||
| 'trash.delete.confirm.cancel'
|
| 'intake-emails.actions.enable'
|
||||||
| 'trash.deleted.success.title'
|
| 'intake-emails.actions.disable'
|
||||||
| 'trash.deleted.success.description'
|
| 'intake-emails.actions.manage-origins'
|
||||||
| 'import-documents.title.error'
|
| 'intake-emails.actions.delete'
|
||||||
| 'import-documents.title.success'
|
| 'intake-emails.delete.confirm.title'
|
||||||
| 'import-documents.title.pending'
|
| 'intake-emails.delete.confirm.message'
|
||||||
| 'import-documents.title.none'
|
| 'intake-emails.delete.confirm.confirm-button'
|
||||||
| 'import-documents.no-import-in-progress'
|
| 'intake-emails.delete.confirm.cancel-button'
|
||||||
| 'api-errors.document.already_exists'
|
| 'intake-emails.delete.success'
|
||||||
| 'api-errors.document.file_too_big'
|
| 'intake-emails.create.success'
|
||||||
| 'api-errors.intake_email.limit_reached'
|
| 'intake-emails.update.success.enabled'
|
||||||
| 'api-errors.user.max_organization_count_reached'
|
| 'intake-emails.update.success.disabled'
|
||||||
| 'api-errors.default'
|
| 'intake-emails.allowed-origins.title'
|
||||||
| 'api-errors.organization.invitation_already_exists'
|
| 'intake-emails.allowed-origins.description'
|
||||||
| 'api-errors.user.already_in_organization'
|
| 'intake-emails.allowed-origins.add.label'
|
||||||
| 'api-errors.user.organization_invitation_limit_reached'
|
| 'intake-emails.allowed-origins.add.placeholder'
|
||||||
| 'api-errors.demo.not_available'
|
| 'intake-emails.allowed-origins.add.button'
|
||||||
|
| 'intake-emails.allowed-origins.add.error.exists'
|
||||||
| 'api-keys.permissions.documents.title'
|
| 'api-keys.permissions.documents.title'
|
||||||
| 'api-keys.permissions.documents.documents:create'
|
| 'api-keys.permissions.documents.documents:create'
|
||||||
| 'api-keys.permissions.documents.documents:read'
|
| 'api-keys.permissions.documents.documents:read'
|
||||||
@@ -238,41 +439,49 @@ export type LocaleKeys =
|
|||||||
| 'webhooks.delete.confirm.cancel-button'
|
| 'webhooks.delete.confirm.cancel-button'
|
||||||
| 'webhooks.events.documents.document:created.description'
|
| 'webhooks.events.documents.document:created.description'
|
||||||
| 'webhooks.events.documents.document:deleted.description'
|
| 'webhooks.events.documents.document:deleted.description'
|
||||||
| 'organizations.members.title'
|
| 'layout.menu.home'
|
||||||
| 'organizations.members.description'
|
| 'layout.menu.documents'
|
||||||
| 'organizations.members.invite-member'
|
| 'layout.menu.tags'
|
||||||
| 'organizations.members.invite-member-disabled-tooltip'
|
| 'layout.menu.tagging-rules'
|
||||||
| 'organizations.members.remove-from-organization'
|
| 'layout.menu.deleted-documents'
|
||||||
| 'organizations.members.role'
|
| 'layout.menu.organization-settings'
|
||||||
| 'organizations.members.roles.owner'
|
| 'layout.menu.api-keys'
|
||||||
| 'organizations.members.roles.admin'
|
| 'layout.menu.settings'
|
||||||
| 'organizations.members.roles.member'
|
| 'layout.menu.account'
|
||||||
| 'organizations.members.delete.confirm.title'
|
| 'layout.menu.general-settings'
|
||||||
| 'organizations.members.delete.confirm.message'
|
| 'layout.menu.intake-emails'
|
||||||
| 'organizations.members.delete.confirm.confirm-button'
|
| 'layout.menu.webhooks'
|
||||||
| 'organizations.members.delete.confirm.cancel-button'
|
| 'layout.menu.members'
|
||||||
| 'organizations.members.delete.success'
|
| 'layout.menu.invitations'
|
||||||
| 'organizations.members.update-role.success'
|
| 'layout.theme.light'
|
||||||
| 'organizations.invite-member.title'
|
| 'layout.theme.dark'
|
||||||
| 'organizations.invite-member.description'
|
| 'layout.theme.system'
|
||||||
| 'organizations.invite-member.form.email.label'
|
| 'layout.search.placeholder'
|
||||||
| 'organizations.invite-member.form.email.placeholder'
|
| 'layout.menu.import-document'
|
||||||
| 'organizations.invite-member.form.email.required'
|
| 'user-menu.account-settings'
|
||||||
| 'organizations.invite-member.form.role.label'
|
| 'user-menu.api-keys'
|
||||||
| 'organizations.invite-member.form.submit'
|
| 'user-menu.invitations'
|
||||||
| 'organizations.invite-member.success.message'
|
| 'user-menu.language'
|
||||||
| 'organizations.invite-member.success.description'
|
| 'user-menu.logout'
|
||||||
| 'organizations.invite-member.error.message'
|
| 'command-palette.search.placeholder'
|
||||||
| 'invitations.list.title'
|
| 'command-palette.no-results'
|
||||||
| 'invitations.list.description'
|
| 'command-palette.sections.documents'
|
||||||
| 'invitations.list.empty.title'
|
| 'command-palette.sections.theme'
|
||||||
| 'invitations.list.empty.description'
|
| 'api-errors.document.already_exists'
|
||||||
| 'invitations.list.headers.organization'
|
| 'api-errors.document.file_too_big'
|
||||||
| 'invitations.list.headers.created'
|
| 'api-errors.intake_email.limit_reached'
|
||||||
| 'invitations.list.headers.actions'
|
| 'api-errors.user.max_organization_count_reached'
|
||||||
| 'invitations.list.actions.accept'
|
| 'api-errors.default'
|
||||||
| 'invitations.list.actions.reject'
|
| 'api-errors.organization.invitation_already_exists'
|
||||||
| 'invitations.list.actions.accept.success.message'
|
| 'api-errors.user.already_in_organization'
|
||||||
| 'invitations.list.actions.accept.success.description'
|
| 'api-errors.user.organization_invitation_limit_reached'
|
||||||
| 'invitations.list.actions.reject.success.message'
|
| 'api-errors.demo.not_available'
|
||||||
| 'invitations.list.actions.reject.success.description';
|
| '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';
|
||||||
|
|||||||
@@ -3,10 +3,11 @@ import type { Component, JSX } from 'solid-js';
|
|||||||
import type { IntakeEmail } from '../intake-emails.types';
|
import type { IntakeEmail } from '../intake-emails.types';
|
||||||
import { safely } from '@corentinth/chisels';
|
import { safely } from '@corentinth/chisels';
|
||||||
import { useParams } from '@solidjs/router';
|
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 { createSignal, For, Show, Suspense } from 'solid-js';
|
||||||
import * as v from 'valibot';
|
import * as v from 'valibot';
|
||||||
import { useConfig } from '@/modules/config/config.provider';
|
import { useConfig } from '@/modules/config/config.provider';
|
||||||
|
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||||
import { useConfirmModal } from '@/modules/shared/confirm';
|
import { useConfirmModal } from '@/modules/shared/confirm';
|
||||||
import { createForm } from '@/modules/shared/form/form';
|
import { createForm } from '@/modules/shared/form/form';
|
||||||
import { isHttpErrorWithCode } from '@/modules/shared/http/http-errors';
|
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 AllowedOriginsDialog: Component<{ children: (props: DialogTriggerProps) => JSX.Element; intakeEmails: IntakeEmail }> = (props) => {
|
||||||
const [getAllowedOrigins, setAllowedOrigins] = createSignal([...props.intakeEmails.allowedOrigins]);
|
const [getAllowedOrigins, setAllowedOrigins] = createSignal([...props.intakeEmails.allowedOrigins]);
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
const update = async () => {
|
const update = async () => {
|
||||||
await updateIntakeEmail({
|
await updateIntakeEmail({
|
||||||
@@ -47,7 +49,7 @@ const AllowedOriginsDialog: Component<{ children: (props: DialogTriggerProps) =>
|
|||||||
}),
|
}),
|
||||||
onSubmit: async ({ email }) => {
|
onSubmit: async ({ email }) => {
|
||||||
if (getAllowedOrigins().includes(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]);
|
setAllowedOrigins(origins => [...origins, email]);
|
||||||
@@ -67,13 +69,9 @@ const AllowedOriginsDialog: Component<{ children: (props: DialogTriggerProps) =>
|
|||||||
|
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Allowed origins</DialogTitle>
|
<DialogTitle>{t('intake-emails.allowed-origins.title')}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Only emails sent to
|
{t('intake-emails.allowed-origins.description', { email: props.intakeEmails.emailAddress })}
|
||||||
{' '}
|
|
||||||
<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.
|
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
@@ -81,13 +79,13 @@ const AllowedOriginsDialog: Component<{ children: (props: DialogTriggerProps) =>
|
|||||||
<Field name="email">
|
<Field name="email">
|
||||||
{(field, inputProps) => (
|
{(field, inputProps) => (
|
||||||
<TextFieldRoot class="flex flex-col gap-1 mb-4 mt-4">
|
<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">
|
<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">
|
<Button type="submit">
|
||||||
<div class="i-tabler-plus size-4 mr-2" />
|
<div class="i-tabler-plus size-4 mr-2" />
|
||||||
Add
|
{t('intake-emails.allowed-origins.add.button')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -130,26 +128,28 @@ const AllowedOriginsDialog: Component<{ children: (props: DialogTriggerProps) =>
|
|||||||
|
|
||||||
export const IntakeEmailsPage: Component = () => {
|
export const IntakeEmailsPage: Component = () => {
|
||||||
const { config } = useConfig();
|
const { config } = useConfig();
|
||||||
|
const { t, te } = useI18n();
|
||||||
|
|
||||||
if (!config.intakeEmails.isEnabled) {
|
if (!config.intakeEmails.isEnabled) {
|
||||||
return (
|
return (
|
||||||
<div class="p-6 max-w-screen-md mx-auto mt-10">
|
<div class="p-6 max-w-screen-md mx-auto mt-10">
|
||||||
|
<h1 class="text-xl font-semibold">{t('intake-emails.title')}</h1>
|
||||||
<h1 class="text-xl font-semibold">Intake Emails</h1>
|
|
||||||
|
|
||||||
<p class="text-muted-foreground mt-1">
|
<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>
|
</p>
|
||||||
<Card class="px-6 py-4 mt-4 flex items-center gap-4">
|
<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 class="i-tabler-mail-off size-12 text-muted-foreground flex-shrink-0" />
|
||||||
<div>
|
<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">
|
<p class="text-muted-foreground mt-1">
|
||||||
Intake emails are disabled on this instance. Please contact your administrator to enable them. See the
|
{te('intake-emails.disabled.description', {
|
||||||
{' '}
|
documentation: (
|
||||||
<a href="https://docs.papra.app/guides/intake-emails-with-owlrelay/" target="_blank" class="text-primary">documentation</a>
|
<a href="https://docs.papra.app/guides/intake-emails-with-owlrelay/" target="_blank" class="text-primary">
|
||||||
{' '}
|
{t('intake-emails.disabled.documentation')}
|
||||||
for more information.
|
</a>
|
||||||
|
),
|
||||||
|
})}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -160,7 +160,7 @@ export const IntakeEmailsPage: Component = () => {
|
|||||||
const params = useParams();
|
const params = useParams();
|
||||||
const { confirm } = useConfirmModal();
|
const { confirm } = useConfirmModal();
|
||||||
|
|
||||||
const query = createQuery(() => ({
|
const query = useQuery(() => ({
|
||||||
queryKey: ['organizations', params.organizationId, 'intake-emails'],
|
queryKey: ['organizations', params.organizationId, 'intake-emails'],
|
||||||
queryFn: () => fetchIntakeEmails({ organizationId: params.organizationId }),
|
queryFn: () => fetchIntakeEmails({ organizationId: params.organizationId }),
|
||||||
}));
|
}));
|
||||||
@@ -170,7 +170,7 @@ export const IntakeEmailsPage: Component = () => {
|
|||||||
|
|
||||||
if (isHttpErrorWithCode({ error, code: 'intake_email.limit_reached' })) {
|
if (isHttpErrorWithCode({ error, code: 'intake_email.limit_reached' })) {
|
||||||
createToast({
|
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',
|
type: 'error',
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -184,20 +184,20 @@ export const IntakeEmailsPage: Component = () => {
|
|||||||
await query.refetch();
|
await query.refetch();
|
||||||
|
|
||||||
createToast({
|
createToast({
|
||||||
message: 'Intake email created',
|
message: t('intake-emails.create.success'),
|
||||||
type: 'success',
|
type: 'success',
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteEmail = async ({ intakeEmailId }: { intakeEmailId: string }) => {
|
const deleteEmail = async ({ intakeEmailId }: { intakeEmailId: string }) => {
|
||||||
const confirmed = await confirm({
|
const confirmed = await confirm({
|
||||||
title: 'Delete intake email?',
|
title: t('intake-emails.delete.confirm.title'),
|
||||||
message: 'Are you sure you want to delete this intake email? This action cannot be undone.',
|
message: t('intake-emails.delete.confirm.message'),
|
||||||
cancelButton: {
|
cancelButton: {
|
||||||
text: 'Cancel',
|
text: t('intake-emails.delete.confirm.cancel-button'),
|
||||||
},
|
},
|
||||||
confirmButton: {
|
confirmButton: {
|
||||||
text: 'Delete intake email',
|
text: t('intake-emails.delete.confirm.confirm-button'),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -210,7 +210,7 @@ export const IntakeEmailsPage: Component = () => {
|
|||||||
await query.refetch();
|
await query.refetch();
|
||||||
|
|
||||||
createToast({
|
createToast({
|
||||||
message: 'Intake email deleted',
|
message: t('intake-emails.delete.success'),
|
||||||
type: 'success',
|
type: 'success',
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -220,27 +220,25 @@ export const IntakeEmailsPage: Component = () => {
|
|||||||
await query.refetch();
|
await query.refetch();
|
||||||
|
|
||||||
createToast({
|
createToast({
|
||||||
message: `Intake email ${isEnabled ? 'enabled' : 'disabled'}`,
|
message: isEnabled ? t('intake-emails.update.success.enabled') : t('intake-emails.update.success.disabled'),
|
||||||
type: 'success',
|
type: 'success',
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="p-6 max-w-screen-md mx-auto mt-10">
|
<div class="p-6 max-w-screen-md mx-auto mt-10">
|
||||||
|
<h1 class="text-xl font-semibold">{t('intake-emails.title')}</h1>
|
||||||
<h1 class="text-xl font-semibold">Intake Emails</h1>
|
|
||||||
|
|
||||||
<p class="text-muted-foreground mt-1">
|
<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>
|
</p>
|
||||||
|
|
||||||
<Alert variant="default" class="mt-4 flex items-center gap-4 xl:gap-4 text-muted-foreground">
|
<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 " />
|
<div class="i-tabler-info-circle size-10 xl:size-8 text-primary flex-shrink-0 " />
|
||||||
|
|
||||||
<AlertDescription>
|
<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>
|
</AlertDescription>
|
||||||
|
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|
||||||
<Suspense>
|
<Suspense>
|
||||||
@@ -251,14 +249,14 @@ export const IntakeEmailsPage: Component = () => {
|
|||||||
fallback={(
|
fallback={(
|
||||||
<div class="mt-4 py-8 border-2 border-dashed rounded-lg text-center">
|
<div class="mt-4 py-8 border-2 border-dashed rounded-lg text-center">
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title="No intake emails"
|
title={t('intake-emails.empty.title')}
|
||||||
description="Generate an intake address to easily ingest emails attachments."
|
description={t('intake-emails.empty.description')}
|
||||||
class="pt-0"
|
class="pt-0"
|
||||||
icon="i-tabler-mail"
|
icon="i-tabler-mail"
|
||||||
cta={(
|
cta={(
|
||||||
<Button variant="secondary" onClick={createEmail}>
|
<Button variant="secondary" onClick={createEmail}>
|
||||||
<div class="i-tabler-plus size-4 mr-2" />
|
<div class="i-tabler-plus size-4 mr-2" />
|
||||||
Generate intake email
|
{t('intake-emails.empty.generate')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -267,12 +265,15 @@ export const IntakeEmailsPage: Component = () => {
|
|||||||
>
|
>
|
||||||
<div class="mt-4 mb-4 flex items-center justify-between">
|
<div class="mt-4 mb-4 flex items-center justify-between">
|
||||||
<div class="text-muted-foreground">
|
<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>
|
</div>
|
||||||
|
|
||||||
<Button onClick={createEmail}>
|
<Button onClick={createEmail}>
|
||||||
<div class="i-tabler-plus size-4 mr-2" />
|
<div class="i-tabler-plus size-4 mr-2" />
|
||||||
New intake email
|
{t('intake-emails.new')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -290,9 +291,8 @@ export const IntakeEmailsPage: Component = () => {
|
|||||||
{intakeEmail.emailAddress}
|
{intakeEmail.emailAddress}
|
||||||
|
|
||||||
<Show when={!intakeEmail.isEnabled}>
|
<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>
|
</Show>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Show
|
<Show
|
||||||
@@ -300,14 +300,16 @@ export const IntakeEmailsPage: Component = () => {
|
|||||||
fallback={(
|
fallback={(
|
||||||
<div class="text-xs text-warning flex items-center gap-1.5">
|
<div class="text-xs text-warning flex items-center gap-1.5">
|
||||||
<div class="i-tabler-alert-triangle size-3.75" />
|
<div class="i-tabler-alert-triangle size-3.75" />
|
||||||
No allowed email origins
|
{t('intake-emails.no-origins')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div class="text-xs text-muted-foreground flex items-center gap-2">
|
<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>
|
</div>
|
||||||
|
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -318,7 +320,7 @@ export const IntakeEmailsPage: Component = () => {
|
|||||||
onClick={() => updateEmail({ intakeEmailId: intakeEmail.id, isEnabled: !intakeEmail.isEnabled })}
|
onClick={() => updateEmail({ intakeEmailId: intakeEmail.id, isEnabled: !intakeEmail.isEnabled })}
|
||||||
>
|
>
|
||||||
<div class="i-tabler-power size-4 mr-2" />
|
<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>
|
</Button>
|
||||||
|
|
||||||
<AllowedOriginsDialog intakeEmails={intakeEmail}>
|
<AllowedOriginsDialog intakeEmails={intakeEmail}>
|
||||||
@@ -330,7 +332,7 @@ export const IntakeEmailsPage: Component = () => {
|
|||||||
class="flex items-center gap-2 leading-none"
|
class="flex items-center gap-2 leading-none"
|
||||||
>
|
>
|
||||||
<div class="i-tabler-edit size-4" />
|
<div class="i-tabler-edit size-4" />
|
||||||
Manage origins addresses
|
{t('intake-emails.actions.manage-origins')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</AllowedOriginsDialog>
|
</AllowedOriginsDialog>
|
||||||
@@ -342,18 +344,14 @@ export const IntakeEmailsPage: Component = () => {
|
|||||||
class="text-red"
|
class="text-red"
|
||||||
>
|
>
|
||||||
<div class="i-tabler-trash size-4 mr-2" />
|
<div class="i-tabler-trash size-4 mr-2" />
|
||||||
|
{t('intake-emails.actions.delete')}
|
||||||
Delete
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|||||||
@@ -25,13 +25,6 @@ export async function fetchPendingInvitationsCount() {
|
|||||||
return { pendingInvitationsCount };
|
return { pendingInvitationsCount };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function cancelInvitation({ invitationId }: { invitationId: string }) {
|
|
||||||
await apiClient({
|
|
||||||
path: `/api/invitations/${invitationId}`,
|
|
||||||
method: 'DELETE',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function acceptInvitation({ invitationId }: { invitationId: string }) {
|
export async function acceptInvitation({ invitationId }: { invitationId: string }) {
|
||||||
await apiClient({
|
await apiClient({
|
||||||
path: `/api/invitations/${invitationId}/accept`,
|
path: `/api/invitations/${invitationId}/accept`,
|
||||||
@@ -45,3 +38,17 @@ export async function rejectInvitation({ invitationId }: { invitationId: string
|
|||||||
method: 'POST',
|
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',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { Component } from 'solid-js';
|
import type { Component } from 'solid-js';
|
||||||
import { safely } from '@corentinth/chisels';
|
import { safely } from '@corentinth/chisels';
|
||||||
import * as v from 'valibot';
|
import * as v from 'valibot';
|
||||||
|
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||||
import { createForm } from '@/modules/shared/form/form';
|
import { createForm } from '@/modules/shared/form/form';
|
||||||
import { isHttpErrorWithCode } from '@/modules/shared/http/http-errors';
|
import { isHttpErrorWithCode } from '@/modules/shared/http/http-errors';
|
||||||
import { Button } from '@/modules/ui/components/button';
|
import { Button } from '@/modules/ui/components/button';
|
||||||
@@ -11,18 +12,22 @@ export const CreateOrganizationForm: Component<{
|
|||||||
onSubmit: (args: { organizationName: string }) => Promise<void>;
|
onSubmit: (args: { organizationName: string }) => Promise<void>;
|
||||||
initialOrganizationName?: string;
|
initialOrganizationName?: string;
|
||||||
}> = (props) => {
|
}> = (props) => {
|
||||||
|
const { t } = useI18n();
|
||||||
const { form, Form, Field } = createForm({
|
const { form, Form, Field } = createForm({
|
||||||
onSubmit: async ({ organizationName }) => {
|
onSubmit: async ({ organizationName }) => {
|
||||||
const [, error] = await safely(props.onSubmit({ organizationName }));
|
const [, error] = await safely(props.onSubmit({ organizationName }));
|
||||||
|
|
||||||
if (isHttpErrorWithCode({ error, code: 'user.max_organization_count_reached' })) {
|
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;
|
throw error;
|
||||||
},
|
},
|
||||||
schema: v.object({
|
schema: v.object({
|
||||||
organizationName: organizationNameSchema,
|
organizationName: v.pipe(
|
||||||
|
organizationNameSchema,
|
||||||
|
v.nonEmpty(t('organizations.create.form.name.required')),
|
||||||
|
),
|
||||||
}),
|
}),
|
||||||
initialValues: {
|
initialValues: {
|
||||||
organizationName: props.initialOrganizationName,
|
organizationName: props.initialOrganizationName,
|
||||||
@@ -35,8 +40,8 @@ export const CreateOrganizationForm: Component<{
|
|||||||
<Field name="organizationName">
|
<Field name="organizationName">
|
||||||
{(field, inputProps) => (
|
{(field, inputProps) => (
|
||||||
<TextFieldRoot class="flex flex-col gap-1 mb-6">
|
<TextFieldRoot class="flex flex-col gap-1 mb-6">
|
||||||
<TextFieldLabel for="organizationName">Organization name</TextFieldLabel>
|
<TextFieldLabel for="organizationName">{t('organizations.create.form.name.label')}</TextFieldLabel>
|
||||||
<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('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>}
|
{field.error && <div class="text-red-500 text-sm">{field.error}</div>}
|
||||||
</TextFieldRoot>
|
</TextFieldRoot>
|
||||||
)}
|
)}
|
||||||
@@ -44,7 +49,7 @@ export const CreateOrganizationForm: Component<{
|
|||||||
|
|
||||||
<div class="flex justify-end">
|
<div class="flex justify-end">
|
||||||
<Button type="submit" isLoading={form.submitting} class="w-full">
|
<Button type="submit" isLoading={form.submitting} class="w-full">
|
||||||
Create organization
|
{t('organizations.create.form.submit')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { ParentComponent } from 'solid-js';
|
import type { ParentComponent } from 'solid-js';
|
||||||
import type { Organization } from '../organizations.types';
|
import type { Organization } from '../organizations.types';
|
||||||
import { makePersisted } from '@solid-primitives/storage';
|
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 { createContext, createSignal, Show, useContext } from 'solid-js';
|
||||||
import { fetchOrganizations } from '../organizations.services';
|
import { fetchOrganizations } from '../organizations.services';
|
||||||
|
|
||||||
@@ -24,7 +24,7 @@ export function useCurrentOrganization() {
|
|||||||
export const CurrentOrganizationProvider: ParentComponent = (props) => {
|
export const CurrentOrganizationProvider: ParentComponent = (props) => {
|
||||||
const [getCurrentOrganizationId, setCurrentOrganizationId] = makePersisted(createSignal<string | null>(null), { name: 'papra_current_organization_id', storage: localStorage });
|
const [getCurrentOrganizationId, setCurrentOrganizationId] = makePersisted(createSignal<string | null>(null), { name: 'papra_current_organization_id', storage: localStorage });
|
||||||
|
|
||||||
const query = createQuery(() => ({
|
const query = useQuery(() => ({
|
||||||
queryKey: ['organizations'],
|
queryKey: ['organizations'],
|
||||||
queryFn: fetchOrganizations,
|
queryFn: fetchOrganizations,
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useNavigate, useParams } from '@solidjs/router';
|
import { useNavigate, useParams } from '@solidjs/router';
|
||||||
import { useQuery } from '@tanstack/solid-query';
|
import { useQuery } from '@tanstack/solid-query';
|
||||||
|
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||||
import { queryClient } from '@/modules/shared/query/query-client';
|
import { queryClient } from '@/modules/shared/query/query-client';
|
||||||
import { createToast } from '@/modules/ui/components/sonner';
|
import { createToast } from '@/modules/ui/components/sonner';
|
||||||
import { ORGANIZATION_ROLES } from './organizations.constants';
|
import { ORGANIZATION_ROLES } from './organizations.constants';
|
||||||
@@ -7,12 +8,13 @@ import { createOrganization, deleteOrganization, getMembership, updateOrganizati
|
|||||||
|
|
||||||
export function useCreateOrganization() {
|
export function useCreateOrganization() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
createOrganization: async ({ organizationName }: { organizationName: string }) => {
|
createOrganization: async ({ organizationName }: { organizationName: string }) => {
|
||||||
const { organization } = await createOrganization({ name: organizationName });
|
const { organization } = await createOrganization({ name: organizationName });
|
||||||
|
|
||||||
createToast({ type: 'success', message: 'Organization created' });
|
createToast({ type: 'success', message: t('organizations.create.success') });
|
||||||
|
|
||||||
await queryClient.invalidateQueries({
|
await queryClient.invalidateQueries({
|
||||||
queryKey: ['organizations'],
|
queryKey: ['organizations'],
|
||||||
|
|||||||
@@ -5,3 +5,13 @@ export const ORGANIZATION_ROLES = {
|
|||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const ORGANIZATION_ROLES_LIST = Object.values(ORGANIZATION_ROLES);
|
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);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { AsDto } from '../shared/http/http-client.types';
|
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 { apiClient } from '../shared/http/api-client';
|
||||||
import { coerceDates } from '../shared/http/http-client.models';
|
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 }) {
|
export async function removeOrganizationMember({ organizationId, memberId }: { organizationId: string; memberId: string }) {
|
||||||
await apiClient({
|
await apiClient({
|
||||||
path: `/api/organizations/${organizationId}/members/${memberId}`,
|
path: `/api/organizations/${organizationId}/members/${memberId}`,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { User } from 'better-auth/types';
|
import type { User } from 'better-auth/types';
|
||||||
|
import type { ORGANIZATION_INVITATION_STATUS_LIST } from './organizations.constants';
|
||||||
|
|
||||||
export type Organization = {
|
export type Organization = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -15,3 +16,14 @@ export type OrganizationMember = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type OrganizationMemberRole = 'owner' | 'admin' | 'member';
|
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;
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import type { Component } from 'solid-js';
|
import type { Component } from 'solid-js';
|
||||||
import { useNavigate } from '@solidjs/router';
|
import { useNavigate } from '@solidjs/router';
|
||||||
import { createQuery } from '@tanstack/solid-query';
|
import { useQuery } from '@tanstack/solid-query';
|
||||||
import { createEffect, on } from 'solid-js';
|
import { createEffect, on } from 'solid-js';
|
||||||
|
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||||
import { useCurrentUser } from '@/modules/users/composables/useCurrentUser';
|
import { useCurrentUser } from '@/modules/users/composables/useCurrentUser';
|
||||||
import { CreateOrganizationForm } from '../components/create-organization-form.component';
|
import { CreateOrganizationForm } from '../components/create-organization-form.component';
|
||||||
import { useCreateOrganization } from '../organizations.composables';
|
import { useCreateOrganization } from '../organizations.composables';
|
||||||
@@ -11,24 +12,25 @@ export const CreateFirstOrganizationPage: Component = () => {
|
|||||||
const { createOrganization } = useCreateOrganization();
|
const { createOrganization } = useCreateOrganization();
|
||||||
const { user } = useCurrentUser();
|
const { user } = useCurrentUser();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
const getOrganizationName = () => {
|
const getOrganizationName = () => {
|
||||||
const { name } = user;
|
const { name } = user;
|
||||||
|
|
||||||
if (name && name.length > 0) {
|
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'],
|
queryKey: ['organizations'],
|
||||||
queryFn: fetchOrganizations,
|
queryFn: fetchOrganizations,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
createEffect(on(
|
createEffect(on(
|
||||||
() => queries.data?.organizations,
|
() => query.data?.organizations,
|
||||||
(orgs) => {
|
(orgs) => {
|
||||||
if (orgs && orgs.length > 0) {
|
if (orgs && orgs.length > 0) {
|
||||||
navigate('/organizations/create');
|
navigate('/organizations/create');
|
||||||
@@ -40,11 +42,11 @@ export const CreateFirstOrganizationPage: Component = () => {
|
|||||||
<div>
|
<div>
|
||||||
<div class="max-w-md mx-auto pt-12 sm:pt-24 px-6">
|
<div class="max-w-md mx-auto pt-12 sm:pt-24 px-6">
|
||||||
<h1 class="text-xl font-bold">
|
<h1 class="text-xl font-bold">
|
||||||
Create your organization
|
{t('organizations.create-first.title')}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<p class="text-muted-foreground mb-6">
|
<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>
|
</p>
|
||||||
|
|
||||||
<CreateOrganizationForm onSubmit={createOrganization} initialOrganizationName={getOrganizationName()} />
|
<CreateOrganizationForm onSubmit={createOrganization} initialOrganizationName={getOrganizationName()} />
|
||||||
|
|||||||
@@ -1,27 +1,28 @@
|
|||||||
import type { Component } from 'solid-js';
|
import type { Component } from 'solid-js';
|
||||||
import { A } from '@solidjs/router';
|
import { A } from '@solidjs/router';
|
||||||
|
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||||
import { Button } from '@/modules/ui/components/button';
|
import { Button } from '@/modules/ui/components/button';
|
||||||
import { CreateOrganizationForm } from '../components/create-organization-form.component';
|
import { CreateOrganizationForm } from '../components/create-organization-form.component';
|
||||||
import { useCreateOrganization } from '../organizations.composables';
|
import { useCreateOrganization } from '../organizations.composables';
|
||||||
|
|
||||||
export const CreateOrganizationPage: Component = () => {
|
export const CreateOrganizationPage: Component = () => {
|
||||||
|
const { t } = useI18n();
|
||||||
const { createOrganization } = useCreateOrganization();
|
const { createOrganization } = useCreateOrganization();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div class="max-w-md mx-auto pt-12 sm:pt-24 px-6">
|
<div class="max-w-md mx-auto pt-12 sm:pt-24 px-6">
|
||||||
|
|
||||||
<Button as={A} href="/" class="mb-4" variant="outline">
|
<Button as={A} href="/" class="mb-4" variant="outline">
|
||||||
<div class="i-tabler-arrow-left mr-2"></div>
|
<div class="i-tabler-arrow-left mr-2"></div>
|
||||||
Back
|
{t('organizations.create.back')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<h1 class="text-xl font-bold">
|
<h1 class="text-xl font-bold">
|
||||||
Create a new organization
|
{t('organizations.create.title')}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<p class="text-muted-foreground mb-6">
|
<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>
|
</p>
|
||||||
|
|
||||||
<CreateOrganizationForm onSubmit={createOrganization} />
|
<CreateOrganizationForm onSubmit={createOrganization} />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -57,7 +57,9 @@ export const InviteMemberPage: Component = () => {
|
|||||||
createToast({
|
createToast({
|
||||||
message: t('organizations.invite-member.success.message'),
|
message: t('organizations.invite-member.success.message'),
|
||||||
description: t('organizations.invite-member.success.description'),
|
description: t('organizations.invite-member.success.description'),
|
||||||
|
type: 'success',
|
||||||
});
|
});
|
||||||
|
navigate(`/organizations/${params.organizationId}/members`);
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
createToast({
|
createToast({
|
||||||
@@ -153,7 +155,7 @@ export const InviteMemberPage: Component = () => {
|
|||||||
)}
|
)}
|
||||||
</Field>
|
</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')}
|
{t('organizations.invite-member.form.submit')}
|
||||||
<div class="i-tabler-send size-4 ml-1" />
|
<div class="i-tabler-send size-4 ml-1" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { Component } from 'solid-js';
|
import type { Component } from 'solid-js';
|
||||||
import type { OrganizationMemberRole } from '../organizations.types';
|
import type { OrganizationMemberRole } from '../organizations.types';
|
||||||
import { A, useParams } from '@solidjs/router';
|
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 { createSolidTable, flexRender, getCoreRowModel, getPaginationRowModel } from '@tanstack/solid-table';
|
||||||
import { For, Show } from 'solid-js';
|
import { For, Show } from 'solid-js';
|
||||||
import { useI18n } from '@/modules/i18n/i18n.provider';
|
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||||
@@ -22,7 +22,7 @@ const MemberList: Component = () => {
|
|||||||
const params = useParams();
|
const params = useParams();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { confirm } = useConfirmModal();
|
const { confirm } = useConfirmModal();
|
||||||
const query = createQuery(() => ({
|
const query = useQuery(() => ({
|
||||||
queryKey: ['organizations', params.organizationId, 'members'],
|
queryKey: ['organizations', params.organizationId, 'members'],
|
||||||
queryFn: () => fetchOrganizationMembers({ organizationId: params.organizationId }),
|
queryFn: () => fetchOrganizationMembers({ organizationId: params.organizationId }),
|
||||||
}));
|
}));
|
||||||
@@ -30,7 +30,7 @@ const MemberList: Component = () => {
|
|||||||
|
|
||||||
const { getIsAtLeastAdmin, getRole } = useCurrentUserRole({ organizationId: params.organizationId });
|
const { getIsAtLeastAdmin, getRole } = useCurrentUserRole({ organizationId: params.organizationId });
|
||||||
|
|
||||||
const removeMemberMutation = createMutation(() => ({
|
const removeMemberMutation = useMutation(() => ({
|
||||||
mutationFn: ({ memberId }: { memberId: string }) => removeOrganizationMember({ organizationId: params.organizationId, memberId }),
|
mutationFn: ({ memberId }: { memberId: string }) => removeOrganizationMember({ organizationId: params.organizationId, memberId }),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['organizations', params.organizationId, 'members'] });
|
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 }),
|
mutationFn: ({ memberId, role }: { memberId: string; role: OrganizationMemberRole }) => updateOrganizationMemberRole({ organizationId: params.organizationId, memberId, role }),
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
await queryClient.invalidateQueries({ queryKey: ['organizations', params.organizationId, 'members'] });
|
await queryClient.invalidateQueries({ queryKey: ['organizations', params.organizationId, 'members'] });
|
||||||
@@ -87,10 +87,10 @@ const MemberList: Component = () => {
|
|||||||
return query.data?.members ?? [];
|
return query.data?.members ?? [];
|
||||||
},
|
},
|
||||||
columns: [
|
columns: [
|
||||||
{ header: 'Name', accessorKey: 'user.name' },
|
{ header: t('organizations.members.table.headers.name'), accessorKey: 'user.name' },
|
||||||
{ header: 'Email', accessorKey: 'user.email' },
|
{ header: t('organizations.members.table.headers.email'), accessorKey: 'user.email' },
|
||||||
{ header: 'Role', accessorKey: 'role', cell: data => t(`organizations.members.roles.${data.getValue<OrganizationMemberRole>()}`) },
|
{ header: t('organizations.members.table.headers.role'), accessorKey: 'role', cell: data => t(`organizations.members.roles.${data.getValue<OrganizationMemberRole>()}`) },
|
||||||
{ header: 'Actions', id: 'actions', cell: data => (
|
{ header: t('organizations.members.table.headers.actions'), id: 'actions', cell: data => (
|
||||||
<div class="flex items-center justify-end">
|
<div class="flex items-center justify-end">
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger as={Button} variant="ghost" size="icon">
|
<DropdownMenuTrigger as={Button} variant="ghost" size="icon">
|
||||||
@@ -192,10 +192,18 @@ export const MembersPage: Component = () => {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Button as={A} href={`/organizations/${params.organizationId}/invite`}>
|
<div class="flex items-center gap-2">
|
||||||
<div class="i-tabler-plus size-4 mr-2" />
|
<Button as={A} href={`/organizations/${params.organizationId}/invitations`} variant="outline">
|
||||||
{t('organizations.members.invite-member')}
|
<div class="i-tabler-mail size-4 mr-2" />
|
||||||
</Button>
|
{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>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -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 { createdAtColumn, DocumentsPaginatedList, standardActionsColumn, tagsColumn } from '@/modules/documents/components/documents-list.component';
|
||||||
import { useUploadDocuments } from '@/modules/documents/documents.composables';
|
import { useUploadDocuments } from '@/modules/documents/documents.composables';
|
||||||
import { fetchOrganizationDocuments, getOrganizationDocumentsStats } from '@/modules/documents/documents.services';
|
import { fetchOrganizationDocuments, getOrganizationDocumentsStats } from '@/modules/documents/documents.services';
|
||||||
|
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||||
import { Button } from '@/modules/ui/components/button';
|
import { Button } from '@/modules/ui/components/button';
|
||||||
|
|
||||||
export const OrganizationPage: Component = () => {
|
export const OrganizationPage: Component = () => {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
|
const { t } = useI18n();
|
||||||
const [getPagination, setPagination] = createSignal({ pageIndex: 0, pageSize: 100 });
|
const [getPagination, setPagination] = createSignal({ pageIndex: 0, pageSize: 100 });
|
||||||
|
|
||||||
const query = createQueries(() => ({
|
const query = createQueries(() => ({
|
||||||
@@ -39,11 +41,11 @@ export const OrganizationPage: Component = () => {
|
|||||||
? (
|
? (
|
||||||
<>
|
<>
|
||||||
<h2 class="text-xl font-bold ">
|
<h2 class="text-xl font-bold ">
|
||||||
No documents
|
{t('organizations.details.no-documents.title')}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<p class="text-muted-foreground mt-1 mb-6">
|
<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>
|
</p>
|
||||||
|
|
||||||
<DocumentUploadArea />
|
<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">
|
<Button onClick={promptImport} class="h-auto items-start flex-col gap-4 py-4 px-6">
|
||||||
<div class="i-tabler-upload size-6"></div>
|
<div class="i-tabler-upload size-6"></div>
|
||||||
|
|
||||||
Upload documents
|
{t('organizations.details.upload-documents')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Show when={query[1].data?.organizationStats}>
|
<Show when={query[1].data?.organizationStats}>
|
||||||
@@ -69,7 +71,7 @@ export const OrganizationPage: Component = () => {
|
|||||||
{organizationStats().documentsCount}
|
{organizationStats().documentsCount}
|
||||||
</span>
|
</span>
|
||||||
<span class="text-muted-foreground">
|
<span class="text-muted-foreground">
|
||||||
documents in total
|
{t('organizations.details.documents-count')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -80,7 +82,7 @@ export const OrganizationPage: Component = () => {
|
|||||||
{formatBytes({ bytes: organizationStats().documentsSize, base: 1000 })}
|
{formatBytes({ bytes: organizationStats().documentsSize, base: 1000 })}
|
||||||
</span>
|
</span>
|
||||||
<span class="text-muted-foreground">
|
<span class="text-muted-foreground">
|
||||||
total size
|
{t('organizations.details.total-size')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -90,7 +92,7 @@ export const OrganizationPage: Component = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2 class="text-lg font-semibold mb-4">
|
<h2 class="text-lg font-semibold mb-4">
|
||||||
Latest imported documents
|
{t('organizations.details.latest-documents')}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<DocumentsPaginatedList
|
<DocumentsPaginatedList
|
||||||
|
|||||||
@@ -2,10 +2,12 @@ import type { Component } from 'solid-js';
|
|||||||
import type { Organization } from '../organizations.types';
|
import type { Organization } from '../organizations.types';
|
||||||
import { safely } from '@corentinth/chisels';
|
import { safely } from '@corentinth/chisels';
|
||||||
import { useParams } from '@solidjs/router';
|
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 { createSignal, Show, Suspense } from 'solid-js';
|
||||||
import * as v from 'valibot';
|
import * as v from 'valibot';
|
||||||
import { buildTimeConfig } from '@/modules/config/config';
|
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 { useConfirmModal } from '@/modules/shared/confirm';
|
||||||
import { createForm } from '@/modules/shared/form/form';
|
import { createForm } from '@/modules/shared/form/form';
|
||||||
import { getCustomerPortalUrl } from '@/modules/subscriptions/subscriptions.services';
|
import { getCustomerPortalUrl } from '@/modules/subscriptions/subscriptions.services';
|
||||||
@@ -20,24 +22,25 @@ import { fetchOrganization } from '../organizations.services';
|
|||||||
const DeleteOrganizationCard: Component<{ organization: Organization }> = (props) => {
|
const DeleteOrganizationCard: Component<{ organization: Organization }> = (props) => {
|
||||||
const { deleteOrganization } = useDeleteOrganization();
|
const { deleteOrganization } = useDeleteOrganization();
|
||||||
const { confirm } = useConfirmModal();
|
const { confirm } = useConfirmModal();
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
const confirmed = await confirm({
|
const confirmed = await confirm({
|
||||||
title: 'Delete organization',
|
title: t('organization.settings.delete.confirm.title'),
|
||||||
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.',
|
message: t('organization.settings.delete.confirm.message'),
|
||||||
confirmButton: {
|
confirmButton: {
|
||||||
text: 'Delete organization',
|
text: t('organization.settings.delete.confirm.confirm-button'),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
},
|
},
|
||||||
cancelButton: {
|
cancelButton: {
|
||||||
text: 'Cancel',
|
text: t('organization.settings.delete.confirm.cancel-button'),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (confirmed) {
|
if (confirmed) {
|
||||||
await deleteOrganization({ organizationId: props.organization.id });
|
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>
|
<div>
|
||||||
<Card class="border-destructive">
|
<Card class="border-destructive">
|
||||||
<CardHeader class="border-b">
|
<CardHeader class="border-b">
|
||||||
<CardTitle>Delete organization</CardTitle>
|
<CardTitle>{t('organization.settings.delete.title')}</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Deleting this organization will permanently remove all data associated with it.
|
{t('organization.settings.delete.description')}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardFooter class="pt-6">
|
<CardFooter class="pt-6">
|
||||||
<Button onClick={handleDelete} variant="destructive">
|
<Button onClick={handleDelete} variant="destructive">
|
||||||
Delete organization
|
{t('organization.settings.delete.confirm.confirm-button')}
|
||||||
</Button>
|
</Button>
|
||||||
</CardFooter>
|
</CardFooter>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -62,7 +65,14 @@ const DeleteOrganizationCard: Component<{ organization: Organization }> = (props
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const SubscriptionCard: 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 [getIsLoading, setIsLoading] = createSignal(false);
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
const goToCustomerPortal = async () => {
|
const goToCustomerPortal = async () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
@@ -70,7 +80,7 @@ export const SubscriptionCard: Component<{ organization: Organization }> = (prop
|
|||||||
const [result, error] = await safely(getCustomerPortalUrl({ organizationId: props.organization.id }));
|
const [result, error] = await safely(getCustomerPortalUrl({ organizationId: props.organization.id }));
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
createToast({ type: 'error', message: 'Failed to get customer portal URL' });
|
createToast({ type: 'error', message: t('organization.settings.subscription.error') });
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
@@ -86,13 +96,13 @@ export const SubscriptionCard: Component<{ organization: Organization }> = (prop
|
|||||||
return (
|
return (
|
||||||
<Card class="flex flex-col sm:flex-row justify-between gap-4 sm:items-center p-6 ">
|
<Card class="flex flex-col sm:flex-row justify-between gap-4 sm:items-center p-6 ">
|
||||||
<div>
|
<div>
|
||||||
<div class="font-semibold">Subscription</div>
|
<div class="font-semibold">{t('organization.settings.subscription.title')}</div>
|
||||||
<div class="text-sm text-muted-foreground">
|
<div class="text-sm text-muted-foreground">
|
||||||
Manage your billing, invoices and payment methods.
|
{t('organization.settings.subscription.description')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={goToCustomerPortal} isLoading={getIsLoading()} class="flex-shrink-0" disabled={buildTimeConfig.isDemoMode}>
|
<Button onClick={goToCustomerPortal} isLoading={getIsLoading()} class="flex-shrink-0" disabled={buildTimeConfig.isDemoMode}>
|
||||||
Manage subscription
|
{t('organization.settings.subscription.manage')}
|
||||||
</Button>
|
</Button>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
@@ -100,6 +110,7 @@ export const SubscriptionCard: Component<{ organization: Organization }> = (prop
|
|||||||
|
|
||||||
const UpdateOrganizationNameCard: Component<{ organization: Organization }> = (props) => {
|
const UpdateOrganizationNameCard: Component<{ organization: Organization }> = (props) => {
|
||||||
const { updateOrganization } = useUpdateOrganization();
|
const { updateOrganization } = useUpdateOrganization();
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
const { form, Form, Field } = createForm({
|
const { form, Form, Field } = createForm({
|
||||||
schema: v.object({
|
schema: v.object({
|
||||||
@@ -114,7 +125,7 @@ const UpdateOrganizationNameCard: Component<{ organization: Organization }> = (p
|
|||||||
organizationName: organizationName.trim(),
|
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>
|
<div>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader class="border-b">
|
<CardHeader class="border-b">
|
||||||
<CardTitle>Organization name</CardTitle>
|
<CardTitle>{t('organization.settings.name.title')}</CardTitle>
|
||||||
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<Form>
|
<Form>
|
||||||
<CardContent class="pt-6 ">
|
<CardContent class="pt-6 ">
|
||||||
|
|
||||||
<Field name="organizationName">
|
<Field name="organizationName">
|
||||||
{(field, inputProps) => (
|
{(field, inputProps) => (
|
||||||
<TextFieldRoot class="flex flex-col gap-1">
|
<TextFieldRoot class="flex flex-col gap-1">
|
||||||
<TextFieldLabel for="organizationName" class="sr-only">
|
<TextFieldLabel for="organizationName" class="sr-only">
|
||||||
Organization name
|
{t('organization.settings.name.title')}
|
||||||
</TextFieldLabel>
|
</TextFieldLabel>
|
||||||
<div class="flex gap-2 flex-col sm:flex-row">
|
<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}>
|
<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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{field.error && <div class="text-red-500 text-sm">{field.error}</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>
|
<div class="text-red-500 text-sm">{form.response.message}</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
||||||
</Form>
|
</Form>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
@@ -158,8 +166,9 @@ const UpdateOrganizationNameCard: Component<{ organization: Organization }> = (p
|
|||||||
|
|
||||||
export const OrganizationsSettingsPage: Component = () => {
|
export const OrganizationsSettingsPage: Component = () => {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
const query = createQuery(() => ({
|
const query = useQuery(() => ({
|
||||||
queryKey: ['organizations', params.organizationId],
|
queryKey: ['organizations', params.organizationId],
|
||||||
queryFn: () => fetchOrganization({ organizationId: params.organizationId }),
|
queryFn: () => fetchOrganization({ organizationId: params.organizationId }),
|
||||||
}));
|
}));
|
||||||
@@ -171,11 +180,11 @@ export const OrganizationsSettingsPage: Component = () => {
|
|||||||
{ getOrganization => (
|
{ getOrganization => (
|
||||||
<>
|
<>
|
||||||
<h1 class="text-xl font-semibold mb-2">
|
<h1 class="text-xl font-semibold mb-2">
|
||||||
Organization settings
|
{t('organization.settings.page.title')}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<p class="text-muted-foreground">
|
<p class="text-muted-foreground">
|
||||||
Manage your organization settings here.
|
{t('organization.settings.page.description')}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="mt-6 flex flex-col gap-6">
|
<div class="mt-6 flex flex-col gap-6">
|
||||||
|
|||||||
@@ -1,19 +1,21 @@
|
|||||||
import type { Component } from 'solid-js';
|
import type { Component } from 'solid-js';
|
||||||
import { A, useNavigate } from '@solidjs/router';
|
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 { createEffect, For, on } from 'solid-js';
|
||||||
|
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||||
import { fetchOrganizations } from '../organizations.services';
|
import { fetchOrganizations } from '../organizations.services';
|
||||||
|
|
||||||
export const OrganizationsPage: Component = () => {
|
export const OrganizationsPage: Component = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
const queries = createQuery(() => ({
|
const query = useQuery(() => ({
|
||||||
queryKey: ['organizations'],
|
queryKey: ['organizations'],
|
||||||
queryFn: fetchOrganizations,
|
queryFn: fetchOrganizations,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
createEffect(on(
|
createEffect(on(
|
||||||
() => queries.data?.organizations,
|
() => query.data?.organizations,
|
||||||
(orgs) => {
|
(orgs) => {
|
||||||
if (orgs && orgs.length === 0) {
|
if (orgs && orgs.length === 0) {
|
||||||
navigate('/organizations/first');
|
navigate('/organizations/first');
|
||||||
@@ -24,15 +26,15 @@ export const OrganizationsPage: Component = () => {
|
|||||||
return (
|
return (
|
||||||
<div class="p-6 mt-4 pb-32 max-w-5xl mx-auto">
|
<div class="p-6 mt-4 pb-32 max-w-5xl mx-auto">
|
||||||
<h2 class="text-xl font-bold mb-2">
|
<h2 class="text-xl font-bold mb-2">
|
||||||
Your organizations
|
{t('organizations.list.title')}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<p class="text-muted-foreground mb-6">
|
<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>
|
</p>
|
||||||
|
|
||||||
<div class="grid gap-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
<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 => (
|
{organization => (
|
||||||
<A
|
<A
|
||||||
href={`/organizations/${organization.id}`}
|
href={`/organizations/${organization.id}`}
|
||||||
@@ -43,7 +45,6 @@ export const OrganizationsPage: Component = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="p-4">
|
<div class="p-4">
|
||||||
|
|
||||||
<div class="w-full text-left font-bold truncate block">
|
<div class="w-full text-left font-bold truncate block">
|
||||||
{organization.name}
|
{organization.name}
|
||||||
</div>
|
</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="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">
|
<div class="font-bold block text-muted-foreground">
|
||||||
Create new organization
|
{t('organizations.list.create-new')}
|
||||||
</div>
|
</div>
|
||||||
</A>
|
</A>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,20 +1,22 @@
|
|||||||
import type { Component } from 'solid-js';
|
import type { Component } from 'solid-js';
|
||||||
import { A } from '@solidjs/router';
|
import { A } from '@solidjs/router';
|
||||||
|
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||||
import { Button } from '@/modules/ui/components/button';
|
import { Button } from '@/modules/ui/components/button';
|
||||||
|
|
||||||
export const NotFoundPage: Component = () => {
|
export const NotFoundPage: Component = () => {
|
||||||
|
const { t } = useI18n();
|
||||||
return (
|
return (
|
||||||
<div class="h-screen flex flex-col items-center justify-center p-6">
|
<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="flex items-center flex-row sm:gap-24">
|
||||||
<div class="max-w-350px">
|
<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">
|
<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>
|
</p>
|
||||||
<Button as={A} href="/" class="mt-4" variant="default">
|
<Button as={A} href="/" class="mt-4" variant="default">
|
||||||
<div class="i-tabler-arrow-left mr-2"></div>
|
<div class="i-tabler-arrow-left mr-2"></div>
|
||||||
Go back to home
|
{t('not-found.back-to-home')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { Component } from 'solid-js';
|
import type { Component } from 'solid-js';
|
||||||
import type { TaggingRuleForCreation } from '../tagging-rules.types';
|
import type { TaggingRuleForCreation } from '../tagging-rules.types';
|
||||||
import { useNavigate, useParams } from '@solidjs/router';
|
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 { useI18n } from '@/modules/i18n/i18n.provider';
|
||||||
import { createToast } from '@/modules/ui/components/sonner';
|
import { createToast } from '@/modules/ui/components/sonner';
|
||||||
import { TaggingRuleForm } from '../components/tagging-rule-form.component';
|
import { TaggingRuleForm } from '../components/tagging-rule-form.component';
|
||||||
@@ -12,7 +12,7 @@ export const CreateTaggingRulePage: Component = () => {
|
|||||||
const params = useParams();
|
const params = useParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const createTaggingRuleMutation = createMutation(() => ({
|
const createTaggingRuleMutation = useMutation(() => ({
|
||||||
mutationFn: async ({ taggingRule }: { taggingRule: TaggingRuleForCreation }) => {
|
mutationFn: async ({ taggingRule }: { taggingRule: TaggingRuleForCreation }) => {
|
||||||
await createTaggingRule({ taggingRule, organizationId: params.organizationId });
|
await createTaggingRule({ taggingRule, organizationId: params.organizationId });
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { Component } from 'solid-js';
|
import type { Component } from 'solid-js';
|
||||||
import type { TaggingRule } from '../tagging-rules.types';
|
import type { TaggingRule } from '../tagging-rules.types';
|
||||||
import { A, useParams } from '@solidjs/router';
|
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 { For, Match, Show, Switch } from 'solid-js';
|
||||||
import { useConfig } from '@/modules/config/config.provider';
|
import { useConfig } from '@/modules/config/config.provider';
|
||||||
import { useI18n } from '@/modules/i18n/i18n.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 });
|
return t('tagging-rules.list.card.conditions', { count });
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteTaggingRuleMutation = createMutation(() => ({
|
const deleteTaggingRuleMutation = useMutation(() => ({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
await deleteTaggingRule({ organizationId: props.taggingRule.organizationId, taggingRuleId: props.taggingRule.id });
|
await deleteTaggingRule({ organizationId: props.taggingRule.organizationId, taggingRuleId: props.taggingRule.id });
|
||||||
},
|
},
|
||||||
@@ -82,7 +82,7 @@ export const TaggingRulesPage: Component = () => {
|
|||||||
const { config } = useConfig();
|
const { config } = useConfig();
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
|
|
||||||
const query = createQuery(() => ({
|
const query = useQuery(() => ({
|
||||||
queryKey: ['organizations', params.organizationId, 'tagging-rules'],
|
queryKey: ['organizations', params.organizationId, 'tagging-rules'],
|
||||||
queryFn: () => fetchTaggingRules({ organizationId: params.organizationId }),
|
queryFn: () => fetchTaggingRules({ organizationId: params.organizationId }),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { Component } from 'solid-js';
|
import type { Component } from 'solid-js';
|
||||||
import type { TaggingRuleForCreation } from '../tagging-rules.types';
|
import type { TaggingRuleForCreation } from '../tagging-rules.types';
|
||||||
import { useNavigate, useParams } from '@solidjs/router';
|
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 { Show } from 'solid-js';
|
||||||
import { useI18n } from '@/modules/i18n/i18n.provider';
|
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||||
import { queryClient } from '@/modules/shared/query/query-client';
|
import { queryClient } from '@/modules/shared/query/query-client';
|
||||||
@@ -14,12 +14,12 @@ export const UpdateTaggingRulePage: Component = () => {
|
|||||||
const params = useParams();
|
const params = useParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const query = createQuery(() => ({
|
const query = useQuery(() => ({
|
||||||
queryKey: ['organizations', params.organizationId, 'tagging-rules', params.taggingRuleId],
|
queryKey: ['organizations', params.organizationId, 'tagging-rules', params.taggingRuleId],
|
||||||
queryFn: () => getTaggingRule({ organizationId: params.organizationId, taggingRuleId: params.taggingRuleId }),
|
queryFn: () => getTaggingRule({ organizationId: params.organizationId, taggingRuleId: params.taggingRuleId }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const updateTaggingRuleMutation = createMutation(() => ({
|
const updateTaggingRuleMutation = useMutation(() => ({
|
||||||
mutationFn: async ({ taggingRule }: { taggingRule: TaggingRuleForCreation }) => {
|
mutationFn: async ({ taggingRule }: { taggingRule: TaggingRuleForCreation }) => {
|
||||||
await updateTaggingRule({ organizationId: params.organizationId, taggingRuleId: params.taggingRuleId, taggingRule });
|
await updateTaggingRule({ organizationId: params.organizationId, taggingRuleId: params.taggingRuleId, taggingRule });
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { Component } from 'solid-js';
|
import type { Component } from 'solid-js';
|
||||||
import type { Tag } from '../tags.types';
|
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 { createSignal, For } from 'solid-js';
|
||||||
import { Combobox, ComboboxContent, ComboboxInput, ComboboxItem, ComboboxTrigger } from '@/modules/ui/components/combobox';
|
import { Combobox, ComboboxContent, ComboboxInput, ComboboxItem, ComboboxTrigger } from '@/modules/ui/components/combobox';
|
||||||
import { fetchTags } from '../tags.services';
|
import { fetchTags } from '../tags.services';
|
||||||
@@ -15,7 +15,7 @@ export const DocumentTagPicker: Component<{
|
|||||||
}> = (props) => {
|
}> = (props) => {
|
||||||
const [getSelectedTagIds, setSelectedTagIds] = createSignal<string[]>(props.tagIds);
|
const [getSelectedTagIds, setSelectedTagIds] = createSignal<string[]>(props.tagIds);
|
||||||
|
|
||||||
const query = createQuery(() => ({
|
const query = useQuery(() => ({
|
||||||
queryKey: ['organizations', props.organizationId, 'tags'],
|
queryKey: ['organizations', props.organizationId, 'tags'],
|
||||||
queryFn: () => fetchTags({ organizationId: props.organizationId }),
|
queryFn: () => fetchTags({ organizationId: props.organizationId }),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
import type { DialogTriggerProps } from '@kobalte/core/dialog';
|
import type { DialogTriggerProps } from '@kobalte/core/dialog';
|
||||||
import type { Component, JSX } from 'solid-js';
|
import type { Component, JSX } from 'solid-js';
|
||||||
import type { Tag as TagType } from '../tags.types';
|
import type { Tag as TagType } from '../tags.types';
|
||||||
|
import { safely } from '@corentinth/chisels';
|
||||||
import { getValues } from '@modular-forms/solid';
|
import { getValues } from '@modular-forms/solid';
|
||||||
import { A, useParams } from '@solidjs/router';
|
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 { createSignal, For, Show, Suspense } from 'solid-js';
|
||||||
import * as v from 'valibot';
|
import * as v from 'valibot';
|
||||||
import { useI18n } from '@/modules/i18n/i18n.provider';
|
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||||
import { useConfirmModal } from '@/modules/shared/confirm';
|
import { useConfirmModal } from '@/modules/shared/confirm';
|
||||||
import { timeAgo } from '@/modules/shared/date/time-ago';
|
import { timeAgo } from '@/modules/shared/date/time-ago';
|
||||||
import { createForm } from '@/modules/shared/form/form';
|
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 { queryClient } from '@/modules/shared/query/query-client';
|
||||||
import { Button } from '@/modules/ui/components/button';
|
import { Button } from '@/modules/ui/components/button';
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/modules/ui/components/dialog';
|
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 };
|
initialValues?: { name?: string; color?: string; description?: string | null };
|
||||||
submitLabel?: string;
|
submitLabel?: string;
|
||||||
}> = (props) => {
|
}> = (props) => {
|
||||||
|
const { t } = useI18n();
|
||||||
const { form, Form, Field } = createForm({
|
const { form, Form, Field } = createForm({
|
||||||
onSubmit: props.onSubmit,
|
onSubmit: props.onSubmit,
|
||||||
schema: v.object({
|
schema: v.object({
|
||||||
name: v.pipe(
|
name: v.pipe(
|
||||||
v.string(),
|
v.string(),
|
||||||
v.trim(),
|
v.trim(),
|
||||||
v.nonEmpty('Please enter a tag name'),
|
v.nonEmpty(t('tags.form.name.required')),
|
||||||
v.maxLength(64, 'Tag name must be less than 64 characters'),
|
v.maxLength(64, t('tags.form.name.max-length')),
|
||||||
),
|
),
|
||||||
color: v.pipe(
|
color: v.pipe(
|
||||||
v.string(),
|
v.string(),
|
||||||
v.trim(),
|
v.trim(),
|
||||||
v.nonEmpty('Please enter a color'),
|
v.nonEmpty(t('tags.form.color.required')),
|
||||||
v.hexColor('The hex color is badly formatted.'),
|
v.hexColor(t('tags.form.color.invalid')),
|
||||||
),
|
),
|
||||||
description: v.pipe(
|
description: v.pipe(
|
||||||
v.string(),
|
v.string(),
|
||||||
v.trim(),
|
v.trim(),
|
||||||
v.maxLength(256, 'Description must be less than 256 characters'),
|
v.maxLength(256, t('tags.form.description.max-length')),
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
initialValues: {
|
initialValues: {
|
||||||
@@ -60,8 +63,8 @@ const TagForm: Component<{
|
|||||||
<Field name="name">
|
<Field name="name">
|
||||||
{(field, inputProps) => (
|
{(field, inputProps) => (
|
||||||
<TextFieldRoot class="flex flex-col gap-1 mb-4">
|
<TextFieldRoot class="flex flex-col gap-1 mb-4">
|
||||||
<TextFieldLabel for="name">Name</TextFieldLabel>
|
<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="Eg. Contracts" />
|
<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>}
|
{field.error && <div class="text-red-500 text-sm">{field.error}</div>}
|
||||||
</TextFieldRoot>
|
</TextFieldRoot>
|
||||||
)}
|
)}
|
||||||
@@ -70,8 +73,8 @@ const TagForm: Component<{
|
|||||||
<Field name="color">
|
<Field name="color">
|
||||||
{(field, inputProps) => (
|
{(field, inputProps) => (
|
||||||
<TextFieldRoot class="flex flex-col gap-1 mb-4">
|
<TextFieldRoot class="flex flex-col gap-1 mb-4">
|
||||||
<TextFieldLabel for="color">Color</TextFieldLabel>
|
<TextFieldLabel for="color">{t('tags.form.color.label')}</TextFieldLabel>
|
||||||
<TextField id="color" {...inputProps} autoFocus value={field.value} aria-invalid={Boolean(field.error)} placeholder="Eg. #FF0000" />
|
<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>}
|
{field.error && <div class="text-red-500 text-sm">{field.error}</div>}
|
||||||
</TextFieldRoot>
|
</TextFieldRoot>
|
||||||
)}
|
)}
|
||||||
@@ -81,10 +84,10 @@ const TagForm: Component<{
|
|||||||
{(field, inputProps) => (
|
{(field, inputProps) => (
|
||||||
<TextFieldRoot class="flex flex-col gap-1 mb-4">
|
<TextFieldRoot class="flex flex-col gap-1 mb-4">
|
||||||
<TextFieldLabel for="description">
|
<TextFieldLabel for="description">
|
||||||
Description
|
{t('tags.form.description.label')}
|
||||||
<span class="font-normal ml-1 text-muted-foreground">(optional)</span>
|
<span class="font-normal ml-1 text-muted-foreground">{t('tags.form.description.optional')}</span>
|
||||||
</TextFieldLabel>
|
</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>}
|
{field.error && <div class="text-red-500 text-sm">{field.error}</div>}
|
||||||
</TextFieldRoot>
|
</TextFieldRoot>
|
||||||
)}
|
)}
|
||||||
@@ -92,7 +95,7 @@ const TagForm: Component<{
|
|||||||
|
|
||||||
<div class="flex flex-row-reverse justify-between items-center mt-6">
|
<div class="flex flex-row-reverse justify-between items-center mt-6">
|
||||||
<Button type="submit">
|
<Button type="submit">
|
||||||
{props.submitLabel ?? 'Create tag'}
|
{props.submitLabel ?? t('tags.create')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{getFormValues().name && (
|
{getFormValues().name && (
|
||||||
@@ -110,14 +113,24 @@ export const CreateTagModal: Component<{
|
|||||||
organizationId: string;
|
organizationId: string;
|
||||||
}> = (props) => {
|
}> = (props) => {
|
||||||
const [getIsModalOpen, setIsModalOpen] = createSignal(false);
|
const [getIsModalOpen, setIsModalOpen] = createSignal(false);
|
||||||
|
const { t } = useI18n();
|
||||||
|
const { getErrorMessage } = useI18nApiErrors({ t });
|
||||||
|
|
||||||
const onSubmit = async ({ name, color, description }: { name: string; color: string; description: string }) => {
|
const onSubmit = async ({ name, color, description }: { name: string; color: string; description: string }) => {
|
||||||
await createTag({
|
const [,error] = await safely(createTag({
|
||||||
name,
|
name,
|
||||||
color,
|
color,
|
||||||
description,
|
description,
|
||||||
organizationId: props.organizationId,
|
organizationId: props.organizationId,
|
||||||
});
|
}));
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
createToast({
|
||||||
|
message: getErrorMessage({ error }),
|
||||||
|
type: 'error',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await queryClient.invalidateQueries({
|
await queryClient.invalidateQueries({
|
||||||
queryKey: ['organizations', props.organizationId],
|
queryKey: ['organizations', props.organizationId],
|
||||||
@@ -125,7 +138,7 @@ export const CreateTagModal: Component<{
|
|||||||
});
|
});
|
||||||
|
|
||||||
createToast({
|
createToast({
|
||||||
message: `Tag "${name}" created successfully.`,
|
message: t('tags.create.success', { name }),
|
||||||
type: 'success',
|
type: 'success',
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -137,7 +150,7 @@ export const CreateTagModal: Component<{
|
|||||||
<DialogTrigger as={props.children} />
|
<DialogTrigger as={props.children} />
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Create a new tag</DialogTitle>
|
<DialogTitle>{t('tags.create')}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<TagForm onSubmit={onSubmit} initialValues={{ color: '#d8ff75' }} />
|
<TagForm onSubmit={onSubmit} initialValues={{ color: '#d8ff75' }} />
|
||||||
@@ -152,6 +165,7 @@ const UpdateTagModal: Component<{
|
|||||||
tag: TagType;
|
tag: TagType;
|
||||||
}> = (props) => {
|
}> = (props) => {
|
||||||
const [getIsModalOpen, setIsModalOpen] = createSignal(false);
|
const [getIsModalOpen, setIsModalOpen] = createSignal(false);
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
const onSubmit = async ({ name, color, description }: { name: string; color: string; description: string }) => {
|
const onSubmit = async ({ name, color, description }: { name: string; color: string; description: string }) => {
|
||||||
await updateTag({
|
await updateTag({
|
||||||
@@ -168,7 +182,7 @@ const UpdateTagModal: Component<{
|
|||||||
});
|
});
|
||||||
|
|
||||||
createToast({
|
createToast({
|
||||||
message: `Tag "${name}" updated successfully.`,
|
message: t('tags.update.success', { name }),
|
||||||
type: 'success',
|
type: 'success',
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -180,10 +194,10 @@ const UpdateTagModal: Component<{
|
|||||||
<DialogTrigger as={props.children} />
|
<DialogTrigger as={props.children} />
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Update tag</DialogTitle>
|
<DialogTitle>{t('tags.update')}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<TagForm onSubmit={onSubmit} initialValues={props.tag} submitLabel="Update tag" />
|
<TagForm onSubmit={onSubmit} initialValues={props.tag} submitLabel={t('tags.update')} />
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
@@ -194,21 +208,21 @@ export const TagsPage: Component = () => {
|
|||||||
const { confirm } = useConfirmModal();
|
const { confirm } = useConfirmModal();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
const query = createQuery(() => ({
|
const query = useQuery(() => ({
|
||||||
queryKey: ['organizations', params.organizationId, 'tags'],
|
queryKey: ['organizations', params.organizationId, 'tags'],
|
||||||
queryFn: () => fetchTags({ organizationId: params.organizationId }),
|
queryFn: () => fetchTags({ organizationId: params.organizationId }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const del = async ({ tag }: { tag: TagType }) => {
|
const del = async ({ tag }: { tag: TagType }) => {
|
||||||
const confirmed = await confirm({
|
const confirmed = await confirm({
|
||||||
title: 'Delete tag',
|
title: t('tags.delete.confirm.title'),
|
||||||
message: 'Are you sure you want to delete this tag? Deleting a tag will remove it from all documents.',
|
message: t('tags.delete.confirm.message'),
|
||||||
cancelButton: {
|
cancelButton: {
|
||||||
text: 'Cancel',
|
text: t('tags.delete.confirm.cancel-button'),
|
||||||
variant: 'secondary',
|
variant: 'secondary',
|
||||||
},
|
},
|
||||||
confirmButton: {
|
confirmButton: {
|
||||||
text: 'Delete',
|
text: t('tags.delete.confirm.confirm-button'),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -228,7 +242,7 @@ export const TagsPage: Component = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
createToast({
|
createToast({
|
||||||
message: `Tag deleted successfully.`,
|
message: t('tags.delete.success'),
|
||||||
type: '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 class="flex justify-between sm:items-center pb-6 gap-4 flex-col sm:flex-row">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-xl font-bold ">
|
<h2 class="text-xl font-bold ">
|
||||||
Documents Tags
|
{t('tags.title')}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<p class="text-muted-foreground mt-1">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -274,7 +288,7 @@ export const TagsPage: Component = () => {
|
|||||||
{props => (
|
{props => (
|
||||||
<Button class="w-full" {...props}>
|
<Button class="w-full" {...props}>
|
||||||
<div class="i-tabler-plus size-4 mr-2" />
|
<div class="i-tabler-plus size-4 mr-2" />
|
||||||
Create tag
|
{t('tags.create')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</CreateTagModal>
|
</CreateTagModal>
|
||||||
@@ -284,12 +298,12 @@ export const TagsPage: Component = () => {
|
|||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>Tag</TableHead>
|
<TableHead>{t('tags.table.headers.tag')}</TableHead>
|
||||||
<TableHead>Description</TableHead>
|
<TableHead>{t('tags.table.headers.description')}</TableHead>
|
||||||
<TableHead>Documents</TableHead>
|
<TableHead>{t('tags.table.headers.documents')}</TableHead>
|
||||||
<TableHead>Created</TableHead>
|
<TableHead>{t('tags.table.headers.created')}</TableHead>
|
||||||
<TableHead class="text-right">
|
<TableHead class="text-right">
|
||||||
Actions
|
{t('tags.table.headers.actions')}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
@@ -302,7 +316,7 @@ export const TagsPage: Component = () => {
|
|||||||
<Tag name={tag.name} color={tag.color} />
|
<Tag name={tag.name} color={tag.color} />
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</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>
|
<TableCell>
|
||||||
<A href={`/organizations/${params.organizationId}/documents?tags=${tag.id}`} class="inline-flex items-center gap-1 hover:underline">
|
<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" />
|
<div class="i-tabler-file-text size-5 text-muted-foreground" />
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export const OrganizationSettingsLayout: ParentComponent = (props) => {
|
|||||||
<div class="i-tabler-arrow-left size-5"></div>
|
<div class="i-tabler-arrow-left size-5"></div>
|
||||||
</Button>
|
</Button>
|
||||||
<h1 class="text-base font-bold">
|
<h1 class="text-base font-bold">
|
||||||
Organization Settings
|
{t('organization.settings.title')}
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { Component, ParentComponent } from 'solid-js';
|
|||||||
import type { Organization } from '@/modules/organizations/organizations.types';
|
import type { Organization } from '@/modules/organizations/organizations.types';
|
||||||
|
|
||||||
import { useNavigate, useParams } from '@solidjs/router';
|
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 { get } from 'lodash-es';
|
||||||
import { createEffect, on } from 'solid-js';
|
import { createEffect, on } from 'solid-js';
|
||||||
import { DocumentUploadProvider } from '@/modules/documents/components/document-import-status.component';
|
import { DocumentUploadProvider } from '@/modules/documents/components/document-import-status.component';
|
||||||
@@ -148,7 +148,7 @@ export const OrganizationLayout: ParentComponent = (props) => {
|
|||||||
const params = useParams();
|
const params = useParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const query = createQuery(() => ({
|
const query = useQuery(() => ({
|
||||||
queryKey: ['organizations', params.organizationId],
|
queryKey: ['organizations', params.organizationId],
|
||||||
queryFn: () => fetchOrganization({ organizationId: params.organizationId }),
|
queryFn: () => fetchOrganization({ organizationId: params.organizationId }),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { usePendingInvitationsCount } from '@/modules/invitations/composables/us
|
|||||||
import { cn } from '@/modules/shared/style/cn';
|
import { cn } from '@/modules/shared/style/cn';
|
||||||
import { useThemeStore } from '@/modules/theme/theme.store';
|
import { useThemeStore } from '@/modules/theme/theme.store';
|
||||||
import { Button } from '@/modules/ui/components/button';
|
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 { Sheet, SheetContent, SheetTrigger } from '../components/sheet';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../components/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '../components/tooltip';
|
||||||
|
|
||||||
@@ -127,20 +127,21 @@ export const SideNav: Component<{
|
|||||||
|
|
||||||
export const ThemeSwitcher: Component = () => {
|
export const ThemeSwitcher: Component = () => {
|
||||||
const themeStore = useThemeStore();
|
const themeStore = useThemeStore();
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DropdownMenuItem onClick={() => themeStore.setColorMode({ mode: 'light' })} class="flex items-center gap-2 cursor-pointer">
|
<DropdownMenuItem onClick={() => themeStore.setColorMode({ mode: 'light' })} class="flex items-center gap-2 cursor-pointer">
|
||||||
<div class="i-tabler-sun text-lg"></div>
|
<div class="i-tabler-sun text-lg"></div>
|
||||||
Light Mode
|
{t('layout.theme.light')}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={() => themeStore.setColorMode({ mode: 'dark' })} class="flex items-center gap-2 cursor-pointer">
|
<DropdownMenuItem onClick={() => themeStore.setColorMode({ mode: 'dark' })} class="flex items-center gap-2 cursor-pointer">
|
||||||
<div class="i-tabler-moon text-lg"></div>
|
<div class="i-tabler-moon text-lg"></div>
|
||||||
Dark Mode
|
{t('layout.theme.dark')}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={() => themeStore.setColorMode({ mode: 'system' })} class="flex items-center gap-2 cursor-pointer">
|
<DropdownMenuItem onClick={() => themeStore.setColorMode({ mode: 'system' })} class="flex items-center gap-2 cursor-pointer">
|
||||||
<div class="i-tabler-device-laptop text-lg"></div>
|
<div class="i-tabler-device-laptop text-lg"></div>
|
||||||
System Mode
|
{t('layout.theme.system')}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -154,9 +155,9 @@ export const LanguageSwitcher: Component = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<DropdownMenuRadioGroup value={getLocale()} onChange={setLocale}>
|
||||||
{locales.map(locale => (
|
{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}>
|
<span translate="no" lang={getLocale() === locale.key ? undefined : locale.key}>
|
||||||
{locale.name}
|
{locale.name}
|
||||||
</span>
|
</span>
|
||||||
@@ -167,9 +168,9 @@ export const LanguageSwitcher: Component = () => {
|
|||||||
)
|
)
|
||||||
</span>
|
</span>
|
||||||
</Show>
|
</Show>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuRadioItem>
|
||||||
))}
|
))}
|
||||||
</>
|
</DropdownMenuRadioGroup>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -210,7 +211,7 @@ export const SidenavLayout: ParentComponent<{
|
|||||||
{(props.showSearch ?? true) && (
|
{(props.showSearch ?? true) && (
|
||||||
<Button variant="outline" class="lg:min-w-64 justify-start" onClick={openCommandPalette}>
|
<Button variant="outline" class="lg:min-w-64 justify-start" onClick={openCommandPalette}>
|
||||||
<div class="i-tabler-search size-4 mr-2"></div>
|
<div class="i-tabler-search size-4 mr-2"></div>
|
||||||
Search...
|
{t('layout.search.placeholder')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -220,7 +221,7 @@ export const SidenavLayout: ParentComponent<{
|
|||||||
<Button onClick={promptImport}>
|
<Button onClick={promptImport}>
|
||||||
<div class="i-tabler-upload size-4"></div>
|
<div class="i-tabler-upload size-4"></div>
|
||||||
<span class="hidden sm:inline ml-2">
|
<span class="hidden sm:inline ml-2">
|
||||||
Import a document
|
{t('layout.menu.import-document')}
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
@@ -243,20 +244,20 @@ export const SidenavLayout: ParentComponent<{
|
|||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent class="w-42">
|
<DropdownMenuContent class="min-w-48">
|
||||||
<DropdownMenuItem class="flex items-center gap-2 cursor-pointer" as={A} href="/settings">
|
<DropdownMenuItem class="flex items-center gap-2 cursor-pointer" as={A} href="/settings">
|
||||||
<div class="i-tabler-settings size-4 text-muted-foreground"></div>
|
<div class="i-tabler-settings size-4 text-muted-foreground"></div>
|
||||||
Account settings
|
{t('user-menu.account-settings')}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
<DropdownMenuItem class="flex items-center gap-2 cursor-pointer" as={A} href="/api-keys">
|
<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>
|
<div class="i-tabler-key size-4 text-muted-foreground"></div>
|
||||||
API keys
|
{t('user-menu.api-keys')}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
<DropdownMenuItem class="flex items-center gap-2 cursor-pointer" as={A} href="/invitations">
|
<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>
|
<div class="i-tabler-mail-plus size-4 text-muted-foreground"></div>
|
||||||
{t('layout.menu.invitations')}
|
{t('user-menu.invitations')}
|
||||||
<Show when={getPendingInvitationsCount() > 0}>
|
<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">
|
<div class="ml-auto bg-primary text-primary-foreground rounded-xl text-xs px-1.5 py-0.8 font-bold leading-none">
|
||||||
{getPendingInvitationsCount() }
|
{getPendingInvitationsCount() }
|
||||||
@@ -265,12 +266,13 @@ export const SidenavLayout: ParentComponent<{
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
<DropdownMenuSub>
|
<DropdownMenuSub>
|
||||||
|
|
||||||
<DropdownMenuSubTrigger class="flex items-center gap-2 cursor-pointer">
|
<DropdownMenuSubTrigger class="flex items-center gap-2 cursor-pointer">
|
||||||
<div class="i-tabler-language size-4 text-muted-foreground"></div>
|
<div class="i-tabler-language size-4 text-muted-foreground"></div>
|
||||||
Language
|
{t('user-menu.language')}
|
||||||
</DropdownMenuSubTrigger>
|
</DropdownMenuSubTrigger>
|
||||||
|
|
||||||
<DropdownMenuSubContent>
|
<DropdownMenuSubContent class="min-w-48">
|
||||||
<LanguageSwitcher />
|
<LanguageSwitcher />
|
||||||
</DropdownMenuSubContent>
|
</DropdownMenuSubContent>
|
||||||
</DropdownMenuSub>
|
</DropdownMenuSub>
|
||||||
@@ -283,7 +285,7 @@ export const SidenavLayout: ParentComponent<{
|
|||||||
class="flex items-center gap-2 cursor-pointer"
|
class="flex items-center gap-2 cursor-pointer"
|
||||||
>
|
>
|
||||||
<div class="i-tabler-logout size-4 text-muted-foreground"></div>
|
<div class="i-tabler-logout size-4 text-muted-foreground"></div>
|
||||||
Logout
|
{t('user-menu.logout')}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import type { Component } from 'solid-js';
|
import type { Component } from 'solid-js';
|
||||||
import { useNavigate } from '@solidjs/router';
|
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 { createSignal, Show, Suspense } from 'solid-js';
|
||||||
import * as v from 'valibot';
|
import * as v from 'valibot';
|
||||||
import { signOut } from '@/modules/auth/auth.services';
|
import { signOut } from '@/modules/auth/auth.services';
|
||||||
|
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||||
import { createForm } from '@/modules/shared/form/form';
|
import { createForm } from '@/modules/shared/form/form';
|
||||||
import { Button } from '@/modules/ui/components/button';
|
import { Button } from '@/modules/ui/components/button';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/modules/ui/components/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/modules/ui/components/card';
|
||||||
@@ -16,6 +17,7 @@ import { fetchCurrentUser } from '../users.services';
|
|||||||
const LogoutCard: Component = () => {
|
const LogoutCard: Component = () => {
|
||||||
const [getIsLoading, setIsLoading] = createSignal(false);
|
const [getIsLoading, setIsLoading] = createSignal(false);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
@@ -26,29 +28,31 @@ const LogoutCard: Component = () => {
|
|||||||
return (
|
return (
|
||||||
<Card class="flex flex-row justify-between items-center p-6 border-destructive">
|
<Card class="flex flex-row justify-between items-center p-6 border-destructive">
|
||||||
<div class="flex flex-col gap-1.5">
|
<div class="flex flex-col gap-1.5">
|
||||||
<CardTitle>Logout</CardTitle>
|
<CardTitle>{t('user.settings.logout.title')}</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Logout from your account. You can login again later.
|
{t('user.settings.logout.description')}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={handleLogout} variant="destructive" isLoading={getIsLoading()}>
|
<Button onClick={handleLogout} variant="destructive" isLoading={getIsLoading()}>
|
||||||
Logout
|
{t('user.settings.logout.button')}
|
||||||
</Button>
|
</Button>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const UserEmailCard: Component<{ email: string }> = (props) => {
|
const UserEmailCard: Component<{ email: string }> = (props) => {
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader class="border-b">
|
<CardHeader class="border-b">
|
||||||
<CardTitle>Email address</CardTitle>
|
<CardTitle>{t('user.settings.email.title')}</CardTitle>
|
||||||
<CardDescription>Your email address cannot be changed.</CardDescription>
|
<CardDescription>{t('user.settings.email.description')}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent class="pt-6">
|
<CardContent class="pt-6">
|
||||||
<TextFieldRoot>
|
<TextFieldRoot>
|
||||||
<TextFieldLabel for="email" class="sr-only">
|
<TextFieldLabel for="email" class="sr-only">
|
||||||
Email address
|
{t('user.settings.email.label')}
|
||||||
</TextFieldLabel>
|
</TextFieldLabel>
|
||||||
<TextField id="email" value={props.email} disabled readOnly />
|
<TextField id="email" value={props.email} disabled readOnly />
|
||||||
</TextFieldRoot>
|
</TextFieldRoot>
|
||||||
@@ -59,6 +63,7 @@ const UserEmailCard: Component<{ email: string }> = (props) => {
|
|||||||
|
|
||||||
const UpdateFullNameCard: Component<{ name: string }> = (props) => {
|
const UpdateFullNameCard: Component<{ name: string }> = (props) => {
|
||||||
const { updateCurrentUser } = useUpdateCurrentUser();
|
const { updateCurrentUser } = useUpdateCurrentUser();
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
const { form, Form, Field } = createForm({
|
const { form, Form, Field } = createForm({
|
||||||
schema: v.object({
|
schema: v.object({
|
||||||
@@ -72,15 +77,15 @@ const UpdateFullNameCard: Component<{ name: string }> = (props) => {
|
|||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
});
|
});
|
||||||
|
|
||||||
createToast({ type: 'success', message: 'Your full name has been updated' });
|
createToast({ type: 'success', message: t('user.settings.name.updated') });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader class="border-b">
|
<CardHeader class="border-b">
|
||||||
<CardTitle>Full name</CardTitle>
|
<CardTitle>{t('user.settings.name.title')}</CardTitle>
|
||||||
<CardDescription>Your full name is displayed to other organization members.</CardDescription>
|
<CardDescription>{t('user.settings.name.description')}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<Form>
|
<Form>
|
||||||
@@ -89,13 +94,13 @@ const UpdateFullNameCard: Component<{ name: string }> = (props) => {
|
|||||||
{(field, inputProps) => (
|
{(field, inputProps) => (
|
||||||
<TextFieldRoot class="flex flex-col gap-1">
|
<TextFieldRoot class="flex flex-col gap-1">
|
||||||
<TextFieldLabel for="name" class="sr-only">
|
<TextFieldLabel for="name" class="sr-only">
|
||||||
Full name
|
{t('user.settings.name.label')}
|
||||||
</TextFieldLabel>
|
</TextFieldLabel>
|
||||||
<div class="flex gap-2 flex-col sm:flex-row">
|
<div class="flex gap-2 flex-col sm:flex-row">
|
||||||
<TextField
|
<TextField
|
||||||
type="text"
|
type="text"
|
||||||
id="name"
|
id="name"
|
||||||
placeholder="Eg. John Doe"
|
placeholder={t('user.settings.name.placeholder')}
|
||||||
{...inputProps}
|
{...inputProps}
|
||||||
value={field.value}
|
value={field.value}
|
||||||
aria-invalid={Boolean(field.error)}
|
aria-invalid={Boolean(field.error)}
|
||||||
@@ -106,7 +111,7 @@ const UpdateFullNameCard: Component<{ name: string }> = (props) => {
|
|||||||
class="flex-shrink-0"
|
class="flex-shrink-0"
|
||||||
disabled={field.value?.trim() === props.name}
|
disabled={field.value?.trim() === props.name}
|
||||||
>
|
>
|
||||||
Update name
|
{t('user.settings.name.update')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{field.error && <div class="text-red-500 text-sm">{field.error}</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 = () => {
|
export const UserSettingsPage: Component = () => {
|
||||||
const query = createQuery(() => ({
|
const { t } = useI18n();
|
||||||
|
const query = useQuery(() => ({
|
||||||
queryKey: ['users', 'me'],
|
queryKey: ['users', 'me'],
|
||||||
queryFn: fetchCurrentUser,
|
queryFn: fetchCurrentUser,
|
||||||
}));
|
}));
|
||||||
@@ -134,8 +140,8 @@ export const UserSettingsPage: Component = () => {
|
|||||||
{getUser => (
|
{getUser => (
|
||||||
<>
|
<>
|
||||||
<div class="border-b pb-4">
|
<div class="border-b pb-4">
|
||||||
<h1 class="text-2xl font-semibold mb-1">User settings</h1>
|
<h1 class="text-2xl font-semibold mb-1">{t('user.settings.title')}</h1>
|
||||||
<p class="text-muted-foreground">Manage your account settings here.</p>
|
<p class="text-muted-foreground">{t('user.settings.description')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-6 flex flex-col gap-6">
|
<div class="mt-6 flex flex-col gap-6">
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export type UserMe = {
|
|||||||
export type User = {
|
export type User = {
|
||||||
id: string;
|
id: string;
|
||||||
email: string;
|
email: string;
|
||||||
|
name: string;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
provider: string;
|
provider: string;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { Component } from 'solid-js';
|
|||||||
import type { Webhook } from '../webhooks.types';
|
import type { Webhook } from '../webhooks.types';
|
||||||
import { setValue } from '@modular-forms/solid';
|
import { setValue } from '@modular-forms/solid';
|
||||||
import { A, useNavigate, useParams } from '@solidjs/router';
|
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 { createSignal, Show, Suspense } from 'solid-js';
|
||||||
import * as v from 'valibot';
|
import * as v from 'valibot';
|
||||||
import { useI18n } from '@/modules/i18n/i18n.provider';
|
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||||
@@ -171,7 +171,7 @@ export const EditWebhookPage: Component = () => {
|
|||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
|
|
||||||
const webhookQuery = createQuery(() => ({
|
const webhookQuery = useQuery(() => ({
|
||||||
queryKey: ['webhook', params.organizationId, params.webhookId],
|
queryKey: ['webhook', params.organizationId, params.webhookId],
|
||||||
queryFn: () => fetchWebhook({
|
queryFn: () => fetchWebhook({
|
||||||
organizationId: params.organizationId,
|
organizationId: params.organizationId,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { Component } from 'solid-js';
|
import type { Component } from 'solid-js';
|
||||||
import type { Webhook } from '../webhooks.types';
|
import type { Webhook } from '../webhooks.types';
|
||||||
import { A, useParams } from '@solidjs/router';
|
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 { format } from 'date-fns';
|
||||||
import { For, Match, Show, Suspense, Switch } from 'solid-js';
|
import { For, Match, Show, Suspense, Switch } from 'solid-js';
|
||||||
import { useI18n } from '@/modules/i18n/i18n.provider';
|
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||||
@@ -17,7 +17,7 @@ export const WebhookCard: Component<{ webhook: Webhook }> = ({ webhook }) => {
|
|||||||
const { confirm } = useConfirmModal();
|
const { confirm } = useConfirmModal();
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
|
|
||||||
const deleteWebhookMutation = createMutation(() => ({
|
const deleteWebhookMutation = useMutation(() => ({
|
||||||
mutationFn: deleteWebhook,
|
mutationFn: deleteWebhook,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['webhooks', params.organizationId] });
|
queryClient.invalidateQueries({ queryKey: ['webhooks', params.organizationId] });
|
||||||
@@ -95,7 +95,7 @@ export const WebhookCard: Component<{ webhook: Webhook }> = ({ webhook }) => {
|
|||||||
export const WebhooksPage: Component = () => {
|
export const WebhooksPage: Component = () => {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const query = createQuery(() => ({
|
const query = useQuery(() => ({
|
||||||
queryKey: ['webhooks', params.organizationId],
|
queryKey: ['webhooks', params.organizationId],
|
||||||
queryFn: () => fetchWebhooks({ organizationId: params.organizationId }),
|
queryFn: () => fetchWebhooks({ organizationId: params.organizationId }),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { RouteDefinition } from '@solidjs/router';
|
import type { RouteDefinition } from '@solidjs/router';
|
||||||
import { Navigate, useParams } 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 { Match, Show, Suspense, Switch } from 'solid-js';
|
||||||
import { ApiKeysPage } from './modules/api-keys/pages/api-keys.page';
|
import { ApiKeysPage } from './modules/api-keys/pages/api-keys.page';
|
||||||
import { CreateApiKeyPage } from './modules/api-keys/pages/create-api-key.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 { fetchOrganizations } from './modules/organizations/organizations.services';
|
||||||
import { CreateFirstOrganizationPage } from './modules/organizations/pages/create-first-organization.page';
|
import { CreateFirstOrganizationPage } from './modules/organizations/pages/create-first-organization.page';
|
||||||
import { CreateOrganizationPage } from './modules/organizations/pages/create-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 { InviteMemberPage } from './modules/organizations/pages/invite-member.page';
|
||||||
import { MembersPage } from './modules/organizations/pages/members.page';
|
import { MembersPage } from './modules/organizations/pages/members.page';
|
||||||
import { OrganizationPage } from './modules/organizations/pages/organization.page';
|
import { OrganizationPage } from './modules/organizations/pages/organization.page';
|
||||||
@@ -47,7 +48,7 @@ export const routes: RouteDefinition[] = [
|
|||||||
component: () => {
|
component: () => {
|
||||||
const { getLatestOrganizationId } = useCurrentUser();
|
const { getLatestOrganizationId } = useCurrentUser();
|
||||||
|
|
||||||
const query = createQuery(() => ({
|
const query = useQuery(() => ({
|
||||||
queryKey: ['organizations'],
|
queryKey: ['organizations'],
|
||||||
queryFn: fetchOrganizations,
|
queryFn: fetchOrganizations,
|
||||||
}));
|
}));
|
||||||
@@ -139,6 +140,10 @@ export const routes: RouteDefinition[] = [
|
|||||||
path: '/invite',
|
path: '/invite',
|
||||||
component: InviteMemberPage,
|
component: InviteMemberPage,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/invitations',
|
||||||
|
component: InvitationsListPage,
|
||||||
|
},
|
||||||
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
73
apps/papra-client/src/scripts/sync-i18n-key-order.script.ts
Normal file
73
apps/papra-client/src/scripts/sync-i18n-key-order.script.ts
Normal 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();
|
||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
} from 'unocss';
|
} from 'unocss';
|
||||||
import { presetAnimations } from 'unocss-preset-animations';
|
import { presetAnimations } from 'unocss-preset-animations';
|
||||||
import { ssoProviders } from './src/modules/auth/auth.constants';
|
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({
|
export default defineConfig({
|
||||||
presets: [
|
presets: [
|
||||||
@@ -113,7 +113,10 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
safelist: [
|
safelist: [
|
||||||
...uniq(values(iconByFileType)),
|
...uniq([
|
||||||
...(ssoProviders.map(p => p.icon)),
|
...values(iconByFileType),
|
||||||
|
...values(documentActivityIcon),
|
||||||
|
...(ssoProviders.map(p => p.icon)),
|
||||||
|
]),
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,26 @@
|
|||||||
# @papra/app-server
|
# @papra/app-server
|
||||||
|
|
||||||
|
## 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
|
## 0.5.0
|
||||||
|
|
||||||
### Minor Changes
|
### Minor Changes
|
||||||
|
|||||||
12
apps/papra-server/migrations/0006_document-activity-log.sql
Normal file
12
apps/papra-server/migrations/0006_document-activity-log.sql
Normal 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
|
||||||
|
);
|
||||||
1970
apps/papra-server/migrations/meta/0006_snapshot.json
Normal file
1970
apps/papra-server/migrations/meta/0006_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -43,6 +43,13 @@
|
|||||||
"when": 1747575029264,
|
"when": 1747575029264,
|
||||||
"tag": "0005_organizations-invitations-improvement",
|
"tag": "0005_organizations-invitations-improvement",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 6,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1748554484124,
|
||||||
|
"tag": "0006_document-activity-log",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@papra/app-server",
|
"name": "@papra/app-server",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"version": "0.5.0",
|
"version": "0.6.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"packageManager": "pnpm@10.9.0",
|
"packageManager": "pnpm@10.9.0",
|
||||||
"description": "Papra app server",
|
"description": "Papra app server",
|
||||||
@@ -30,14 +30,14 @@
|
|||||||
"stripe:webhook": "stripe listen --forward-to localhost:1221/api/stripe/webhook"
|
"stripe:webhook": "stripe listen --forward-to localhost:1221/api/stripe/webhook"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.722.0",
|
"@aws-sdk/client-s3": "^3.817.0",
|
||||||
"@aws-sdk/lib-storage": "^3.722.0",
|
"@aws-sdk/lib-storage": "^3.817.0",
|
||||||
"@azure/storage-blob": "^12.27.0",
|
"@azure/storage-blob": "^12.27.0",
|
||||||
"@corentinth/chisels": "^1.1.0",
|
"@corentinth/chisels": "^1.3.1",
|
||||||
"@corentinth/friendly-ids": "^0.0.1",
|
"@corentinth/friendly-ids": "^0.0.1",
|
||||||
"@crowlog/async-context-plugin": "^1.0.0",
|
"@crowlog/async-context-plugin": "^1.2.1",
|
||||||
"@crowlog/logger": "^1.1.0",
|
"@crowlog/logger": "^1.2.1",
|
||||||
"@hono/node-server": "^1.13.7",
|
"@hono/node-server": "^1.14.3",
|
||||||
"@libsql/client": "^0.14.0",
|
"@libsql/client": "^0.14.0",
|
||||||
"@owlrelay/api-sdk": "^0.0.2",
|
"@owlrelay/api-sdk": "^0.0.2",
|
||||||
"@owlrelay/webhook": "^0.0.3",
|
"@owlrelay/webhook": "^0.0.3",
|
||||||
@@ -46,42 +46,44 @@
|
|||||||
"@paralleldrive/cuid2": "^2.2.2",
|
"@paralleldrive/cuid2": "^2.2.2",
|
||||||
"backblaze-b2": "^1.7.0",
|
"backblaze-b2": "^1.7.0",
|
||||||
"better-auth": "catalog:",
|
"better-auth": "catalog:",
|
||||||
"c12": "^3.0.2",
|
"c12": "^3.0.4",
|
||||||
"chokidar": "^4.0.3",
|
"chokidar": "^4.0.3",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"drizzle-kit": "^0.30.6",
|
"drizzle-kit": "^0.30.6",
|
||||||
"drizzle-orm": "^0.38.3",
|
"drizzle-orm": "^0.38.4",
|
||||||
"figue": "^2.2.3",
|
"figue": "^2.2.3",
|
||||||
"hono": "^4.6.15",
|
"hono": "^4.7.10",
|
||||||
"lodash-es": "^4.17.21",
|
"lodash-es": "^4.17.21",
|
||||||
"mime-types": "^3.0.1",
|
"mime-types": "^3.0.1",
|
||||||
"nanoid": "^5.1.5",
|
"nanoid": "^5.1.5",
|
||||||
"node-cron": "^3.0.3",
|
"node-cron": "^3.0.3",
|
||||||
|
"nodemailer": "^7.0.3",
|
||||||
"p-limit": "^6.2.0",
|
"p-limit": "^6.2.0",
|
||||||
"p-queue": "^8.1.0",
|
"p-queue": "^8.1.0",
|
||||||
"picomatch": "^4.0.2",
|
"picomatch": "^4.0.2",
|
||||||
"posthog-node": "^4.11.1",
|
"posthog-node": "^4.17.2",
|
||||||
"resend": "^4.1.2",
|
"resend": "^4.5.1",
|
||||||
"sanitize-html": "^2.17.0",
|
"sanitize-html": "^2.17.0",
|
||||||
"stripe": "^17.7.0",
|
"stripe": "^17.7.0",
|
||||||
"tsx": "^4.19.2",
|
"tsx": "^4.19.4",
|
||||||
"zod": "^3.24.1"
|
"zod": "^3.25.28"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@antfu/eslint-config": "catalog:",
|
"@antfu/eslint-config": "catalog:",
|
||||||
"@crowlog/pretty": "^1.1.1",
|
"@crowlog/pretty": "^1.2.1",
|
||||||
"@total-typescript/ts-reset": "^0.6.1",
|
"@total-typescript/ts-reset": "^0.6.1",
|
||||||
"@types/backblaze-b2": "^1.5.6",
|
"@types/backblaze-b2": "^1.5.6",
|
||||||
"@types/lodash-es": "^4.17.12",
|
"@types/lodash-es": "^4.17.12",
|
||||||
"@types/mime-types": "^2.1.4",
|
"@types/mime-types": "^2.1.4",
|
||||||
"@types/node": "^22.10.2",
|
"@types/node": "catalog:",
|
||||||
"@types/node-cron": "^3.0.11",
|
"@types/node-cron": "^3.0.11",
|
||||||
|
"@types/nodemailer": "^6.4.17",
|
||||||
"@types/picomatch": "^4.0.0",
|
"@types/picomatch": "^4.0.0",
|
||||||
"@types/sanitize-html": "^2.16.0",
|
"@types/sanitize-html": "^2.16.0",
|
||||||
"@vitest/coverage-v8": "catalog:",
|
"@vitest/coverage-v8": "catalog:",
|
||||||
"esbuild": "^0.24.2",
|
"esbuild": "^0.24.2",
|
||||||
"eslint": "catalog:",
|
"eslint": "catalog:",
|
||||||
"memfs": "^4.17.0",
|
"memfs": "^4.17.2",
|
||||||
"typescript": "catalog:",
|
"typescript": "catalog:",
|
||||||
"vitest": "catalog:"
|
"vitest": "catalog:"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ export function getAuth({
|
|||||||
|
|
||||||
advanced: {
|
advanced: {
|
||||||
// Drizzle tables handle the id generation
|
// Drizzle tables handle the id generation
|
||||||
generateId: false,
|
database: { generateId: false },
|
||||||
},
|
},
|
||||||
socialProviders: {
|
socialProviders: {
|
||||||
github: {
|
github: {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { Database } from './database.types';
|
|||||||
import { apiKeyOrganizationsTable, apiKeysTable } from '../../api-keys/api-keys.tables';
|
import { apiKeyOrganizationsTable, apiKeysTable } from '../../api-keys/api-keys.tables';
|
||||||
import { documentsTable } from '../../documents/documents.table';
|
import { documentsTable } from '../../documents/documents.table';
|
||||||
import { intakeEmailsTable } from '../../intake-emails/intake-emails.tables';
|
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 { organizationSubscriptionsTable } from '../../subscriptions/subscriptions.tables';
|
||||||
import { taggingRuleActionsTable, taggingRuleConditionsTable, taggingRulesTable } from '../../tagging-rules/tagging-rules.tables';
|
import { taggingRuleActionsTable, taggingRuleConditionsTable, taggingRulesTable } from '../../tagging-rules/tagging-rules.tables';
|
||||||
import { documentsTagsTable, tagsTable } from '../../tags/tags.table';
|
import { documentsTagsTable, tagsTable } from '../../tags/tags.table';
|
||||||
@@ -42,6 +42,7 @@ const seedTables = {
|
|||||||
webhooks: webhooksTable,
|
webhooks: webhooksTable,
|
||||||
webhookEvents: webhookEventsTable,
|
webhookEvents: webhookEventsTable,
|
||||||
webhookDeliveries: webhookDeliveriesTable,
|
webhookDeliveries: webhookDeliveriesTable,
|
||||||
|
organizationInvitations: organizationInvitationsTable,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
type SeedTablesRows = {
|
type SeedTablesRows = {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { RouteDefinitionContext } from './server.types';
|
import type { RouteDefinitionContext } from './server.types';
|
||||||
import { registerApiKeysRoutes } from '../api-keys/api-keys.routes';
|
import { registerApiKeysRoutes } from '../api-keys/api-keys.routes';
|
||||||
import { registerConfigRoutes } from '../config/config.routes';
|
import { registerConfigRoutes } from '../config/config.routes';
|
||||||
|
import { registerDocumentActivityRoutes } from '../documents/document-activity/document-activity.routes';
|
||||||
import { registerDocumentsRoutes } from '../documents/documents.routes';
|
import { registerDocumentsRoutes } from '../documents/documents.routes';
|
||||||
import { registerIntakeEmailsRoutes } from '../intake-emails/intake-emails.routes';
|
import { registerIntakeEmailsRoutes } from '../intake-emails/intake-emails.routes';
|
||||||
import { registerInvitationsRoutes } from '../invitations/invitations.routes';
|
import { registerInvitationsRoutes } from '../invitations/invitations.routes';
|
||||||
@@ -27,4 +28,5 @@ export function registerRoutes(context: RouteDefinitionContext) {
|
|||||||
registerApiKeysRoutes(context);
|
registerApiKeysRoutes(context);
|
||||||
registerWebhooksRoutes(context);
|
registerWebhooksRoutes(context);
|
||||||
registerInvitationsRoutes(context);
|
registerInvitationsRoutes(context);
|
||||||
|
registerDocumentActivityRoutes(context);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,21 +29,15 @@ export const configDefinition = {
|
|||||||
},
|
},
|
||||||
client: {
|
client: {
|
||||||
baseUrl: {
|
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(),
|
schema: z.string().url(),
|
||||||
default: 'http://localhost:3000',
|
default: 'http://localhost:3000',
|
||||||
env: 'CLIENT_BASE_URL',
|
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: {
|
server: {
|
||||||
baseUrl: {
|
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(),
|
schema: z.string().url(),
|
||||||
default: 'http://localhost:1221',
|
default: 'http://localhost:1221',
|
||||||
env: 'SERVER_BASE_URL',
|
env: 'SERVER_BASE_URL',
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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' }),
|
||||||
|
});
|
||||||
@@ -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];
|
||||||
@@ -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);
|
||||||
@@ -6,19 +6,17 @@ import { getUser } from '../app/auth/auth.models';
|
|||||||
import { organizationIdSchema } from '../organizations/organization.schemas';
|
import { organizationIdSchema } from '../organizations/organization.schemas';
|
||||||
import { createOrganizationsRepository } from '../organizations/organizations.repository';
|
import { createOrganizationsRepository } from '../organizations/organizations.repository';
|
||||||
import { ensureUserIsInOrganization } from '../organizations/organizations.usecases';
|
import { ensureUserIsInOrganization } from '../organizations/organizations.usecases';
|
||||||
import { createPlansRepository } from '../plans/plans.repository';
|
|
||||||
import { createError } from '../shared/errors/errors';
|
import { createError } from '../shared/errors/errors';
|
||||||
import { validateFormData, validateJsonBody, validateParams, validateQuery } from '../shared/validation/validation';
|
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 { createWebhookRepository } from '../webhooks/webhook.repository';
|
||||||
import { triggerWebhooks } from '../webhooks/webhook.usecases';
|
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 { createDocumentIsNotDeletedError } from './documents.errors';
|
||||||
import { isDocumentSizeLimitEnabled } from './documents.models';
|
import { isDocumentSizeLimitEnabled } from './documents.models';
|
||||||
import { createDocumentsRepository } from './documents.repository';
|
import { createDocumentsRepository } from './documents.repository';
|
||||||
import { documentIdSchema } from './documents.schemas';
|
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';
|
import { createDocumentStorageService } from './storage/documents.storage.services';
|
||||||
|
|
||||||
export function registerDocumentsRoutes(context: RouteDefinitionContext) {
|
export function registerDocumentsRoutes(context: RouteDefinitionContext) {
|
||||||
@@ -89,26 +87,16 @@ function setupCreateDocumentRoute({ app, config, db, trackingServices }: RouteDe
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const documentsRepository = createDocumentsRepository({ db });
|
const createDocument = await createDocumentCreationUsecase({
|
||||||
const documentsStorageService = await createDocumentStorageService({ config });
|
db,
|
||||||
const plansRepository = createPlansRepository({ config });
|
config,
|
||||||
const subscriptionsRepository = createSubscriptionsRepository({ db });
|
trackingServices,
|
||||||
const taggingRulesRepository = createTaggingRulesRepository({ db });
|
});
|
||||||
const tagsRepository = createTagsRepository({ db });
|
|
||||||
const webhookRepository = createWebhookRepository({ db });
|
|
||||||
|
|
||||||
const { document } = await createDocument({
|
const { document } = await createDocument({
|
||||||
file,
|
file,
|
||||||
userId,
|
userId,
|
||||||
organizationId,
|
organizationId,
|
||||||
documentsRepository,
|
|
||||||
documentsStorageService,
|
|
||||||
plansRepository,
|
|
||||||
subscriptionsRepository,
|
|
||||||
trackingServices,
|
|
||||||
taggingRulesRepository,
|
|
||||||
tagsRepository,
|
|
||||||
webhookRepository,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return context.json({
|
return context.json({
|
||||||
@@ -245,6 +233,8 @@ function setupDeleteDocumentRoute({ app, db }: RouteDefinitionContext) {
|
|||||||
const documentsRepository = createDocumentsRepository({ db });
|
const documentsRepository = createDocumentsRepository({ db });
|
||||||
const organizationsRepository = createOrganizationsRepository({ db });
|
const organizationsRepository = createOrganizationsRepository({ db });
|
||||||
const webhookRepository = createWebhookRepository({ db });
|
const webhookRepository = createWebhookRepository({ db });
|
||||||
|
const documentActivityRepository = createDocumentActivityRepository({ db });
|
||||||
|
|
||||||
await ensureUserIsInOrganization({ userId, organizationId, organizationsRepository });
|
await ensureUserIsInOrganization({ userId, organizationId, organizationsRepository });
|
||||||
await ensureDocumentExists({ documentId, organizationId, documentsRepository });
|
await ensureDocumentExists({ documentId, organizationId, documentsRepository });
|
||||||
|
|
||||||
@@ -257,6 +247,13 @@ function setupDeleteDocumentRoute({ app, db }: RouteDefinitionContext) {
|
|||||||
payload: { documentId, organizationId },
|
payload: { documentId, organizationId },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
deferRegisterDocumentActivityLog({
|
||||||
|
documentId,
|
||||||
|
event: 'deleted',
|
||||||
|
userId,
|
||||||
|
documentActivityRepository,
|
||||||
|
});
|
||||||
|
|
||||||
return context.json({
|
return context.json({
|
||||||
success: true,
|
success: true,
|
||||||
});
|
});
|
||||||
@@ -279,6 +276,7 @@ function setupRestoreDocumentRoute({ app, db }: RouteDefinitionContext) {
|
|||||||
|
|
||||||
const documentsRepository = createDocumentsRepository({ db });
|
const documentsRepository = createDocumentsRepository({ db });
|
||||||
const organizationsRepository = createOrganizationsRepository({ db });
|
const organizationsRepository = createOrganizationsRepository({ db });
|
||||||
|
const documentActivityRepository = createDocumentActivityRepository({ db });
|
||||||
|
|
||||||
await ensureUserIsInOrganization({ userId, organizationId, organizationsRepository });
|
await ensureUserIsInOrganization({ userId, organizationId, organizationsRepository });
|
||||||
|
|
||||||
@@ -290,6 +288,13 @@ function setupRestoreDocumentRoute({ app, db }: RouteDefinitionContext) {
|
|||||||
|
|
||||||
await documentsRepository.restoreDocument({ documentId, organizationId });
|
await documentsRepository.restoreDocument({ documentId, organizationId });
|
||||||
|
|
||||||
|
deferRegisterDocumentActivityLog({
|
||||||
|
documentId,
|
||||||
|
event: 'restored',
|
||||||
|
userId,
|
||||||
|
documentActivityRepository,
|
||||||
|
});
|
||||||
|
|
||||||
return context.body(null, 204);
|
return context.body(null, 204);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -457,7 +462,7 @@ function setupUpdateDocumentRoute({ app, db }: RouteDefinitionContext) {
|
|||||||
documentId: documentIdSchema,
|
documentId: documentIdSchema,
|
||||||
})),
|
})),
|
||||||
validateJsonBody(z.object({
|
validateJsonBody(z.object({
|
||||||
name: z.string().min(1).optional(),
|
name: z.string().min(1).max(255).optional(),
|
||||||
content: z.string().min(1).optional(),
|
content: z.string().min(1).optional(),
|
||||||
}).refine(data => data.name !== undefined || data.content !== undefined, {
|
}).refine(data => data.name !== undefined || data.content !== undefined, {
|
||||||
message: 'At least one of \'name\' or \'content\' must be provided',
|
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 documentsRepository = createDocumentsRepository({ db });
|
||||||
const organizationsRepository = createOrganizationsRepository({ db });
|
const organizationsRepository = createOrganizationsRepository({ db });
|
||||||
|
const documentActivityRepository = createDocumentActivityRepository({ db });
|
||||||
|
|
||||||
await ensureUserIsInOrganization({ userId, organizationId, organizationsRepository });
|
await ensureUserIsInOrganization({ userId, organizationId, organizationsRepository });
|
||||||
await ensureDocumentExists({ documentId, organizationId, documentsRepository });
|
await ensureDocumentExists({ documentId, organizationId, documentsRepository });
|
||||||
@@ -479,6 +485,16 @@ function setupUpdateDocumentRoute({ app, db }: RouteDefinitionContext) {
|
|||||||
...updateData,
|
...updateData,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
deferRegisterDocumentActivityLog({
|
||||||
|
documentId,
|
||||||
|
event: 'updated',
|
||||||
|
userId,
|
||||||
|
documentActivityRepository,
|
||||||
|
eventData: {
|
||||||
|
updatedFields: Object.entries(updateData).filter(([_, value]) => value !== undefined).map(([key]) => key),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
return context.json({ document });
|
return context.json({ document });
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,19 +1,15 @@
|
|||||||
import type { Config } from '../config/config.types';
|
|
||||||
import { describe, expect, test } from 'vitest';
|
import { describe, expect, test } from 'vitest';
|
||||||
import { createInMemoryDatabase } from '../app/database/database.test-utils';
|
import { createInMemoryDatabase } from '../app/database/database.test-utils';
|
||||||
|
import { overrideConfig } from '../config/config.test-utils';
|
||||||
import { ORGANIZATION_ROLES } from '../organizations/organizations.constants';
|
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 { 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 { documentsTagsTable } from '../tags/tags.table';
|
||||||
import { createDummyTrackingServices } from '../tracking/tracking.services';
|
import { documentActivityLogTable } from './document-activity/document-activity.table';
|
||||||
import { createWebhookRepository } from '../webhooks/webhook.repository';
|
|
||||||
import { createDocumentAlreadyExistsError } from './documents.errors';
|
import { createDocumentAlreadyExistsError } from './documents.errors';
|
||||||
import { createDocumentsRepository } from './documents.repository';
|
import { createDocumentsRepository } from './documents.repository';
|
||||||
import { documentsTable } from './documents.table';
|
import { documentsTable } from './documents.table';
|
||||||
import { createDocument } from './documents.usecases';
|
import { createDocumentCreationUsecase } from './documents.usecases';
|
||||||
import { createDocumentStorageService } from './storage/documents.storage.services';
|
import { createDocumentStorageService } from './storage/documents.storage.services';
|
||||||
|
|
||||||
describe('documents usecases', () => {
|
describe('documents usecases', () => {
|
||||||
@@ -25,15 +21,18 @@ describe('documents usecases', () => {
|
|||||||
organizationMembers: [{ organizationId: 'organization-1', userId: 'user-1', role: ORGANIZATION_ROLES.OWNER }],
|
organizationMembers: [{ organizationId: 'organization-1', userId: 'user-1', role: ORGANIZATION_ROLES.OWNER }],
|
||||||
});
|
});
|
||||||
|
|
||||||
const documentsRepository = createDocumentsRepository({ db });
|
const config = overrideConfig({
|
||||||
const documentsStorageService = await createDocumentStorageService({ config: { documentsStorage: { driver: 'in-memory' } } as Config });
|
organizationPlans: { isFreePlanUnlimited: true },
|
||||||
const plansRepository = createPlansRepository({ config: { organizationPlans: { isFreePlanUnlimited: true } } as Config });
|
documentsStorage: { driver: 'in-memory' },
|
||||||
const subscriptionsRepository = createSubscriptionsRepository({ db });
|
});
|
||||||
const trackingServices = createDummyTrackingServices();
|
const documentsStorageService = await createDocumentStorageService({ config });
|
||||||
const taggingRulesRepository = createTaggingRulesRepository({ db });
|
|
||||||
const tagsRepository = createTagsRepository({ db });
|
const createDocument = await createDocumentCreationUsecase({
|
||||||
const webhookRepository = createWebhookRepository({ db });
|
db,
|
||||||
const generateDocumentId = () => 'doc_1';
|
config,
|
||||||
|
generateDocumentId: () => 'doc_1',
|
||||||
|
documentsStorageService,
|
||||||
|
});
|
||||||
|
|
||||||
const file = new File(['content'], 'file.txt', { type: 'text/plain' });
|
const file = new File(['content'], 'file.txt', { type: 'text/plain' });
|
||||||
const userId = 'user-1';
|
const userId = 'user-1';
|
||||||
@@ -43,15 +42,6 @@ describe('documents usecases', () => {
|
|||||||
file,
|
file,
|
||||||
userId,
|
userId,
|
||||||
organizationId,
|
organizationId,
|
||||||
documentsRepository,
|
|
||||||
documentsStorageService,
|
|
||||||
generateDocumentId,
|
|
||||||
plansRepository,
|
|
||||||
subscriptionsRepository,
|
|
||||||
trackingServices,
|
|
||||||
taggingRulesRepository,
|
|
||||||
tagsRepository,
|
|
||||||
webhookRepository,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(document).to.include({
|
expect(document).to.include({
|
||||||
@@ -86,16 +76,20 @@ describe('documents usecases', () => {
|
|||||||
organizationMembers: [{ organizationId: 'organization-1', userId: 'user-1', role: ORGANIZATION_ROLES.OWNER }],
|
organizationMembers: [{ organizationId: 'organization-1', userId: 'user-1', role: ORGANIZATION_ROLES.OWNER }],
|
||||||
});
|
});
|
||||||
|
|
||||||
const documentsRepository = createDocumentsRepository({ db });
|
const config = overrideConfig({
|
||||||
const documentsStorageService = await createDocumentStorageService({ config: { documentsStorage: { driver: 'in-memory' } } as Config });
|
organizationPlans: { isFreePlanUnlimited: true },
|
||||||
const plansRepository = createPlansRepository({ config: { organizationPlans: { isFreePlanUnlimited: true } } as Config });
|
documentsStorage: { driver: 'in-memory' },
|
||||||
const subscriptionsRepository = createSubscriptionsRepository({ db });
|
});
|
||||||
const trackingServices = createDummyTrackingServices();
|
|
||||||
const taggingRulesRepository = createTaggingRulesRepository({ db });
|
const documentsStorageService = await createDocumentStorageService({ config });
|
||||||
const tagsRepository = createTagsRepository({ db });
|
|
||||||
const webhookRepository = createWebhookRepository({ db });
|
|
||||||
let documentIdIndex = 1;
|
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 file = new File(['content'], 'file.txt', { type: 'text/plain' });
|
||||||
const userId = 'user-1';
|
const userId = 'user-1';
|
||||||
@@ -105,15 +99,6 @@ describe('documents usecases', () => {
|
|||||||
file,
|
file,
|
||||||
userId,
|
userId,
|
||||||
organizationId,
|
organizationId,
|
||||||
documentsRepository,
|
|
||||||
documentsStorageService,
|
|
||||||
plansRepository,
|
|
||||||
subscriptionsRepository,
|
|
||||||
generateDocumentId,
|
|
||||||
trackingServices,
|
|
||||||
taggingRulesRepository,
|
|
||||||
tagsRepository,
|
|
||||||
webhookRepository,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(document1).to.include({
|
expect(document1).to.include({
|
||||||
@@ -134,15 +119,6 @@ describe('documents usecases', () => {
|
|||||||
file,
|
file,
|
||||||
userId,
|
userId,
|
||||||
organizationId,
|
organizationId,
|
||||||
documentsRepository,
|
|
||||||
documentsStorageService,
|
|
||||||
plansRepository,
|
|
||||||
subscriptionsRepository,
|
|
||||||
generateDocumentId,
|
|
||||||
trackingServices,
|
|
||||||
taggingRulesRepository,
|
|
||||||
tagsRepository,
|
|
||||||
webhookRepository,
|
|
||||||
}),
|
}),
|
||||||
).rejects.toThrow(
|
).rejects.toThrow(
|
||||||
createDocumentAlreadyExistsError(),
|
createDocumentAlreadyExistsError(),
|
||||||
@@ -202,26 +178,20 @@ describe('documents usecases', () => {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
const documentsRepository = createDocumentsRepository({ db });
|
const config = overrideConfig({
|
||||||
const documentsStorageService = await createDocumentStorageService({ config: { documentsStorage: { driver: 'in-memory' } } as Config });
|
organizationPlans: { isFreePlanUnlimited: true },
|
||||||
const plansRepository = createPlansRepository({ config: { organizationPlans: { isFreePlanUnlimited: true } } as Config });
|
documentsStorage: { driver: 'in-memory' },
|
||||||
const subscriptionsRepository = createSubscriptionsRepository({ db });
|
});
|
||||||
const trackingServices = createDummyTrackingServices();
|
|
||||||
const taggingRulesRepository = createTaggingRulesRepository({ db });
|
const createDocument = await createDocumentCreationUsecase({
|
||||||
const tagsRepository = createTagsRepository({ db });
|
db,
|
||||||
const webhookRepository = createWebhookRepository({ db });
|
config,
|
||||||
|
});
|
||||||
|
|
||||||
// 3. Re-create the document
|
// 3. Re-create the document
|
||||||
const { document: documentRestored } = await createDocument({
|
const { document: documentRestored } = await createDocument({
|
||||||
file: new File(['hello world'], 'file-2.txt', { type: 'text/plain' }),
|
file: new File(['hello world'], 'file-2.txt', { type: 'text/plain' }),
|
||||||
organizationId: 'organization-1',
|
organizationId: 'organization-1',
|
||||||
documentsRepository,
|
|
||||||
documentsStorageService,
|
|
||||||
plansRepository,
|
|
||||||
subscriptionsRepository,
|
|
||||||
trackingServices,
|
|
||||||
taggingRulesRepository,
|
|
||||||
tagsRepository,
|
|
||||||
webhookRepository,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(documentRestored).to.deep.include({
|
expect(documentRestored).to.deep.include({
|
||||||
@@ -255,15 +225,25 @@ describe('documents usecases', () => {
|
|||||||
organizationMembers: [{ organizationId: 'organization-1', userId: 'user-1', role: ORGANIZATION_ROLES.OWNER }],
|
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 documentsRepository = createDocumentsRepository({ db });
|
||||||
const documentsStorageService = await createDocumentStorageService({ config: { documentsStorage: { driver: 'in-memory' } } as Config });
|
const documentsStorageService = await createDocumentStorageService({ config });
|
||||||
const plansRepository = createPlansRepository({ config: { organizationPlans: { isFreePlanUnlimited: true } } as Config });
|
|
||||||
const subscriptionsRepository = createSubscriptionsRepository({ db });
|
const createDocument = await createDocumentCreationUsecase({
|
||||||
const trackingServices = createDummyTrackingServices();
|
db,
|
||||||
const taggingRulesRepository = createTaggingRulesRepository({ db });
|
config,
|
||||||
const tagsRepository = createTagsRepository({ db });
|
generateDocumentId: () => 'doc_1',
|
||||||
const webhookRepository = createWebhookRepository({ db });
|
documentsRepository: {
|
||||||
const generateDocumentId = () => 'doc_1';
|
...documentsRepository,
|
||||||
|
saveOrganizationDocument: async () => {
|
||||||
|
throw new Error('Macron, explosion!');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const file = new File(['content'], 'file.txt', { type: 'text/plain' });
|
const file = new File(['content'], 'file.txt', { type: 'text/plain' });
|
||||||
const userId = 'user-1';
|
const userId = 'user-1';
|
||||||
@@ -274,20 +254,6 @@ describe('documents usecases', () => {
|
|||||||
file,
|
file,
|
||||||
userId,
|
userId,
|
||||||
organizationId,
|
organizationId,
|
||||||
documentsRepository: {
|
|
||||||
...documentsRepository,
|
|
||||||
saveOrganizationDocument: async () => {
|
|
||||||
throw new Error('Macron, explosion!');
|
|
||||||
},
|
|
||||||
},
|
|
||||||
documentsStorageService,
|
|
||||||
plansRepository,
|
|
||||||
subscriptionsRepository,
|
|
||||||
generateDocumentId,
|
|
||||||
trackingServices,
|
|
||||||
taggingRulesRepository,
|
|
||||||
tagsRepository,
|
|
||||||
webhookRepository,
|
|
||||||
}),
|
}),
|
||||||
).rejects.toThrow(new Error('Macron, explosion!'));
|
).rejects.toThrow(new Error('Macron, explosion!'));
|
||||||
|
|
||||||
@@ -299,5 +265,56 @@ describe('documents usecases', () => {
|
|||||||
documentsStorageService.getFileStream({ storageKey: 'organization-1/originals/doc_1.txt' }),
|
documentsStorageService.getFileStream({ storageKey: 'organization-1/originals/doc_1.txt' }),
|
||||||
).rejects.toThrow('File not found');
|
).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',
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type { TaggingRulesRepository } from '../tagging-rules/tagging-rules.repo
|
|||||||
import type { TagsRepository } from '../tags/tags.repository';
|
import type { TagsRepository } from '../tags/tags.repository';
|
||||||
import type { TrackingServices } from '../tracking/tracking.services';
|
import type { TrackingServices } from '../tracking/tracking.services';
|
||||||
import type { WebhookRepository } from '../webhooks/webhook.repository';
|
import type { WebhookRepository } from '../webhooks/webhook.repository';
|
||||||
|
import type { DocumentActivityRepository } from './document-activity/document-activity.repository';
|
||||||
import type { DocumentsRepository } from './documents.repository';
|
import type { DocumentsRepository } from './documents.repository';
|
||||||
import type { Document } from './documents.types';
|
import type { Document } from './documents.types';
|
||||||
import type { DocumentStorageService } from './storage/documents.storage.services';
|
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 { createTrackingServices } from '../tracking/tracking.services';
|
||||||
import { createWebhookRepository } from '../webhooks/webhook.repository';
|
import { createWebhookRepository } from '../webhooks/webhook.repository';
|
||||||
import { triggerWebhooks } from '../webhooks/webhook.usecases';
|
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 { createDocumentAlreadyExistsError, createDocumentNotDeletedError, createDocumentNotFoundError } from './documents.errors';
|
||||||
import { buildOriginalDocumentKey, generateDocumentId as generateDocumentIdImpl } from './documents.models';
|
import { buildOriginalDocumentKey, generateDocumentId as generateDocumentIdImpl } from './documents.models';
|
||||||
import { createDocumentsRepository } from './documents.repository';
|
import { createDocumentsRepository } from './documents.repository';
|
||||||
@@ -56,6 +59,7 @@ export async function createDocument({
|
|||||||
taggingRulesRepository,
|
taggingRulesRepository,
|
||||||
tagsRepository,
|
tagsRepository,
|
||||||
webhookRepository,
|
webhookRepository,
|
||||||
|
documentActivityRepository,
|
||||||
logger = createLogger({ namespace: 'documents:usecases' }),
|
logger = createLogger({ namespace: 'documents:usecases' }),
|
||||||
}: {
|
}: {
|
||||||
file: File;
|
file: File;
|
||||||
@@ -70,6 +74,7 @@ export async function createDocument({
|
|||||||
taggingRulesRepository: TaggingRulesRepository;
|
taggingRulesRepository: TaggingRulesRepository;
|
||||||
tagsRepository: TagsRepository;
|
tagsRepository: TagsRepository;
|
||||||
webhookRepository: WebhookRepository;
|
webhookRepository: WebhookRepository;
|
||||||
|
documentActivityRepository: DocumentActivityRepository;
|
||||||
logger?: Logger;
|
logger?: Logger;
|
||||||
}) {
|
}) {
|
||||||
const {
|
const {
|
||||||
@@ -115,6 +120,13 @@ export async function createDocument({
|
|||||||
logger,
|
logger,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
deferRegisterDocumentActivityLog({
|
||||||
|
documentId: document.id,
|
||||||
|
event: 'created',
|
||||||
|
userId,
|
||||||
|
documentActivityRepository,
|
||||||
|
});
|
||||||
|
|
||||||
await applyTaggingRules({ document, taggingRulesRepository, tagsRepository });
|
await applyTaggingRules({ document, taggingRulesRepository, tagsRepository });
|
||||||
|
|
||||||
await triggerWebhooks({
|
await triggerWebhooks({
|
||||||
@@ -134,38 +146,32 @@ export async function createDocument({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type CreateDocumentUsecase = Awaited<ReturnType<typeof createDocumentCreationUsecase>>;
|
export type CreateDocumentUsecase = Awaited<ReturnType<typeof createDocumentCreationUsecase>>;
|
||||||
|
export type DocumentUsecaseDependencies = Omit<Parameters<typeof createDocument>[0], 'file' | 'userId' | 'organizationId'>;
|
||||||
|
|
||||||
export async function createDocumentCreationUsecase({
|
export async function createDocumentCreationUsecase({
|
||||||
db,
|
db,
|
||||||
config,
|
config,
|
||||||
logger = createLogger({ namespace: 'documents:usecases' }),
|
...initialDeps
|
||||||
generateDocumentId = generateDocumentIdImpl,
|
|
||||||
documentsStorageService,
|
|
||||||
}: {
|
}: {
|
||||||
db: Database;
|
db: Database;
|
||||||
config: Config;
|
config: Config;
|
||||||
logger?: Logger;
|
} & Partial<DocumentUsecaseDependencies>) {
|
||||||
documentsStorageService?: DocumentStorageService;
|
|
||||||
generateDocumentId?: () => string;
|
|
||||||
}) {
|
|
||||||
const deps = {
|
const deps = {
|
||||||
documentsRepository: createDocumentsRepository({ db }),
|
documentsRepository: initialDeps.documentsRepository ?? createDocumentsRepository({ db }),
|
||||||
documentsStorageService: documentsStorageService ?? await createDocumentStorageService({ config }),
|
documentsStorageService: initialDeps.documentsStorageService ?? await createDocumentStorageService({ config }),
|
||||||
plansRepository: createPlansRepository({ config }),
|
plansRepository: initialDeps.plansRepository ?? createPlansRepository({ config }),
|
||||||
subscriptionsRepository: createSubscriptionsRepository({ db }),
|
subscriptionsRepository: initialDeps.subscriptionsRepository ?? createSubscriptionsRepository({ db }),
|
||||||
trackingServices: createTrackingServices({ config }),
|
trackingServices: initialDeps.trackingServices ?? createTrackingServices({ config }),
|
||||||
taggingRulesRepository: createTaggingRulesRepository({ db }),
|
taggingRulesRepository: initialDeps.taggingRulesRepository ?? createTaggingRulesRepository({ db }),
|
||||||
tagsRepository: createTagsRepository({ db }),
|
tagsRepository: initialDeps.tagsRepository ?? createTagsRepository({ db }),
|
||||||
webhookRepository: createWebhookRepository({ db }),
|
webhookRepository: initialDeps.webhookRepository ?? createWebhookRepository({ db }),
|
||||||
generateDocumentId,
|
documentActivityRepository: initialDeps.documentActivityRepository ?? createDocumentActivityRepository({ db }),
|
||||||
logger,
|
|
||||||
|
generateDocumentId: initialDeps.generateDocumentId,
|
||||||
|
logger: initialDeps.logger,
|
||||||
};
|
};
|
||||||
|
|
||||||
return async ({ file, userId, organizationId }: { file: File; userId?: string; organizationId: string }) => createDocument({
|
return async (args: { file: File; userId?: string; organizationId: string }) => createDocument({ ...args, ...deps });
|
||||||
file,
|
|
||||||
userId,
|
|
||||||
organizationId,
|
|
||||||
...deps,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleExistingDocument({
|
async function handleExistingDocument({
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import type { EmailDriverFactory } from '../emails.types';
|
||||||
|
|
||||||
|
export function defineEmailDriverFactory(factory: EmailDriverFactory) {
|
||||||
|
return factory;
|
||||||
|
}
|
||||||
12
apps/papra-server/src/modules/emails/drivers/email-driver.ts
Normal file
12
apps/papra-server/src/modules/emails/drivers/email-driver.ts
Normal 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;
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { defineEmailDriverFactory } from '../email-driver.models';
|
||||||
|
|
||||||
|
export const LOGGER_EMAIL_DRIVER_NAME = 'logger';
|
||||||
|
|
||||||
|
export const loggerEmailDriverFactory = defineEmailDriverFactory(({ config, logger }) => {
|
||||||
|
const { fromEmail } = config.emails;
|
||||||
|
const { level } = config.emails.drivers.logger;
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: LOGGER_EMAIL_DRIVER_NAME,
|
||||||
|
sendEmail: async ({ to, subject, html, from }) => {
|
||||||
|
logger[level]({
|
||||||
|
to,
|
||||||
|
subject,
|
||||||
|
from: from ?? fromEmail,
|
||||||
|
html,
|
||||||
|
}, 'Sending email');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import type { ConfigDefinition } from 'figue';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const resendEmailDriverConfig = {
|
||||||
|
resendApiKey: {
|
||||||
|
doc: 'The API key for the Resend email service',
|
||||||
|
schema: z.string(),
|
||||||
|
default: '',
|
||||||
|
env: 'RESEND_API_KEY',
|
||||||
|
},
|
||||||
|
} as const satisfies ConfigDefinition;
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { Resend } from 'resend';
|
||||||
|
import { createError } from '../../../shared/errors/errors';
|
||||||
|
import { defineEmailDriverFactory } from '../email-driver.models';
|
||||||
|
|
||||||
|
export const RESEND_EMAIL_DRIVER_NAME = 'resend';
|
||||||
|
|
||||||
|
export const resendEmailDriverFactory = defineEmailDriverFactory(({ config, logger }) => {
|
||||||
|
const { fromEmail } = config.emails;
|
||||||
|
const { resendApiKey } = config.emails.drivers.resend;
|
||||||
|
|
||||||
|
const resendClient = new Resend(resendApiKey);
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: RESEND_EMAIL_DRIVER_NAME,
|
||||||
|
sendEmail: async ({ to, subject, html, from }) => {
|
||||||
|
const { error } = await resendClient.emails.send({
|
||||||
|
from: from ?? fromEmail,
|
||||||
|
to,
|
||||||
|
subject,
|
||||||
|
html,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
logger.error({ error, to, subject, from }, 'Failed to send email with Resend');
|
||||||
|
|
||||||
|
throw createError({
|
||||||
|
code: 'email.send_failed',
|
||||||
|
message: 'Failed to send email',
|
||||||
|
statusCode: 500,
|
||||||
|
isInternal: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info({ to, subject, from }, 'Email sent');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import type { ConfigDefinition } from 'figue';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { booleanishSchema } from '../../../config/config.schemas';
|
||||||
|
import { parseJson } from '../../../intake-emails/intake-emails.schemas';
|
||||||
|
|
||||||
|
export const smtpEmailDriverConfig = {
|
||||||
|
host: {
|
||||||
|
doc: 'The host of the SMTP server',
|
||||||
|
schema: z.string().optional(),
|
||||||
|
default: '',
|
||||||
|
env: 'SMTP_HOST',
|
||||||
|
},
|
||||||
|
port: {
|
||||||
|
doc: 'The port of the SMTP server',
|
||||||
|
schema: z.coerce.number(),
|
||||||
|
default: 587,
|
||||||
|
env: 'SMTP_PORT',
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
doc: 'The user of the SMTP server',
|
||||||
|
schema: z.string().optional(),
|
||||||
|
default: undefined,
|
||||||
|
env: 'SMTP_USER',
|
||||||
|
},
|
||||||
|
password: {
|
||||||
|
doc: 'The password of the SMTP server',
|
||||||
|
schema: z.string().optional(),
|
||||||
|
default: undefined,
|
||||||
|
env: 'SMTP_PASSWORD',
|
||||||
|
},
|
||||||
|
secure: {
|
||||||
|
doc: 'Whether to use a secure connection to the SMTP server',
|
||||||
|
schema: booleanishSchema,
|
||||||
|
default: false,
|
||||||
|
env: 'SMTP_SECURE',
|
||||||
|
},
|
||||||
|
rawConfig: {
|
||||||
|
doc: 'The raw configuration for the nodemailer SMTP client in JSON format for advanced use cases. If set, this will override all other config options. See https://nodemailer.com/smtp/ for more details.',
|
||||||
|
schema: z.string().transform(parseJson).optional(),
|
||||||
|
default: undefined,
|
||||||
|
env: 'SMTP_JSON_CONFIG',
|
||||||
|
},
|
||||||
|
} as const satisfies ConfigDefinition;
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import nodemailer from 'nodemailer';
|
||||||
|
import { createError } from '../../../shared/errors/errors';
|
||||||
|
import { defineEmailDriverFactory } from '../email-driver.models';
|
||||||
|
|
||||||
|
export const SMTP_EMAIL_DRIVER_NAME = 'smtp';
|
||||||
|
|
||||||
|
export const smtpEmailDriverFactory = defineEmailDriverFactory(({ config, logger }) => {
|
||||||
|
const { fromEmail } = config.emails;
|
||||||
|
const { host, port, secure, user, password, rawConfig } = config.emails.drivers.smtp;
|
||||||
|
|
||||||
|
const transporter = nodemailer.createTransport(rawConfig ?? {
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
secure,
|
||||||
|
auth: {
|
||||||
|
user,
|
||||||
|
pass: password,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: SMTP_EMAIL_DRIVER_NAME,
|
||||||
|
sendEmail: async ({ to, subject, html, from }) => {
|
||||||
|
try {
|
||||||
|
const { messageId } = await transporter.sendMail({
|
||||||
|
from: from ?? fromEmail,
|
||||||
|
to,
|
||||||
|
subject,
|
||||||
|
html,
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info({ messageId }, 'Email sent');
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ error }, 'Failed to send email');
|
||||||
|
|
||||||
|
throw createError({
|
||||||
|
code: 'email.send_failed',
|
||||||
|
message: 'Failed to send email',
|
||||||
|
statusCode: 500,
|
||||||
|
isInternal: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -1,24 +1,27 @@
|
|||||||
import type { ConfigDefinition } from 'figue';
|
import type { ConfigDefinition } from 'figue';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { booleanishSchema } from '../config/config.schemas';
|
import { emailDriverFactoryNames } from './drivers/email-driver';
|
||||||
|
import { LOGGER_EMAIL_DRIVER_NAME } from './drivers/logger/logger.email-driver';
|
||||||
|
import { loggerEmailDriverConfig } from './drivers/logger/logger.email-driver.config';
|
||||||
|
import { resendEmailDriverConfig } from './drivers/resend/resend.email-driver.config';
|
||||||
|
import { smtpEmailDriverConfig } from './drivers/smtp/smtp.email-driver.config';
|
||||||
|
|
||||||
export const emailsConfig = {
|
export const emailsConfig = {
|
||||||
resendApiKey: {
|
|
||||||
doc: 'The API key for Resend (use to send emails)',
|
|
||||||
schema: z.string(),
|
|
||||||
default: 'set-me',
|
|
||||||
env: 'RESEND_API_KEY',
|
|
||||||
},
|
|
||||||
fromEmail: {
|
fromEmail: {
|
||||||
doc: 'The email address to send emails from',
|
doc: 'The email address to send emails from',
|
||||||
schema: z.string(),
|
schema: z.string(),
|
||||||
default: 'Papra <auth@mail.papra.app>',
|
default: 'Papra <auth@mail.papra.app>',
|
||||||
env: 'EMAILS_FROM_ADDRESS',
|
env: 'EMAILS_FROM_ADDRESS',
|
||||||
},
|
},
|
||||||
dryRun: {
|
driverName: {
|
||||||
doc: 'Whether to run the email service in dry run mode',
|
doc: `The driver to use when sending emails, value can be one of: ${emailDriverFactoryNames.map(x => `\`${x}\``).join(', ')}. Using \`logger\` will not send anything but log them instead`,
|
||||||
schema: booleanishSchema,
|
schema: z.enum(emailDriverFactoryNames as [string, ...string[]]),
|
||||||
default: false,
|
default: LOGGER_EMAIL_DRIVER_NAME,
|
||||||
env: 'EMAILS_DRY_RUN',
|
env: 'EMAILS_DRIVER',
|
||||||
|
},
|
||||||
|
drivers: {
|
||||||
|
resend: resendEmailDriverConfig,
|
||||||
|
logger: loggerEmailDriverConfig,
|
||||||
|
smtp: smtpEmailDriverConfig,
|
||||||
},
|
},
|
||||||
} as const satisfies ConfigDefinition;
|
} as const satisfies ConfigDefinition;
|
||||||
|
|||||||
@@ -1,47 +1,30 @@
|
|||||||
import type { Config } from '../config/config.types';
|
import type { Config } from '../config/config.types';
|
||||||
import { injectArguments } from '@corentinth/chisels';
|
import type { EmailDriverName } from './drivers/email-driver';
|
||||||
import { Resend } from 'resend';
|
|
||||||
import { createError } from '../shared/errors/errors';
|
import { createError } from '../shared/errors/errors';
|
||||||
import { createLogger } from '../shared/logger/logger';
|
import { createLogger } from '../shared/logger/logger';
|
||||||
|
import { emailDrivers } from './drivers/email-driver';
|
||||||
const logger = createLogger({ namespace: 'emails.services' });
|
|
||||||
|
|
||||||
export type EmailsServices = ReturnType<typeof createEmailsServices>;
|
export type EmailsServices = ReturnType<typeof createEmailsServices>;
|
||||||
|
|
||||||
export function createEmailsServices({ config }: { config: Config }) {
|
export function createEmailsServices({ config }: { config: Config }) {
|
||||||
return injectArguments(
|
const { driverName } = config.emails;
|
||||||
{
|
|
||||||
sendEmail,
|
|
||||||
},
|
|
||||||
{ config },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function sendEmail({ config, ...rest }: { from?: string; to: string | string[]; subject: string; config: Config; html: string }) {
|
const emailDriver = emailDrivers[driverName as EmailDriverName];
|
||||||
const { resendApiKey, dryRun, fromEmail } = config.emails;
|
|
||||||
|
|
||||||
if (dryRun) {
|
if (!emailDriver) {
|
||||||
logger.info({ ...rest }, 'Dry run enabled, skipping email sending');
|
|
||||||
return { emailId: 'dry-run' };
|
|
||||||
}
|
|
||||||
|
|
||||||
const resend = new Resend(resendApiKey);
|
|
||||||
|
|
||||||
const { error, data } = await resend.emails.send({ from: fromEmail, ...rest });
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
logger.error({ error, ...rest }, 'Failed to send email');
|
|
||||||
throw createError({
|
throw createError({
|
||||||
code: 'email.send_failed',
|
message: `Invalid email driver ${driverName}`,
|
||||||
message: 'Failed to send email',
|
code: 'emails.invalid_driver',
|
||||||
statusCode: 500,
|
statusCode: 500,
|
||||||
isInternal: true,
|
isInternal: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const { id: emailId } = data ?? {};
|
const logger = createLogger({ namespace: 'emails.services' });
|
||||||
|
|
||||||
logger.info({ emailId }, 'Email sent');
|
logger.info({ driverName }, 'Creating emails services');
|
||||||
|
|
||||||
return { emailId };
|
const emailServices = emailDriver({ config, logger });
|
||||||
|
|
||||||
|
return emailServices;
|
||||||
}
|
}
|
||||||
|
|||||||
14
apps/papra-server/src/modules/emails/emails.types.ts
Normal file
14
apps/papra-server/src/modules/emails/emails.types.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import type { Config } from '../config/config.types';
|
||||||
|
import type { Logger } from '../shared/logger/logger';
|
||||||
|
|
||||||
|
export type EmailServices = {
|
||||||
|
name: string;
|
||||||
|
sendEmail: (args: {
|
||||||
|
to: string;
|
||||||
|
subject: string;
|
||||||
|
html: string;
|
||||||
|
from?: string;
|
||||||
|
}) => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type EmailDriverFactory = (args: { config: Config; logger: Logger }) => EmailServices;
|
||||||
@@ -4,8 +4,7 @@ import { z } from 'zod';
|
|||||||
import { createUnauthorizedError } from '../app/auth/auth.errors';
|
import { createUnauthorizedError } from '../app/auth/auth.errors';
|
||||||
import { requireAuthentication } from '../app/auth/auth.middleware';
|
import { requireAuthentication } from '../app/auth/auth.middleware';
|
||||||
import { getUser } from '../app/auth/auth.models';
|
import { getUser } from '../app/auth/auth.models';
|
||||||
import { createDocumentsRepository } from '../documents/documents.repository';
|
import { createDocumentCreationUsecase } from '../documents/documents.usecases';
|
||||||
import { createDocumentStorageService } from '../documents/storage/documents.storage.services';
|
|
||||||
import { organizationIdSchema } from '../organizations/organization.schemas';
|
import { organizationIdSchema } from '../organizations/organization.schemas';
|
||||||
import { createOrganizationsRepository } from '../organizations/organizations.repository';
|
import { createOrganizationsRepository } from '../organizations/organizations.repository';
|
||||||
import { ensureUserIsInOrganization } from '../organizations/organizations.usecases';
|
import { ensureUserIsInOrganization } from '../organizations/organizations.usecases';
|
||||||
@@ -15,9 +14,6 @@ import { getHeader } from '../shared/headers/headers.models';
|
|||||||
import { createLogger } from '../shared/logger/logger';
|
import { createLogger } from '../shared/logger/logger';
|
||||||
import { validateFormData, validateJsonBody, validateParams } from '../shared/validation/validation';
|
import { validateFormData, validateJsonBody, validateParams } from '../shared/validation/validation';
|
||||||
import { createSubscriptionsRepository } from '../subscriptions/subscriptions.repository';
|
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 { INTAKE_EMAILS_INGEST_ROUTE } from './intake-emails.constants';
|
import { INTAKE_EMAILS_INGEST_ROUTE } from './intake-emails.constants';
|
||||||
import { createIntakeEmailsRepository } from './intake-emails.repository';
|
import { createIntakeEmailsRepository } from './intake-emails.repository';
|
||||||
import { intakeEmailIdSchema, intakeEmailsIngestionMetaSchema, parseJson } from './intake-emails.schemas';
|
import { intakeEmailIdSchema, intakeEmailsIngestionMetaSchema, parseJson } from './intake-emails.schemas';
|
||||||
@@ -191,27 +187,19 @@ function setupIngestIntakeEmailRoute({ app, db, config, trackingServices }: Rout
|
|||||||
}
|
}
|
||||||
|
|
||||||
const intakeEmailsRepository = createIntakeEmailsRepository({ db });
|
const intakeEmailsRepository = createIntakeEmailsRepository({ db });
|
||||||
const documentsRepository = createDocumentsRepository({ db });
|
|
||||||
const documentsStorageService = await createDocumentStorageService({ config });
|
const createDocument = await createDocumentCreationUsecase({
|
||||||
const plansRepository = createPlansRepository({ config });
|
db,
|
||||||
const subscriptionsRepository = createSubscriptionsRepository({ db });
|
config,
|
||||||
const taggingRulesRepository = createTaggingRulesRepository({ db });
|
trackingServices,
|
||||||
const tagsRepository = createTagsRepository({ db });
|
});
|
||||||
const webhookRepository = createWebhookRepository({ db });
|
|
||||||
|
|
||||||
await processIntakeEmailIngestion({
|
await processIntakeEmailIngestion({
|
||||||
fromAddress: email.from.address,
|
fromAddress: email.from.address,
|
||||||
recipientsAddresses: email.to.map(({ address }) => address),
|
recipientsAddresses: email.to.map(({ address }) => address),
|
||||||
attachments,
|
attachments,
|
||||||
intakeEmailsRepository,
|
intakeEmailsRepository,
|
||||||
documentsRepository,
|
createDocument,
|
||||||
documentsStorageService,
|
|
||||||
plansRepository,
|
|
||||||
subscriptionsRepository,
|
|
||||||
trackingServices,
|
|
||||||
taggingRulesRepository,
|
|
||||||
tagsRepository,
|
|
||||||
webhookRepository,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return context.body(null, 202);
|
return context.body(null, 202);
|
||||||
|
|||||||
@@ -1,21 +1,15 @@
|
|||||||
import type { Config } from '../config/config.types';
|
|
||||||
import type { PlansRepository } from '../plans/plans.repository';
|
import type { PlansRepository } from '../plans/plans.repository';
|
||||||
import { createInMemoryLoggerTransport } from '@crowlog/logger';
|
import { createInMemoryLoggerTransport } from '@crowlog/logger';
|
||||||
import { asc } from 'drizzle-orm';
|
import { asc } from 'drizzle-orm';
|
||||||
import { pick } from 'lodash-es';
|
import { pick } from 'lodash-es';
|
||||||
import { describe, expect, test } from 'vitest';
|
import { describe, expect, test } from 'vitest';
|
||||||
import { createInMemoryDatabase } from '../app/database/database.test-utils';
|
import { createInMemoryDatabase } from '../app/database/database.test-utils';
|
||||||
import { createDocumentsRepository } from '../documents/documents.repository';
|
import { overrideConfig } from '../config/config.test-utils';
|
||||||
import { documentsTable } from '../documents/documents.table';
|
import { documentsTable } from '../documents/documents.table';
|
||||||
import { createDocumentStorageService } from '../documents/storage/documents.storage.services';
|
import { createDocumentCreationUsecase } from '../documents/documents.usecases';
|
||||||
import { PLUS_PLAN_ID } from '../plans/plans.constants';
|
import { PLUS_PLAN_ID } from '../plans/plans.constants';
|
||||||
import { createPlansRepository } from '../plans/plans.repository';
|
|
||||||
import { createLogger } from '../shared/logger/logger';
|
import { createLogger } from '../shared/logger/logger';
|
||||||
import { createSubscriptionsRepository } from '../subscriptions/subscriptions.repository';
|
import { createSubscriptionsRepository } from '../subscriptions/subscriptions.repository';
|
||||||
import { createTaggingRulesRepository } from '../tagging-rules/tagging-rules.repository';
|
|
||||||
import { createTagsRepository } from '../tags/tags.repository';
|
|
||||||
import { createDummyTrackingServices } from '../tracking/tracking.services';
|
|
||||||
import { createWebhookRepository } from '../webhooks/webhook.repository';
|
|
||||||
import { createIntakeEmailLimitReachedError } from './intake-emails.errors';
|
import { createIntakeEmailLimitReachedError } from './intake-emails.errors';
|
||||||
import { createIntakeEmailsRepository } from './intake-emails.repository';
|
import { createIntakeEmailsRepository } from './intake-emails.repository';
|
||||||
import { intakeEmailsTable } from './intake-emails.tables';
|
import { intakeEmailsTable } from './intake-emails.tables';
|
||||||
@@ -31,14 +25,14 @@ describe('intake-emails usecases', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const intakeEmailsRepository = createIntakeEmailsRepository({ db });
|
const intakeEmailsRepository = createIntakeEmailsRepository({ db });
|
||||||
const documentsRepository = createDocumentsRepository({ db });
|
|
||||||
const documentsStorageService = await createDocumentStorageService({ config: { documentsStorage: { driver: 'in-memory' } } as Config });
|
const createDocument = await createDocumentCreationUsecase({
|
||||||
const plansRepository = createPlansRepository({ config: { organizationPlans: { isFreePlanUnlimited: true } } as Config });
|
db,
|
||||||
const subscriptionsRepository = createSubscriptionsRepository({ db });
|
config: overrideConfig({
|
||||||
const trackingServices = createDummyTrackingServices();
|
documentsStorage: { driver: 'in-memory' },
|
||||||
const taggingRulesRepository = createTaggingRulesRepository({ db });
|
organizationPlans: { isFreePlanUnlimited: true },
|
||||||
const tagsRepository = createTagsRepository({ db });
|
}),
|
||||||
const webhookRepository = createWebhookRepository({ db });
|
});
|
||||||
|
|
||||||
await ingestEmailForRecipient({
|
await ingestEmailForRecipient({
|
||||||
fromAddress: 'foo@example.fr',
|
fromAddress: 'foo@example.fr',
|
||||||
@@ -48,14 +42,7 @@ describe('intake-emails usecases', () => {
|
|||||||
new File(['content2'], 'file2.txt', { type: 'text/plain' }),
|
new File(['content2'], 'file2.txt', { type: 'text/plain' }),
|
||||||
],
|
],
|
||||||
intakeEmailsRepository,
|
intakeEmailsRepository,
|
||||||
documentsRepository,
|
createDocument,
|
||||||
documentsStorageService,
|
|
||||||
plansRepository,
|
|
||||||
subscriptionsRepository,
|
|
||||||
trackingServices,
|
|
||||||
taggingRulesRepository,
|
|
||||||
tagsRepository,
|
|
||||||
webhookRepository,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const documents = await db.select().from(documentsTable).orderBy(asc(documentsTable.name));
|
const documents = await db.select().from(documentsTable).orderBy(asc(documentsTable.name));
|
||||||
@@ -78,29 +65,22 @@ describe('intake-emails usecases', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const intakeEmailsRepository = createIntakeEmailsRepository({ db });
|
const intakeEmailsRepository = createIntakeEmailsRepository({ db });
|
||||||
const documentsRepository = createDocumentsRepository({ db });
|
|
||||||
const documentsStorageService = await createDocumentStorageService({ config: { documentsStorage: { driver: 'in-memory' } } as Config });
|
const createDocument = await createDocumentCreationUsecase({
|
||||||
const plansRepository = createPlansRepository({ config: { organizationPlans: { isFreePlanUnlimited: true } } as Config });
|
db,
|
||||||
const subscriptionsRepository = createSubscriptionsRepository({ db });
|
config: overrideConfig({
|
||||||
const trackingServices = createDummyTrackingServices();
|
documentsStorage: { driver: 'in-memory' },
|
||||||
const taggingRulesRepository = createTaggingRulesRepository({ db });
|
organizationPlans: { isFreePlanUnlimited: true },
|
||||||
const tagsRepository = createTagsRepository({ db });
|
}),
|
||||||
const webhookRepository = createWebhookRepository({ db });
|
});
|
||||||
|
|
||||||
await ingestEmailForRecipient({
|
await ingestEmailForRecipient({
|
||||||
fromAddress: 'foo@example.fr',
|
fromAddress: 'foo@example.fr',
|
||||||
recipientAddress: 'email-1@papra.email',
|
recipientAddress: 'email-1@papra.email',
|
||||||
attachments: [new File(['content'], 'file.txt', { type: 'text/plain' })],
|
attachments: [new File(['content'], 'file.txt', { type: 'text/plain' })],
|
||||||
intakeEmailsRepository,
|
intakeEmailsRepository,
|
||||||
documentsRepository,
|
createDocument,
|
||||||
documentsStorageService,
|
|
||||||
plansRepository,
|
|
||||||
subscriptionsRepository,
|
|
||||||
trackingServices,
|
|
||||||
logger,
|
logger,
|
||||||
taggingRulesRepository,
|
|
||||||
tagsRepository,
|
|
||||||
webhookRepository,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(loggerTransport.getLogs({ excludeTimestampMs: true })).to.eql([
|
expect(loggerTransport.getLogs({ excludeTimestampMs: true })).to.eql([
|
||||||
@@ -116,29 +96,22 @@ describe('intake-emails usecases', () => {
|
|||||||
const { db } = await createInMemoryDatabase();
|
const { db } = await createInMemoryDatabase();
|
||||||
|
|
||||||
const intakeEmailsRepository = createIntakeEmailsRepository({ db });
|
const intakeEmailsRepository = createIntakeEmailsRepository({ db });
|
||||||
const documentsRepository = createDocumentsRepository({ db });
|
|
||||||
const documentsStorageService = await createDocumentStorageService({ config: { documentsStorage: { driver: 'in-memory' } } as Config });
|
const createDocument = await createDocumentCreationUsecase({
|
||||||
const plansRepository = createPlansRepository({ config: { organizationPlans: { isFreePlanUnlimited: true } } as Config });
|
db,
|
||||||
const subscriptionsRepository = createSubscriptionsRepository({ db });
|
config: overrideConfig({
|
||||||
const trackingServices = createDummyTrackingServices();
|
documentsStorage: { driver: 'in-memory' },
|
||||||
const taggingRulesRepository = createTaggingRulesRepository({ db });
|
organizationPlans: { isFreePlanUnlimited: true },
|
||||||
const tagsRepository = createTagsRepository({ db });
|
}),
|
||||||
const webhookRepository = createWebhookRepository({ db });
|
});
|
||||||
|
|
||||||
await ingestEmailForRecipient({
|
await ingestEmailForRecipient({
|
||||||
fromAddress: 'foo@example.fr',
|
fromAddress: 'foo@example.fr',
|
||||||
recipientAddress: 'bar@example.fr',
|
recipientAddress: 'bar@example.fr',
|
||||||
attachments: [new File(['content'], 'file.txt', { type: 'text/plain' })],
|
attachments: [new File(['content'], 'file.txt', { type: 'text/plain' })],
|
||||||
intakeEmailsRepository,
|
intakeEmailsRepository,
|
||||||
documentsRepository,
|
createDocument,
|
||||||
documentsStorageService,
|
|
||||||
plansRepository,
|
|
||||||
subscriptionsRepository,
|
|
||||||
trackingServices,
|
|
||||||
taggingRulesRepository,
|
|
||||||
tagsRepository,
|
|
||||||
logger,
|
logger,
|
||||||
webhookRepository,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(loggerTransport.getLogs({ excludeTimestampMs: true })).to.eql([
|
expect(loggerTransport.getLogs({ excludeTimestampMs: true })).to.eql([
|
||||||
@@ -159,29 +132,22 @@ describe('intake-emails usecases', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const intakeEmailsRepository = createIntakeEmailsRepository({ db });
|
const intakeEmailsRepository = createIntakeEmailsRepository({ db });
|
||||||
const documentsRepository = createDocumentsRepository({ db });
|
|
||||||
const documentsStorageService = await createDocumentStorageService({ config: { documentsStorage: { driver: 'in-memory' } } as Config });
|
const createDocument = await createDocumentCreationUsecase({
|
||||||
const plansRepository = createPlansRepository({ config: { organizationPlans: { isFreePlanUnlimited: true } } as Config });
|
db,
|
||||||
const subscriptionsRepository = createSubscriptionsRepository({ db });
|
config: overrideConfig({
|
||||||
const trackingServices = createDummyTrackingServices();
|
documentsStorage: { driver: 'in-memory' },
|
||||||
const taggingRulesRepository = createTaggingRulesRepository({ db });
|
organizationPlans: { isFreePlanUnlimited: true },
|
||||||
const tagsRepository = createTagsRepository({ db });
|
}),
|
||||||
const webhookRepository = createWebhookRepository({ db });
|
});
|
||||||
|
|
||||||
await ingestEmailForRecipient({
|
await ingestEmailForRecipient({
|
||||||
fromAddress: 'a-non-allowed-adress@example.fr',
|
fromAddress: 'a-non-allowed-adress@example.fr',
|
||||||
recipientAddress: 'email-1@papra.email',
|
recipientAddress: 'email-1@papra.email',
|
||||||
attachments: [new File(['content'], 'file.txt', { type: 'text/plain' })],
|
attachments: [new File(['content'], 'file.txt', { type: 'text/plain' })],
|
||||||
intakeEmailsRepository,
|
intakeEmailsRepository,
|
||||||
documentsRepository,
|
createDocument,
|
||||||
documentsStorageService,
|
|
||||||
plansRepository,
|
|
||||||
subscriptionsRepository,
|
|
||||||
trackingServices,
|
|
||||||
taggingRulesRepository,
|
|
||||||
tagsRepository,
|
|
||||||
logger,
|
logger,
|
||||||
webhookRepository,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(loggerTransport.getLogs({ excludeTimestampMs: true })).to.eql([
|
expect(loggerTransport.getLogs({ excludeTimestampMs: true })).to.eql([
|
||||||
@@ -213,14 +179,14 @@ describe('intake-emails usecases', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const intakeEmailsRepository = createIntakeEmailsRepository({ db });
|
const intakeEmailsRepository = createIntakeEmailsRepository({ db });
|
||||||
const documentsRepository = createDocumentsRepository({ db });
|
|
||||||
const documentsStorageService = await createDocumentStorageService({ config: { documentsStorage: { driver: 'in-memory' } } as Config });
|
const createDocument = await createDocumentCreationUsecase({
|
||||||
const plansRepository = createPlansRepository({ config: { organizationPlans: { isFreePlanUnlimited: true } } as Config });
|
db,
|
||||||
const subscriptionsRepository = createSubscriptionsRepository({ db });
|
config: overrideConfig({
|
||||||
const trackingServices = createDummyTrackingServices();
|
documentsStorage: { driver: 'in-memory' },
|
||||||
const taggingRulesRepository = createTaggingRulesRepository({ db });
|
organizationPlans: { isFreePlanUnlimited: true },
|
||||||
const tagsRepository = createTagsRepository({ db });
|
}),
|
||||||
const webhookRepository = createWebhookRepository({ db });
|
});
|
||||||
|
|
||||||
await processIntakeEmailIngestion({
|
await processIntakeEmailIngestion({
|
||||||
fromAddress: 'foo@example.fr',
|
fromAddress: 'foo@example.fr',
|
||||||
@@ -229,14 +195,7 @@ describe('intake-emails usecases', () => {
|
|||||||
new File(['content1'], 'file1.txt', { type: 'text/plain' }),
|
new File(['content1'], 'file1.txt', { type: 'text/plain' }),
|
||||||
],
|
],
|
||||||
intakeEmailsRepository,
|
intakeEmailsRepository,
|
||||||
documentsRepository,
|
createDocument,
|
||||||
documentsStorageService,
|
|
||||||
plansRepository,
|
|
||||||
subscriptionsRepository,
|
|
||||||
trackingServices,
|
|
||||||
taggingRulesRepository,
|
|
||||||
tagsRepository,
|
|
||||||
webhookRepository,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const documents = await db.select().from(documentsTable).orderBy(asc(documentsTable.organizationId));
|
const documents = await db.select().from(documentsTable).orderBy(asc(documentsTable.organizationId));
|
||||||
|
|||||||
@@ -1,16 +1,10 @@
|
|||||||
import type { DocumentsRepository } from '../documents/documents.repository';
|
import type { CreateDocumentUsecase } from '../documents/documents.usecases';
|
||||||
import type { DocumentStorageService } from '../documents/storage/documents.storage.services';
|
|
||||||
import type { PlansRepository } from '../plans/plans.repository';
|
import type { PlansRepository } from '../plans/plans.repository';
|
||||||
import type { Logger } from '../shared/logger/logger';
|
import type { Logger } from '../shared/logger/logger';
|
||||||
import type { SubscriptionsRepository } from '../subscriptions/subscriptions.repository';
|
import type { SubscriptionsRepository } from '../subscriptions/subscriptions.repository';
|
||||||
import type { TaggingRulesRepository } from '../tagging-rules/tagging-rules.repository';
|
|
||||||
import type { TagsRepository } from '../tags/tags.repository';
|
|
||||||
import type { TrackingServices } from '../tracking/tracking.services';
|
|
||||||
import type { WebhookRepository } from '../webhooks/webhook.repository';
|
|
||||||
import type { IntakeEmailsServices } from './drivers/intake-emails.drivers.models';
|
import type { IntakeEmailsServices } from './drivers/intake-emails.drivers.models';
|
||||||
import type { IntakeEmailsRepository } from './intake-emails.repository';
|
import type { IntakeEmailsRepository } from './intake-emails.repository';
|
||||||
import { safely } from '@corentinth/chisels';
|
import { safely } from '@corentinth/chisels';
|
||||||
import { createDocument } from '../documents/documents.usecases';
|
|
||||||
import { getOrganizationPlan } from '../plans/plans.usecases';
|
import { getOrganizationPlan } from '../plans/plans.usecases';
|
||||||
import { addLogContext, createLogger } from '../shared/logger/logger';
|
import { addLogContext, createLogger } from '../shared/logger/logger';
|
||||||
import { createIntakeEmailLimitReachedError, createIntakeEmailNotFoundError } from './intake-emails.errors';
|
import { createIntakeEmailLimitReachedError, createIntakeEmailNotFoundError } from './intake-emails.errors';
|
||||||
@@ -48,27 +42,13 @@ export function processIntakeEmailIngestion({
|
|||||||
recipientsAddresses,
|
recipientsAddresses,
|
||||||
attachments,
|
attachments,
|
||||||
intakeEmailsRepository,
|
intakeEmailsRepository,
|
||||||
documentsRepository,
|
createDocument,
|
||||||
documentsStorageService,
|
|
||||||
plansRepository,
|
|
||||||
subscriptionsRepository,
|
|
||||||
trackingServices,
|
|
||||||
taggingRulesRepository,
|
|
||||||
tagsRepository,
|
|
||||||
webhookRepository,
|
|
||||||
}: {
|
}: {
|
||||||
fromAddress: string;
|
fromAddress: string;
|
||||||
recipientsAddresses: string[];
|
recipientsAddresses: string[];
|
||||||
attachments: File[];
|
attachments: File[];
|
||||||
intakeEmailsRepository: IntakeEmailsRepository;
|
intakeEmailsRepository: IntakeEmailsRepository;
|
||||||
documentsRepository: DocumentsRepository;
|
createDocument: CreateDocumentUsecase;
|
||||||
documentsStorageService: DocumentStorageService;
|
|
||||||
plansRepository: PlansRepository;
|
|
||||||
subscriptionsRepository: SubscriptionsRepository;
|
|
||||||
trackingServices: TrackingServices;
|
|
||||||
taggingRulesRepository: TaggingRulesRepository;
|
|
||||||
tagsRepository: TagsRepository;
|
|
||||||
webhookRepository: WebhookRepository;
|
|
||||||
}) {
|
}) {
|
||||||
return Promise.all(
|
return Promise.all(
|
||||||
recipientsAddresses.map(recipientAddress => safely(
|
recipientsAddresses.map(recipientAddress => safely(
|
||||||
@@ -77,14 +57,7 @@ export function processIntakeEmailIngestion({
|
|||||||
recipientAddress,
|
recipientAddress,
|
||||||
attachments,
|
attachments,
|
||||||
intakeEmailsRepository,
|
intakeEmailsRepository,
|
||||||
documentsRepository,
|
createDocument,
|
||||||
documentsStorageService,
|
|
||||||
plansRepository,
|
|
||||||
subscriptionsRepository,
|
|
||||||
trackingServices,
|
|
||||||
taggingRulesRepository,
|
|
||||||
tagsRepository,
|
|
||||||
webhookRepository,
|
|
||||||
}),
|
}),
|
||||||
)),
|
)),
|
||||||
);
|
);
|
||||||
@@ -95,29 +68,15 @@ export async function ingestEmailForRecipient({
|
|||||||
recipientAddress,
|
recipientAddress,
|
||||||
attachments,
|
attachments,
|
||||||
intakeEmailsRepository,
|
intakeEmailsRepository,
|
||||||
documentsRepository,
|
|
||||||
documentsStorageService,
|
|
||||||
logger = createLogger({ namespace: 'intake-emails.ingest' }),
|
logger = createLogger({ namespace: 'intake-emails.ingest' }),
|
||||||
plansRepository,
|
createDocument,
|
||||||
trackingServices,
|
|
||||||
subscriptionsRepository,
|
|
||||||
taggingRulesRepository,
|
|
||||||
tagsRepository,
|
|
||||||
webhookRepository,
|
|
||||||
}: {
|
}: {
|
||||||
fromAddress: string;
|
fromAddress: string;
|
||||||
recipientAddress: string;
|
recipientAddress: string;
|
||||||
attachments: File[];
|
attachments: File[];
|
||||||
intakeEmailsRepository: IntakeEmailsRepository;
|
intakeEmailsRepository: IntakeEmailsRepository;
|
||||||
documentsRepository: DocumentsRepository;
|
|
||||||
documentsStorageService: DocumentStorageService;
|
|
||||||
plansRepository: PlansRepository;
|
|
||||||
subscriptionsRepository: SubscriptionsRepository;
|
|
||||||
trackingServices: TrackingServices;
|
|
||||||
taggingRulesRepository: TaggingRulesRepository;
|
|
||||||
tagsRepository: TagsRepository;
|
|
||||||
webhookRepository: WebhookRepository;
|
|
||||||
logger?: Logger;
|
logger?: Logger;
|
||||||
|
createDocument: CreateDocumentUsecase;
|
||||||
}) {
|
}) {
|
||||||
const { intakeEmail } = await intakeEmailsRepository.getIntakeEmailByEmailAddress({ emailAddress: recipientAddress });
|
const { intakeEmail } = await intakeEmailsRepository.getIntakeEmailByEmailAddress({ emailAddress: recipientAddress });
|
||||||
|
|
||||||
@@ -150,14 +109,6 @@ export async function ingestEmailForRecipient({
|
|||||||
const [result, error] = await safely(createDocument({
|
const [result, error] = await safely(createDocument({
|
||||||
file,
|
file,
|
||||||
organizationId: intakeEmail.organizationId,
|
organizationId: intakeEmail.organizationId,
|
||||||
documentsStorageService,
|
|
||||||
documentsRepository,
|
|
||||||
plansRepository,
|
|
||||||
subscriptionsRepository,
|
|
||||||
trackingServices,
|
|
||||||
taggingRulesRepository,
|
|
||||||
tagsRepository,
|
|
||||||
webhookRepository,
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { requireAuthentication } from '../app/auth/auth.middleware';
|
|||||||
import { getUser } from '../app/auth/auth.models';
|
import { getUser } from '../app/auth/auth.models';
|
||||||
import { ORGANIZATION_INVITATION_STATUS, ORGANIZATION_ROLES } from '../organizations/organizations.constants';
|
import { ORGANIZATION_INVITATION_STATUS, ORGANIZATION_ROLES } from '../organizations/organizations.constants';
|
||||||
import { createOrganizationsRepository } from '../organizations/organizations.repository';
|
import { createOrganizationsRepository } from '../organizations/organizations.repository';
|
||||||
|
import { resendOrganizationInvitation } from '../organizations/organizations.usecases';
|
||||||
import { createError } from '../shared/errors/errors';
|
import { createError } from '../shared/errors/errors';
|
||||||
import { createLogger } from '../shared/logger/logger';
|
import { createLogger } from '../shared/logger/logger';
|
||||||
import { createUsersRepository } from '../users/users.repository';
|
import { createUsersRepository } from '../users/users.repository';
|
||||||
@@ -16,6 +17,7 @@ export function registerInvitationsRoutes(context: RouteDefinitionContext) {
|
|||||||
setupRejectInvitationRoute(context);
|
setupRejectInvitationRoute(context);
|
||||||
setupCancelInvitationRoute(context);
|
setupCancelInvitationRoute(context);
|
||||||
setupGetPendingInvitationsCountRoute(context);
|
setupGetPendingInvitationsCountRoute(context);
|
||||||
|
setupResendInvitationRoute(context);
|
||||||
}
|
}
|
||||||
|
|
||||||
function setupGetInvitationsRoute({ app, db }: RouteDefinitionContext) {
|
function setupGetInvitationsRoute({ app, db }: RouteDefinitionContext) {
|
||||||
@@ -172,3 +174,26 @@ function setupCancelInvitationRoute({ app, db }: RouteDefinitionContext) {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setupResendInvitationRoute({ app, db, config, emailsServices }: RouteDefinitionContext) {
|
||||||
|
app.post(
|
||||||
|
'/api/invitations/:invitationId/resend',
|
||||||
|
requireAuthentication(),
|
||||||
|
async (context) => {
|
||||||
|
const { invitationId } = context.req.param();
|
||||||
|
const { userId } = getUser({ context });
|
||||||
|
|
||||||
|
const organizationsRepository = createOrganizationsRepository({ db });
|
||||||
|
|
||||||
|
await resendOrganizationInvitation({
|
||||||
|
invitationId,
|
||||||
|
userId,
|
||||||
|
organizationsRepository,
|
||||||
|
emailsServices,
|
||||||
|
config,
|
||||||
|
});
|
||||||
|
|
||||||
|
return context.body(null, 204);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# Organization invitations specs
|
||||||
|
|
||||||
|
Privileged members of an organization (owner or admin) can invite other users to join the organization.
|
||||||
|
|
||||||
|
- Invitations are valid for 7 days (configurable)
|
||||||
|
- Invitations can be cancelled by a privileged member of the organization.
|
||||||
|
- Invitations can be rejected by the recipient.
|
||||||
|
- Invitations can be accepted by the recipient.
|
||||||
|
- Invitations that are expired, cancelled or rejected can be resend by a privileged member of the organization.
|
||||||
|
|
||||||
|
## Invitation statuses
|
||||||
|
|
||||||
|
- `pending`: The invitation has been sent but not yet accepted or rejected.
|
||||||
|
- `accepted`: The invitation has been accepted by the recipient.
|
||||||
|
- `rejected`: The invitation has been rejected by the recipient.
|
||||||
|
- `expired`: The invitation has expired.
|
||||||
|
- `cancelled`: The invitation has been cancelled by a privileged member of the organization.
|
||||||
|
|
||||||
|
State machine:
|
||||||
|
```mermaid
|
||||||
|
stateDiagram-v2
|
||||||
|
[*] --> pending
|
||||||
|
pending --> accepted
|
||||||
|
pending --> rejected
|
||||||
|
pending --> expired: 7 days
|
||||||
|
pending --> cancelled
|
||||||
|
expired --> pending: resend
|
||||||
|
cancelled --> pending: resend
|
||||||
|
rejected --> pending: resend
|
||||||
|
accepted --> [*]
|
||||||
|
```
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import type { OrganizationInvitation } from './organizations.types';
|
||||||
|
import { describe, expect, test } from 'vitest';
|
||||||
|
import { ORGANIZATION_INVITATION_STATUS } from './organizations.constants';
|
||||||
|
import { ensureInvitationStatus } from './organizations.repository.models';
|
||||||
|
|
||||||
|
describe('organizations repository models', () => {
|
||||||
|
describe('ensureInvitationStatus', () => {
|
||||||
|
test('when retrieving a pending invitation from the db, it may be just expired', () => {
|
||||||
|
expect(
|
||||||
|
ensureInvitationStatus({
|
||||||
|
invitation: {
|
||||||
|
id: '1',
|
||||||
|
status: ORGANIZATION_INVITATION_STATUS.PENDING,
|
||||||
|
expiresAt: new Date('2025-05-12'),
|
||||||
|
} as OrganizationInvitation,
|
||||||
|
now: new Date('2025-05-13'),
|
||||||
|
}),
|
||||||
|
).to.eql({
|
||||||
|
id: '1',
|
||||||
|
status: ORGANIZATION_INVITATION_STATUS.EXPIRED,
|
||||||
|
expiresAt: new Date('2025-05-12'),
|
||||||
|
});
|
||||||
|
|
||||||
|
// non expired invitation
|
||||||
|
expect(
|
||||||
|
ensureInvitationStatus({
|
||||||
|
invitation: {
|
||||||
|
id: '1',
|
||||||
|
status: ORGANIZATION_INVITATION_STATUS.PENDING,
|
||||||
|
expiresAt: new Date('2025-05-14'),
|
||||||
|
} as OrganizationInvitation,
|
||||||
|
now: new Date('2025-05-13'),
|
||||||
|
}),
|
||||||
|
).to.eql({
|
||||||
|
id: '1',
|
||||||
|
status: ORGANIZATION_INVITATION_STATUS.PENDING,
|
||||||
|
expiresAt: new Date('2025-05-14'),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Non pending invitation
|
||||||
|
expect(
|
||||||
|
ensureInvitationStatus({
|
||||||
|
invitation: {
|
||||||
|
id: '1',
|
||||||
|
status: ORGANIZATION_INVITATION_STATUS.EXPIRED,
|
||||||
|
expiresAt: new Date('2025-05-12'),
|
||||||
|
} as OrganizationInvitation,
|
||||||
|
now: new Date('2025-05-13'),
|
||||||
|
}),
|
||||||
|
).to.eql({
|
||||||
|
id: '1',
|
||||||
|
status: ORGANIZATION_INVITATION_STATUS.EXPIRED,
|
||||||
|
expiresAt: new Date('2025-05-12'),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
ensureInvitationStatus({
|
||||||
|
invitation: {
|
||||||
|
id: '1',
|
||||||
|
status: ORGANIZATION_INVITATION_STATUS.CANCELLED,
|
||||||
|
expiresAt: new Date('2025-05-12'),
|
||||||
|
} as OrganizationInvitation,
|
||||||
|
now: new Date('2025-05-13'),
|
||||||
|
}),
|
||||||
|
).to.eql({
|
||||||
|
id: '1',
|
||||||
|
status: ORGANIZATION_INVITATION_STATUS.CANCELLED,
|
||||||
|
expiresAt: new Date('2025-05-12'),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nullish invitation stay nullish', () => {
|
||||||
|
expect(
|
||||||
|
ensureInvitationStatus({
|
||||||
|
invitation: null,
|
||||||
|
}),
|
||||||
|
).to.eql(null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import type { OrganizationInvitation } from './organizations.types';
|
||||||
|
import { isAfter } from 'date-fns';
|
||||||
|
import { ORGANIZATION_INVITATION_STATUS } from './organizations.constants';
|
||||||
|
|
||||||
|
export function ensureInvitationStatus({ invitation, now = new Date() }: { invitation?: OrganizationInvitation | null | undefined; now?: Date }) {
|
||||||
|
if (!invitation) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (invitation.status !== ORGANIZATION_INVITATION_STATUS.PENDING) {
|
||||||
|
return invitation;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (invitation.expiresAt && isAfter(invitation.expiresAt, now)) {
|
||||||
|
return invitation;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...invitation, status: ORGANIZATION_INVITATION_STATUS.EXPIRED };
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user