Compare commits

..

67 Commits

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

* fix: Added changeset entry

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

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

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

---------

Co-authored-by: Corentin Thomasset <corentin.thomasset74@gmail.com>
2025-06-15 21:51:13 +02:00
Corentin Thomasset
585c53cd9d chore(changesets): added /llms.txt announcement changesets (#357) 2025-06-14 19:16:28 +02:00
Corentin Thomasset
f035458e16 feat(docs): added descriptions in docs-navigation.json (#354) 2025-06-14 00:37:47 +02:00
Corentin Thomasset
556fd8b167 feat(docs): added navigation json export (#341) 2025-06-10 21:30:56 +02:00
Corentin Thomasset
81e85295ba chore(release): update versions (#334)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-06-07 17:40:28 +02:00
Corentin Thomasset
1c574b8305 feat(script): ensure local database directory exists before running scripts (#337) 2025-06-07 17:26:28 +02:00
Corentin Thomasset
ff830c234a fix(client): corrected version release link (#333) 2025-06-07 15:09:08 +02:00
Corentin Thomasset
451564f354 docs(readme): updated features statuses (#328) 2025-06-07 14:58:21 +02:00
Corentin Thomasset
ecd6af45c8 docs(README): update project status and add sponsorship section (#327) 2025-06-06 22:04:49 +00:00
Corentin Thomasset
cb652c7166 chore(release): update versions (#323)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-06-04 21:32:34 +02:00
Corentin Thomasset
17ca8f8f81 fix(documents): update Content-Disposition header to support UTF-8 encoded filenames (#326) 2025-06-04 21:30:06 +02:00
Corentin Thomasset
f54b8e162a feat(docs): auto compute urls from port in dc generator (#322) 2025-06-04 13:47:52 +02:00
Corentin Thomasset
6b435bba79 chore(release): update versions (#305)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-06-04 00:09:45 +02:00
Corentin Thomasset
8ccdb74834 refactor(docker): added base url override in docker (#320) 2025-06-03 22:04:15 +00:00
Corentin Thomasset
60059c895c feat(invitations): add invitations management page (#319) 2025-06-03 22:13:21 +02:00
Corentin Thomasset
6e22a93dff feat(locales): add fr translations for document activity logging (#318) 2025-05-30 13:58:00 +02:00
Corentin Thomasset
79c1d3206b feat(documents): added document activity logging (#317) 2025-05-30 13:45:25 +02:00
Corentin Thomasset
48a953a584 refactor(client): migrated tanstack createQuery and createMutation to useQuery and useMutation (#316) 2025-05-28 21:51:30 +02:00
Corentin Thomasset
fdb90fa164 feat(tags): add error handling for existing tags (#315) 2025-05-27 21:09:46 +02:00
Corentin Thomasset
e9a205c0a3 feat(documents): added document renaming (#314) 2025-05-27 20:11:05 +02:00
Corentin Thomasset
278db63fc8 chore(deps): updated some dependencies version (#313) 2025-05-27 13:46:43 +02:00
Corentin Thomasset
e5ef40f36c chore(version): added missing changeset for password reset fix (#312) 2025-05-26 20:30:24 +00:00
Corentin Thomasset
27c9e39422 fix(auth): fix deprecated better-auth database id generation conf (#311) 2025-05-26 20:27:32 +00:00
Corentin Thomasset
91d2e236d0 fix(auth): corrected password reset navigation guard (#310) 2025-05-26 22:19:33 +02:00
Corentin Thomasset
d4f72e889a refactor(client): hide manage subscription section (#309) 2025-05-26 21:42:19 +02:00
Corentin Thomasset
759a3ff713 feat(i18n): extracted hard coded text for i18n (#308) 2025-05-26 01:14:43 +02:00
Corentin Thomasset
34862991fb chore(cf): added security headers in docs and papra-client (#307) 2025-05-25 12:05:38 +00:00
Corentin Thomasset
f0876fdc63 feat(server): added smtp client support for emailing (#306) 2025-05-25 11:47:12 +02:00
Corentin Thomasset
cb38d66485 refactor(emails): restructure emails service to support multiple drivers (#304) 2025-05-25 01:26:28 +02:00
Corentin Thomasset
c28af1407f chore(release): update versions (#303)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-05-24 19:58:09 +02:00
Corentin Thomasset
b62ddf2bc4 chore(docker): add EMAILS_DRY_RUN environment variable to Dockerfiles (#302) 2025-05-24 17:55:59 +00:00
Corentin Thomasset
fa7909c62d chore(release): add 'actions' permission to changeset workflow (#301) 2025-05-24 17:38:19 +00:00
Corentin Thomasset
1996b51b4d chore(release): update versions (#292)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-05-24 18:17:41 +02:00
Corentin Thomasset
734027f00c feat(docs): updated feature list statuses (#300) 2025-05-24 18:08:32 +02:00
Corentin Thomasset
557cde940c feat(organizations): added member role update functionality (#299) 2025-05-24 17:13:32 +02:00
Corentin Thomasset
26a83052bd fix(intake-emails): enhance disabled intake-email state (#298) 2025-05-24 12:27:09 +00:00
Corentin Thomasset
5aac3f7ba6 fix(demo): added missing routes in demo (#297) 2025-05-24 12:16:05 +00:00
Corentin Thomasset
0ddc2340f0 fix(locales): update registration page description (#296) 2025-05-24 11:24:44 +00:00
Corentin Thomasset
438a31171c feat(auth): added support for custom oauth2 providers (#295) 2025-05-24 03:12:39 +02:00
Corentin Thomasset
53bf93f128 feat(doc): added a papra docker compose generator (#293) 2025-05-23 21:24:08 +00:00
Corentin Thomasset
b400b3f18d feat(database): ensure local database directory en boot (#294) 2025-05-23 22:21:33 +02:00
Corentin Thomasset
0627ec25a4 feat(organizations): add permission check for invitation (#291) 2025-05-21 23:06:43 +02:00
Corentin Thomasset
72e5a9a4de feat(invitations): added organizations invitations and multi-user (#289) 2025-05-21 21:53:56 +02:00
Corentin Thomasset
268ac8e358 chore(release): update Docker release workflow to use version input parameter (#286) 2025-05-14 13:11:19 +02:00
Corentin Thomasset
249b3bcfd2 chore(release): update versions (#285)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-05-13 22:44:37 +02:00
Corentin Thomasset
d7838b5d57 chore(release): remove commitMode from release job configuration (#284) 2025-05-13 20:14:08 +00:00
Corentin Thomasset
f170ddd817 chore(release): use PAT for release PR creation (#283) 2025-05-13 20:05:57 +00:00
Corentin Thomasset
4f53c70854 chore(release): update permissions for release job (#281) 2025-05-13 16:44:47 +00:00
Corentin Thomasset
85fa5c4342 chore(version): added changeset for versioning (#280) 2025-05-13 13:48:55 +02:00
Corentin Thomasset
c5d984a3a0 refactor(docker): build transitive dependencies (#277) 2025-05-08 20:47:57 +02:00
Corentin Thomasset
565bd8d7fd feat(webhooks): added webhook management and logic (#276) 2025-05-08 18:52:11 +02:00
Corentin Thomasset
9b72aa886c feat(cli): added cli documentation (#275) 2025-05-02 23:30:33 +02:00
Corentin Thomasset
7410455093 feat(cli): setup base cli (#274) 2025-05-02 00:20:57 +02:00
riskpoint-per
dd8f194fd0 feat(server): add azure blob storage support (#261)
* add azure blob storage support

* set stream to nodejs.readable

* Update apps/papra-server/src/modules/documents/storage/drivers/az-blob/az-blob.storage-driver.ts

Co-authored-by: Corentin Thomasset <corentin.thomasset74@gmail.com>

* fix lock file

* bugfixes

* fix lint issues

---------

Co-authored-by: Corentin Thomasset <corentin.thomasset74@gmail.com>
2025-04-28 09:34:28 +02:00
Corentin Thomasset
803c39cbc8 chore(packages): added api sdk package (#270) 2025-04-27 22:08:59 +02:00
Joshua Anderson
096331a4ee feat(server): add support for b2 object storage type (#232)
* feat(b2): add support for b2 object storage type

* feat(b2): fix order of tsconfig entries

* feat(b2): fix accidental responseType change

* fix(b2): remove unnecessary try-catches

* refactor(b2): use error factories
2025-04-27 21:35:29 +02:00
Corentin Thomasset
59ba9465f6 docs(features): marked tagging rules and folder ingestion as available features (#268) 2025-04-27 13:57:11 +00:00
Corentin Thomasset
a1056702af feat(docs): fixed broken links with auto check (#267) 2025-04-27 13:20:34 +00:00
Corentin Thomasset
fd44897bca fix(documents): hard delete file in storage driver (#266) 2025-04-27 14:52:05 +02:00
Corentin Thomasset
332d836d11 fix(ingestion-folders): added schema validation coercion in config (#265) 2025-04-27 12:11:05 +00:00
Corentin Thomasset
f613198cbd refactor(storage): replace generic error messages with specific file not found errors (#264) 2025-04-27 13:52:47 +02:00
Corentin Thomasset
80491a5a58 chore(deps): update eslint and and eslint config (#260) 2025-04-27 13:43:17 +02:00
Corentin Thomasset
605e21a410 chore(deps): updated pnpm to 10.9.0 in all package.json files (#258) 2025-04-25 13:15:32 +00:00
Corentin Thomasset
dec589b6ed fix(documents): remove incorrect default tab value (#259) 2025-04-25 13:08:39 +00:00
311 changed files with 22972 additions and 3544 deletions

8
.changeset/README.md Normal file
View File

@@ -0,0 +1,8 @@
# Changesets
Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works
with multi-package repos, or single-package repos to help you version and publish your code. You can
find the full documentation for it [in our repository](https://github.com/changesets/changesets)
We have a quick list of common questions to get you started engaging with this project in
[our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md)

19
.changeset/config.json Normal file
View File

@@ -0,0 +1,19 @@
{
"$schema": "https://unpkg.com/@changesets/config@3.1.1/schema.json",
"changelog": [
"@changesets/changelog-github",
{ "repo": "papra-hq/papra"}
],
"commit": false,
"fixed": [
["@papra/app-client", "@papra/app-server"]
],
"access": "public",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": [],
"privatePackages": {
"tag": true,
"version": true
}
}

View File

@@ -26,7 +26,9 @@ jobs:
cache: 'pnpm'
- name: Install dependencies
run: pnpm i --frozen-lockfile
run: |
pnpm i --frozen-lockfile
pnpm --filter "@papra/app-server^..." build
- name: Run linters
run: pnpm lint

View File

@@ -0,0 +1,41 @@
name: CI - Api SDK
on:
pull_request:
push:
branches:
- main
jobs:
ci-packages-api-sdk:
name: CI - Api SDK
runs-on: ubuntu-latest
defaults:
run:
working-directory: packages/api-sdk
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- name: Install pnpm
uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: 'pnpm'
- name: Install dependencies
run: pnpm i
- name: Run linters
run: pnpm lint
- name: Type check
run: pnpm typecheck
# - name: Run unit test
# run: pnpm test
- name: Build the app
run: pnpm build

44
.github/workflows/ci-packages-cli.yaml vendored Normal file
View File

@@ -0,0 +1,44 @@
name: CI - CLI
on:
pull_request:
push:
branches:
- main
jobs:
ci-packages-cli:
name: CI - CLI
runs-on: ubuntu-latest
defaults:
run:
working-directory: packages/cli
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- name: Install pnpm
uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: 'pnpm'
- name: Install dependencies
run: pnpm i
- name: Build related packages
run: cd ../api-sdk && pnpm build
- name: Run linters
run: pnpm lint
- name: Type check
run: pnpm typecheck
# - name: Run unit test
# run: pnpm test
- name: Build the app
run: pnpm build

View File

@@ -0,0 +1,41 @@
name: CI - Webhooks
on:
pull_request:
push:
branches:
- main
jobs:
ci-packages-webhooks:
name: CI - Webhooks
runs-on: ubuntu-latest
defaults:
run:
working-directory: packages/webhooks
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- name: Install pnpm
uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: 'pnpm'
- name: Install dependencies
run: pnpm i
- name: Run linters
run: pnpm lint
- name: Type check
run: pnpm typecheck
- name: Run unit test
run: pnpm test
- name: Build the app
run: pnpm build

View File

@@ -1,9 +1,12 @@
name: Release new versions
on:
push:
tags:
- 'v*.*.*'
workflow_dispatch:
inputs:
version:
description: 'Version to release'
required: true
type: string
permissions:
contents: read
@@ -14,14 +17,6 @@ jobs:
name: Release Docker images
runs-on: ubuntu-latest
steps:
- name: Get release version from tag
if: ${{ github.event_name == 'push' }}
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_ENV
- name: Get release version from input
if: ${{ github.event_name == 'workflow_dispatch' }}
run: echo "RELEASE_VERSION=${{ github.event.inputs.release_version }}" >> $GITHUB_ENV
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
@@ -53,9 +48,9 @@ jobs:
push: true
tags: |
corentinth/papra:latest-root
corentinth/papra:${{ env.RELEASE_VERSION }}-root
corentinth/papra:${{ inputs.version }}-root
ghcr.io/papra-hq/papra:latest-root
ghcr.io/papra-hq/papra:${{ env.RELEASE_VERSION }}-root
ghcr.io/papra-hq/papra:${{ inputs.version }}-root
- name: Build and push rootless Docker image
uses: docker/build-push-action@v6
@@ -67,7 +62,7 @@ jobs:
tags: |
corentinth/papra:latest
corentinth/papra:latest-rootless
corentinth/papra:${{ env.RELEASE_VERSION }}-rootless
corentinth/papra:${{ inputs.version }}-rootless
ghcr.io/papra-hq/papra:latest
ghcr.io/papra-hq/papra:latest-rootless
ghcr.io/papra-hq/papra:${{ env.RELEASE_VERSION }}-rootless
ghcr.io/papra-hq/papra:${{ inputs.version }}-rootless

53
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,53 @@
name: Release
on:
push:
branches:
- main
concurrency: ${{ github.workflow }}-${{ github.ref }}
jobs:
release:
name: Release
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
id-token: write
actions: write
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- name: Install pnpm
uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: 'pnpm'
- name: Install dependencies
run: pnpm i
- name: Create Release Pull Request
id: changesets
uses: changesets/action@v1
with:
# Note: pnpm install after versioning is necessary to refresh lockfile
version: pnpm run version
publish: pnpm exec changeset publish
commit: "chore(release): update versions"
title: "chore(release): update versions"
env:
GITHUB_TOKEN: ${{ secrets.CHANGESET_GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Trigger Docker build
if: steps.changesets.outputs.published == 'true' && contains(fromJson(steps.changesets.outputs.publishedPackages).*.name, '@papra/app-server')
run: |
VERSION=$(echo '${{ steps.changesets.outputs.publishedPackages }}' | jq -r '.[] | select(.name=="@papra/app-server") | .version')
echo "VERSION: $VERSION"
gh workflow run release-docker.yaml -f version="$VERSION"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

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

View File

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

43
apps/docs/CHANGELOG.md Normal file
View File

@@ -0,0 +1,43 @@
# @papra/docs
## 0.5.1
### Patch Changes
- [#357](https://github.com/papra-hq/papra/pull/357) [`585c53c`](https://github.com/papra-hq/papra/commit/585c53cd9d0d7dbd517dbb1adddfd9e7b70f9fe5) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added a /llms.txt on main website
## 0.5.0
### Minor Changes
- [#337](https://github.com/papra-hq/papra/pull/337) [`1c574b8`](https://github.com/papra-hq/papra/commit/1c574b8305eb7bde4f1b75ac38a610ca0120a613) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added troubleshooting page
### Patch Changes
- [#337](https://github.com/papra-hq/papra/pull/337) [`1c574b8`](https://github.com/papra-hq/papra/commit/1c574b8305eb7bde4f1b75ac38a610ca0120a613) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added `docker compose up` command in dc generator
## 0.4.2
### Patch Changes
- [#322](https://github.com/papra-hq/papra/pull/322) [`f54b8e1`](https://github.com/papra-hq/papra/commit/f54b8e162acd6cfe92241aaa649847fc03ca5852) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Auto computes urls from the provided port
## 0.4.1
### Patch Changes
- [#320](https://github.com/papra-hq/papra/pull/320) [`8ccdb74`](https://github.com/papra-hq/papra/commit/8ccdb748349a3cacf38f032fd4d3beebce202487) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added base url configuration in docker compose generator
## 0.4.0
### Minor Changes
- [#295](https://github.com/papra-hq/papra/pull/295) [`438a311`](https://github.com/papra-hq/papra/commit/438a31171c606138c4b7fa299fdd58dcbeaaf298) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added support for custom oauth2 providers
- [#293](https://github.com/papra-hq/papra/pull/293) [`53bf93f`](https://github.com/papra-hq/papra/commit/53bf93f128b54ad1d3553e18680c87ab23155f8d) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added a papra docker-compose.yml generator
## 0.3.1
### Patch Changes
- [#280](https://github.com/papra-hq/papra/pull/280) [`85fa5c4`](https://github.com/papra-hq/papra/commit/85fa5c43424d139f5c2752a3ad644082e61d3d67) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Fix broken lint and added auto link check

View File

@@ -1,8 +1,11 @@
import { env } from 'node:process';
import starlight from '@astrojs/starlight';
import { defineConfig } from 'astro/config';
import starlightLinksValidator from 'starlight-links-validator';
import starlightThemeRapide from 'starlight-theme-rapide';
import UnoCSS from 'unocss/astro';
import { sidebar } from './src/content/navigation';
import posthogRawScript from './src/scripts/posthog.script.js?raw';
const posthogApiKey = env.POSTHOG_API_KEY;
@@ -15,19 +18,20 @@ const posthogScript = posthogRawScript.replace('[POSTHOG-API-KEY]', posthogApiKe
export default defineConfig({
site: 'https://docs.papra.app',
integrations: [
UnoCSS(),
starlight({
plugins: [starlightThemeRapide()],
plugins: [starlightThemeRapide(), starlightLinksValidator({ exclude: ['http://localhost:1221'] })],
title: 'Papra Docs',
logo: {
dark: './src/assets/logo-dark.svg',
light: './src/assets/logo-light.svg',
alt: 'Papra Logo',
},
social: {
github: 'https://github.com/papra-hq/papra',
blueSky: 'https://bsky.app/profile/papra.app',
discord: 'https://papra.app/discord',
},
social: [
{ href: 'https://github.com/papra-hq/papra', icon: 'github', label: 'GitHub' },
{ href: 'https://bsky.app/profile/papra.app', icon: 'blueSky', label: 'BlueSky' },
{ href: 'https://papra.app/discord', icon: 'discord', label: 'Discord' },
],
expressiveCode: {
themes: ['vitesse-black', 'vitesse-light'],
},
@@ -37,7 +41,7 @@ export default defineConfig({
sidebar,
favicon: '/favicon.svg',
head: [
// Add ICO favicon fallback for Safari.
// Add ICO favicon fallback for Safari.
{
tag: 'link',
attrs: {

1382
apps/docs/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,9 @@
{
"name": "@papra/docs",
"type": "module",
"version": "0.3.0",
"packageManager": "pnpm@9.15.4",
"version": "0.5.1",
"private": true,
"packageManager": "pnpm@10.9.0",
"description": "Papra documentation website",
"author": "Corentin Thomasset <corentinth@proton.me> (https://corentin.tech)",
"license": "AGPL-3.0-or-later",
@@ -17,10 +18,16 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@astrojs/starlight": "^0.31.0",
"astro": "^5.1.5",
"@astrojs/solid-js": "^5.1.0",
"@astrojs/starlight": "^0.34.3",
"astro": "^5.8.0",
"sharp": "^0.32.5",
"starlight-theme-rapide": "^0.3.0",
"shiki": "^3.4.2",
"starlight-links-validator": "^0.16.0",
"starlight-theme-rapide": "^0.5.0",
"tailwind-merge": "^2.6.0",
"unocss-preset-animations": "^1.2.1",
"yaml": "^2.8.0",
"zod-to-json-schema": "^3.24.5"
},
"devDependencies": {
@@ -33,6 +40,7 @@
"figue": "^2.2.2",
"lodash-es": "^4.17.21",
"marked": "^15.0.6",
"typescript": "^5.7.3"
"typescript": "^5.7.3",
"unocss": "0.65.0-beta.2"
}
}

View File

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

View File

@@ -1,4 +1,38 @@
:root[data-theme='dark'] {
--background: 240 4% 10%;
--foreground: 0 0% 98%;
--card: 240 4% 8%;
--card-foreground: 0 0% 98%;
--popover: 240 4% 8%;
--popover-foreground: 0 0% 98%;
--primary: 77 100% 74%;
--primary-foreground: 0 0% 9%;
--secondary: 0 0% 14.9%;
--secondary-foreground: 0 0% 98%;
--muted: 0 0% 14.9%;
--muted-foreground: 0 0% 63.9%;
--accent: 0 0% 14.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--warning: 31 98% 50%;
--warning-foreground: 0 0% 98%;
--border: 345 4% 17%;
--input: 0 0% 89.8%;
--ring: 0 0% 3.9%;
--radius: 0.5rem;
--background-color: #0c0d0f!important;
--accent-color: #fff!important;
--foreground-color: #9ea3a2!important;
@@ -55,4 +89,8 @@
.site-title img {
width: 1.8rem !important;
}
}
pre.shiki {
border-radius: 0.5rem!important;
}

View File

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

View File

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

View File

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

View File

@@ -12,7 +12,7 @@ This guide will show you how to setup a Cloudflare Email worker to receive email
<Aside type="note">
Setting up a Papra Intake Emails with a Cloudflare Email worker requires bit of knowledge about how to setup a CF Email worker and how to configure your Papra instance to receive emails.
For a simpler solution, you can use the official [OwlRelay integration](/docs/guides/intake-emails-with-papra-email-intake) guide.
For a simpler solution, you can use the official [OwlRelay integration](/guides/intake-emails-with-owlrelay) guide.
</Aside>
## Prerequisites
@@ -23,7 +23,7 @@ In order to follow this guide, you need:
- a publicly accessible Papra instance
- basic development skills (git and node.js to setup the Email Worker)
If you prefer a simpler solution, you can use the official [OwlRelay integration](/docs/guides/intake-emails-with-papra-email-intake) guide.
If you prefer a simpler solution, you can use the official [OwlRelay integration](/guides/intake-emails-with-owlrelay) guide.
## How it works

View File

@@ -91,7 +91,7 @@ environment:
## Configuration
You can find the list of all configuration options in the [configuration reference](/docs/configuration-reference), the related variables are prefixed with `INGESTION_FOLDER_`.
You can find the list of all configuration options in the [configuration reference](/self-hosting/configuration), the related variables are prefixed with `INGESTION_FOLDER_`.
## Edge cases and behaviors

View File

@@ -0,0 +1,181 @@
---
title: Setup Custom OAuth2 Providers
description: Step-by-step guide to setup custom OAuth2 providers for authentication in your Papra instance.
slug: guides/setup-custom-oauth2-providers
---
import { Aside } from '@astrojs/starlight/components';
import { Steps } from '@astrojs/starlight/components';
This guide will show you how to configure custom OAuth2 providers for authentication in your Papra instance.
<Aside type="note">
Papra's OAuth2 implementation is based on the [Better Auth Generic OAuth plugin](https://www.better-auth.com/docs/plugins/generic-oauth). For more detailed information about the configuration options and advanced usage, please refer to their documentation.
</Aside>
## Prerequisites
In order to follow this guide, you need:
- A custom OAuth2 provider
- An accessible Papra instance
- Basic understanding of OAuth2 flows
## Configuration
To set up custom OAuth2 providers, you'll need to configure the `AUTH_PROVIDERS_CUSTOMS` environment variable with an array of provider configurations. Here's an example:
```bash
AUTH_PROVIDERS_CUSTOMS='[
{
"providerId": "custom-oauth2",
"providerName": "Custom OAuth2",
"providerIconUrl": "https://api.iconify.design/tabler:login-2.svg",
"clientId": "your-client-id",
"clientSecret": "your-client-secret",
"type": "oidc",
"discoveryUrl": "https://your-provider.tld/.well-known/openid-configuration",
"scopes": ["openid", "profile", "email"]
}
]'
```
Each provider configuration supports the following fields:
- `providerId`: A unique identifier for the OAuth provider
- `providerName`: The display name of the provider
- `providerIconUrl`: URL of the icon to display (optional) you can use a base64 encoded image or an url to a remote image.
- `clientId`: OAuth client ID
- `clientSecret`: OAuth client secret
- `type`: Type of OAuth flow ("oauth2" or "oidc")
- `discoveryUrl`: URL to fetch OAuth 2.0 configuration (recommended for OIDC providers)
- `authorizationUrl`: URL for the authorization endpoint (required for OAuth2 if not using discoveryUrl)
- `tokenUrl`: URL for the token endpoint (required for OAuth2 if not using discoveryUrl)
- `userInfoUrl`: URL for the user info endpoint (required for OAuth2 if not using discoveryUrl)
- `scopes`: Array of OAuth scopes to request
- `redirectURI`: Custom redirect URI (optional)
- `responseType`: OAuth response type (defaults to "code")
- `prompt`: Controls the authentication experience ("select_account", "consent", "login", "none")
- `pkce`: Whether to use PKCE (Proof Key for Code Exchange)
- `accessType`: Access type for the authorization request
## Setup
<Steps>
1. **Configure your OAuth2 Provider**
First, you'll need to register your application with your OAuth2 provider. This typically involves:
- Creating a new application in your provider's dashboard
- Setting up the redirect URI (usually `https://<your-papra-instance>/api/auth/oauth2/callback/:providerId`)
- Obtaining the client ID and client secret
- Configuring the required scopes
2. **Configure Papra**
Add the `AUTH_PROVIDERS_CUSTOMS` environment variable to your Papra instance. Here are some examples:
For OIDC providers:
```json
[
{
"providerId": "custom-oauth2",
"providerName": "Custom OAuth2",
"providerIconUrl": "https://api.iconify.design/tabler:login-2.svg",
"clientId": "your-client-id",
"clientSecret": "your-client-secret",
"type": "oidc",
"discoveryUrl": "https://your-provider.tld/.well-known/openid-configuration",
"scopes": ["openid", "profile", "email"]
}
]
```
For standard OAuth2 providers:
```json
[
{
"providerId": "custom-oauth2",
"providerName": "Custom OAuth2",
"providerIconUrl": "https://api.iconify.design/tabler:login-2.svg",
"clientId": "your-client-id",
"clientSecret": "your-client-secret",
"type": "oauth2",
"authorizationUrl": "https://your-provider.tld/oauth2/authorize",
"tokenUrl": "https://your-provider.tld/oauth2/token",
"userInfoUrl": "https://your-provider.tld/oauth2/userinfo",
"scopes": ["profile", "email"]
}
]
```
<Aside type="note">
The `discoveryUrl` is recommended for OIDC providers as it automatically configures all the necessary endpoints.
For standard OAuth2 providers, you'll need to specify the endpoints manually.
</Aside>
3. **Test the Configuration**
- Restart your Papra instance to apply the changes
- Go to the login page
- You should see your custom OAuth2 providers as login options
- Try logging in with a test account
</Steps>
## Troubleshooting
### Providers Not Showing Up
If your OAuth2 providers are not showing up on the login page:
- Check that the JSON configuration in `AUTH_PROVIDERS_CUSTOMS` is valid
- Ensure all required fields are provided
- Verify that the provider IDs are unique
### Authentication Fails
If authentication fails:
- Verify that the redirect URI is correctly configured in your OAuth2 provider
- Check that the client ID and client secret are correct
- Ensure the required scopes are properly configured
- Check the Papra logs for any error messages
### OIDC Discovery Issues
If you're using OIDC and experiencing issues:
- Verify that the `discoveryUrl` is accessible
- Check that the provider supports OIDC discovery
- Ensure the provider's configuration is properly exposed through the discovery endpoint
## Security Considerations
<Aside type="caution">
Always use HTTPS for your OAuth2 endpoints and ensure your client secret is kept secure.
Consider using PKCE (Proof Key for Code Exchange) for additional security by setting `pkce: true` in your configuration.
</Aside>
## Multiple Providers
You can configure multiple custom OAuth2 providers by adding them to the array:
```json
[
{
"providerId": "custom-oauth2-1",
"providerName": "Custom OAuth2 Provider 1",
"type": "oidc",
"discoveryUrl": "https://provider1.tld/.well-known/openid-configuration",
"clientId": "client-id-1",
"clientSecret": "client-secret-1",
"scopes": ["openid", "profile", "email"]
},
{
"providerId": "custom-oauth2-2",
"providerName": "Custom OAuth2 Provider 2",
"type": "oidc",
"discoveryUrl": "https://provider2.tld/.well-known/openid-configuration",
"clientId": "client-id-2",
"clientSecret": "client-secret-2",
"scopes": ["openid", "profile", "email"]
}
]
```

View File

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

View File

@@ -0,0 +1,89 @@
---
title: CLI Documentation
description: Learn how to use the Papra CLI to interact with your Papra instance from the command line.
slug: resources/cli
---
The Papra CLI is a command-line interface tool that helps you interact with the Papra platform from your terminal.
## Installation
For the moment, the CLI is only available as an NPM package.
```bash
# using pnpm
pnpm i -g @papra/cli
# or using npm
npm i -g @papra/cli
# or using yarn
yarn add -g @papra/cli
```
The CLI will be installed globally, so you can use it from anywhere in your system with the `papra` command.
## Configuration
Before using the CLI, you need to configure it with your API credentials.
### Initial Setup
To initialize the configuration, run:
```bash
papra config init
```
This command will prompt you for:
- **Instance URL**: Your Papra instance URL (e.g., `https://api.papra.app`)
- **API Key**: Your personal API key (can be created in your User Settings)
### Managing Configuration
You can manage your configuration using the following commands:
- `papra config list`: View your current configuration
- `papra config set api-key`: Set or update your API key
- `papra config set api-url`: Set or update your instance URL
- `papra config set default-org-id`: Set a default organization ID
### Organization IDs
Since Papra supports multiple organizations, you may need to specify the organization ID when importing documents for example. If want, you can set a default organization ID in your configuration.
```bash
papra config set default-org-id <organization-id>
papra documents import <file-path>
# or
papra documents import -o <organization-id> <file-path>
```
## Available Commands
### Importing documents
The `import` command allows you to import a document into your Papra organization.
```bash
papra documents import -o <organization-id> <file-path>
```
## Getting Help
For more information about any command, you can use the `--help` flag:
```bash
papra --help
papra config --help
papra documents --help
```
## About the CLI
The CLI is built using the [citty](https://github.com/unjs/citty) framework and the [Papra TS SDK](https://github.com/papra-hq/papra/tree/main/packages/api-sdk).

View File

@@ -1,6 +1,6 @@
---
title: Papra documentation
description: Papra documentation.
description: Documentation for Papra, the minimalistic document archiving platform.
hero:
title: Papra Docs
tagline: Documentation for Papra, the minimalistic document archiving platform.
@@ -51,11 +51,11 @@ In today's digital world, managing countless important documents efficiently and
- **Tags**: Organize your documents with tags.
- **Email ingestion**: Send/forward emails to a generated address to automatically import documents.
- **Content extraction**: Automatically extract text from images or scanned documents for search.
- *In progress:* **i18n**: Support for multiple languages.
- *Coming soon:* **Tagging Rules**: Automatically tag documents based on custom rules.
- *Coming soon:* **Folder ingestion**: Automatically import documents from a folder.
- *Coming soon:* **SDK and API**: Build your own applications on top of Papra.
- *Coming soon:* **CLI**: Manage your documents from the command line.
- **Tagging Rules**: Automatically tag documents based on custom rules.
- **Folder ingestion**: Automatically import documents from a folder.
- **API, SDK and webhooks**: Build your own applications on top of Papra.
- **CLI**: Manage your documents from the command line.
- **i18n**: Support for multiple languages.
- *Coming soon:* **Document sharing**: Share documents with others.
- *Coming soon:* **Document requests**: Generate upload links for people to add documents.

View File

@@ -1,6 +1,6 @@
import type { StarlightUserConfig } from '@astrojs/starlight/types';
export const sidebar: StarlightUserConfig['sidebar'] = [
export const sidebar = [
{
label: 'Getting Started',
items: [
@@ -12,6 +12,7 @@ export const sidebar: StarlightUserConfig['sidebar'] = [
items: [
{ label: 'Using Docker', slug: 'self-hosting/using-docker' },
{ label: 'Using Docker Compose', slug: 'self-hosting/using-docker-compose' },
{ label: 'Docker Compose Generator', link: '/docker-compose-generator', badge: { text: 'new', variant: 'note' } },
{ label: 'Configuration', slug: 'self-hosting/configuration' },
],
},
@@ -30,11 +31,23 @@ export const sidebar: StarlightUserConfig['sidebar'] = [
label: 'Setup Ingestion Folder',
slug: 'guides/setup-ingestion-folder',
},
{
label: 'Setup Custom OAuth2 Providers',
slug: 'guides/setup-custom-oauth2-providers',
},
],
},
{
label: 'Resources',
items: [
{
label: 'Troubleshooting',
slug: 'resources/troubleshooting',
},
{
label: 'CLI Documentation',
slug: 'resources/cli',
},
{
label: 'Security Policy',
link: 'https://github.com/papra-hq/papra/blob/main/SECURITY.md',
@@ -42,6 +55,7 @@ export const sidebar: StarlightUserConfig['sidebar'] = [
target: '_blank',
},
},
],
},
];
] satisfies StarlightUserConfig['sidebar'];

View File

@@ -0,0 +1,493 @@
---
import { codeToHtml } from 'shiki';
const images = {
GitHub: 'ghcr.io/papra-hq/papra',
DockerHub: 'corentinth/papra',
};
const defaultDockerCompose = `
services:
papra:
image: ghcr.io/papra-hq/papra:latest
container_name: papra
restart: unless-stopped
ports:
- 1221:1221
environment:
- AUTH_SECRET=change-me
- CLIENT_BASE_URL=http://localhost:1221
- SERVER_BASE_URL=http://localhost:1221
volumes:
- ./app-data:/app/app-data
user: 1000:1000
`.trim();
const dcHtml = await codeToHtml(defaultDockerCompose, { theme: 'vitesse-black', lang: 'yaml' });
const defaultCommand = `mkdir -p ./app-data/{db,documents} && docker compose up -d`;
---
<h2 class="mt-8 mb-2">General settings</h2>
<div class="flex items-center gap-2 mt-1">
<label for="port" class="min-w-32">External port</label>
<input id="port" class="input-field" value="1221" type="number" min="1024" max="65535" placeholder="eg: 1221" />
</div>
<div class="flex items-center gap-2 mt-1">
<label for="app-base-url" class="min-w-32">App base URL</label>
<input id="app-base-url" class="input-field" type="text" placeholder="eg: https://papra.example.com" value="http://localhost:1221" />
</div>
<div class="flex items-center gap-2 mt-1">
<label for="source" class="min-w-32">Image source</label>
<select class="input-field mt-0" id="source">
{Object.entries(images).map(([registry, imageName]) => <option class="bg-background" value={imageName}>{`${registry} - ${imageName}`}</option>)}
</select>
</div>
<div class="flex items-center gap-2 mt-1">
<label for="service-name" class="min-w-32">Service Name</label>
<input id="service-name" class="input-field" value="papra" type="text" placeholder="eg: papra" />
</div>
<div class="flex items-center gap-2 mt-1">
<label
for="auth-secret"
class="min-w-32"
>
Auth secret
</label>
<div class="flex items-center gap-2 mt-0 w-full">
<input class="input-field font-mono" id="auth-secret" type="text" placeholder="eg: 1234567890" />
<button class="btn bg-muted" id="refresh-secret"> Refresh </button>
</div>
</div>
<div class="flex items-center gap-2 mt-1">
<label for="volume-path" class="min-w-32">Volume path</label>
<input id="volume-path" class="input-field" value="./app-data" type="text" placeholder="eg: ./app-data" />
</div>
<div class="flex items-center gap-2 mt-1">
<label for="privileged-mode" class="min-w-32">Privileged mode</label>
<div class="flex items-center gap-2 mt-0 w-full">
<select class="input-field mt-0" id="privileged-mode">
<option value="false" class="bg-background">Rootless</option>
<option value="true" class="bg-background">Root</option>
</select>
</div>
</div>
<h2 class="mt-8 mb-2">Ingestion folder</h2>
<div class="flex items-center gap-2 mt-1">
<label for="ingestion-enabled" class="min-w-32">Enable ingestion</label>
<div class="flex items-center gap-2 mt-0 w-full">
<select class="input-field mt-0" id="ingestion-enabled">
<option value="false" class="bg-background">Disabled</option>
<option value="true" class="bg-background">Enabled</option>
</select>
</div>
</div>
<div class="flex items-center gap-2 mt-1" id="ingestion-path-container" style="display: none;">
<label for="ingestion-path" class="min-w-32">Ingestion path</label>
<input id="ingestion-path" class="input-field" value="./ingestion" type="text" placeholder="eg: ./ingestion" />
</div>
<h2 class="mt-8 mb-2">Intake emails</h2>
<div class="flex items-center gap-2 mt-1">
<label for="intake-email-enabled" class="min-w-32">Enabled</label>
<div class="flex items-center gap-2 mt-0 w-full">
<select class="input-field mt-0" id="intake-email-enabled">
<option value="false" class="bg-background">Disabled</option>
<option value="true" class="bg-background">Enabled</option>
</select>
</div>
</div>
<div class="flex items-center gap-2 mt-1" id="intake-email-driver-container" style="display: none;">
<label for="intake-email-driver" class="min-w-32">Driver</label>
<div class="flex items-center gap-2 mt-0 w-full">
<select class="input-field mt-0" id="intake-email-driver">
<option value="owlrelay" class="bg-background">OwlRelay</option>
<option value="random-username" class="bg-background">Cloudflare Email Worker</option>
</select>
</div>
</div>
<div id="intake-email-owlrelay-config" style="display: none;" class="mt-1">
<div class="flex items-center gap-2 mt-1">
<label for="intake-email-owlrelay-api-key" class="min-w-32">API Key</label>
<input id="intake-email-owlrelay-api-key" class="input-field" type="text" placeholder="owrl_*****" />
</div>
<div class="flex items-center gap-2 mt-1">
<label for="intake-email-owlrelay-webhook-url" class="min-w-32">Webhook URL</label>
<input id="intake-email-owlrelay-webhook-url" class="input-field" type="text" placeholder="https://your-instance.com/api/intake-emails/ingest" value="https://localhost:1221/api/intake-emails/ingest" />
</div>
</div>
<div id="intake-email-cf-worker-config" style="display: none;" class="mt-1">
<div class="flex items-center gap-2 mt-1">
<label for="intake-email-cf-email-domain" class="min-w-32">Email domain</label>
<input id="intake-email-cf-email-domain" class="input-field" type="text" placeholder="papra.email" />
</div>
</div>
<div class="flex items-center gap-2 mt-1" id="intake-email-webhook-secret-container" style="display: none;">
<label for="intake-email-webhook-secret" class="min-w-32">Webhook secret</label>
<div class="flex items-center gap-2 mt-0 w-full">
<input class="input-field font-mono" id="intake-email-webhook-secret" type="text" placeholder="a-random-key" />
<button class="btn bg-muted" id="refresh-webhook-secret">Refresh</button>
</div>
</div>
<div id="docker-compose-output" class="mt-12" set:html={dcHtml} />
<pre id="command-output" class="bg-card p-4 rounded-md text-muted-foreground text-sm font-mono overflow-x-auto">{defaultCommand}</pre>
<div class="flex items-center gap-2 mt-4">
<button class="btn bg-muted mt-0" id="download-button">Download docker-compose.yml</button>
<button class="btn bg-muted mt-0" id="copy-button">Copy docker compose to clipboard</button>
<button class="btn bg-muted mt-0" id="copy-command-button">Copy command</button>
</div>
<script>
import { codeToHtml } from 'shiki';
import { stringify } from 'yaml';
const portInput = document.getElementById('port') as HTMLInputElement;
const sourceSelect = document.getElementById('source') as HTMLSelectElement;
const serviceNameInput = document.getElementById('service-name') as HTMLInputElement;
const authSecretInput = document.getElementById('auth-secret') as HTMLInputElement;
const appBaseUrlInput = document.getElementById('app-base-url') as HTMLInputElement;
const refreshSecretButton = document.getElementById('refresh-secret');
const copyButton = document.getElementById('copy-button');
const dockerComposeOutput = document.getElementById('docker-compose-output');
const downloadButton = document.getElementById('download-button');
const volumePathInput = document.getElementById('volume-path') as HTMLInputElement;
const privilegedModeSelect = document.getElementById('privileged-mode') as HTMLSelectElement;
const ingestionEnabledSelect = document.getElementById('ingestion-enabled') as HTMLSelectElement;
const ingestionPathInput = document.getElementById('ingestion-path') as HTMLInputElement;
const ingestionPathContainer = document.getElementById('ingestion-path-container') as HTMLDivElement;
const intakeEmailEnabledSelect = document.getElementById('intake-email-enabled') as HTMLSelectElement;
const intakeDriverSelect = document.getElementById('intake-email-driver') as HTMLSelectElement;
const owlrelayConfig = document.getElementById('intake-email-owlrelay-config') as HTMLDivElement;
const cfWorkerConfig = document.getElementById('intake-email-cf-worker-config') as HTMLDivElement;
const owlrelayApiKeyInput = document.getElementById('intake-email-owlrelay-api-key') as HTMLInputElement;
const owlrelayWebhookUrlInput = document.getElementById('intake-email-owlrelay-webhook-url') as HTMLInputElement;
const cfEmailDomainInput = document.getElementById('intake-email-cf-email-domain') as HTMLInputElement;
const webhookSecretInput = document.getElementById('intake-email-webhook-secret') as HTMLInputElement;
const refreshWebhookSecretButton = document.getElementById('refresh-webhook-secret');
const commandOutput = document.getElementById('command-output');
const copyCommandButton = document.getElementById('copy-command-button');
// Track whether the app base URL has been customized by the user
let isAppBaseUrlCustomized = false;
// Track whether the webhook URL has been customized by the user
let isWebhookUrlCustomized = false;
function getRandomString() {
const alphabet = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
return Array.from({ length: 48 }, () => alphabet[Math.floor(Math.random() * alphabet.length)]).join('');
}
function copyToClipboard(text: string) {
navigator.clipboard.writeText(text);
}
function isDefaultAppBaseUrl(url: string, port: string): boolean {
return url === `http://localhost:${port}`;
}
function generateDefaultWebhookUrl(baseUrl: string): string {
// Remove trailing slash if present
const cleanBaseUrl = baseUrl.replace(/\/$/, '');
return `${cleanBaseUrl}/api/intake-emails/ingest`;
}
function isDefaultWebhookUrl(webhookUrl: string, baseUrl: string): boolean {
return webhookUrl === generateDefaultWebhookUrl(baseUrl);
}
function refreshIsWebhookUrlCustomized() {
const currentBaseUrl = appBaseUrlInput.value.trim();
const currentWebhookUrl = owlrelayWebhookUrlInput.value.trim();
if (isDefaultWebhookUrl(currentWebhookUrl, currentBaseUrl)) {
isWebhookUrlCustomized = false;
} else {
isWebhookUrlCustomized = true;
}
}
function refreshIsAppBaseUrlCustomized() {
const currentPort = portInput.value;
const currentUrl = appBaseUrlInput.value.trim();
if (isDefaultAppBaseUrl(currentUrl, currentPort)) {
isAppBaseUrlCustomized = false;
} else {
isAppBaseUrlCustomized = true;
}
}
function updateWebhookUrlFromBaseUrl() {
if (!isWebhookUrlCustomized) {
const baseUrl = appBaseUrlInput.value.trim();
if (baseUrl) {
owlrelayWebhookUrlInput.value = generateDefaultWebhookUrl(baseUrl);
}
}
}
function updateAppBaseUrlFromPort() {
if (!isAppBaseUrlCustomized) {
const port = portInput.value;
appBaseUrlInput.value = `http://localhost:${port}`;
// Also update webhook URL when app base URL changes
updateWebhookUrlFromBaseUrl();
}
}
function handlePortChange() {
updateAppBaseUrlFromPort();
updateDockerCompose();
}
function handleAppBaseUrlChange() {
refreshIsAppBaseUrlCustomized();
updateWebhookUrlFromBaseUrl();
updateDockerCompose();
}
function handleWebhookUrlChange() {
refreshIsWebhookUrlCustomized();
updateDockerCompose();
}
function getDockerComposeYml() {
const serviceName = serviceNameInput.value;
const isRootless = privilegedModeSelect.value === 'false';
const image = sourceSelect.value;
const port = portInput.value;
const authSecret = authSecretInput.value;
const volumePath = volumePathInput.value;
const isIngestionEnabled = ingestionEnabledSelect.value === 'true';
const ingestionPath = ingestionPathInput.value;
const intakeEmailEnabled = intakeEmailEnabledSelect.value === 'true';
const intakeDriver = intakeDriverSelect.value;
const webhookSecret = webhookSecretInput.value;
const appBaseUrl = appBaseUrlInput.value.trim();
const version = isRootless ? 'latest' : 'latest-root';
const fullImage = `${image}:${version}`;
// Determine base URLs
const clientBaseUrl = appBaseUrl || `http://localhost:${port}`;
const serverBaseUrl = appBaseUrl || `http://localhost:${port}`;
const environment = [
`AUTH_SECRET=${authSecret}`,
`CLIENT_BASE_URL=${clientBaseUrl}`,
`SERVER_BASE_URL=${serverBaseUrl}`,
isIngestionEnabled && 'INGESTION_FOLDER_IS_ENABLED=true',
intakeEmailEnabled && 'INTAKE_EMAILS_IS_ENABLED=true',
intakeEmailEnabled && `INTAKE_EMAILS_DRIVER=${intakeDriver}`,
intakeEmailEnabled && `INTAKE_EMAILS_WEBHOOK_SECRET=${webhookSecret}`,
intakeEmailEnabled && intakeDriver === 'owlrelay' && owlrelayApiKeyInput.value && `OWLRELAY_API_KEY=${owlrelayApiKeyInput.value}`,
intakeEmailEnabled && intakeDriver === 'owlrelay' && owlrelayWebhookUrlInput.value && `OWLRELAY_WEBHOOK_URL=${owlrelayWebhookUrlInput.value}`,
intakeEmailEnabled && intakeDriver === 'random-username' && cfEmailDomainInput.value && `INTAKE_EMAILS_EMAIL_GENERATION_DOMAIN=${cfEmailDomainInput.value}`,
].flat().filter(Boolean);
const volumes = [
`${volumePath}:/app/app-data`,
isIngestionEnabled && `${ingestionPath}:/app/ingestion`,
].filter(Boolean);
const dc = {
services: {
[serviceName]: {
image: fullImage,
container_name: serviceName,
restart: 'unless-stopped',
ports: [`${port}:1221`],
environment,
volumes,
...(isRootless && {
user: '1000:1000',
}),
},
},
};
return stringify(dc);
}
function getStartCommand() {
const volumePath = volumePathInput.value;
const volumePathNormalized = volumePath.replace(/\/$/, '');
const volumeWithSubdirs = `${volumePathNormalized}/{db,documents}`;
const mkdirCommand = `mkdir -p ${volumeWithSubdirs}`;
const dockerCommand = 'docker compose up -d';
return `${mkdirCommand} && ${dockerCommand}`;
}
async function updateDockerCompose() {
const dockerCompose = getDockerComposeYml();
const command = getStartCommand();
const html = await codeToHtml(dockerCompose, { theme: 'vitesse-black', lang: 'yaml' });
if (dockerComposeOutput) {
dockerComposeOutput.innerHTML = html;
}
if (commandOutput) {
commandOutput.textContent = command;
}
}
function handleCopy() {
const dockerCompose = getDockerComposeYml();
copyToClipboard(dockerCompose);
if (copyButton) {
copyButton.textContent = 'Copied!';
}
setTimeout(() => {
if (copyButton) {
copyButton.textContent = 'Copy to clipboard';
}
}, 1000);
}
function handleRefreshSecret() {
authSecretInput.value = getRandomString();
updateDockerCompose();
}
function handleDownload() {
const dockerCompose = getDockerComposeYml();
const blob = new Blob([dockerCompose], { type: 'text/yaml' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'docker-compose.yml';
a.click();
}
function handleIngestionEnabledChange() {
const isEnabled = ingestionEnabledSelect.value === 'true';
ingestionPathContainer.style.display = isEnabled ? 'flex' : 'none';
updateDockerCompose();
}
function handleIntakeEmailEnabledChange() {
const isEnabled = intakeEmailEnabledSelect.value === 'true';
const driverContainer = document.getElementById('intake-email-driver-container');
const webhookSecretContainer = document.getElementById('intake-email-webhook-secret-container');
if (driverContainer) {
driverContainer.style.display = isEnabled ? 'flex' : 'none';
}
if (webhookSecretContainer) {
webhookSecretContainer.style.display = isEnabled ? 'flex' : 'none';
}
if (!isEnabled) {
// Reset driver-specific configs when disabled
if (owlrelayConfig) {
owlrelayConfig.style.display = 'none';
}
if (cfWorkerConfig) {
cfWorkerConfig.style.display = 'none';
}
} else {
// Show the appropriate driver config
handleIntakeDriverChange();
}
updateDockerCompose();
}
function handleIntakeDriverChange() {
const driver = intakeDriverSelect.value;
const isEnabled = intakeEmailEnabledSelect.value === 'true';
if (!isEnabled) {
return;
}
if (owlrelayConfig) {
owlrelayConfig.style.display = driver === 'owlrelay' ? 'block' : 'none';
}
if (cfWorkerConfig) {
cfWorkerConfig.style.display = driver === 'random-username' ? 'block' : 'none';
}
updateDockerCompose();
}
function handleRefreshWebhookSecret() {
webhookSecretInput.value = getRandomString();
updateDockerCompose();
}
function handleCopyCommand() {
const command = getStartCommand();
copyToClipboard(command);
if (copyCommandButton) {
copyCommandButton.textContent = 'Copied!';
}
setTimeout(() => {
if (copyCommandButton) {
copyCommandButton.textContent = 'Copy command';
}
}, 1000);
}
// Add event listeners
portInput.addEventListener('input', handlePortChange);
sourceSelect.addEventListener('change', updateDockerCompose);
serviceNameInput.addEventListener('input', updateDockerCompose);
authSecretInput.addEventListener('input', updateDockerCompose);
appBaseUrlInput.addEventListener('input', handleAppBaseUrlChange);
refreshSecretButton?.addEventListener('click', handleRefreshSecret);
copyButton?.addEventListener('click', handleCopy);
downloadButton?.addEventListener('click', handleDownload);
volumePathInput.addEventListener('input', updateDockerCompose);
privilegedModeSelect.addEventListener('change', updateDockerCompose);
ingestionEnabledSelect.addEventListener('change', handleIngestionEnabledChange);
ingestionPathInput.addEventListener('input', updateDockerCompose);
intakeEmailEnabledSelect.addEventListener('change', handleIntakeEmailEnabledChange);
intakeDriverSelect.addEventListener('change', handleIntakeDriverChange);
owlrelayApiKeyInput.addEventListener('input', updateDockerCompose);
owlrelayWebhookUrlInput.addEventListener('input', handleWebhookUrlChange);
cfEmailDomainInput.addEventListener('input', updateDockerCompose);
webhookSecretInput.addEventListener('input', updateDockerCompose);
refreshWebhookSecretButton?.addEventListener('click', handleRefreshWebhookSecret);
copyCommandButton?.addEventListener('click', handleCopyCommand);
authSecretInput.value = getRandomString();
// Initial render
updateDockerCompose();
// Initial setup
handleIngestionEnabledChange();
handleIntakeEmailEnabledChange();
webhookSecretInput.value = getRandomString();
</script>

View File

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

View File

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

View File

@@ -1,5 +1,10 @@
{
"extends": "astro/tsconfigs/strict",
"include": [".astro/types.d.ts", "**/*"],
"exclude": ["dist"]
"include": [
".astro/types.d.ts",
"**/*"
],
"exclude": [
"dist"
]
}

97
apps/docs/uno.config.ts Normal file
View File

@@ -0,0 +1,97 @@
import {
defineConfig,
presetUno,
transformerDirectives,
transformerVariantGroup,
} from 'unocss';
import presetAnimations from 'unocss-preset-animations';
export default defineConfig({
presets: [
presetUno({
dark: {
dark: '[data-kb-theme="dark"]',
light: '[data-kb-theme="light"]',
},
prefix: '',
}),
presetAnimations(),
],
transformers: [transformerVariantGroup(), transformerDirectives()],
theme: {
colors: {
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))',
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))',
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))',
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))',
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))',
},
popover: {
DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))',
},
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))',
},
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)',
},
animation: {
keyframes: {
'accordion-down':
'{ from { height: 0 } to { height: var(--kb-accordion-content-height) } }',
'accordion-up':
'{ from { height: var(--kb-accordion-content-height) } to { height: 0 } }',
'collapsible-down':
'{ from { height: 0 } to { height: var(--kb-collapsible-content-height) } }',
'collapsible-up':
'{ from { height: var(--kb-collapsible-content-height) } to { height: 0 } }',
'caret-blink': '{ 0%,70%,100% { opacity: 1 } 20%,50% { opacity: 0 } }',
},
timingFns: {
'accordion-down': 'ease-out',
'accordion-up': 'ease-out',
'collapsible-down': 'ease-out',
'collapsible-up': 'ease-out',
'caret-blink': 'ease-out',
},
durations: {
'accordion-down': '0.2s',
'accordion-up': '0.2s',
'collapsible-down': '0.2s',
'collapsible-up': '0.2s',
'caret-blink': '1.25s',
},
counts: {
'caret-blink': 'infinite',
},
},
},
shortcuts: {
'input-field': 'flex h-9 w-full bg-none outline-none rounded-lg border border-border border-solid bg-inherit px-3 py-1 text-sm shadow-none placeholder:text-muted-foreground focus-visible:(outline-none ring-1.5 ring-ring) disabled:(cursor-not-allowed opacity-50) transition-shadow',
'btn': 'text-sm font-medium hover:opacity-80 rounded-lg transition-all px-4 py-2 bg-none outline-none border-none cursor-pointer',
},
});

View File

@@ -0,0 +1,69 @@
# @papra/app-client
## 0.6.3
### Patch Changes
- [#357](https://github.com/papra-hq/papra/pull/357) [`585c53c`](https://github.com/papra-hq/papra/commit/585c53cd9d0d7dbd517dbb1adddfd9e7b70f9fe5) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added a /llms.txt on main website
- [#359](https://github.com/papra-hq/papra/pull/359) [`0c2cf69`](https://github.com/papra-hq/papra/commit/0c2cf698d1a9e9a3cea023920b10cfcd5d83be14) Thanks [@Mavv3006](https://github.com/Mavv3006)! - Add German translation
## 0.6.2
### Patch Changes
- [#333](https://github.com/papra-hq/papra/pull/333) [`ff830c2`](https://github.com/papra-hq/papra/commit/ff830c234a02ddb4cbc480cf77ef49b8de35fbae) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Fixed version release link
## 0.6.1
## 0.6.0
### Minor Changes
- [#317](https://github.com/papra-hq/papra/pull/317) [`79c1d32`](https://github.com/papra-hq/papra/commit/79c1d3206b140cf8b3d33ef8bda6098dcf4c9c9c) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added document activity log
- [#319](https://github.com/papra-hq/papra/pull/319) [`60059c8`](https://github.com/papra-hq/papra/commit/60059c895c4860cbfda69d3c989ad00542def65b) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added pending invitation management page
### Patch Changes
- [#309](https://github.com/papra-hq/papra/pull/309) [`d4f72e8`](https://github.com/papra-hq/papra/commit/d4f72e889a4d39214de998942bc0eb88cd5cee3d) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Disable "Manage subscription" from organization setting by default
- [#308](https://github.com/papra-hq/papra/pull/308) [`759a3ff`](https://github.com/papra-hq/papra/commit/759a3ff713db8337061418b9c9b122b957479343) Thanks [@CorentinTh](https://github.com/CorentinTh)! - I18n: full support for French language
- [#312](https://github.com/papra-hq/papra/pull/312) [`e5ef40f`](https://github.com/papra-hq/papra/commit/e5ef40f36c27ea25dc8a79ef2805d673761eec2a) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Fixed an issue with the reset-password page navigation guard that prevented reset
## 0.5.1
## 0.5.0
### Minor Changes
- [#295](https://github.com/papra-hq/papra/pull/295) [`438a311`](https://github.com/papra-hq/papra/commit/438a31171c606138c4b7fa299fdd58dcbeaaf298) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added support for custom oauth2 providers
- [#291](https://github.com/papra-hq/papra/pull/291) [`0627ec2`](https://github.com/papra-hq/papra/commit/0627ec25a422b7b820b08740cfc2905f9c55c00e) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added invitation system to add users to an organization
### Patch Changes
- [#296](https://github.com/papra-hq/papra/pull/296) [`0ddc234`](https://github.com/papra-hq/papra/commit/0ddc2340f092cf6fe5bf2175b55fb46db7681c36) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Fix register page description
## 0.4.0
### Minor Changes
- [#280](https://github.com/papra-hq/papra/pull/280) [`85fa5c4`](https://github.com/papra-hq/papra/commit/85fa5c43424d139f5c2752a3ad644082e61d3d67) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added webhook management
- [#280](https://github.com/papra-hq/papra/pull/280) [`85fa5c4`](https://github.com/papra-hq/papra/commit/85fa5c43424d139f5c2752a3ad644082e61d3d67) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added API keys support
- [#280](https://github.com/papra-hq/papra/pull/280) [`85fa5c4`](https://github.com/papra-hq/papra/commit/85fa5c43424d139f5c2752a3ad644082e61d3d67) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added document searchable content edit
### Patch Changes
- [#280](https://github.com/papra-hq/papra/pull/280) [`85fa5c4`](https://github.com/papra-hq/papra/commit/85fa5c43424d139f5c2752a3ad644082e61d3d67) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added tag creation button in document page
- [#280](https://github.com/papra-hq/papra/pull/280) [`85fa5c4`](https://github.com/papra-hq/papra/commit/85fa5c43424d139f5c2752a3ad644082e61d3d67) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Improved tag selector input wrapping
- [#280](https://github.com/papra-hq/papra/pull/280) [`85fa5c4`](https://github.com/papra-hq/papra/commit/85fa5c43424d139f5c2752a3ad644082e61d3d67) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Properly handle file names without extensions
- [#280](https://github.com/papra-hq/papra/pull/280) [`85fa5c4`](https://github.com/papra-hq/papra/commit/85fa5c43424d139f5c2752a3ad644082e61d3d67) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Wrap text in document preview
- [#280](https://github.com/papra-hq/papra/pull/280) [`85fa5c4`](https://github.com/papra-hq/papra/commit/85fa5c43424d139f5c2752a3ad644082e61d3d67) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Excluded deleted documents from doc count

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,8 @@
import type { Component } from 'solid-js';
import type { LocaleKeys } from '@/modules/i18n/locales.types';
import { createSignal, For } from 'solid-js';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { Checkbox, CheckboxControl, CheckboxLabel } from '@/modules/ui/components/checkbox';
import { type Component, createSignal, For } from 'solid-js';
import { API_KEY_PERMISSIONS } from '../api-keys.constants';
export const ApiKeyPermissionsPicker: Component<{ permissions: string[]; onChange: (permissions: string[]) => void }> = (props) => {

View File

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

View File

@@ -1,3 +1,8 @@
import type { Component } from 'solid-js';
import { setValue } from '@modular-forms/solid';
import { A } from '@solidjs/router';
import { createSignal, Show } from 'solid-js';
import * as v from 'valibot';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { createForm } from '@/modules/shared/form/form';
import { queryClient } from '@/modules/shared/query/query-client';
@@ -5,10 +10,6 @@ import { CopyButton } from '@/modules/shared/utils/copy';
import { Button } from '@/modules/ui/components/button';
import { createToast } from '@/modules/ui/components/sonner';
import { TextField, TextFieldLabel, TextFieldRoot } from '@/modules/ui/components/textfield';
import { setValue } from '@modular-forms/solid';
import { A } from '@solidjs/router';
import { type Component, createSignal, Show } from 'solid-js';
import * as v from 'valibot';
import { API_KEY_PERMISSIONS_LIST } from '../api-keys.constants';
import { createApiKey } from '../api-keys.services';
import { ApiKeyPermissionsPicker } from '../components/api-key-permissions-picker.component';

View File

@@ -1,4 +1,5 @@
import type { Config } from '../config/config';
import type { SsoProviderConfig } from './auth.types';
import { get } from 'lodash-es';
import { ssoProviders } from './auth.constants';
@@ -8,8 +9,15 @@ export function isAuthErrorWithCode({ error, code }: { error: unknown; code: str
export const isEmailVerificationRequiredError = ({ error }: { error: unknown }) => isAuthErrorWithCode({ error, code: 'EMAIL_NOT_VERIFIED' });
export function getEnabledSsoProviderConfigs({ config }: { config: Config }) {
const enabledSsoProviders = ssoProviders.filter(({ key }) => get(config, `auth.providers.${key}.isEnabled`));
export function getEnabledSsoProviderConfigs({ config }: { config: Config }): SsoProviderConfig[] {
const enabledSsoProviders: SsoProviderConfig[] = [
...ssoProviders.filter(({ key }) => get(config, `auth.providers.${key}.isEnabled`)),
...config.auth.providers.customs.map(({ providerId, providerName, providerIconUrl }) => ({
key: providerId,
name: providerName,
icon: providerIconUrl ?? 'i-tabler-login-2',
})),
];
return enabledSsoProviders;
}

View File

@@ -1,5 +1,8 @@
import { createAuthClient as createBetterAuthClient } from 'better-auth/solid';
import type { Config } from '../config/config';
import type { SsoProviderConfig } from './auth.types';
import { genericOAuthClient } from 'better-auth/client/plugins';
import { createAuthClient as createBetterAuthClient } from 'better-auth/solid';
import { buildTimeConfig } from '../config/config';
import { trackingServices } from '../tracking/tracking.services';
import { createDemoAuthClient } from './auth.demo.services';
@@ -7,6 +10,9 @@ import { createDemoAuthClient } from './auth.demo.services';
export function createAuthClient() {
const client = createBetterAuthClient({
baseURL: buildTimeConfig.baseApiUrl,
plugins: [
genericOAuthClient(),
],
});
return {
@@ -38,3 +44,17 @@ export const {
} = buildTimeConfig.isDemoMode
? createDemoAuthClient()
: createAuthClient();
export async function authWithProvider({ provider, config }: { provider: SsoProviderConfig; config: Config }) {
const isCustomProvider = config.auth.providers.customs.some(({ providerId }) => providerId === provider.key);
if (isCustomProvider) {
signIn.oauth2({
providerId: provider.key,
callbackURL: config.baseUrl,
});
return;
}
await signIn.social({ provider: provider.key as 'github' | 'google', callbackURL: config.baseUrl });
}

View File

@@ -1,3 +1,4 @@
import type { ssoProviders } from './auth.constants';
export type SsoProviderKey = (typeof ssoProviders)[number]['key'];
export type SsoProviderKey = (typeof ssoProviders)[number]['key'] | string & {};
export type SsoProviderConfig = { key: SsoProviderKey; name: string; icon: string };

View File

@@ -1,9 +1,9 @@
import type { Component } from 'solid-js';
import { A } from '@solidjs/router';
import { useConfig } from '@/modules/config/config.provider';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { createVitrineUrl } from '@/modules/shared/utils/urls';
import { Button } from '@/modules/ui/components/button';
import { A } from '@solidjs/router';
export const AuthLegalLinks: Component = () => {
const { config } = useConfig();

View File

@@ -1,7 +1,8 @@
import type { Component, ComponentProps } from 'solid-js';
import { splitProps } from 'solid-js';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { cn } from '@/modules/shared/style/cn';
import { Button } from '@/modules/ui/components/button';
import { type Component, type ComponentProps, splitProps } from 'solid-js';
const providers = [
{

View File

@@ -1,19 +1,33 @@
import type { Component } from 'solid-js';
import { createSignal, Match, Switch } from 'solid-js';
import { cn } from '@/modules/shared/style/cn';
import { Button } from '@/modules/ui/components/button';
import { createSignal } from 'solid-js';
export const SsoProviderButton: Component<{ name: string; icon: string; onClick: () => Promise<void>; label: string }> = (props) => {
export const SsoProviderButton: Component<{ name: string; icon?: string; onClick: () => Promise<void>; label: string }> = (props) => {
const [getIsLoading, setIsLoading] = createSignal(false);
const navigateToProvider = async () => {
const onClick = async () => {
setIsLoading(true);
await props.onClick();
};
return (
<Button variant="secondary" class="block w-full flex items-center justify-center" onClick={navigateToProvider} disabled={getIsLoading()}>
<span class={cn(`mr-2 size-4.5 inline-block`, getIsLoading() ? 'i-tabler-loader-2 animate-spin' : props.icon)} />
<Button variant="secondary" class="block w-full flex items-center justify-center gap-2" onClick={onClick} disabled={getIsLoading()}>
<Switch>
<Match when={getIsLoading()}>
<span class="i-tabler-loader-2 animate-spin" />
</Match>
<Match when={props.icon?.startsWith('i-')}>
<span class={cn(`size-4.5`, props.icon)} />
</Match>
<Match when={props.icon}>
<img src={props.icon} alt={props.name} class="size-4.5" />
</Match>
</Switch>
{props.label}
</Button>
);

View File

@@ -1,5 +1,6 @@
import type { Component } from 'solid-js';
import { Navigate } from '@solidjs/router';
import { type Component, Suspense } from 'solid-js';
import { Suspense } from 'solid-js';
import { Dynamic } from 'solid-js/web';
import { match } from 'ts-pattern';
import { useSession } from '../auth.services';

View File

@@ -1,4 +1,8 @@
import type { SsoProviderKey } from '../auth.types';
import type { Component } from 'solid-js';
import type { SsoProviderConfig } from '../auth.types';
import { A, useNavigate } from '@solidjs/router';
import { createSignal, For, Show } from 'solid-js';
import * as v from 'valibot';
import { useConfig } from '@/modules/config/config.provider';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { createForm } from '@/modules/shared/form/form';
@@ -6,12 +10,9 @@ import { Button } from '@/modules/ui/components/button';
import { Checkbox, CheckboxControl, CheckboxLabel } from '@/modules/ui/components/checkbox';
import { Separator } from '@/modules/ui/components/separator';
import { TextField, TextFieldLabel, TextFieldRoot } from '@/modules/ui/components/textfield';
import { A, useNavigate } from '@solidjs/router';
import { type Component, createSignal, For, Show } from 'solid-js';
import * as v from 'valibot';
import { AuthLayout } from '../../ui/layouts/auth-layout.component';
import { getEnabledSsoProviderConfigs, isEmailVerificationRequiredError } from '../auth.models';
import { signIn } from '../auth.services';
import { authWithProvider, signIn } from '../auth.services';
import { AuthLegalLinks } from '../components/legal-links.component';
import { SsoProviderButton } from '../components/sso-provider-button.component';
@@ -85,9 +86,11 @@ export const EmailLoginForm: Component = () => {
)}
</Field>
<Button variant="link" as={A} class="inline p-0! h-auto" href="/request-password-reset">
{t('auth.login.form.forgot-password.label')}
</Button>
<Show when={config.auth.isPasswordResetEnabled}>
<Button variant="link" as={A} class="inline p-0! h-auto" href="/request-password-reset">
{t('auth.login.form.forgot-password.label')}
</Button>
</Show>
</div>
<Button type="submit" class="w-full">{t('auth.login.form.submit')}</Button>
@@ -104,8 +107,8 @@ export const LoginPage: Component = () => {
const [getShowEmailLogin, setShowEmailLogin] = createSignal(false);
const loginWithProvider = async (provider: { key: SsoProviderKey }) => {
await signIn.social({ provider: provider.key, callbackURL: config.baseUrl });
const loginWithProvider = async (provider: SsoProviderConfig) => {
await authWithProvider({ provider, config });
};
const getHasSsoProviders = () => getEnabledSsoProviderConfigs({ config }).length > 0;

View File

@@ -1,16 +1,17 @@
import type { ssoProviders } from '../auth.constants';
import type { Component } from 'solid-js';
import type { SsoProviderConfig } from '../auth.types';
import { A, useNavigate } from '@solidjs/router';
import { createSignal, For, Show } from 'solid-js';
import * as v from 'valibot';
import { useConfig } from '@/modules/config/config.provider';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { createForm } from '@/modules/shared/form/form';
import { Button } from '@/modules/ui/components/button';
import { Separator } from '@/modules/ui/components/separator';
import { TextField, TextFieldLabel, TextFieldRoot } from '@/modules/ui/components/textfield';
import { A, useNavigate } from '@solidjs/router';
import { type Component, createSignal, For, Show } from 'solid-js';
import * as v from 'valibot';
import { AuthLayout } from '../../ui/layouts/auth-layout.component';
import { getEnabledSsoProviderConfigs } from '../auth.models';
import { signIn, signUp } from '../auth.services';
import { authWithProvider, signUp } from '../auth.services';
import { AuthLegalLinks } from '../components/legal-links.component';
import { SsoProviderButton } from '../components/sso-provider-button.component';
@@ -132,8 +133,8 @@ export const RegisterPage: Component = () => {
const [getShowEmailRegister, setShowEmailRegister] = createSignal(false);
const registerWithProvider = async (provider: typeof ssoProviders[number]) => {
await signIn.social({ provider: provider.key });
const registerWithProvider = async (provider: SsoProviderConfig) => {
await authWithProvider({ provider, config });
};
const getHasSsoProviders = () => getEnabledSsoProviderConfigs({ config }).length > 0;
@@ -168,7 +169,7 @@ export const RegisterPage: Component = () => {
name={provider.name}
icon={provider.icon}
onClick={() => registerWithProvider(provider)}
label={t('auth.register.register-with-provider', { provider: t(`auth.register.providers.${provider.key}`) })}
label={t('auth.register.register-with-provider', { provider: provider.name })}
/>
)}
</For>

View File

@@ -1,12 +1,13 @@
import type { Component } from 'solid-js';
import { buildUrl } from '@corentinth/chisels';
import { A, useNavigate } from '@solidjs/router';
import { createSignal, onMount } from 'solid-js';
import * as v from 'valibot';
import { useConfig } from '@/modules/config/config.provider';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { createForm } from '@/modules/shared/form/form';
import { Button } from '@/modules/ui/components/button';
import { TextField, TextFieldLabel, TextFieldRoot } from '@/modules/ui/components/textfield';
import { buildUrl } from '@corentinth/chisels';
import { A, useNavigate } from '@solidjs/router';
import { type Component, createSignal, onMount } from 'solid-js';
import * as v from 'valibot';
import { AuthLayout } from '../../ui/layouts/auth-layout.component';
import { forgetPassword } from '../auth.services';
import { OpenEmailProvider } from '../components/open-email-provider.component';
@@ -57,7 +58,7 @@ export const RequestPasswordResetPage: Component = () => {
const navigate = useNavigate();
onMount(() => {
if (config.auth.isPasswordResetEnabled) {
if (!config.auth.isPasswordResetEnabled) {
navigate('/login');
}
});

View File

@@ -1,12 +1,12 @@
import type { Component } from 'solid-js';
import { A, Navigate, useNavigate, useSearchParams } from '@solidjs/router';
import { createSignal, onMount } from 'solid-js';
import * as v from 'valibot';
import { useConfig } from '@/modules/config/config.provider';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { createForm } from '@/modules/shared/form/form';
import { Button } from '@/modules/ui/components/button';
import { TextField, TextFieldLabel, TextFieldRoot } from '@/modules/ui/components/textfield';
import { A, Navigate, useNavigate, useSearchParams } from '@solidjs/router';
import { type Component, createSignal } from 'solid-js';
import { onMount } from 'solid-js';
import * as v from 'valibot';
import { AuthLayout } from '../../ui/layouts/auth-layout.component';
import { resetPassword } from '../auth.services';
@@ -62,7 +62,7 @@ export const ResetPasswordPage: Component = () => {
const navigate = useNavigate();
onMount(() => {
if (config.auth.isPasswordResetEnabled) {
if (!config.auth.isPasswordResetEnabled) {
navigate('/login');
}
});

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,5 @@
import type { ApiKey } from '../api-keys/api-keys.types';
import type { Webhook } from '../webhooks/webhooks.types';
import { get } from 'lodash-es';
import { FetchError } from 'ofetch';
import { createRouter } from 'radix3';
@@ -11,6 +12,7 @@ import {
tagDocumentStorage,
taggingRuleStorage,
tagStorage,
webhooksStorage,
} from './demo.storage';
import { findMany, getValues } from './demo.storage.models';
@@ -191,7 +193,7 @@ const inMemoryApiMock: Record<string, { handler: any }> = {
const {
pageIndex = 0,
pageSize = 5,
searchQuery = '',
searchQuery: rawSearchQuery = '',
} = query ?? {};
const organization = organizationStorage.getItem(organizationId);
@@ -199,7 +201,9 @@ const inMemoryApiMock: Record<string, { handler: any }> = {
const documents = await findMany(documentStorage, document => document?.organizationId === organizationId);
const filteredDocuments = documents.filter(document => document?.name.includes(searchQuery) && !document?.deletedAt);
const searchQuery = rawSearchQuery.trim().toLowerCase();
const filteredDocuments = documents.filter(document => document?.name.toLowerCase().includes(searchQuery) && !document?.deletedAt);
return {
documents: filteredDocuments.slice(pageIndex * pageSize, (pageIndex + 1) * pageSize),
@@ -565,6 +569,55 @@ const inMemoryApiMock: Record<string, { handler: any }> = {
},
}),
...defineHandler({
path: '/api/organizations/:organizationId/members',
method: 'GET',
handler: async ({ params: { organizationId } }) => {
return {
members: [{
id: 'mem_1',
user: {
id: 'usr_1',
email: 'jane.doe@papra.app',
name: 'Jane Doe',
},
role: 'owner',
organizationId,
}],
};
},
}),
...defineHandler({
path: '/api/organizations/:organizationId/members/invitations',
method: 'POST',
handler: async () => {
throw Object.assign(new FetchError('Not available in demo'), {
status: 501,
data: {
error: {
message: 'This feature is not available in demo',
code: 'demo.not_available',
},
},
});
},
}),
...defineHandler({
path: '/api/organizations/:organizationId/members/me',
method: 'GET',
handler: async ({ params: { organizationId } }) => {
return {
member: {
id: 'mem_1',
role: 'owner',
organizationId,
},
};
},
}),
...defineHandler({
path: '/api/api-keys',
method: 'GET',
@@ -606,6 +659,80 @@ const inMemoryApiMock: Record<string, { handler: any }> = {
await apiKeyStorage.removeItem(apiKeyId);
},
}),
...defineHandler({
path: '/api/invitations/count',
method: 'GET',
handler: async () => ({ pendingInvitationsCount: 0 }),
}),
...defineHandler({
path: '/api/invitations',
method: 'GET',
handler: async () => ({ invitations: [] }),
}),
...defineHandler({
path: '/api/organizations/:organizationId/webhooks',
method: 'GET',
handler: async ({ params: { organizationId } }) => {
const webhooks = await findMany(webhooksStorage, webhook => webhook.organizationId === organizationId);
return { webhooks };
},
}),
...defineHandler({
path: '/api/organizations/:organizationId/webhooks',
method: 'POST',
handler: async ({ params: { organizationId }, body }) => {
const webhook: Webhook = {
id: createId({ prefix: 'webhook' }),
organizationId,
name: get(body, 'name'),
url: get(body, 'url'),
enabled: true,
events: get(body, 'events'),
createdAt: new Date(),
updatedAt: new Date(),
};
await webhooksStorage.setItem(webhook.id, webhook);
return { webhook };
},
}),
...defineHandler({
path: '/api/organizations/:organizationId/webhooks/:webhookId',
method: 'GET',
handler: async ({ params: { webhookId } }) => {
const webhook = await webhooksStorage.getItem(webhookId);
return { webhook };
},
}),
...defineHandler({
path: '/api/organizations/:organizationId/webhooks/:webhookId',
method: 'DELETE',
handler: async ({ params: { webhookId } }) => {
await webhooksStorage.removeItem(webhookId);
},
}),
...defineHandler({
path: '/api/organizations/:organizationId/webhooks/:webhookId',
method: 'PUT',
handler: async ({ params: { webhookId }, body }) => {
const webhook = await webhooksStorage.getItem(webhookId);
assert(webhook, { status: 404 });
await webhooksStorage.setItem(webhookId, Object.assign(webhook, body, { updatedAt: new Date() }));
return { webhook };
},
}),
};
export const router = createRouter({ routes: inMemoryApiMock, strictTrailingSlash: false });

View File

@@ -4,7 +4,7 @@ import type { HttpClientOptions, ResponseType } from '../shared/http/http-client
import { joinUrlPaths } from '@corentinth/chisels';
import { router } from './demo-api-mock';
export async function demoHttpClient<A, R extends ResponseType = 'json'>(options: HttpClientOptions<R>): Promise<MappedResponseType< R, A>> {
export async function demoHttpClient<A, R extends ResponseType = 'json'>(options: HttpClientOptions<R>): Promise<MappedResponseType<R, A>> {
const path = `/${joinUrlPaths(options.method ?? 'GET', options.url)}`;
const matchedRoute = router.lookup(path);

View File

@@ -1,5 +1,6 @@
import type { Component } from 'solid-js';
import { A, useNavigate } from '@solidjs/router';
import { type Component, createSignal } from 'solid-js';
import { createSignal } from 'solid-js';
import { Portal } from 'solid-js/web';
import { buildTimeConfig } from '../config/config';
import { useI18n } from '../i18n/i18n.provider';

View File

@@ -8,7 +8,7 @@ export async function getValues<T extends StorageValue>(storage: Storage<T>): Pr
return values;
}
export async function findOne<T extends StorageValue>(storage: Storage<T>, predicate: (value: T) => boolean): Promise< T | null> {
export async function findOne<T extends StorageValue>(storage: Storage<T>, predicate: (value: T) => boolean): Promise<T | null> {
const values = await getValues(storage);
const found = values.find(predicate);

View File

@@ -3,6 +3,7 @@ import type { Document } from '../documents/documents.types';
import type { Organization } from '../organizations/organizations.types';
import type { TaggingRule } from '../tagging-rules/tagging-rules.types';
import type { Tag } from '../tags/tags.types';
import type { Webhook } from '../webhooks/webhooks.types';
import { createStorage, prefixStorage } from 'unstorage';
import localStorageDriver from 'unstorage/drivers/localstorage';
import { trackingServices } from '../tracking/tracking.services';
@@ -18,6 +19,7 @@ export const tagStorage = prefixStorage<Omit<Tag, 'documentsCount'>>(storage, 't
export const tagDocumentStorage = prefixStorage<{ documentId: string; tagId: string; id: string }>(storage, 'tagDocuments');
export const taggingRuleStorage = prefixStorage<TaggingRule>(storage, 'taggingRules');
export const apiKeyStorage = prefixStorage<ApiKey>(storage, 'apiKeys');
export const webhooksStorage = prefixStorage<Webhook>(storage, 'webhooks');
export async function clearDemoStorage() {
await storage.clear();

View File

@@ -1,43 +0,0 @@
import type { Component } from 'solid-js';
import { Button } from '@/modules/ui/components/button';
import { makePersisted } from '@solid-primitives/storage';
import { createSignal, Show } from 'solid-js';
import { Portal } from 'solid-js/web';
export const DevToolsOverlayComponent: Component = () => {
const [isCollapsed, setIsCollapsed] = makePersisted(createSignal<boolean>(true), { name: 'papra-dev-tools-collapsed', storage: localStorage });
return (
<Portal>
<Show
when={!isCollapsed()}
fallback={(
<Button
onClick={() => setIsCollapsed(false)}
variant="secondary"
class="fixed bottom-0 left-50% -translate-x-1/2 z-50 rounded-b-none shadow"
>
<div class="i-tabler-chevron-up size-5" />
</Button>
)}
>
<div class="fixed bottom-0 left-50% -translate-x-1/2 z-50 bg-card rounded-t-xl shadow w-full max-w-500px border border-b-none">
<div class="flex items-center justify-between py-2 px-5">
<span class="text-sm font-medium">
Dev Tools
</span>
<Button
onClick={() => setIsCollapsed(true)}
variant="ghost"
size="icon"
>
<div class="i-tabler-chevron-down size-5" />
</Button>
</div>
</div>
</Show>
</Portal>
);
};

View File

@@ -1,12 +0,0 @@
import type { Component } from 'solid-js';
import { lazy, Show } from 'solid-js';
const DevToolsOverlay = lazy(() => import('./dev-tools-overlay.component').then(m => ({ default: m.DevToolsOverlayComponent })));
export const DevTools: Component = () => {
return (
<Show when={import.meta.env.DEV}>
<DevToolsOverlay />
</Show>
);
};

View File

@@ -1,15 +1,15 @@
import type { ParentComponent } from 'solid-js';
import type { Document } from '../documents.types';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { promptUploadFiles } from '@/modules/shared/files/upload';
import { useI18nApiErrors } from '@/modules/shared/http/composables/i18n-api-errors';
import { cn } from '@/modules/shared/style/cn';
import { Button } from '@/modules/ui/components/button';
import { safely } from '@corentinth/chisels';
import { A } from '@solidjs/router';
import { throttle } from 'lodash-es';
import { createContext, createSignal, For, Match, Show, Switch, useContext } from 'solid-js';
import { Portal } from 'solid-js/web';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { promptUploadFiles } from '@/modules/shared/files/upload';
import { useI18nApiErrors } from '@/modules/shared/http/composables/i18n-api-errors';
import { cn } from '@/modules/shared/style/cn';
import { Button } from '@/modules/ui/components/button';
import { invalidateOrganizationDocumentsQuery } from '../documents.composables';
import { uploadDocument } from '../documents.services';
@@ -17,7 +17,7 @@ const DocumentUploadContext = createContext<{
uploadDocuments: (args: { files: File[]; organizationId: string }) => Promise<void>;
}>();
export function useDocumentUpload({ organizationId }: { organizationId: string }) {
export function useDocumentUpload({ getOrganizationId }: { getOrganizationId: () => string }) {
const context = useContext(DocumentUploadContext);
if (!context) {
@@ -27,11 +27,11 @@ export function useDocumentUpload({ organizationId }: { organizationId: string }
const { uploadDocuments } = context;
return {
uploadDocuments: async ({ files }: { files: File[] }) => uploadDocuments({ files, organizationId }),
uploadDocuments: async ({ files }: { files: File[] }) => uploadDocuments({ files, organizationId: getOrganizationId() }),
promptImport: async () => {
const { files } = await promptUploadFiles();
await uploadDocuments({ files, organizationId });
await uploadDocuments({ files, organizationId: getOrganizationId() });
},
};
}
@@ -50,7 +50,7 @@ type TaskError = {
type Task = TaskSuccess | TaskError | {
file: File;
status: 'pending' | 'uploading' ;
status: 'pending' | 'uploading';
};
export const DocumentUploadProvider: ParentComponent = (props) => {

View File

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

View File

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

View File

@@ -1,10 +1,10 @@
import type { Component } from 'solid-js';
import { useParams } from '@solidjs/router';
import { createSignal } from 'solid-js';
import { promptUploadFiles } from '@/modules/shared/files/upload';
import { queryClient } from '@/modules/shared/query/query-client';
import { cn } from '@/modules/shared/style/cn';
import { Button } from '@/modules/ui/components/button';
import { useParams } from '@solidjs/router';
import { createSignal } from 'solid-js';
import { uploadDocument } from '../documents.services';
export const DocumentUploadArea: Component<{ organizationId?: string }> = (props) => {

View File

@@ -1,7 +1,12 @@
import type { Tag } from '@/modules/tags/tags.types';
import type { TooltipTriggerProps } from '@kobalte/core/tooltip';
import type { ColumnDef } from '@tanstack/solid-table';
import type { Accessor, Component, Setter } from 'solid-js';
import type { Document } from '../documents.types';
import type { Tag } from '@/modules/tags/tags.types';
import { formatBytes } from '@corentinth/chisels';
import { A } from '@solidjs/router';
import { createSolidTable, flexRender, getCoreRowModel, getPaginationRowModel } from '@tanstack/solid-table';
import { For, Match, Show, Switch } from 'solid-js';
import { timeAgo } from '@/modules/shared/date/time-ago';
import { cn } from '@/modules/shared/style/cn';
import { TagLink } from '@/modules/tags/components/tag.component';
@@ -9,10 +14,6 @@ import { Button } from '@/modules/ui/components/button';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/modules/ui/components/select';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/modules/ui/components/table';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/modules/ui/components/tooltip';
import { formatBytes } from '@corentinth/chisels';
import { A } from '@solidjs/router';
import { createSolidTable, flexRender, getCoreRowModel, getPaginationRowModel } from '@tanstack/solid-table';
import { type Accessor, type Component, For, Match, type Setter, Show, Switch } from 'solid-js';
import { getDocumentIcon, getDocumentNameExtension, getDocumentNameWithoutExtension } from '../document.models';
import { DocumentManagementDropdown } from './document-management-dropdown.component';

View File

@@ -1,6 +1,6 @@
import type { Component } from 'solid-js';
import { cn } from '@/modules/shared/style/cn';
import { createSignal, onCleanup } from 'solid-js';
import { cn } from '@/modules/shared/style/cn';
export const GlobalDropArea: Component<{ onFilesDrop?: (args: { files: File[] }) => void }> = (props) => {
const [isDragging, setIsDragging] = createSignal(false);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,3 +1,5 @@
import { readFile } from 'node:fs/promises';
import { glob } from 'tinyglobby';
import { describe, expect, test } from 'vitest';
const rawLocales = import.meta.glob('../../locales/*.yml', { eager: true });
@@ -28,4 +30,41 @@ describe('locales', () => {
});
});
}
test('all keys in en.yml must be used in the app (dynamic keys are manually excluded)', async () => {
const srcFileNames = await glob(['src/**/*.{ts,tsx}', '!src/**/*.test.*', '!src/modules/i18n/locales.types.ts'], { cwd: process.cwd() });
// Exclude keys that are used in dynamic contexts
const dynamicKeysMatchers = [
/^api-errors\./, // api-errors.document.already_exists
/^auth\.register\.providers\.[a-z0-9:]+$/, // auth.register.providers.google
/^webhooks\.events\.documents\.[a-z0-9:]+.description$/, // webhooks.events.organization.organization:created
/^api-keys\.permissions\.[a-z0-9:]+\.[a-z0-9:]+$/, // api-keys.permissions.documents.documents:delete
/^organizations\.members\.roles\.[a-z0-9]+$/, // organizations.members.roles.admin
/^activity\.document\.[a-z0-9:]+$/, // activity.document.created
/^organizations\.invitations\.status\.[a-z0-9:]+$/, // organizations.invitations.status.pending
];
const keys = new Set(
Object
.keys(defaultLocal)
.filter(key => !dynamicKeysMatchers.some(matcher => matcher.test(key))),
);
for (const srcFileName of srcFileNames) {
const fileContent = await readFile(srcFileName, 'utf-8');
for (const key of keys) {
if (fileContent.includes(key)) {
keys.delete(key);
}
}
if (keys.size === 0) {
break;
}
}
expect([...keys]).to.eql([], 'Unused keys found in en.yml, please remove them (or add them to the dynamic keys matchers in locales.test.ts if they are used in dynamic contexts)');
});
});

View File

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

View File

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

View File

@@ -0,0 +1,15 @@
import { useQuery } from '@tanstack/solid-query';
import { fetchPendingInvitationsCount } from '../invitations.services';
export function usePendingInvitationsCount() {
const query = useQuery(() => ({
queryKey: ['invitations', 'count'],
staleTime: 1000 * 60 * 5, // 5 minutes
queryFn: fetchPendingInvitationsCount,
}));
return {
...query,
getPendingInvitationsCount: () => query.data?.pendingInvitationsCount ?? 0,
};
}

View File

@@ -0,0 +1,54 @@
import type { Organization } from '../organizations/organizations.types';
import { apiClient } from '../shared/http/api-client';
import { coerceDates } from '../shared/http/http-client.models';
export async function fetchInvitations() {
const { invitations } = await apiClient<{ invitations: { id: string; organization: Organization }[] }>({
path: '/api/invitations',
method: 'GET',
});
return {
invitations: invitations.map(i => ({
...coerceDates(i),
organization: coerceDates(i.organization),
})),
};
}
export async function fetchPendingInvitationsCount() {
const { pendingInvitationsCount } = await apiClient<{ pendingInvitationsCount: number }>({
path: '/api/invitations/count',
method: 'GET',
});
return { pendingInvitationsCount };
}
export async function acceptInvitation({ invitationId }: { invitationId: string }) {
await apiClient({
path: `/api/invitations/${invitationId}/accept`,
method: 'POST',
});
}
export async function rejectInvitation({ invitationId }: { invitationId: string }) {
await apiClient({
path: `/api/invitations/${invitationId}/reject`,
method: 'POST',
});
}
export async function resendInvitation({ invitationId }: { invitationId: string }) {
await apiClient({
path: `/api/invitations/${invitationId}/resend`,
method: 'POST',
});
}
export async function cancelInvitation({ invitationId }: { invitationId: string }) {
await apiClient({
path: `/api/invitations/${invitationId}/cancel`,
method: 'POST',
});
}

View File

@@ -0,0 +1,106 @@
import type { Component } from 'solid-js';
import { useMutation, useQuery } from '@tanstack/solid-query';
import { createSolidTable, flexRender, getCoreRowModel, getPaginationRowModel } from '@tanstack/solid-table';
import { For, Show } from 'solid-js';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { timeAgo } from '@/modules/shared/date/time-ago';
import { queryClient } from '@/modules/shared/query/query-client';
import { Button } from '@/modules/ui/components/button';
import { EmptyState } from '@/modules/ui/components/empty';
import { createToast } from '@/modules/ui/components/sonner';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/modules/ui/components/table';
import { acceptInvitation, fetchInvitations, rejectInvitation } from '../invitations.services';
export const InvitationsPage: Component = () => {
const { t } = useI18n();
const query = useQuery(() => ({
queryKey: ['invitations'],
queryFn: fetchInvitations,
}));
const acceptInvitationMutation = useMutation(() => ({
mutationFn: acceptInvitation,
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ['invitations'] });
await queryClient.invalidateQueries({ queryKey: ['organizations'] });
createToast({
message: t('invitations.list.actions.accept.success.message'),
description: t('invitations.list.actions.accept.success.description'),
});
},
}));
const rejectInvitationMutation = useMutation(() => ({
mutationFn: rejectInvitation,
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ['invitations'] });
createToast({
message: t('invitations.list.actions.reject.success.message'),
description: t('invitations.list.actions.reject.success.description'),
});
},
}));
const table = createSolidTable({
get data() {
return query.data?.invitations ?? [];
},
columns: [
{
header: t('invitations.list.headers.organization'),
accessorKey: 'organization.name',
},
{
header: t('invitations.list.headers.created'),
accessorKey: 'createdAt',
cell: data => <time dateTime={data.getValue()}>{timeAgo({ date: data.getValue() })}</time>,
},
{
header: () => <div class="text-right">{t('invitations.list.headers.actions')}</div>,
id: 'actions',
cell: data => (
<div class="flex items-center justify-end gap-2">
<Button size="sm" onClick={() => acceptInvitationMutation.mutate({ invitationId: data.row.original.id })}>
{t('invitations.list.actions.accept')}
</Button>
<Button size="sm" variant="destructive" onClick={() => rejectInvitationMutation.mutate({ invitationId: data.row.original.id })}>
{t('invitations.list.actions.reject')}
</Button>
</div>
),
},
],
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
});
return (
<div class="p-6 mt-12 pb-32 mx-auto max-w-xl w-full">
<div class="border-b pb-4 mb-6">
<h1 class="text-2xl font-semibold mb-1">{t('invitations.list.title')}</h1>
<p class="text-muted-foreground">{t('invitations.list.description')}</p>
</div>
<Show when={query.data?.invitations.length} fallback={<EmptyState title={t('invitations.list.empty.title')} icon="i-tabler-mail" description={t('invitations.list.empty.description')} />}>
<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>
);
};

View File

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

View File

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

View File

@@ -1,16 +1,20 @@
import { useNavigate, useParams } from '@solidjs/router';
import { useQuery } from '@tanstack/solid-query';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { queryClient } from '@/modules/shared/query/query-client';
import { createToast } from '@/modules/ui/components/sonner';
import { useNavigate } from '@solidjs/router';
import { createOrganization, deleteOrganization, updateOrganization } from './organizations.services';
import { ORGANIZATION_ROLES } from './organizations.constants';
import { createOrganization, deleteOrganization, getMembership, updateOrganization } from './organizations.services';
export function useCreateOrganization() {
const navigate = useNavigate();
const { t } = useI18n();
return {
createOrganization: async ({ organizationName }: { organizationName: string }) => {
const { organization } = await createOrganization({ name: organizationName });
createToast({ type: 'success', message: 'Organization created' });
createToast({ type: 'success', message: t('organizations.create.success') });
await queryClient.invalidateQueries({
queryKey: ['organizations'],
@@ -50,3 +54,29 @@ export function useDeleteOrganization() {
},
};
}
export function useCurrentUserRole({ organizationId }: { organizationId?: string } = {}) {
const params = useParams();
const getOrganizationId = () => organizationId ?? params.organizationId;
const query = useQuery(() => ({
queryKey: ['organizations', getOrganizationId(), 'members', 'me'],
queryFn: () => getMembership({ organizationId: getOrganizationId() }),
}));
const getRole = () => query.data?.member.role;
const getIsMember = () => getRole() === ORGANIZATION_ROLES.MEMBER;
const getIsAdmin = () => getRole() === ORGANIZATION_ROLES.ADMIN;
const getIsOwner = () => getRole() === ORGANIZATION_ROLES.OWNER;
const getIsAtLeastAdmin = () => getIsAdmin() || getIsOwner();
return {
query,
getRole,
getIsMember,
getIsAdmin,
getIsOwner,
getIsAtLeastAdmin,
};
}

View File

@@ -0,0 +1,17 @@
export const ORGANIZATION_ROLES = {
OWNER: 'owner',
ADMIN: 'admin',
MEMBER: 'member',
} as const;
export const ORGANIZATION_ROLES_LIST = Object.values(ORGANIZATION_ROLES);
export const ORGANIZATION_INVITATION_STATUS = {
PENDING: 'pending',
ACCEPTED: 'accepted',
REJECTED: 'rejected',
EXPIRED: 'expired',
CANCELLED: 'cancelled',
} as const;
export const ORGANIZATION_INVITATION_STATUS_LIST = Object.values(ORGANIZATION_INVITATION_STATUS);

View File

@@ -0,0 +1,26 @@
import type { OrganizationMemberRole } from './organizations.types';
import { ORGANIZATION_ROLES } from './organizations.constants';
export function getIsMemberRoleDisabled({
currentUserRole,
memberRole,
targetRole,
}: {
currentUserRole?: OrganizationMemberRole;
memberRole: OrganizationMemberRole;
targetRole: OrganizationMemberRole;
}) {
if (currentUserRole === ORGANIZATION_ROLES.MEMBER) {
return true;
}
if (memberRole === ORGANIZATION_ROLES.OWNER) {
return true;
}
if (targetRole === ORGANIZATION_ROLES.OWNER) {
return true;
}
return false;
}

View File

@@ -1,8 +1,16 @@
import type { AsDto } from '../shared/http/http-client.types';
import type { Organization } from './organizations.types';
import type { Organization, OrganizationInvitation, OrganizationMember, OrganizationMemberRole } from './organizations.types';
import { apiClient } from '../shared/http/api-client';
import { coerceDates } from '../shared/http/http-client.models';
export async function inviteOrganizationMember({ organizationId, email, role }: { organizationId: string; email: string; role: OrganizationMemberRole }) {
await apiClient({
path: `/api/organizations/${organizationId}/members/invitations`,
method: 'POST',
body: { email, role },
});
}
export async function fetchOrganizations() {
const { organizations } = await apiClient<{ organizations: AsDto<Organization>[] }>({
path: '/api/organizations',
@@ -55,3 +63,55 @@ export async function deleteOrganization({ organizationId }: { organizationId: s
method: 'DELETE',
});
}
export async function fetchOrganizationMembers({ organizationId }: { organizationId: string }) {
const { members } = await apiClient<{ members: AsDto<OrganizationMember>[] }>({
path: `/api/organizations/${organizationId}/members`,
method: 'GET',
});
return {
members: members.map(({ user, ...rest }) => coerceDates({ user: coerceDates(user), ...rest })),
};
}
export async function fetchOrganizationInvitations({ organizationId }: { organizationId: string }) {
const { invitations } = await apiClient<{ invitations: AsDto<OrganizationInvitation>[] }>({
path: `/api/organizations/${organizationId}/members/invitations`,
method: 'GET',
});
return {
invitations: invitations.map(coerceDates),
};
}
export async function removeOrganizationMember({ organizationId, memberId }: { organizationId: string; memberId: string }) {
await apiClient({
path: `/api/organizations/${organizationId}/members/${memberId}`,
method: 'DELETE',
});
}
export async function getMembership({ organizationId }: { organizationId: string }) {
const { member } = await apiClient<{ member: AsDto<OrganizationMember> }>({
path: `/api/organizations/${organizationId}/members/me`,
method: 'GET',
});
return {
member: coerceDates(member),
};
}
export async function updateOrganizationMemberRole({ organizationId, memberId, role }: { organizationId: string; memberId: string; role: OrganizationMemberRole }) {
const { member } = await apiClient<{ member: AsDto<OrganizationMember> }>({
path: `/api/organizations/${organizationId}/members/${memberId}`,
method: 'PATCH',
body: { role },
});
return {
member: coerceDates(member),
};
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,172 @@
import type { Component } from 'solid-js';
import { setValue } from '@modular-forms/solid';
import { useNavigate, useParams } from '@solidjs/router';
import { useMutation } from '@tanstack/solid-query';
import { onMount, Show } from 'solid-js';
import * as v from 'valibot';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { createForm } from '@/modules/shared/form/form';
import { useI18nApiErrors } from '@/modules/shared/http/composables/i18n-api-errors';
import { queryClient } from '@/modules/shared/query/query-client';
import { Button } from '@/modules/ui/components/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/modules/ui/components/select';
import { createToast } from '@/modules/ui/components/sonner';
import {
TextField,
TextFieldLabel,
TextFieldRoot,
} from '@/modules/ui/components/textfield';
import { useCurrentUserRole } from '../organizations.composables';
import { ORGANIZATION_ROLES } from '../organizations.constants';
import { inviteOrganizationMember } from '../organizations.services';
type InvitableRole = 'member' | 'admin';
export const InviteMemberPage: Component = () => {
const { t } = useI18n();
const params = useParams();
const { getErrorMessage } = useI18nApiErrors({ t });
const { getIsAtLeastAdmin } = useCurrentUserRole({ organizationId: params.organizationId });
const navigate = useNavigate();
onMount(() => {
if (!getIsAtLeastAdmin()) {
navigate(`/organizations/${params.organizationId}`);
}
});
const tRole = (role: InvitableRole) => t(`organizations.members.roles.${role}`);
const inviteMemberMutation = useMutation(() => ({
mutationFn: ({ email, role }: { email: string; role: InvitableRole }) =>
inviteOrganizationMember({
organizationId: params.organizationId,
email,
role,
}),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ['organizations', params.organizationId, 'invitations'],
});
createToast({
message: t('organizations.invite-member.success.message'),
description: t('organizations.invite-member.success.description'),
type: 'success',
});
navigate(`/organizations/${params.organizationId}/members`);
},
onError: (error) => {
createToast({
message: t('organizations.invite-member.error.message'),
description: getErrorMessage({ error }),
type: 'error',
});
},
}));
const { Form, Field, form } = createForm({
schema: v.object({
email: v.pipe(
v.string(),
v.trim(),
v.email(t('organizations.invite-member.form.email.required')),
v.toLowerCase(),
),
role: v.picklist([ORGANIZATION_ROLES.MEMBER, ORGANIZATION_ROLES.ADMIN]),
}),
initialValues: {
role: ORGANIZATION_ROLES.MEMBER,
},
onSubmit: async ({ email, role }) => {
inviteMemberMutation.mutate({ email, role });
},
});
return (
<div class="p-6 max-w-screen-md mx-auto mt-4">
<div class="border-b mb-6 pb-4">
<h1 class="text-xl font-bold">
{t('organizations.invite-member.title')}
</h1>
<p class="text-sm text-muted-foreground">
{t('organizations.invite-member.description')}
</p>
</div>
<div class="mt-10 max-w-xs mx-auto">
<Form>
<Field name="email">
{(field, inputProps) => (
<TextFieldRoot class="flex flex-col mb-6">
<TextFieldLabel for="email">
{t('organizations.invite-member.form.email.label')}
</TextFieldLabel>
<TextField
type="email"
id="email"
placeholder={t(
'organizations.invite-member.form.email.placeholder',
)}
{...inputProps}
/>
{field.error && (
<div class="text-red-500 text-sm">{field.error}</div>
)}
</TextFieldRoot>
)}
</Field>
<Field name="role">
{field => (
<div>
<label for="role" class="text-sm font-medium mb-1 block">
{t('organizations.invite-member.form.role.label')}
</label>
<Select
id="role"
options={[
ORGANIZATION_ROLES.MEMBER,
ORGANIZATION_ROLES.ADMIN,
]}
itemComponent={props => (
<SelectItem item={props.item}>
{tRole(props.item.rawValue)}
</SelectItem>
)}
value={field.value}
onChange={value =>
setValue(form, 'role', value as InvitableRole)}
>
<SelectTrigger>
<SelectValue<string>>
{state =>
tRole(state.selectedOption() as InvitableRole)}
</SelectValue>
</SelectTrigger>
<SelectContent />
</Select>
</div>
)}
</Field>
<Button type="submit" class="w-full mt-6" isLoading={inviteMemberMutation.isPending}>
{t('organizations.invite-member.form.submit')}
<div class="i-tabler-send size-4 ml-1" />
</Button>
<Show when={inviteMemberMutation.isError}>
<div class="text-red-500 text-sm">
{getErrorMessage({ error: inviteMemberMutation.error })}
</div>
</Show>
</Form>
</div>
</div>
);
};

View File

@@ -0,0 +1,213 @@
import type { Component } from 'solid-js';
import type { OrganizationMemberRole } from '../organizations.types';
import { A, useParams } from '@solidjs/router';
import { useMutation, useQuery } from '@tanstack/solid-query';
import { createSolidTable, flexRender, getCoreRowModel, getPaginationRowModel } from '@tanstack/solid-table';
import { For, Show } from 'solid-js';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { useConfirmModal } from '@/modules/shared/confirm';
import { useI18nApiErrors } from '@/modules/shared/http/composables/i18n-api-errors';
import { queryClient } from '@/modules/shared/query/query-client';
import { Button } from '@/modules/ui/components/button';
import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuGroupLabel, DropdownMenuItem, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/modules/ui/components/dropdown-menu';
import { createToast } from '@/modules/ui/components/sonner';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/modules/ui/components/table';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/modules/ui/components/tooltip';
import { useCurrentUserRole } from '../organizations.composables';
import { ORGANIZATION_ROLES } from '../organizations.constants';
import { getIsMemberRoleDisabled } from '../organizations.models';
import { fetchOrganizationMembers, removeOrganizationMember, updateOrganizationMemberRole } from '../organizations.services';
const MemberList: Component = () => {
const params = useParams();
const { t } = useI18n();
const { confirm } = useConfirmModal();
const query = useQuery(() => ({
queryKey: ['organizations', params.organizationId, 'members'],
queryFn: () => fetchOrganizationMembers({ organizationId: params.organizationId }),
}));
const { getErrorMessage } = useI18nApiErrors({ t });
const { getIsAtLeastAdmin, getRole } = useCurrentUserRole({ organizationId: params.organizationId });
const removeMemberMutation = useMutation(() => ({
mutationFn: ({ memberId }: { memberId: string }) => removeOrganizationMember({ organizationId: params.organizationId, memberId }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['organizations', params.organizationId, 'members'] });
createToast({
message: t('organizations.members.delete.success'),
});
},
}));
const updateMemberRoleMutation = useMutation(() => ({
mutationFn: ({ memberId, role }: { memberId: string; role: OrganizationMemberRole }) => updateOrganizationMemberRole({ organizationId: params.organizationId, memberId, role }),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ['organizations', params.organizationId, 'members'] });
createToast({
message: t('organizations.members.update-role.success'),
});
},
onError: (error) => {
createToast({
message: getErrorMessage({ error }),
type: 'error',
});
},
}));
const handleDelete = async ({ memberId }: { memberId: string }) => {
const confirmed = await confirm({
title: t('organizations.members.delete.confirm.title'),
message: t('organizations.members.delete.confirm.message'),
confirmButton: {
text: t('organizations.members.delete.confirm.confirm-button'),
variant: 'destructive',
},
cancelButton: {
text: t('organizations.members.delete.confirm.cancel-button'),
},
});
if (!confirmed) {
return;
}
removeMemberMutation.mutate({ memberId });
};
const handleUpdateMemberRole = async ({ memberId, role }: { memberId: string; role: OrganizationMemberRole }) => {
await updateMemberRoleMutation.mutateAsync({ memberId, role });
};
const table = createSolidTable({
get data() {
return query.data?.members ?? [];
},
columns: [
{ header: t('organizations.members.table.headers.name'), accessorKey: 'user.name' },
{ header: t('organizations.members.table.headers.email'), accessorKey: 'user.email' },
{ header: t('organizations.members.table.headers.role'), accessorKey: 'role', cell: data => t(`organizations.members.roles.${data.getValue<OrganizationMemberRole>()}`) },
{ header: t('organizations.members.table.headers.actions'), id: 'actions', cell: data => (
<div class="flex items-center justify-end">
<DropdownMenu>
<DropdownMenuTrigger as={Button} variant="ghost" size="icon">
<div class="i-tabler-dots-vertical size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem
onClick={() => handleDelete({ memberId: data.row.original.id })}
disabled={data.row.original.role === ORGANIZATION_ROLES.OWNER || !getIsAtLeastAdmin()}
>
<div class="i-tabler-user-x size-4 mr-2" />
{t('organizations.members.remove-from-organization')}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuGroupLabel class="font-normal">{t('organizations.members.role')}</DropdownMenuGroupLabel>
<DropdownMenuRadioGroup value={data.row.original.role} onChange={role => handleUpdateMemberRole({ memberId: data.row.original.id, role: role as OrganizationMemberRole })}>
<DropdownMenuRadioItem
value={ORGANIZATION_ROLES.OWNER}
disabled={getIsMemberRoleDisabled({ currentUserRole: getRole(), memberRole: data.row.original.role, targetRole: ORGANIZATION_ROLES.OWNER })}
>
{t(`organizations.members.roles.owner`)}
</DropdownMenuRadioItem>
<DropdownMenuRadioItem
value={ORGANIZATION_ROLES.ADMIN}
disabled={getIsMemberRoleDisabled({ currentUserRole: getRole(), memberRole: data.row.original.role, targetRole: ORGANIZATION_ROLES.ADMIN })}
>
{t(`organizations.members.roles.admin`)}
</DropdownMenuRadioItem>
<DropdownMenuRadioItem
value={ORGANIZATION_ROLES.MEMBER}
disabled={getIsMemberRoleDisabled({ currentUserRole: getRole(), memberRole: data.row.original.role, targetRole: ORGANIZATION_ROLES.MEMBER })}
>
{t(`organizations.members.roles.member`)}
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
) },
],
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
});
return (
<div>
<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>
</div>
);
};
export const MembersPage: Component = () => {
const { t } = useI18n();
const params = useParams();
const { getIsAtLeastAdmin } = useCurrentUserRole({ organizationId: params.organizationId });
return (
<div class="p-6 max-w-screen-md mx-auto mt-4">
<div class="border-b mb-6 pb-4 flex justify-between items-center">
<div>
<h1 class="text-xl font-bold">
{t('organizations.members.title')}
</h1>
<p class="text-sm text-muted-foreground">
{t('organizations.members.description')}
</p>
</div>
<Show
when={getIsAtLeastAdmin()}
fallback={(
<Tooltip>
<TooltipTrigger>
<Button disabled>
<div class="i-tabler-plus size-4 mr-2" />
{t('organizations.members.invite-member')}
</Button>
</TooltipTrigger>
<TooltipContent>
{t('organizations.members.invite-member-disabled-tooltip')}
</TooltipContent>
</Tooltip>
)}
>
<div class="flex items-center gap-2">
<Button as={A} href={`/organizations/${params.organizationId}/invitations`} variant="outline">
<div class="i-tabler-mail size-4 mr-2" />
{t('organizations.invitations.title')}
</Button>
<Button as={A} href={`/organizations/${params.organizationId}/invite`}>
<div class="i-tabler-plus size-4 mr-2" />
{t('organizations.members.invite-member')}
</Button>
</div>
</Show>
</div>
<MemberList />
</div>
);
};

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
import { buildTimeConfig } from '@/modules/config/config';
import type { HttpClientOptions, ResponseType } from './http-client';
import { safely } from '@corentinth/chisels';
import { httpClient, type HttpClientOptions, type ResponseType } from './http-client';
import { buildTimeConfig } from '@/modules/config/config';
import { httpClient } from './http-client';
import { isHttpErrorWithStatusCode } from './http-errors';
export async function apiClient<T, R extends ResponseType = 'json'>({

View File

@@ -1,6 +1,6 @@
import type { LocaleKeys } from '@/modules/i18n/locales.types';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { get } from 'lodash-es';
import { useI18n } from '@/modules/i18n/i18n.provider';
export function useI18nApiErrors({ t = useI18n().t }: { t?: ReturnType<typeof useI18n>['t'] } = {}) {
const getTranslationFromApiErrorCode = ({ code }: { code: string }) => {

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