Compare commits

...

47 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
221 changed files with 14982 additions and 3473 deletions

View File

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

View File

@@ -15,6 +15,7 @@ jobs:
contents: write contents: write
pull-requests: write pull-requests: write
id-token: write id-token: write
actions: write
steps: steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
@@ -40,4 +41,13 @@ jobs:
title: "chore(release): update versions" title: "chore(release): update versions"
env: env:
GITHUB_TOKEN: ${{ secrets.CHANGESET_GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.CHANGESET_GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_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. - This command will update the file [`locales.types.ts`](./apps/papra-client/src/modules/i18n/locale.types.ts) with the new/removed keys.
- When developing in papra-client (using `pnpm dev`), the i18n types definition will automatically update when you touch the [`en.yml`](./apps/papra-client/src/locales/en.yml) file, so no need to run the command above. - When developing in papra-client (using `pnpm dev`), **the i18n types definition will automatically update** when you touch the [`en.yml`](./apps/papra-client/src/locales/en.yml) file, so no need to run the command above.
> [!TIP]
> You can use the command `pnpm script:sync-i18n-key-order` to sync the order of the keys in the i18n files, it'll also add the missing keys as comments.
## Development Setup ## Development Setup

View File

@@ -39,12 +39,9 @@ A live demo of the platform is available at [demo.papra.app](https://demo.papra.
## Project Status ## 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 Feedback and bug reports are highly appreciated to help us improve the platform.
- ✅ Self-hosting is fully supported
- 🚧 Some advanced features are still in development
- 📝 Feedback and bug reports are highly appreciated
## Features ## Features
@@ -61,13 +58,19 @@ Papra is currently in **beta**. The core functionality is stable and usable, but
- **Content extraction**: Automatically extract text from images or scanned documents for search. - **Content extraction**: Automatically extract text from images or scanned documents for search.
- **Tagging Rules**: Automatically tag documents based on custom rules. - **Tagging Rules**: Automatically tag documents based on custom rules.
- **Folder ingestion**: Automatically import documents from a folder. - **Folder ingestion**: Automatically import documents from a folder.
- *In progress:* **i18n**: Support for multiple languages. - **CLI**: Manage your documents from the command line.
- *Coming soon:* **SDK and API**: Build your own applications on top of Papra. - **API, SDK and webhooks**: Build your own applications on top of Papra.
- *Coming soon:* **CLI**: Manage your documents from the command line. - **i18n**: Support for multiple languages.
- *Coming soon:* **Document sharing**: Share documents with others. - *Coming soon:* **Document sharing**: Share documents with others.
- *Coming soon:* **Document requests**: Generate upload links for people to add documents. - *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:* **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:* **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 ## Self-hosting

View File

@@ -1,5 +1,41 @@
# @papra/docs # @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 ## 0.3.1
### Patch Changes ### Patch Changes

View File

@@ -3,7 +3,9 @@ import starlight from '@astrojs/starlight';
import { defineConfig } from 'astro/config'; import { defineConfig } from 'astro/config';
import starlightLinksValidator from 'starlight-links-validator'; import starlightLinksValidator from 'starlight-links-validator';
import starlightThemeRapide from 'starlight-theme-rapide'; import starlightThemeRapide from 'starlight-theme-rapide';
import UnoCSS from 'unocss/astro';
import { sidebar } from './src/content/navigation'; import { sidebar } from './src/content/navigation';
import posthogRawScript from './src/scripts/posthog.script.js?raw'; import posthogRawScript from './src/scripts/posthog.script.js?raw';
const posthogApiKey = env.POSTHOG_API_KEY; const posthogApiKey = env.POSTHOG_API_KEY;
@@ -16,6 +18,7 @@ const posthogScript = posthogRawScript.replace('[POSTHOG-API-KEY]', posthogApiKe
export default defineConfig({ export default defineConfig({
site: 'https://docs.papra.app', site: 'https://docs.papra.app',
integrations: [ integrations: [
UnoCSS(),
starlight({ starlight({
plugins: [starlightThemeRapide(), starlightLinksValidator({ exclude: ['http://localhost:1221'] })], plugins: [starlightThemeRapide(), starlightLinksValidator({ exclude: ['http://localhost:1221'] })],
title: 'Papra Docs', title: 'Papra Docs',
@@ -38,7 +41,7 @@ export default defineConfig({
sidebar, sidebar,
favicon: '/favicon.svg', favicon: '/favicon.svg',
head: [ head: [
// Add ICO favicon fallback for Safari. // Add ICO favicon fallback for Safari.
{ {
tag: 'link', tag: 'link',
attrs: { attrs: {

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
{ {
"name": "@papra/docs", "name": "@papra/docs",
"type": "module", "type": "module",
"version": "0.3.1", "version": "0.5.1",
"private": true, "private": true,
"packageManager": "pnpm@10.9.0", "packageManager": "pnpm@10.9.0",
"description": "Papra documentation website", "description": "Papra documentation website",
@@ -18,11 +18,16 @@
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"@astrojs/starlight": "^0.34.2", "@astrojs/solid-js": "^5.1.0",
"astro": "^5.7.10", "@astrojs/starlight": "^0.34.3",
"astro": "^5.8.0",
"sharp": "^0.32.5", "sharp": "^0.32.5",
"shiki": "^3.4.2",
"starlight-links-validator": "^0.16.0", "starlight-links-validator": "^0.16.0",
"starlight-theme-rapide": "^0.5.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" "zod-to-json-schema": "^3.24.5"
}, },
"devDependencies": { "devDependencies": {
@@ -35,6 +40,7 @@
"figue": "^2.2.2", "figue": "^2.2.2",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"marked": "^15.0.6", "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'] { :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; --background-color: #0c0d0f!important;
--accent-color: #fff!important; --accent-color: #fff!important;
--foreground-color: #9ea3a2!important; --foreground-color: #9ea3a2!important;
@@ -55,4 +89,8 @@
.site-title img { .site-title img {
width: 1.8rem !important; width: 1.8rem !important;
} }
pre.shiki {
border-radius: 0.5rem!important;
}

View File

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

View File

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

View File

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

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

@@ -1,6 +1,6 @@
--- ---
title: Papra documentation title: Papra documentation
description: Papra documentation. description: Documentation for Papra, the minimalistic document archiving platform.
hero: hero:
title: Papra Docs title: Papra Docs
tagline: Documentation for Papra, the minimalistic document archiving platform. tagline: Documentation for Papra, the minimalistic document archiving platform.
@@ -53,9 +53,9 @@ In today's digital world, managing countless important documents efficiently and
- **Content extraction**: Automatically extract text from images or scanned documents for search. - **Content extraction**: Automatically extract text from images or scanned documents for search.
- **Tagging Rules**: Automatically tag documents based on custom rules. - **Tagging Rules**: Automatically tag documents based on custom rules.
- **Folder ingestion**: Automatically import documents from a folder. - **Folder ingestion**: Automatically import documents from a folder.
- *In progress:* **i18n**: Support for multiple languages. - **API, SDK and webhooks**: Build your own applications on top of Papra.
- *Coming soon:* **SDK and API**: Build your own applications on top of Papra. - **CLI**: Manage your documents from the command line.
- *Coming soon:* **CLI**: Manage your documents from the command line. - **i18n**: Support for multiple languages.
- *Coming soon:* **Document sharing**: Share documents with others. - *Coming soon:* **Document sharing**: Share documents with others.
- *Coming soon:* **Document requests**: Generate upload links for people to add documents. - *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'; import type { StarlightUserConfig } from '@astrojs/starlight/types';
export const sidebar: StarlightUserConfig['sidebar'] = [ export const sidebar = [
{ {
label: 'Getting Started', label: 'Getting Started',
items: [ items: [
@@ -12,6 +12,7 @@ export const sidebar: StarlightUserConfig['sidebar'] = [
items: [ items: [
{ label: 'Using Docker', slug: 'self-hosting/using-docker' }, { label: 'Using Docker', slug: 'self-hosting/using-docker' },
{ label: 'Using Docker Compose', slug: 'self-hosting/using-docker-compose' }, { 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' }, { label: 'Configuration', slug: 'self-hosting/configuration' },
], ],
}, },
@@ -30,11 +31,19 @@ export const sidebar: StarlightUserConfig['sidebar'] = [
label: 'Setup Ingestion Folder', label: 'Setup Ingestion Folder',
slug: 'guides/setup-ingestion-folder', slug: 'guides/setup-ingestion-folder',
}, },
{
label: 'Setup Custom OAuth2 Providers',
slug: 'guides/setup-custom-oauth2-providers',
},
], ],
}, },
{ {
label: 'Resources', label: 'Resources',
items: [ items: [
{
label: 'Troubleshooting',
slug: 'resources/troubleshooting',
},
{ {
label: 'CLI Documentation', label: 'CLI Documentation',
slug: 'resources/cli', slug: 'resources/cli',
@@ -46,6 +55,7 @@ export const sidebar: StarlightUserConfig['sidebar'] = [
target: '_blank', 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", "extends": "astro/tsconfigs/strict",
"include": [".astro/types.d.ts", "**/*"], "include": [
"exclude": ["dist"] ".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

@@ -1,5 +1,51 @@
# @papra/app-client # @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 ## 0.4.0
### Minor Changes ### Minor Changes

View File

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

View File

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

View File

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

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.title: Reset your password
auth.request-password-reset.description: Enter your email to reset your password. auth.request-password-reset.description: Enter your email to reset your password.
auth.request-password-reset.requested: If an account exists for this email, we've sent you an email to reset your password. auth.request-password-reset.requested: If an account exists for this email, we've sent you an email to reset your password.
@@ -38,7 +40,7 @@ auth.login.form.forgot-password.label: Forgot password?
auth.login.form.submit: Login auth.login.form.submit: Login
auth.register.title: Register to Papra 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-email: Register with email
auth.register.register-with-provider: Register with {{ provider }} auth.register.register-with-provider: Register with {{ provider }}
auth.register.providers.google: Google auth.register.providers.google: Google
@@ -69,22 +71,256 @@ auth.legal-links.description: By continuing, you acknowledge that you understand
auth.legal-links.terms: Terms of Service auth.legal-links.terms: Terms of Service
auth.legal-links.privacy: Privacy Policy auth.legal-links.privacy: Privacy Policy
# User settings
user.settings.title: User settings
user.settings.description: Manage your account settings here.
user.settings.email.title: Email address
user.settings.email.description: Your email address cannot be changed.
user.settings.email.label: Email address
user.settings.name.title: Full name
user.settings.name.description: Your full name is displayed to other organization members.
user.settings.name.label: Full name
user.settings.name.placeholder: Eg. John Doe
user.settings.name.update: Update name
user.settings.name.updated: Your full name has been updated
user.settings.logout.title: Logout
user.settings.logout.description: Logout from your account. You can login again later.
user.settings.logout.button: Logout
# Organizations
organizations.list.title: Your organizations
organizations.list.description: Organizations are a way to group your documents and manage access to them. You can create multiple organizations and invite your team members to collaborate.
organizations.list.create-new: Create new organization
organizations.details.no-documents.title: No documents
organizations.details.no-documents.description: There are no documents in this organization yet. Start by uploading some documents.
organizations.details.upload-documents: Upload documents
organizations.details.documents-count: documents in total
organizations.details.total-size: total size
organizations.details.latest-documents: Latest imported documents
organizations.create.title: Create a new organization
organizations.create.description: Your documents will be grouped by organization. You can create multiple organizations to separate your documents, for example, for personal and work documents.
organizations.create.back: Back
organizations.create.error.max-count-reached: You have reached the maximum number of organizations you can create, if you need to create more, please contact support.
organizations.create.form.name.label: Organization name
organizations.create.form.name.placeholder: Eg. Acme Inc.
organizations.create.form.name.required: Please enter an organization name
organizations.create.form.submit: Create organization
organizations.create.success: Organization created successfully
organizations.create-first.title: Create your organization
organizations.create-first.description: Your documents will be grouped by organization. You can create multiple organizations to separate your documents, for example, for personal and work documents.
organizations.create-first.default-name: My organization
organizations.create-first.user-name: "{{ name }}'s organization"
organization.settings.title: Organization Settings
organization.settings.page.title: Organization settings
organization.settings.page.description: Manage your organization settings here.
organization.settings.name.title: Organization name
organization.settings.name.update: Update name
organization.settings.name.placeholder: Eg. Acme Inc.
organization.settings.name.updated: Organization name updated
organization.settings.subscription.title: Subscription
organization.settings.subscription.description: Manage your billing, invoices and payment methods.
organization.settings.subscription.manage: Manage subscription
organization.settings.subscription.error: Failed to get customer portal URL
organization.settings.delete.title: Delete organization
organization.settings.delete.description: Deleting this organization will permanently remove all data associated with it.
organization.settings.delete.confirm.title: Delete organization
organization.settings.delete.confirm.message: Are you sure you want to delete this organization? This action cannot be undone, and all data associated with this organization will be permanently removed.
organization.settings.delete.confirm.confirm-button: Delete organization
organization.settings.delete.confirm.cancel-button: Cancel
organization.settings.delete.success: Organization deleted
organizations.members.title: Members
organizations.members.description: Manage your organization members
organizations.members.invite-member: Invite member
organizations.members.invite-member-disabled-tooltip: Only admins or owners can invite members to the organization
organizations.members.remove-from-organization: Remove from organization
organizations.members.role: Role
organizations.members.roles.owner: Owner
organizations.members.roles.admin: Admin
organizations.members.roles.member: Member
organizations.members.delete.confirm.title: Remove member
organizations.members.delete.confirm.message: Are you sure you want to remove this member from the organization?
organizations.members.delete.confirm.confirm-button: Remove
organizations.members.delete.confirm.cancel-button: Cancel
organizations.members.delete.success: Member removed from organization
organizations.members.update-role.success: Member role updated
organizations.members.table.headers.name: Name
organizations.members.table.headers.email: Email
organizations.members.table.headers.role: Role
organizations.members.table.headers.created: Created
organizations.members.table.headers.actions: Actions
organizations.invite-member.title: Invite member
organizations.invite-member.description: Invite a member to your organization
organizations.invite-member.form.email.label: Email
organizations.invite-member.form.email.placeholder: 'Example: ada@papra.app'
organizations.invite-member.form.email.required: Please enter a valid email address
organizations.invite-member.form.role.label: Role
organizations.invite-member.form.submit: Invite to organization
organizations.invite-member.success.message: Member invited
organizations.invite-member.success.description: The email has been invited to the organization.
organizations.invite-member.error.message: Failed to invite member
organizations.invitations.title: Invitations
organizations.invitations.description: Manage your organization invitations
organizations.invitations.list.cta: Invite member
organizations.invitations.list.empty.title: No pending invitations
organizations.invitations.list.empty.description: You haven't been invited to any organizations yet.
organizations.invitations.status.pending: Pending
organizations.invitations.status.accepted: Accepted
organizations.invitations.status.rejected: Rejected
organizations.invitations.status.expired: Expired
organizations.invitations.status.cancelled: Cancelled
organizations.invitations.resend: Resend invitation
organizations.invitations.cancel.title: Cancel invitation
organizations.invitations.cancel.description: Are you sure you want to cancel this invitation?
organizations.invitations.cancel.confirm: Cancel invitation
organizations.invitations.cancel.cancel: Cancel
organizations.invitations.resend.title: Resend invitation
organizations.invitations.resend.description: Are you sure you want to resend this invitation? This will send a new email to the recipient.
organizations.invitations.resend.confirm: Resend invitation
organizations.invitations.resend.cancel: Cancel
invitations.list.title: Invitations
invitations.list.description: Manage your organization invitations
invitations.list.empty.title: No pending invitations
invitations.list.empty.description: You haven't been invited to any organizations yet.
invitations.list.headers.organization: Organization
invitations.list.headers.status: Status
invitations.list.headers.created: Created
invitations.list.headers.actions: Actions
invitations.list.actions.accept: Accept
invitations.list.actions.reject: Reject
invitations.list.actions.accept.success.message: Invitation accepted
invitations.list.actions.accept.success.description: The invitation has been accepted.
invitations.list.actions.reject.success.message: Invitation rejected
invitations.list.actions.reject.success.description: The invitation has been rejected.
# Documents
documents.list.title: Documents
documents.list.no-documents.title: No documents
documents.list.no-documents.description: There are no documents in this organization yet. Start by uploading some documents.
documents.list.no-results: No documents found
documents.tabs.info: Info
documents.tabs.content: Content
documents.tabs.activity: Activity
documents.deleted.message: This document has been deleted and will be permanently removed in {{ days }} days.
documents.actions.download: Download
documents.actions.open-in-new-tab: Open in new tab
documents.actions.restore: Restore
documents.actions.delete: Delete
documents.actions.edit: Edit
documents.actions.cancel: Cancel
documents.actions.save: Save
documents.actions.saving: Saving...
documents.content.alert: The content of the document is automatically extracted from the document on upload. It is only used for search and indexing purposes.
documents.info.id: ID
documents.info.name: Name
documents.info.type: Type
documents.info.size: Size
documents.info.created-at: Created At
documents.info.updated-at: Updated At
documents.info.never: Never
documents.rename.title: Rename document
documents.rename.form.name.label: Name
documents.rename.form.name.placeholder: 'Example: Invoice 2024'
documents.rename.form.name.required: Please enter a name for the document
documents.rename.form.name.max-length: The name must be less than 255 characters
documents.rename.form.submit: Rename document
documents.rename.success: Document renamed successfully
documents.rename.cancel: Cancel
import-documents.title.error: '{{ count }} documents failed'
import-documents.title.success: '{{ count }} documents imported'
import-documents.title.pending: '{{ count }} / {{ total }} documents imported'
import-documents.title.none: Import documents
import-documents.no-import-in-progress: No document import in progress
documents.deleted.title: Deleted documents
documents.deleted.empty.title: No deleted documents
documents.deleted.empty.description: You have no deleted documents. Documents that are deleted will be moved to the trash bin for {{ days }} days.
documents.deleted.retention-notice: All deleted documents are stored in the trash bin for {{ days }} days. Passing this delay, the documents will be permanently deleted, and you will not be able to restore them.
documents.deleted.deleted-at: Deleted
documents.deleted.restoring: Restoring...
documents.deleted.deleting: Deleting...
trash.delete-all.button: Delete all
trash.delete-all.confirm.title: Permanently delete all documents?
trash.delete-all.confirm.description: Are you sure you want to permanently delete all documents from the trash? This action cannot be undone.
trash.delete-all.confirm.label: Delete
trash.delete-all.confirm.cancel: Cancel
trash.delete.button: Delete
trash.delete.confirm.title: Permanently delete document?
trash.delete.confirm.description: Are you sure you want to permanently delete this document from the trash? This action cannot be undone.
trash.delete.confirm.label: Delete
trash.delete.confirm.cancel: Cancel
trash.deleted.success.title: Document deleted
trash.deleted.success.description: The document has been permanently deleted.
activity.document.created: The document has been created
activity.document.updated.single: The {{ field }} has been updated
activity.document.updated.multiple: The {{ fields }} have been updated
activity.document.updated: The document has been updated
activity.document.deleted: The document has been deleted
activity.document.restored: The document has been restored
activity.document.tagged: Tag {{ tag }} has been added
activity.document.untagged: Tag {{ tag }} has been removed
activity.document.user.name: by {{ name }}
activity.load-more: Load more
activity.no-more-activities: No more activities for this document
# Tags
tags.no-tags.title: No tags yet tags.no-tags.title: No tags yet
tags.no-tags.description: This organization has no tags yet. Tags are used to categorize documents. You can add tags to your documents to make them easier to find and organize. tags.no-tags.description: This organization has no tags yet. Tags are used to categorize documents. You can add tags to your documents to make them easier to find and organize.
tags.no-tags.create-tag: Create tag tags.no-tags.create-tag: Create tag
layout.menu.home: Home tags.title: Documents Tags
layout.menu.documents: Documents tags.description: Tags are used to categorize documents. You can add tags to your documents to make them easier to find and organize.
layout.menu.tags: Tags tags.create: Create tag
layout.menu.tagging-rules: Tagging rules tags.update: Update tag
layout.menu.deleted-documents: Deleted documents tags.delete: Delete tag
layout.menu.organization-settings: Settings tags.delete.confirm.title: Delete tag
layout.menu.api-keys: API keys tags.delete.confirm.message: Are you sure you want to delete this tag? Deleting a tag will remove it from all documents.
layout.menu.settings: Settings tags.delete.confirm.confirm-button: Delete
layout.menu.account: Account tags.delete.confirm.cancel-button: Cancel
layout.menu.general-settings: General settings tags.delete.success: Tag deleted successfully
layout.menu.intake-emails: Intake emails tags.create.success: Tag "{{ name }}" created successfully.
layout.menu.webhooks: Webhooks tags.update.success: Tag "{{ name }}" updated successfully.
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.name: document name
tagging-rules.field.content: document content tagging-rules.field.content: document content
@@ -135,36 +371,42 @@ tagging-rules.update.error: Failed to update tagging rule
tagging-rules.update.submit: Update rule tagging-rules.update.submit: Update rule
tagging-rules.update.cancel: Cancel tagging-rules.update.cancel: Cancel
demo.popup.description: This is a demo environment, all data is save to your browser local storage. # Intake emails
demo.popup.discord: Join the {{ discordLink }} to get support, propose features or just chat.
demo.popup.discord-link-label: Discord server
demo.popup.reset: Reset demo data
demo.popup.hide: Hide
trash.delete-all.button: Delete all intake-emails.title: Intake Emails
trash.delete-all.confirm.title: Permanently delete all documents? intake-emails.description: Intake emails address are used to automatically ingest emails into Papra. Just forward emails to the intake email address and their attachments will be added to your organization's documents.
trash.delete-all.confirm.description: Are you sure you want to permanently delete all documents from the trash? This action cannot be undone. intake-emails.disabled.title: Intake Emails are disabled
trash.delete-all.confirm.label: Delete intake-emails.disabled.description: Intake emails are disabled on this instance. Please contact your administrator to enable them. See the {{ documentation }} for more information.
trash.delete-all.confirm.cancel: Cancel intake-emails.disabled.documentation: documentation
trash.delete.button: Delete intake-emails.info: Only enabled intake emails from allowed origins will be processed. You can enable or disable an intake email at any time.
trash.delete.confirm.title: Permanently delete document? intake-emails.empty.title: No intake emails
trash.delete.confirm.description: Are you sure you want to permanently delete this document from the trash? This action cannot be undone. intake-emails.empty.description: Generate an intake address to easily ingest emails attachments.
trash.delete.confirm.label: Delete intake-emails.empty.generate: Generate intake email
trash.delete.confirm.cancel: Cancel intake-emails.count: '{{ count }} intake email{{ plural }} for this organization'
trash.deleted.success.title: Document deleted intake-emails.new: New intake email
trash.deleted.success.description: The document has been permanently deleted. intake-emails.disabled-label: (Disabled)
intake-emails.no-origins: No allowed email origins
intake-emails.allowed-origins: Allowed from {{ count }} address{{ plural }}
intake-emails.actions.enable: Enable
intake-emails.actions.disable: Disable
intake-emails.actions.manage-origins: Manage origins addresses
intake-emails.actions.delete: Delete
intake-emails.delete.confirm.title: Delete intake email?
intake-emails.delete.confirm.message: Are you sure you want to delete this intake email? This action cannot be undone.
intake-emails.delete.confirm.confirm-button: Delete intake email
intake-emails.delete.confirm.cancel-button: Cancel
intake-emails.delete.success: Intake email deleted
intake-emails.create.success: Intake email created
intake-emails.update.success.enabled: Intake email enabled
intake-emails.update.success.disabled: Intake email disabled
intake-emails.allowed-origins.title: Allowed origins
intake-emails.allowed-origins.description: Only emails sent to {{ email }} from these origins will be processed. If no origins are specified, all emails will be discarded.
intake-emails.allowed-origins.add.label: Add allowed origin email
intake-emails.allowed-origins.add.placeholder: Eg. ada@papra.app
intake-emails.allowed-origins.add.button: Add
intake-emails.allowed-origins.add.error.exists: This email is already in the allowed origins for this intake email
import-documents.title.error: '{{ count }} documents failed' # API keys
import-documents.title.success: '{{ count }} documents imported'
import-documents.title.pending: '{{ count }} / {{ total }} documents imported'
import-documents.title.none: Import documents
import-documents.no-import-in-progress: No document import in progress
api-errors.document.already_exists: The document already exists
api-errors.document.file_too_big: The document file is too big
api-errors.intake_email.limit_reached: The maximum number of intake emails for this organization has been reached. Please upgrade your plan to create more intake emails.
api-errors.user.max_organization_count_reached: You have reached the maximum number of organizations you can create, if you need to create more, please contact support.
api-errors.default: An error occurred while processing your request.
api-keys.permissions.documents.title: Documents api-keys.permissions.documents.title: Documents
api-keys.permissions.documents.documents:create: Create documents api-keys.permissions.documents.documents:create: Create documents
@@ -202,6 +444,8 @@ api-keys.delete.confirm.message: Are you sure you want to delete this API key? T
api-keys.delete.confirm.confirm-button: Delete api-keys.delete.confirm.confirm-button: Delete
api-keys.delete.confirm.cancel-button: Cancel api-keys.delete.confirm.cancel-button: Cancel
# Webhooks
webhooks.list.title: Webhooks webhooks.list.title: Webhooks
webhooks.list.description: Manage your organization webhooks webhooks.list.description: Manage your organization webhooks
webhooks.list.empty.title: No webhooks webhooks.list.empty.title: No webhooks
@@ -210,7 +454,6 @@ webhooks.list.create: Create webhook
webhooks.list.card.last-triggered: Last triggered webhooks.list.card.last-triggered: Last triggered
webhooks.list.card.never: Never webhooks.list.card.never: Never
webhooks.list.card.created: Created webhooks.list.card.created: Created
webhooks.create.title: Create webhook webhooks.create.title: Create webhook
webhooks.create.description: Create a new webhook to receive events webhooks.create.description: Create a new webhook to receive events
webhooks.create.success: Webhook created successfully webhooks.create.success: Webhook created successfully
@@ -243,3 +486,67 @@ webhooks.delete.confirm.cancel-button: Cancel
webhooks.events.documents.document:created.description: Document created webhooks.events.documents.document:created.description: Document created
webhooks.events.documents.document:deleted.description: Document deleted webhooks.events.documents.document:deleted.description: Document deleted
# 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.title: Réinitialiser votre mot de passe
auth.request-password-reset.description: Entrez votre email pour réinitialiser votre mot de passe. auth.request-password-reset.description: Entrez votre email pour réinitialiser votre mot de passe.
auth.request-password-reset.requested: Si un compte existe pour cet email, nous vous avons envoyé un email pour réinitialiser votre mot de passe. auth.request-password-reset.requested: Si un compte existe pour cet email, nous vous avons envoyé un email pour réinitialiser votre mot de passe.
@@ -38,7 +40,7 @@ auth.login.form.forgot-password.label: Mot de passe oublié ?
auth.login.form.submit: Connexion auth.login.form.submit: Connexion
auth.register.title: S'inscrire à Papra 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-email: S'inscrire avec email
auth.register.register-with-provider: S'inscrire avec {{ provider }} auth.register.register-with-provider: S'inscrire avec {{ provider }}
auth.register.providers.google: Google auth.register.providers.google: Google
@@ -69,30 +71,265 @@ auth.legal-links.description: En continuant, vous reconnaissez que vous comprene
auth.legal-links.terms: Conditions d'utilisation auth.legal-links.terms: Conditions d'utilisation
auth.legal-links.privacy: Politique de confidentialité auth.legal-links.privacy: Politique de confidentialité
# User settings
user.settings.title: Paramètres de l'utilisateur
user.settings.description: Gérez vos paramètres de compte ici.
user.settings.email.title: Adresse email
user.settings.email.description: Votre adresse email ne peut pas être modifiée.
user.settings.email.label: Adresse email
user.settings.name.title: Nom complet
user.settings.name.description: Votre nom complet est affiché aux autres membres de l'organisation.
user.settings.name.label: Nom complet
user.settings.name.placeholder: 'Exemple: John Doe'
user.settings.name.update: Mettre à jour le nom
user.settings.name.updated: Votre nom complet a été mis à jour
user.settings.logout.title: Déconnexion
user.settings.logout.description: Déconnectez-vous de votre compte. Vous pouvez vous reconnecter plus tard.
user.settings.logout.button: Déconnexion
# Organizations
organizations.list.title: Vos organisations
organizations.list.description: Les organisations sont un moyen de grouper vos documents et de gérer l'accès à eux. Vous pouvez créer plusieurs organisations et inviter vos membres de l'équipe à collaborer.
organizations.list.create-new: Créer une nouvelle organisation
organizations.details.no-documents.title: Aucun document
organizations.details.no-documents.description: Il n'y a pas de documents dans cette organisation. Commencez par télécharger des documents.
organizations.details.upload-documents: Télécharger des documents
organizations.details.documents-count: documents en total
organizations.details.total-size: taille totale
organizations.details.latest-documents: Derniers documents importés
organizations.create.title: Créer une nouvelle organisation
organizations.create.description: Vos documents seront regroupés par organisation. Vous pouvez créer plusieurs organisations pour séparer vos documents, par exemple, pour les documents personnels et professionnels.
organizations.create.back: Retour
organizations.create.error.max-count-reached: Vous avez atteint le nombre maximum d'organisations que vous pouvez créer, si vous avez besoin de créer plus, veuillez contacter le support.
organizations.create.form.name.label: Nom de l'organisation
organizations.create.form.name.placeholder: 'Exemple: Acme Inc.'
organizations.create.form.name.required: Veuillez entrer un nom pour l'organisation
organizations.create.form.submit: Créer l'organisation
organizations.create.success: Organisation créée avec succès
organizations.create-first.title: Créer votre organisation
organizations.create-first.description: Vos documents seront regroupés par organisation. Vous pouvez créer plusieurs organisations pour séparer vos documents, par exemple, pour les documents personnels et professionnels.
organizations.create-first.default-name: Mon organisation
organizations.create-first.user-name: "{{ name }}'s organisation"
organization.settings.title: Paramètres de l'organisation
organization.settings.page.title: Paramètres de l'organisation
organization.settings.page.description: Gérez les paramètres de votre organisation ici.
organization.settings.name.title: Nom de l'organisation
organization.settings.name.update: Modifier le nom
organization.settings.name.placeholder: 'Exemple: Acme Inc.'
organization.settings.name.updated: Nom de l'organisation mis à jour
organization.settings.subscription.title: Subscription
organization.settings.subscription.description: Gérez votre facturation, vos factures et vos méthodes de paiement.
organization.settings.subscription.manage: Gérer la souscription
organization.settings.subscription.error: Échec de la récupération de l'URL du portail client
organization.settings.delete.title: Supprimer l'organisation
organization.settings.delete.description: Supprimer cette organisation supprimera définitivement toutes les données associées à elle.
organization.settings.delete.confirm.title: Supprimer l'organisation
organization.settings.delete.confirm.message: Êtes-vous sûr de vouloir supprimer cette organisation ? Cette action est irréversible, et toutes les données associées à cette organisation seront supprimées définitivement.
organization.settings.delete.confirm.confirm-button: Supprimer l'organisation
organization.settings.delete.confirm.cancel-button: Annuler
organization.settings.delete.success: Organisation supprimée
organizations.members.title: Membres
organizations.members.description: Gérez les membres de votre organisation.
organizations.members.invite-member: Inviter un membre
organizations.members.invite-member-disabled-tooltip: Seuls les administrateurs ou les propriétaires peuvent inviter des membres à l'organisation
organizations.members.remove-from-organization: Retirer de l'organisation
organizations.members.role: Rôle
organizations.members.roles.owner: Propriétaire
organizations.members.roles.admin: Admin
organizations.members.roles.member: Membre
organizations.members.delete.confirm.title: Retirer un membre
organizations.members.delete.confirm.message: Êtes-vous sûr de vouloir retirer ce membre de l'organisation ?
organizations.members.delete.confirm.confirm-button: Retirer
organizations.members.delete.confirm.cancel-button: Annuler
organizations.members.delete.success: Membre retiré de l'organisation
organizations.members.update-role.success: Rôle du membre mis à jour
organizations.members.table.headers.name: Nom
organizations.members.table.headers.email: Email
organizations.members.table.headers.role: Rôle
# organizations.members.table.headers.created: Created
organizations.members.table.headers.actions: Actions
organizations.invite-member.title: Inviter un membre
organizations.invite-member.description: Invite un membre à votre organisation
organizations.invite-member.form.email.label: Email
organizations.invite-member.form.email.placeholder: 'Exemple: ada@papra.app'
organizations.invite-member.form.email.required: Veuillez entrer une adresse email valide
organizations.invite-member.form.role.label: Rôle
organizations.invite-member.form.submit: Inviter à l'organisation
organizations.invite-member.success.message: Membre invité
organizations.invite-member.success.description: L'email a été invité à l'organisation.
organizations.invite-member.error.message: Échec de l'invitation du membre
organizations.invitations.title: Invitations
organizations.invitations.description: Gérez les invitations de votre organisation.
organizations.invitations.list.cta: Inviter un membre
organizations.invitations.list.empty.title: Aucune invitation en attente
organizations.invitations.list.empty.description: Vous n'avez pas été invité à aucune organisation.
organizations.invitations.status.pending: En attente
organizations.invitations.status.accepted: Accepté
organizations.invitations.status.rejected: Refusé
organizations.invitations.status.expired: Expiré
organizations.invitations.status.cancelled: Annulé
organizations.invitations.resend: Renvoyer l'invitation
organizations.invitations.cancel.title: Annuler l'invitation
organizations.invitations.cancel.description: Êtes-vous sûr de vouloir annuler cette invitation ?
organizations.invitations.cancel.confirm: Annuler l'invitation
organizations.invitations.cancel.cancel: Annuler
organizations.invitations.resend.title: Renvoyer l'invitation
organizations.invitations.resend.description: Êtes-vous sûr de vouloir renvoyer cette invitation ? Cela enverra un nouvel email à l'invité.
organizations.invitations.resend.confirm: Renvoyer l'invitation
organizations.invitations.resend.cancel: Annuler
invitations.list.title: Invitations
invitations.list.description: Gérez les invitations de votre organisation.
invitations.list.empty.title: Aucune invitation en attente
invitations.list.empty.description: Vous n'avez pas été invité à aucune organisation.
invitations.list.headers.organization: Organisation
# invitations.list.headers.status: Status
invitations.list.headers.created: Créé
invitations.list.headers.actions: Actions
invitations.list.actions.accept: Accepter
invitations.list.actions.reject: Refuser
invitations.list.actions.accept.success.message: Invitation acceptée
invitations.list.actions.accept.success.description: L'invitation a été acceptée.
invitations.list.actions.reject.success.message: Invitation refusée
invitations.list.actions.reject.success.description: L'invitation a été refusée.
# Documents
documents.list.title: Documents
documents.list.no-documents.title: Aucun document
documents.list.no-documents.description: Il n'y a pas de documents dans cette organisation. Commencez par télécharger des documents.
documents.list.no-results: Aucun document trouvé
documents.tabs.info: Info
documents.tabs.content: Contenu
documents.tabs.activity: Activité
documents.deleted.message: Ce document a été supprimé et sera supprimé définitivement dans {{ days }} jours.
documents.actions.download: Télécharger
documents.actions.open-in-new-tab: Ouvrir dans un nouvel onglet
documents.actions.restore: Restaurer
documents.actions.delete: Supprimer
documents.actions.edit: Modifier
documents.actions.cancel: Annuler
documents.actions.save: Enregistrer
documents.actions.saving: Enregistrement...
documents.content.alert: Le contenu du document est automatiquement extrait du document lors de l'import. Il est uniquement utilisé pour la recherche et l'indexation.
documents.info.id: ID
documents.info.name: Nom
documents.info.type: Type
documents.info.size: Taille
documents.info.created-at: Créé le
documents.info.updated-at: Mis à jour le
documents.info.never: Jamais
documents.rename.title: Renommer le document
documents.rename.form.name.label: Nom
documents.rename.form.name.placeholder: 'Exemple: Facture 2024'
documents.rename.form.name.required: Veuillez entrer un nom pour le document
documents.rename.form.name.max-length: Le nom doit contenir moins de 255 caractères
documents.rename.form.submit: Renommer
documents.rename.success: Document renommé avec succès
documents.rename.cancel: Annuler
import-documents.title.error: '{{ count }} documents ont échoué'
import-documents.title.success: '{{ count }} documents ont été importés'
import-documents.title.pending: '{{ count }} / {{ total }} documents importés'
import-documents.title.none: Importer des documents
import-documents.no-import-in-progress: Aucune importation de documents en cours
documents.deleted.title: Documents supprimés
documents.deleted.empty.title: Aucun document supprimé
documents.deleted.empty.description: Vous n'avez pas de documents supprimés. Les documents supprimés seront déplacés dans la corbeille pour {{ days }} jours.
documents.deleted.retention-notice: Tous les documents supprimés sont stockés dans la corbeille pour {{ days }} jours. Passé ce délai, les documents seront supprimés définitivement, et vous ne pourrez plus les restaurer.
documents.deleted.deleted-at: Supprimé
documents.deleted.restoring: Restauration...
documents.deleted.deleting: Suppression...
trash.delete-all.button: Supprimer tous les documents
trash.delete-all.confirm.title: Supprimer définitivement tous les documents ?
trash.delete-all.confirm.description: Êtes-vous sûr de vouloir supprimer définitivement tous les documents de la corbeille ? Cette action est irréversible.
trash.delete-all.confirm.label: Supprimer
trash.delete-all.confirm.cancel: Annuler
trash.delete.button: Supprimer
trash.delete.confirm.title: Supprimer définitivement le document ?
trash.delete.confirm.description: Êtes-vous sûr de vouloir supprimer définitivement ce document de la corbeille ? Cette action est irréversible.
trash.delete.confirm.label: Supprimer
trash.delete.confirm.cancel: Annuler
trash.deleted.success.title: Document supprimé
trash.deleted.success.description: Le document a été supprimé définitivement.
activity.document.created: Le document a été créé
activity.document.updated.single: Le {{ field }} a été mis à jour
activity.document.updated.multiple: Les {{ fields }} ont été mis à jour
activity.document.updated: Le document a été mis à jour
activity.document.deleted: Le document a été supprimé
activity.document.restored: Le document a été restauré
activity.document.tagged: Le tag {{ tag }} a été ajouté
activity.document.untagged: Le tag {{ tag }} a été supprimé
activity.document.user.name: par {{ name }}
activity.load-more: Charger plus
activity.no-more-activities: Aucune activité pour ce document
# Tags
tags.no-tags.title: Aucun tag tags.no-tags.title: Aucun tag
tags.no-tags.description: Cette organisation n'a pas de tags. Les tags sont utilisés pour catégoriser les documents. Vous pouvez ajouter des tags à vos documents pour les rendre plus faciles à trouver et à organiser. tags.no-tags.description: Cette organisation n'a pas de tags. Les tags sont utilisés pour catégoriser les documents. Vous pouvez ajouter des tags à vos documents pour les rendre plus faciles à trouver et à organiser.
tags.no-tags.create-tag: Créer un tag tags.no-tags.create-tag: Créer un tag
layout.menu.home: Accueil tags.title: Tags de documents
layout.menu.documents: Documents tags.description: Les tags sont utilisés pour catégoriser les documents. Vous pouvez ajouter des tags à vos documents pour les rendre plus faciles à trouver et à organiser.
layout.menu.tags: Tags tags.create: Créer un tag
layout.menu.tagging-rules: Règles de catégorisation tags.update: Mettre à jour un tag
layout.menu.deleted-documents: Documents supprimés tags.delete: Supprimer un tag
layout.menu.organization-settings: Paramètres tags.delete.confirm.title: Supprimer un tag
layout.menu.api-keys: API keys tags.delete.confirm.message: Êtes-vous sûr de vouloir supprimer ce tag ? Supprimer un tag supprimera toutes les règles de catégorisation qui l'utilisent.
layout.menu.settings: Paramètres tags.delete.confirm.confirm-button: Supprimer
layout.menu.account: Compte tags.delete.confirm.cancel-button: Annuler
tags.delete.success: Tag supprimé avec succès
tags.create.success: Tag "{{ name }}" créé avec succès.
tags.update.success: Tag "{{ name }}" mis à jour avec succès.
tags.form.name.label: Nom
tags.form.name.placeholder: 'Exemple: Contrats'
tags.form.name.required: Veuillez entrer un nom pour le tag
tags.form.name.max-length: Le nom du tag doit contenir moins de 64 caractères
tags.form.color.label: Couleur
tags.form.color.placeholder: 'Exemple: #FF0000'
tags.form.color.required: Veuillez entrer une couleur
tags.form.color.invalid: La couleur hexadécimale est mal formatée.
tags.form.description.label: Description
tags.form.description.optional: (optionnel)
tags.form.description.placeholder: "Exemple: Tous les contrats signés par l'entreprise"
tags.form.description.max-length: La description doit contenir moins de 256 caractères
tags.form.no-description: Aucune description
tags.table.headers.tag: Tag
tags.table.headers.description: Description
tags.table.headers.documents: Documents
tags.table.headers.created: Date de création
tags.table.headers.actions: Actions
# Tagging rules
tagging-rules.field.name: nom du document tagging-rules.field.name: nom du document
tagging-rules.field.content: contenu du document tagging-rules.field.content: contenu du document
tagging-rules.operator.equals: égal à tagging-rules.operator.equals: égal à
tagging-rules.operator.not-equals: différent de tagging-rules.operator.not-equals: différent de
tagging-rules.operator.contains: contient tagging-rules.operator.contains: contient
tagging-rules.operator.not-contains: ne contient pas tagging-rules.operator.not-contains: ne contient pas
tagging-rules.operator.starts-with: commence par tagging-rules.operator.starts-with: commence par
tagging-rules.operator.ends-with: finit par tagging-rules.operator.ends-with: finit par
tagging-rules.list.title: Règles de catégorisation tagging-rules.list.title: Règles de catégorisation
tagging-rules.list.description: Gérez vos règles de catégorisation, pour catégoriser automatiquement les documents en fonction de conditions que vous définissez. tagging-rules.list.description: Gérez vos règles de catégorisation, pour catégoriser automatiquement les documents en fonction de conditions que vous définissez.
tagging-rules.list.demo-warning: 'Note: Cette instance est une démo, les règles de catégorisation ne seront pas appliquées aux documents ajoutés.' tagging-rules.list.demo-warning: 'Note: Cette instance est une démo, les règles de catégorisation ne seront pas appliquées aux documents ajoutés.'
@@ -134,36 +371,42 @@ tagging-rules.update.error: Échec de la mise à jour de la règle de catégoris
tagging-rules.update.submit: Mettre à jour la règle tagging-rules.update.submit: Mettre à jour la règle
tagging-rules.update.cancel: Annuler tagging-rules.update.cancel: Annuler
demo.popup.description: Cette instance est une démo, toutes les données sont sauvegardées dans le stockage local de votre navigateur. # Intake emails
demo.popup.discord: Rejoignez le {{ discordLink }} pour obtenir de l'aide, proposer des fonctionnalités ou simplement discuter.
demo.popup.discord-link-label: Serveur Discord
demo.popup.reset: Réinitialiser les données de la démo
demo.popup.hide: Masquer
trash.delete-all.button: Supprimer tous les documents intake-emails.title: Adresses de réception
trash.delete-all.confirm.title: Supprimer définitivement tous les documents ? intake-emails.description: Les adresses de réception sont utilisées pour ingérer automatiquement les emails dans Papra. Il suffit de les envoyer à l'adresse de réception et leurs pièces jointes seront ajoutées à vos documents.
trash.delete-all.confirm.description: Êtes-vous sûr de vouloir supprimer définitivement tous les documents de la corbeille ? Cette action est irréversible. intake-emails.disabled.title: Les adresses de réception sont désactivées
trash.delete-all.confirm.label: Supprimer intake-emails.disabled.description: Les adresses de réception sont désactivées sur cette instance. Veuillez contacter votre administrateur pour les activer. Voir la {{ documentation }} pour plus d'informations.
trash.delete-all.confirm.cancel: Annuler intake-emails.disabled.documentation: documentation
trash.delete.button: Supprimer intake-emails.info: Seules les adresses de réception activées depuis les origines autorisées seront traitées. Vous pouvez activer ou désactiver une adresse de réception à tout moment.
trash.delete.confirm.title: Supprimer définitivement le document ? intake-emails.empty.title: Aucune adresse de réception
trash.delete.confirm.description: Êtes-vous sûr de vouloir supprimer définitivement ce document de la corbeille ? Cette action est irréversible. intake-emails.empty.description: Générez une adresse de réception pour ingérer facilement les pièces jointes des emails.
trash.delete.confirm.label: Supprimer intake-emails.empty.generate: Générer une adresse de réception
trash.delete.confirm.cancel: Annuler intake-emails.count: '{{ count }} intake email{{ plural }} for this organization'
trash.deleted.success.title: Document supprimé intake-emails.new: Nouvelle adresse de réception
trash.deleted.success.description: Le document a été supprimé définitivement. intake-emails.disabled-label: (Désactivé)
intake-emails.no-origins: Aucune adresse de réception autorisée
intake-emails.allowed-origins: Autorisées depuis {{ count }} adresse{{ plural }}
intake-emails.actions.enable: Activer
intake-emails.actions.disable: Désactiver
intake-emails.actions.manage-origins: Gérer les adresses d'origine
intake-emails.actions.delete: Supprimer
intake-emails.delete.confirm.title: Supprimer l'adresse de réception ?
intake-emails.delete.confirm.message: Êtes-vous sûr de vouloir supprimer cette adresse de réception ? Cette action est irréversible.
intake-emails.delete.confirm.confirm-button: Supprimer l'adresse de réception
intake-emails.delete.confirm.cancel-button: Annuler
intake-emails.delete.success: Adresse de réception supprimée
intake-emails.create.success: Adresse de réception créée
intake-emails.update.success.enabled: Adresse de réception activée
intake-emails.update.success.disabled: Adresse de réception désactivée
intake-emails.allowed-origins.title: Adresses d'origine autorisées
intake-emails.allowed-origins.description: Seuls les emails envoyés à {{ email }} depuis ces adresses d'origine seront traités. Si aucune adresse d'origine n'est spécifiée, tous les emails seront rejetés.
intake-emails.allowed-origins.add.label: Ajouter une adresse d'origine autorisée
intake-emails.allowed-origins.add.placeholder: 'Exemple: ada@papra.app'
intake-emails.allowed-origins.add.button: Ajouter
intake-emails.allowed-origins.add.error.exists: Cette adresse email est déjà dans les adresses d'origine autorisées pour cette adresse de réception
import-documents.title.error: '{{ count }} documents ont échoué' # API keys
import-documents.title.success: '{{ count }} documents ont été importés'
import-documents.title.pending: '{{ count }} / {{ total }} documents importés'
import-documents.title.none: Importer des documents
import-documents.no-import-in-progress: Aucune importation de documents en cours
api-errors.document.already_exists: Le document existe déjà
api-errors.document.file_too_big: Le fichier du document est trop grand
api-errors.intake_email.limit_reached: Le nombre maximum d'emails de réception pour cette organisation a été atteint. Veuillez mettre à niveau votre plan pour créer plus d'emails de réception.
api-errors.user.max_organization_count_reached: Vous avez atteint le nombre maximum d'organisations que vous pouvez créer, si vous avez besoin de créer plus, veuillez contacter le support.
api-errors.default: Une erreur est survenue lors du traitement de votre requête.
api-keys.permissions.documents.title: Documents api-keys.permissions.documents.title: Documents
api-keys.permissions.documents.documents:create: Créer des documents api-keys.permissions.documents.documents:create: Créer des documents
@@ -200,3 +443,110 @@ api-keys.delete.confirm.title: Supprimer la clé API
api-keys.delete.confirm.message: Êtes-vous sûr de vouloir supprimer cette clé API ? Cette action est irréversible. api-keys.delete.confirm.message: Êtes-vous sûr de vouloir supprimer cette clé API ? Cette action est irréversible.
api-keys.delete.confirm.confirm-button: Supprimer api-keys.delete.confirm.confirm-button: Supprimer
api-keys.delete.confirm.cancel-button: Annuler api-keys.delete.confirm.cancel-button: Annuler
# Webhooks
webhooks.list.title: Webhooks
webhooks.list.description: Gérez vos webhooks ici.
webhooks.list.empty.title: Aucun webhook
webhooks.list.empty.description: Créez votre premier webhook pour commencer à recevoir des événements.
webhooks.list.create: Créer un webhook
webhooks.list.card.last-triggered: Dernière invocation
webhooks.list.card.never: Jamais
webhooks.list.card.created: Créée
webhooks.create.title: Créer un webhook
webhooks.create.description: Créez un webhook pour recevoir des événements lorsque des documents sont ajoutés à votre organisation.
webhooks.create.success: Le webhook a été créé avec succès.
webhooks.create.back: Retour aux webhooks
webhooks.create.form.submit: Créer le webhook
webhooks.create.form.name.label: Nom du webhook
webhooks.create.form.name.placeholder: Entrez le nom du webhook
webhooks.create.form.name.required: Le nom est requis
webhooks.create.form.url.label: URL du webhook
webhooks.create.form.url.placeholder: Entrez l'URL du webhook
webhooks.create.form.url.required: L'URL est requise
webhooks.create.form.url.invalid: L'URL est invalide
webhooks.create.form.secret.label: Secret
webhooks.create.form.secret.placeholder: Entrez le secret du webhook
webhooks.create.form.events.label: Événements
webhooks.create.form.events.required: Au moins un événement est requis
webhooks.update.title: Modifier le webhook
webhooks.update.description: Mettez à jour les détails de votre webhook
webhooks.update.success: Le webhook a été mis à jour avec succès
webhooks.update.submit: Mettre à jour le webhook
webhooks.update.cancel: Annuler
webhooks.update.form.secret.placeholder: Entrez un nouveau secret
webhooks.update.form.secret.placeholder-redacted: '[Secret masqué]'
webhooks.update.form.rotate-secret.button: Rotation du secret
webhooks.delete.success: Le webhook a été supprimé avec succès
webhooks.delete.confirm.title: Supprimer le webhook
webhooks.delete.confirm.message: Êtes-vous sûr de vouloir supprimer ce webhook ? Cette action est irréversible.
webhooks.delete.confirm.confirm-button: Supprimer
webhooks.delete.confirm.cancel-button: Annuler
webhooks.events.documents.document:created.description: Document créé
webhooks.events.documents.document:deleted.description: Document supprimé
# Navigation
layout.menu.home: Accueil
layout.menu.documents: Documents
layout.menu.tags: Tags
layout.menu.tagging-rules: Règles de catégorisation
layout.menu.deleted-documents: Documents supprimés
layout.menu.organization-settings: Paramètres
layout.menu.api-keys: API keys
layout.menu.settings: Paramètres
layout.menu.account: Compte
layout.menu.general-settings: Paramètres généraux
layout.menu.intake-emails: Adresses de réception
layout.menu.webhooks: Webhooks
layout.menu.members: Membres
layout.menu.invitations: Invitations
layout.theme.light: Mode clair
layout.theme.dark: Mode sombre
layout.theme.system: Mode système
layout.search.placeholder: Rechercher...
layout.menu.import-document: Importer un document
user-menu.account-settings: Paramètres du compte
user-menu.api-keys: Clés d'API
user-menu.invitations: Invitations
user-menu.language: Langue
user-menu.logout: Déconnexion
# Command palette
command-palette.search.placeholder: Rechercher des commandes ou des documents
command-palette.no-results: Aucun résultat trouvé
command-palette.sections.documents: Documents
command-palette.sections.theme: Thème
# API errors
api-errors.document.already_exists: Le document existe déjà
api-errors.document.file_too_big: Le fichier du document est trop grand
api-errors.intake_email.limit_reached: Le nombre maximum d'emails de réception pour cette organisation a été atteint. Veuillez mettre à niveau votre plan pour créer plus d'emails de réception.
api-errors.user.max_organization_count_reached: Vous avez atteint le nombre maximum d'organisations que vous pouvez créer, si vous avez besoin de créer plus, veuillez contacter le support.
api-errors.default: Une erreur est survenue lors du traitement de votre requête.
api-errors.organization.invitation_already_exists: Une invitation pour cet email existe déjà dans cette organisation.
api-errors.user.already_in_organization: Cet utilisateur est déjà dans cette organisation.
api-errors.user.organization_invitation_limit_reached: Le nombre maximum d'invitations a été atteint pour aujourd'hui. Veuillez réessayer demain.
api-errors.demo.not_available: Cette fonctionnalité n'est pas disponible dans la démo
api-errors.tags.already_exists: Un tag avec ce nom existe déjà pour cette organisation
# Not found
not-found.title: 404 - Not Found
not-found.description: Désolé, la page que vous cherchez n'existe pas. Veuillez vérifier l'URL et réessayer.
not-found.back-to-home: Retour à l'accueil
# Demo
demo.popup.description: Cette instance est une démo, toutes les données sont sauvegardées dans le stockage local de votre navigateur.
demo.popup.discord: Rejoignez le {{ discordLink }} pour obtenir de l'aide, proposer des fonctionnalités ou simplement discuter.
demo.popup.discord-link-label: Serveur Discord
demo.popup.reset: Réinitialiser la démo
demo.popup.hide: Masquer

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,5 @@
import type { Config } from '../config/config'; import type { Config } from '../config/config';
import type { SsoProviderConfig } from './auth.types';
import { get } from 'lodash-es'; import { get } from 'lodash-es';
import { ssoProviders } from './auth.constants'; 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 const isEmailVerificationRequiredError = ({ error }: { error: unknown }) => isAuthErrorWithCode({ error, code: 'EMAIL_NOT_VERIFIED' });
export function getEnabledSsoProviderConfigs({ config }: { config: Config }) { export function getEnabledSsoProviderConfigs({ config }: { config: Config }): SsoProviderConfig[] {
const enabledSsoProviders = ssoProviders.filter(({ key }) => get(config, `auth.providers.${key}.isEnabled`)); 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; 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 { buildTimeConfig } from '../config/config';
import { trackingServices } from '../tracking/tracking.services'; import { trackingServices } from '../tracking/tracking.services';
import { createDemoAuthClient } from './auth.demo.services'; import { createDemoAuthClient } from './auth.demo.services';
@@ -7,6 +10,9 @@ import { createDemoAuthClient } from './auth.demo.services';
export function createAuthClient() { export function createAuthClient() {
const client = createBetterAuthClient({ const client = createBetterAuthClient({
baseURL: buildTimeConfig.baseApiUrl, baseURL: buildTimeConfig.baseApiUrl,
plugins: [
genericOAuthClient(),
],
}); });
return { return {
@@ -38,3 +44,17 @@ export const {
} = buildTimeConfig.isDemoMode } = buildTimeConfig.isDemoMode
? createDemoAuthClient() ? createDemoAuthClient()
: createAuthClient(); : 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'; 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 type { Component } from 'solid-js';
import { A } from '@solidjs/router';
import { useConfig } from '@/modules/config/config.provider'; import { useConfig } from '@/modules/config/config.provider';
import { useI18n } from '@/modules/i18n/i18n.provider'; import { useI18n } from '@/modules/i18n/i18n.provider';
import { createVitrineUrl } from '@/modules/shared/utils/urls'; import { createVitrineUrl } from '@/modules/shared/utils/urls';
import { Button } from '@/modules/ui/components/button'; import { Button } from '@/modules/ui/components/button';
import { A } from '@solidjs/router';
export const AuthLegalLinks: Component = () => { export const AuthLegalLinks: Component = () => {
const { config } = useConfig(); const { config } = useConfig();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

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; 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 = { 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), baseUrl: asString(import.meta.env.VITE_BASE_URL, window.location.origin),
baseApiUrl: asString(import.meta.env.VITE_BASE_API_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/'), vitrineBaseUrl: asString(import.meta.env.VITE_VITRINE_BASE_URL, 'http://localhost:3000/'),
@@ -18,6 +18,11 @@ export const buildTimeConfig = {
providers: { providers: {
github: { isEnabled: asBoolean(import.meta.env.VITE_AUTH_PROVIDERS_GITHUB_IS_ENABLED, false) }, 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) }, google: { isEnabled: asBoolean(import.meta.env.VITE_AUTH_PROVIDERS_GOOGLE_IS_ENABLED, false) },
customs: [] as {
providerId: string;
providerName: string;
providerIconUrl: string;
}[],
}, },
}, },
documents: { documents: {
@@ -32,6 +37,7 @@ export const buildTimeConfig = {
isEnabled: asBoolean(import.meta.env.VITE_INTAKE_EMAILS_IS_ENABLED, false), isEnabled: asBoolean(import.meta.env.VITE_INTAKE_EMAILS_IS_ENABLED, false),
emailGenerationDomain: asString(import.meta.env.VITE_INTAKE_EMAILS_EMAIL_GENERATION_DOMAIN), emailGenerationDomain: asString(import.meta.env.VITE_INTAKE_EMAILS_EMAIL_GENERATION_DOMAIN),
}, },
isSubscriptionsEnabled: asBoolean(import.meta.env.VITE_IS_SUBSCRIPTIONS_ENABLED, false),
} as const; } as const;
export type Config = typeof buildTimeConfig; export type Config = typeof buildTimeConfig;

View File

@@ -1,4 +1,5 @@
import type { ApiKey } from '../api-keys/api-keys.types'; import type { ApiKey } from '../api-keys/api-keys.types';
import type { Webhook } from '../webhooks/webhooks.types';
import { get } from 'lodash-es'; import { get } from 'lodash-es';
import { FetchError } from 'ofetch'; import { FetchError } from 'ofetch';
import { createRouter } from 'radix3'; import { createRouter } from 'radix3';
@@ -11,6 +12,7 @@ import {
tagDocumentStorage, tagDocumentStorage,
taggingRuleStorage, taggingRuleStorage,
tagStorage, tagStorage,
webhooksStorage,
} from './demo.storage'; } from './demo.storage';
import { findMany, getValues } from './demo.storage.models'; import { findMany, getValues } from './demo.storage.models';
@@ -191,7 +193,7 @@ const inMemoryApiMock: Record<string, { handler: any }> = {
const { const {
pageIndex = 0, pageIndex = 0,
pageSize = 5, pageSize = 5,
searchQuery = '', searchQuery: rawSearchQuery = '',
} = query ?? {}; } = query ?? {};
const organization = organizationStorage.getItem(organizationId); 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 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 { return {
documents: filteredDocuments.slice(pageIndex * pageSize, (pageIndex + 1) * pageSize), 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({ ...defineHandler({
path: '/api/api-keys', path: '/api/api-keys',
method: 'GET', method: 'GET',
@@ -606,6 +659,80 @@ const inMemoryApiMock: Record<string, { handler: any }> = {
await apiKeyStorage.removeItem(apiKeyId); 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 }); export const router = createRouter({ routes: inMemoryApiMock, strictTrailingSlash: false });

View File

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

View File

@@ -1,15 +1,15 @@
import type { ParentComponent } from 'solid-js'; import type { ParentComponent } from 'solid-js';
import type { Document } from '../documents.types'; 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 { safely } from '@corentinth/chisels';
import { A } from '@solidjs/router'; import { A } from '@solidjs/router';
import { throttle } from 'lodash-es'; import { throttle } from 'lodash-es';
import { createContext, createSignal, For, Match, Show, Switch, useContext } from 'solid-js'; import { createContext, createSignal, For, Match, Show, Switch, useContext } from 'solid-js';
import { Portal } from 'solid-js/web'; 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 { invalidateOrganizationDocumentsQuery } from '../documents.composables';
import { uploadDocument } from '../documents.services'; import { uploadDocument } from '../documents.services';
@@ -17,7 +17,7 @@ const DocumentUploadContext = createContext<{
uploadDocuments: (args: { files: File[]; organizationId: string }) => Promise<void>; uploadDocuments: (args: { files: File[]; organizationId: string }) => Promise<void>;
}>(); }>();
export function useDocumentUpload({ organizationId }: { organizationId: string }) { export function useDocumentUpload({ getOrganizationId }: { getOrganizationId: () => string }) {
const context = useContext(DocumentUploadContext); const context = useContext(DocumentUploadContext);
if (!context) { if (!context) {
@@ -27,11 +27,11 @@ export function useDocumentUpload({ organizationId }: { organizationId: string }
const { uploadDocuments } = context; const { uploadDocuments } = context;
return { return {
uploadDocuments: async ({ files }: { files: File[] }) => uploadDocuments({ files, organizationId }), uploadDocuments: async ({ files }: { files: File[] }) => uploadDocuments({ files, organizationId: getOrganizationId() }),
promptImport: async () => { promptImport: async () => {
const { files } = await promptUploadFiles(); const { files } = await promptUploadFiles();
await uploadDocuments({ files, organizationId }); await uploadDocuments({ files, organizationId: getOrganizationId() });
}, },
}; };
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
import type { Component } from 'solid-js'; import type { Component } from 'solid-js';
import { cn } from '@/modules/shared/style/cn';
import { createSignal, onCleanup } from 'solid-js'; import { createSignal, onCleanup } from 'solid-js';
import { cn } from '@/modules/shared/style/cn';
export const GlobalDropArea: Component<{ onFilesDrop?: (args: { files: File[] }) => void }> = (props) => { export const GlobalDropArea: Component<{ onFilesDrop?: (args: { files: File[] }) => void }> = (props) => {
const [isDragging, setIsDragging] = createSignal(false); 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'; import { addDays, differenceInDays } from 'date-fns';
export const iconByFileType = { export const iconByFileType = {
@@ -79,3 +80,16 @@ export function getDocumentNameExtension({ name }: { name: string }) {
return dotSplittedName[dotCount]; return dotSplittedName[dotCount];
} }
export const documentActivityIcon: Record<DocumentActivityEvent, string> = {
created: 'i-tabler-file-plus',
updated: 'i-tabler-file-diff',
deleted: 'i-tabler-file-x',
restored: 'i-tabler-file-check',
tagged: 'i-tabler-tag',
untagged: 'i-tabler-tag-off',
} as const;
export function getDocumentActivityIcon({ event }: { event: DocumentActivityEvent }) {
return documentActivityIcon[event] ?? 'i-tabler-file';
}

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -68,21 +68,223 @@ export type LocaleKeys =
| 'auth.legal-links.description' | 'auth.legal-links.description'
| 'auth.legal-links.terms' | 'auth.legal-links.terms'
| 'auth.legal-links.privacy' | 'auth.legal-links.privacy'
| 'user.settings.title'
| 'user.settings.description'
| 'user.settings.email.title'
| 'user.settings.email.description'
| 'user.settings.email.label'
| 'user.settings.name.title'
| 'user.settings.name.description'
| 'user.settings.name.label'
| 'user.settings.name.placeholder'
| 'user.settings.name.update'
| 'user.settings.name.updated'
| 'user.settings.logout.title'
| 'user.settings.logout.description'
| 'user.settings.logout.button'
| 'organizations.list.title'
| 'organizations.list.description'
| 'organizations.list.create-new'
| 'organizations.details.no-documents.title'
| 'organizations.details.no-documents.description'
| 'organizations.details.upload-documents'
| 'organizations.details.documents-count'
| 'organizations.details.total-size'
| 'organizations.details.latest-documents'
| 'organizations.create.title'
| 'organizations.create.description'
| 'organizations.create.back'
| 'organizations.create.error.max-count-reached'
| 'organizations.create.form.name.label'
| 'organizations.create.form.name.placeholder'
| 'organizations.create.form.name.required'
| 'organizations.create.form.submit'
| 'organizations.create.success'
| 'organizations.create-first.title'
| 'organizations.create-first.description'
| 'organizations.create-first.default-name'
| 'organizations.create-first.user-name'
| 'organization.settings.title'
| 'organization.settings.page.title'
| 'organization.settings.page.description'
| 'organization.settings.name.title'
| 'organization.settings.name.update'
| 'organization.settings.name.placeholder'
| 'organization.settings.name.updated'
| 'organization.settings.subscription.title'
| 'organization.settings.subscription.description'
| 'organization.settings.subscription.manage'
| 'organization.settings.subscription.error'
| 'organization.settings.delete.title'
| 'organization.settings.delete.description'
| 'organization.settings.delete.confirm.title'
| 'organization.settings.delete.confirm.message'
| 'organization.settings.delete.confirm.confirm-button'
| 'organization.settings.delete.confirm.cancel-button'
| 'organization.settings.delete.success'
| 'organizations.members.title'
| 'organizations.members.description'
| 'organizations.members.invite-member'
| 'organizations.members.invite-member-disabled-tooltip'
| 'organizations.members.remove-from-organization'
| 'organizations.members.role'
| 'organizations.members.roles.owner'
| 'organizations.members.roles.admin'
| 'organizations.members.roles.member'
| 'organizations.members.delete.confirm.title'
| 'organizations.members.delete.confirm.message'
| 'organizations.members.delete.confirm.confirm-button'
| 'organizations.members.delete.confirm.cancel-button'
| 'organizations.members.delete.success'
| 'organizations.members.update-role.success'
| 'organizations.members.table.headers.name'
| 'organizations.members.table.headers.email'
| 'organizations.members.table.headers.role'
| 'organizations.members.table.headers.created'
| 'organizations.members.table.headers.actions'
| 'organizations.invite-member.title'
| 'organizations.invite-member.description'
| 'organizations.invite-member.form.email.label'
| 'organizations.invite-member.form.email.placeholder'
| 'organizations.invite-member.form.email.required'
| 'organizations.invite-member.form.role.label'
| 'organizations.invite-member.form.submit'
| 'organizations.invite-member.success.message'
| 'organizations.invite-member.success.description'
| 'organizations.invite-member.error.message'
| 'organizations.invitations.title'
| 'organizations.invitations.description'
| 'organizations.invitations.list.cta'
| 'organizations.invitations.list.empty.title'
| 'organizations.invitations.list.empty.description'
| 'organizations.invitations.status.pending'
| 'organizations.invitations.status.accepted'
| 'organizations.invitations.status.rejected'
| 'organizations.invitations.status.expired'
| 'organizations.invitations.status.cancelled'
| 'organizations.invitations.resend'
| 'organizations.invitations.cancel.title'
| 'organizations.invitations.cancel.description'
| 'organizations.invitations.cancel.confirm'
| 'organizations.invitations.cancel.cancel'
| 'organizations.invitations.resend.title'
| 'organizations.invitations.resend.description'
| 'organizations.invitations.resend.confirm'
| 'organizations.invitations.resend.cancel'
| 'invitations.list.title'
| 'invitations.list.description'
| 'invitations.list.empty.title'
| 'invitations.list.empty.description'
| 'invitations.list.headers.organization'
| 'invitations.list.headers.status'
| 'invitations.list.headers.created'
| 'invitations.list.headers.actions'
| 'invitations.list.actions.accept'
| 'invitations.list.actions.reject'
| 'invitations.list.actions.accept.success.message'
| 'invitations.list.actions.accept.success.description'
| 'invitations.list.actions.reject.success.message'
| 'invitations.list.actions.reject.success.description'
| 'documents.list.title'
| 'documents.list.no-documents.title'
| 'documents.list.no-documents.description'
| 'documents.list.no-results'
| 'documents.tabs.info'
| 'documents.tabs.content'
| 'documents.tabs.activity'
| 'documents.deleted.message'
| 'documents.actions.download'
| 'documents.actions.open-in-new-tab'
| 'documents.actions.restore'
| 'documents.actions.delete'
| 'documents.actions.edit'
| 'documents.actions.cancel'
| 'documents.actions.save'
| 'documents.actions.saving'
| 'documents.content.alert'
| 'documents.info.id'
| 'documents.info.name'
| 'documents.info.type'
| 'documents.info.size'
| 'documents.info.created-at'
| 'documents.info.updated-at'
| 'documents.info.never'
| 'documents.rename.title'
| 'documents.rename.form.name.label'
| 'documents.rename.form.name.placeholder'
| 'documents.rename.form.name.required'
| 'documents.rename.form.name.max-length'
| 'documents.rename.form.submit'
| 'documents.rename.success'
| 'documents.rename.cancel'
| 'import-documents.title.error'
| 'import-documents.title.success'
| 'import-documents.title.pending'
| 'import-documents.title.none'
| 'import-documents.no-import-in-progress'
| 'documents.deleted.title'
| 'documents.deleted.empty.title'
| 'documents.deleted.empty.description'
| 'documents.deleted.retention-notice'
| 'documents.deleted.deleted-at'
| 'documents.deleted.restoring'
| 'documents.deleted.deleting'
| 'trash.delete-all.button'
| 'trash.delete-all.confirm.title'
| 'trash.delete-all.confirm.description'
| 'trash.delete-all.confirm.label'
| 'trash.delete-all.confirm.cancel'
| 'trash.delete.button'
| 'trash.delete.confirm.title'
| 'trash.delete.confirm.description'
| 'trash.delete.confirm.label'
| 'trash.delete.confirm.cancel'
| 'trash.deleted.success.title'
| 'trash.deleted.success.description'
| 'activity.document.created'
| 'activity.document.updated.single'
| 'activity.document.updated.multiple'
| 'activity.document.updated'
| 'activity.document.deleted'
| 'activity.document.restored'
| 'activity.document.tagged'
| 'activity.document.untagged'
| 'activity.document.user.name'
| 'activity.load-more'
| 'activity.no-more-activities'
| 'tags.no-tags.title' | 'tags.no-tags.title'
| 'tags.no-tags.description' | 'tags.no-tags.description'
| 'tags.no-tags.create-tag' | 'tags.no-tags.create-tag'
| 'layout.menu.home' | 'tags.title'
| 'layout.menu.documents' | 'tags.description'
| 'layout.menu.tags' | 'tags.create'
| 'layout.menu.tagging-rules' | 'tags.update'
| 'layout.menu.deleted-documents' | 'tags.delete'
| 'layout.menu.organization-settings' | 'tags.delete.confirm.title'
| 'layout.menu.api-keys' | 'tags.delete.confirm.message'
| 'layout.menu.settings' | 'tags.delete.confirm.confirm-button'
| 'layout.menu.account' | 'tags.delete.confirm.cancel-button'
| 'layout.menu.general-settings' | 'tags.delete.success'
| 'layout.menu.intake-emails' | 'tags.create.success'
| 'layout.menu.webhooks' | 'tags.update.success'
| '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.name'
| 'tagging-rules.field.content' | 'tagging-rules.field.content'
| 'tagging-rules.operator.equals' | 'tagging-rules.operator.equals'
@@ -131,33 +333,38 @@ export type LocaleKeys =
| 'tagging-rules.update.error' | 'tagging-rules.update.error'
| 'tagging-rules.update.submit' | 'tagging-rules.update.submit'
| 'tagging-rules.update.cancel' | 'tagging-rules.update.cancel'
| 'demo.popup.description' | 'intake-emails.title'
| 'demo.popup.discord' | 'intake-emails.description'
| 'demo.popup.discord-link-label' | 'intake-emails.disabled.title'
| 'demo.popup.reset' | 'intake-emails.disabled.description'
| 'demo.popup.hide' | 'intake-emails.disabled.documentation'
| 'trash.delete-all.button' | 'intake-emails.info'
| 'trash.delete-all.confirm.title' | 'intake-emails.empty.title'
| 'trash.delete-all.confirm.description' | 'intake-emails.empty.description'
| 'trash.delete-all.confirm.label' | 'intake-emails.empty.generate'
| 'trash.delete-all.confirm.cancel' | 'intake-emails.count'
| 'trash.delete.button' | 'intake-emails.new'
| 'trash.delete.confirm.title' | 'intake-emails.disabled-label'
| 'trash.delete.confirm.description' | 'intake-emails.no-origins'
| 'trash.delete.confirm.label' | 'intake-emails.allowed-origins'
| 'trash.delete.confirm.cancel' | 'intake-emails.actions.enable'
| 'trash.deleted.success.title' | 'intake-emails.actions.disable'
| 'trash.deleted.success.description' | 'intake-emails.actions.manage-origins'
| 'import-documents.title.error' | 'intake-emails.actions.delete'
| 'import-documents.title.success' | 'intake-emails.delete.confirm.title'
| 'import-documents.title.pending' | 'intake-emails.delete.confirm.message'
| 'import-documents.title.none' | 'intake-emails.delete.confirm.confirm-button'
| 'import-documents.no-import-in-progress' | 'intake-emails.delete.confirm.cancel-button'
| 'api-errors.document.already_exists' | 'intake-emails.delete.success'
| 'api-errors.document.file_too_big' | 'intake-emails.create.success'
| 'api-errors.intake_email.limit_reached' | 'intake-emails.update.success.enabled'
| 'api-errors.user.max_organization_count_reached' | 'intake-emails.update.success.disabled'
| 'api-errors.default' | 'intake-emails.allowed-origins.title'
| '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.title'
| 'api-keys.permissions.documents.documents:create' | 'api-keys.permissions.documents.documents:create'
| 'api-keys.permissions.documents.documents:read' | 'api-keys.permissions.documents.documents:read'
@@ -231,4 +438,50 @@ export type LocaleKeys =
| 'webhooks.delete.confirm.confirm-button' | 'webhooks.delete.confirm.confirm-button'
| 'webhooks.delete.confirm.cancel-button' | 'webhooks.delete.confirm.cancel-button'
| 'webhooks.events.documents.document:created.description' | 'webhooks.events.documents.document:created.description'
| 'webhooks.events.documents.document:deleted.description'; | 'webhooks.events.documents.document:deleted.description'
| '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,7 +1,13 @@
import type { DialogTriggerProps } from '@kobalte/core/dialog'; import type { DialogTriggerProps } from '@kobalte/core/dialog';
import type { Component, JSX } from 'solid-js'; import type { Component, JSX } from 'solid-js';
import type { IntakeEmail } from '../intake-emails.types'; 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 { useConfig } from '@/modules/config/config.provider';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { useConfirmModal } from '@/modules/shared/confirm'; import { useConfirmModal } from '@/modules/shared/confirm';
import { createForm } from '@/modules/shared/form/form'; import { createForm } from '@/modules/shared/form/form';
import { isHttpErrorWithCode } from '@/modules/shared/http/http-errors'; import { isHttpErrorWithCode } from '@/modules/shared/http/http-errors';
@@ -14,15 +20,11 @@ import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, Di
import { EmptyState } from '@/modules/ui/components/empty'; import { EmptyState } from '@/modules/ui/components/empty';
import { createToast } from '@/modules/ui/components/sonner'; import { createToast } from '@/modules/ui/components/sonner';
import { TextField, TextFieldLabel, TextFieldRoot } from '@/modules/ui/components/textfield'; 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 { createSignal, For, Show, Suspense } from 'solid-js';
import * as v from 'valibot';
import { createIntakeEmail, deleteIntakeEmail, fetchIntakeEmails, updateIntakeEmail } from '../intake-emails.services'; import { createIntakeEmail, deleteIntakeEmail, fetchIntakeEmails, updateIntakeEmail } from '../intake-emails.services';
const AllowedOriginsDialog: Component<{ children: (props: DialogTriggerProps) => JSX.Element; intakeEmails: IntakeEmail }> = (props) => { const AllowedOriginsDialog: Component<{ children: (props: DialogTriggerProps) => JSX.Element; intakeEmails: IntakeEmail }> = (props) => {
const [getAllowedOrigins, setAllowedOrigins] = createSignal([...props.intakeEmails.allowedOrigins]); const [getAllowedOrigins, setAllowedOrigins] = createSignal([...props.intakeEmails.allowedOrigins]);
const { t } = useI18n();
const update = async () => { const update = async () => {
await updateIntakeEmail({ await updateIntakeEmail({
@@ -47,7 +49,7 @@ const AllowedOriginsDialog: Component<{ children: (props: DialogTriggerProps) =>
}), }),
onSubmit: async ({ email }) => { onSubmit: async ({ email }) => {
if (getAllowedOrigins().includes(email)) { if (getAllowedOrigins().includes(email)) {
throw new Error('This email is already in the allowed origins for this intake email'); throw new Error(t('intake-emails.allowed-origins.add.error.exists'));
} }
setAllowedOrigins(origins => [...origins, email]); setAllowedOrigins(origins => [...origins, email]);
@@ -67,13 +69,9 @@ const AllowedOriginsDialog: Component<{ children: (props: DialogTriggerProps) =>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>Allowed origins</DialogTitle> <DialogTitle>{t('intake-emails.allowed-origins.title')}</DialogTitle>
<DialogDescription> <DialogDescription>
Only emails sent to {t('intake-emails.allowed-origins.description', { email: props.intakeEmails.emailAddress })}
{' '}
<span class="font-medium text-primary">{props.intakeEmails.emailAddress}</span>
{' '}
from these origins will be processed. If no origins are specified, all emails will be discarded.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@@ -81,13 +79,13 @@ const AllowedOriginsDialog: Component<{ children: (props: DialogTriggerProps) =>
<Field name="email"> <Field name="email">
{(field, inputProps) => ( {(field, inputProps) => (
<TextFieldRoot class="flex flex-col gap-1 mb-4 mt-4"> <TextFieldRoot class="flex flex-col gap-1 mb-4 mt-4">
<TextFieldLabel for="email">Add allowed origin email</TextFieldLabel> <TextFieldLabel for="email">{t('intake-emails.allowed-origins.add.label')}</TextFieldLabel>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<TextField type="email" id="email" placeholder="Eg. ada@papra.app" {...inputProps} autoFocus value={field.value} aria-invalid={Boolean(field.error)} /> <TextField type="email" id="email" placeholder={t('intake-emails.allowed-origins.add.placeholder')} {...inputProps} autoFocus value={field.value} aria-invalid={Boolean(field.error)} />
<Button type="submit"> <Button type="submit">
<div class="i-tabler-plus size-4 mr-2" /> <div class="i-tabler-plus size-4 mr-2" />
Add {t('intake-emails.allowed-origins.add.button')}
</Button> </Button>
</div> </div>
@@ -130,22 +128,39 @@ const AllowedOriginsDialog: Component<{ children: (props: DialogTriggerProps) =>
export const IntakeEmailsPage: Component = () => { export const IntakeEmailsPage: Component = () => {
const { config } = useConfig(); const { config } = useConfig();
const { t, te } = useI18n();
if (!config.intakeEmails.isEnabled) { if (!config.intakeEmails.isEnabled) {
return ( return (
<Card class="p-6"> <div class="p-6 max-w-screen-md mx-auto mt-10">
<h2 class="text-base font-bold">Intake Emails</h2> <h1 class="text-xl font-semibold">{t('intake-emails.title')}</h1>
<p class="text-muted-foreground mt-1"> <p class="text-muted-foreground mt-1">
Intake emails are disabled on this instance. Please contact your administrator to enable them. {t('intake-emails.description')}
</p> </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 params = useParams();
const { confirm } = useConfirmModal(); const { confirm } = useConfirmModal();
const query = createQuery(() => ({ const query = useQuery(() => ({
queryKey: ['organizations', params.organizationId, 'intake-emails'], queryKey: ['organizations', params.organizationId, 'intake-emails'],
queryFn: () => fetchIntakeEmails({ organizationId: params.organizationId }), queryFn: () => fetchIntakeEmails({ organizationId: params.organizationId }),
})); }));
@@ -155,7 +170,7 @@ export const IntakeEmailsPage: Component = () => {
if (isHttpErrorWithCode({ error, code: 'intake_email.limit_reached' })) { if (isHttpErrorWithCode({ error, code: 'intake_email.limit_reached' })) {
createToast({ createToast({
message: 'The maximum number of intake emails for this organization has been reached. Please upgrade your plan to create more intake emails.', message: t('api-errors.intake_email.limit_reached'),
type: 'error', type: 'error',
}); });
@@ -169,20 +184,20 @@ export const IntakeEmailsPage: Component = () => {
await query.refetch(); await query.refetch();
createToast({ createToast({
message: 'Intake email created', message: t('intake-emails.create.success'),
type: 'success', type: 'success',
}); });
}; };
const deleteEmail = async ({ intakeEmailId }: { intakeEmailId: string }) => { const deleteEmail = async ({ intakeEmailId }: { intakeEmailId: string }) => {
const confirmed = await confirm({ const confirmed = await confirm({
title: 'Delete intake email?', title: t('intake-emails.delete.confirm.title'),
message: 'Are you sure you want to delete this intake email? This action cannot be undone.', message: t('intake-emails.delete.confirm.message'),
cancelButton: { cancelButton: {
text: 'Cancel', text: t('intake-emails.delete.confirm.cancel-button'),
}, },
confirmButton: { confirmButton: {
text: 'Delete intake email', text: t('intake-emails.delete.confirm.confirm-button'),
variant: 'destructive', variant: 'destructive',
}, },
}); });
@@ -195,7 +210,7 @@ export const IntakeEmailsPage: Component = () => {
await query.refetch(); await query.refetch();
createToast({ createToast({
message: 'Intake email deleted', message: t('intake-emails.delete.success'),
type: 'success', type: 'success',
}); });
}; };
@@ -205,27 +220,25 @@ export const IntakeEmailsPage: Component = () => {
await query.refetch(); await query.refetch();
createToast({ createToast({
message: `Intake email ${isEnabled ? 'enabled' : 'disabled'}`, message: isEnabled ? t('intake-emails.update.success.enabled') : t('intake-emails.update.success.disabled'),
type: 'success', type: 'success',
}); });
}; };
return ( return (
<div class="p-6 max-w-screen-md mx-auto mt-10"> <div class="p-6 max-w-screen-md mx-auto mt-10">
<h1 class="text-xl font-semibold">{t('intake-emails.title')}</h1>
<h1 class="text-xl font-semibold">Intake Emails</h1>
<p class="text-muted-foreground mt-1"> <p class="text-muted-foreground mt-1">
Intake emails address are used to automatically ingest emails into Papra. Just forward emails to the intake email address and their attachments will be added to your organization's documents. {t('intake-emails.description')}
</p> </p>
<Alert variant="default" class="mt-4 flex items-center gap-4 xl:gap-4 text-muted-foreground"> <Alert variant="default" class="mt-4 flex items-center gap-4 xl:gap-4 text-muted-foreground">
<div class="i-tabler-info-circle size-10 xl:size-8 text-primary flex-shrink-0 " /> <div class="i-tabler-info-circle size-10 xl:size-8 text-primary flex-shrink-0 " />
<AlertDescription> <AlertDescription>
Only enabled intake emails from allowed origins will be processed. You can enable or disable an intake email at any time. {t('intake-emails.info')}
</AlertDescription> </AlertDescription>
</Alert> </Alert>
<Suspense> <Suspense>
@@ -236,14 +249,14 @@ export const IntakeEmailsPage: Component = () => {
fallback={( fallback={(
<div class="mt-4 py-8 border-2 border-dashed rounded-lg text-center"> <div class="mt-4 py-8 border-2 border-dashed rounded-lg text-center">
<EmptyState <EmptyState
title="No intake emails" title={t('intake-emails.empty.title')}
description="Generate an intake address to easily ingest emails attachments." description={t('intake-emails.empty.description')}
class="pt-0" class="pt-0"
icon="i-tabler-mail" icon="i-tabler-mail"
cta={( cta={(
<Button variant="secondary" onClick={createEmail}> <Button variant="secondary" onClick={createEmail}>
<div class="i-tabler-plus size-4 mr-2" /> <div class="i-tabler-plus size-4 mr-2" />
Generate intake email {t('intake-emails.empty.generate')}
</Button> </Button>
)} )}
/> />
@@ -252,12 +265,15 @@ export const IntakeEmailsPage: Component = () => {
> >
<div class="mt-4 mb-4 flex items-center justify-between"> <div class="mt-4 mb-4 flex items-center justify-between">
<div class="text-muted-foreground"> <div class="text-muted-foreground">
{`${intakeEmails().length} intake email${intakeEmails().length > 1 ? 's' : ''} for this organization`} {t('intake-emails.count', {
count: intakeEmails().length,
plural: intakeEmails().length > 1 ? 's' : '',
})}
</div> </div>
<Button onClick={createEmail}> <Button onClick={createEmail}>
<div class="i-tabler-plus size-4 mr-2" /> <div class="i-tabler-plus size-4 mr-2" />
New intake email {t('intake-emails.new')}
</Button> </Button>
</div> </div>
@@ -275,9 +291,8 @@ export const IntakeEmailsPage: Component = () => {
{intakeEmail.emailAddress} {intakeEmail.emailAddress}
<Show when={!intakeEmail.isEnabled}> <Show when={!intakeEmail.isEnabled}>
<span class="text-muted-foreground text-xs ml-2">(Disabled)</span> <span class="text-muted-foreground text-xs ml-2">{t('intake-emails.disabled-label')}</span>
</Show> </Show>
</div> </div>
<Show <Show
@@ -285,14 +300,16 @@ export const IntakeEmailsPage: Component = () => {
fallback={( fallback={(
<div class="text-xs text-warning flex items-center gap-1.5"> <div class="text-xs text-warning flex items-center gap-1.5">
<div class="i-tabler-alert-triangle size-3.75" /> <div class="i-tabler-alert-triangle size-3.75" />
No allowed email origins {t('intake-emails.no-origins')}
</div> </div>
)} )}
> >
<div class="text-xs text-muted-foreground flex items-center gap-2"> <div class="text-xs text-muted-foreground flex items-center gap-2">
{`Allowed from ${intakeEmail.allowedOrigins.length} address${intakeEmail.allowedOrigins.length > 1 ? 'es' : ''}`} {t('intake-emails.allowed-origins', {
count: intakeEmail.allowedOrigins.length,
plural: intakeEmail.allowedOrigins.length > 1 ? 'es' : '',
})}
</div> </div>
</Show> </Show>
</div> </div>
</div> </div>
@@ -303,7 +320,7 @@ export const IntakeEmailsPage: Component = () => {
onClick={() => updateEmail({ intakeEmailId: intakeEmail.id, isEnabled: !intakeEmail.isEnabled })} onClick={() => updateEmail({ intakeEmailId: intakeEmail.id, isEnabled: !intakeEmail.isEnabled })}
> >
<div class="i-tabler-power size-4 mr-2" /> <div class="i-tabler-power size-4 mr-2" />
{intakeEmail.isEnabled ? 'Disable' : 'Enable'} {intakeEmail.isEnabled ? t('intake-emails.actions.disable') : t('intake-emails.actions.enable')}
</Button> </Button>
<AllowedOriginsDialog intakeEmails={intakeEmail}> <AllowedOriginsDialog intakeEmails={intakeEmail}>
@@ -315,7 +332,7 @@ export const IntakeEmailsPage: Component = () => {
class="flex items-center gap-2 leading-none" class="flex items-center gap-2 leading-none"
> >
<div class="i-tabler-edit size-4" /> <div class="i-tabler-edit size-4" />
Manage origins addresses {t('intake-emails.actions.manage-origins')}
</Button> </Button>
)} )}
</AllowedOriginsDialog> </AllowedOriginsDialog>
@@ -327,18 +344,14 @@ export const IntakeEmailsPage: Component = () => {
class="text-red" class="text-red"
> >
<div class="i-tabler-trash size-4 mr-2" /> <div class="i-tabler-trash size-4 mr-2" />
{t('intake-emails.actions.delete')}
Delete
</Button> </Button>
</div> </div>
</div> </div>
)} )}
</For> </For>
</div> </div>
</Show> </Show>
)} )}
</Show> </Show>
</Suspense> </Suspense>

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

View File

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

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 { queryClient } from '@/modules/shared/query/query-client';
import { createToast } from '@/modules/ui/components/sonner'; import { createToast } from '@/modules/ui/components/sonner';
import { useNavigate } from '@solidjs/router'; import { ORGANIZATION_ROLES } from './organizations.constants';
import { createOrganization, deleteOrganization, updateOrganization } from './organizations.services'; import { createOrganization, deleteOrganization, getMembership, updateOrganization } from './organizations.services';
export function useCreateOrganization() { export function useCreateOrganization() {
const navigate = useNavigate(); const navigate = useNavigate();
const { t } = useI18n();
return { return {
createOrganization: async ({ organizationName }: { organizationName: string }) => { createOrganization: async ({ organizationName }: { organizationName: string }) => {
const { organization } = await createOrganization({ name: organizationName }); const { organization } = await createOrganization({ name: organizationName });
createToast({ type: 'success', message: 'Organization created' }); createToast({ type: 'success', message: t('organizations.create.success') });
await queryClient.invalidateQueries({ await queryClient.invalidateQueries({
queryKey: ['organizations'], queryKey: ['organizations'],
@@ -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 { 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 { apiClient } from '../shared/http/api-client';
import { coerceDates } from '../shared/http/http-client.models'; 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() { export async function fetchOrganizations() {
const { organizations } = await apiClient<{ organizations: AsDto<Organization>[] }>({ const { organizations } = await apiClient<{ organizations: AsDto<Organization>[] }>({
path: '/api/organizations', path: '/api/organizations',
@@ -55,3 +63,55 @@ export async function deleteOrganization({ organizationId }: { organizationId: s
method: 'DELETE', 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 = { export type Organization = {
id: string; id: string;
name: string; name: string;
createdAt: Date; createdAt: Date;
updatedAt: 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,8 +1,9 @@
import type { Component } from 'solid-js'; import type { Component } from 'solid-js';
import { useCurrentUser } from '@/modules/users/composables/useCurrentUser';
import { useNavigate } from '@solidjs/router'; import { useNavigate } from '@solidjs/router';
import { createQuery } from '@tanstack/solid-query'; import { useQuery } from '@tanstack/solid-query';
import { createEffect, on } from 'solid-js'; import { createEffect, on } from 'solid-js';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { useCurrentUser } from '@/modules/users/composables/useCurrentUser';
import { CreateOrganizationForm } from '../components/create-organization-form.component'; import { CreateOrganizationForm } from '../components/create-organization-form.component';
import { useCreateOrganization } from '../organizations.composables'; import { useCreateOrganization } from '../organizations.composables';
import { fetchOrganizations } from '../organizations.services'; import { fetchOrganizations } from '../organizations.services';
@@ -11,24 +12,25 @@ export const CreateFirstOrganizationPage: Component = () => {
const { createOrganization } = useCreateOrganization(); const { createOrganization } = useCreateOrganization();
const { user } = useCurrentUser(); const { user } = useCurrentUser();
const navigate = useNavigate(); const navigate = useNavigate();
const { t } = useI18n();
const getOrganizationName = () => { const getOrganizationName = () => {
const { name } = user; const { name } = user;
if (name && name.length > 0) { if (name && name.length > 0) {
return `${name}'s organization`; return t('organizations.create-first.user-name', { name });
} }
return `My organization`; return t('organizations.create-first.default-name');
}; };
const queries = createQuery(() => ({ const query = useQuery(() => ({
queryKey: ['organizations'], queryKey: ['organizations'],
queryFn: fetchOrganizations, queryFn: fetchOrganizations,
})); }));
createEffect(on( createEffect(on(
() => queries.data?.organizations, () => query.data?.organizations,
(orgs) => { (orgs) => {
if (orgs && orgs.length > 0) { if (orgs && orgs.length > 0) {
navigate('/organizations/create'); navigate('/organizations/create');
@@ -40,11 +42,11 @@ export const CreateFirstOrganizationPage: Component = () => {
<div> <div>
<div class="max-w-md mx-auto pt-12 sm:pt-24 px-6"> <div class="max-w-md mx-auto pt-12 sm:pt-24 px-6">
<h1 class="text-xl font-bold"> <h1 class="text-xl font-bold">
Create your organization {t('organizations.create-first.title')}
</h1> </h1>
<p class="text-muted-foreground mb-6"> <p class="text-muted-foreground mb-6">
Your documents will be grouped by organization. You can create multiple organizations to separate your documents, for example, for personal and work documents. {t('organizations.create-first.description')}
</p> </p>
<CreateOrganizationForm onSubmit={createOrganization} initialOrganizationName={getOrganizationName()} /> <CreateOrganizationForm onSubmit={createOrganization} initialOrganizationName={getOrganizationName()} />

View File

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

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
import type { HttpClientOptions, ResponseType } from './http-client'; import type { HttpClientOptions, ResponseType } from './http-client';
import { buildTimeConfig } from '@/modules/config/config';
import { safely } from '@corentinth/chisels'; import { safely } from '@corentinth/chisels';
import { buildTimeConfig } from '@/modules/config/config';
import { httpClient } from './http-client'; import { httpClient } from './http-client';
import { isHttpErrorWithStatusCode } from './http-errors'; import { isHttpErrorWithStatusCode } from './http-errors';

View File

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

View File

@@ -1,7 +1,7 @@
import type { FetchOptions, ResponseType } from 'ofetch'; import type { FetchOptions, ResponseType } from 'ofetch';
import { ofetch } from 'ofetch';
import { buildTimeConfig } from '@/modules/config/config'; import { buildTimeConfig } from '@/modules/config/config';
import { demoHttpClient } from '@/modules/demo/demo-http-client'; import { demoHttpClient } from '@/modules/demo/demo-http-client';
import { ofetch } from 'ofetch';
export { ResponseType }; export { ResponseType };
export type HttpClientOptions<R extends ResponseType = 'json'> = Omit<FetchOptions<R>, 'baseURL'> & { url: string; baseUrl?: string }; export type HttpClientOptions<R extends ResponseType = 'json'> = Omit<FetchOptions<R>, 'baseURL'> & { url: string; baseUrl?: string };

View File

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

View File

@@ -1,6 +1,6 @@
import type { ComponentProps, ParentComponent } from 'solid-js'; import type { ComponentProps, ParentComponent } from 'solid-js';
import { Button } from '@/modules/ui/components/button';
import { createSignal } from 'solid-js'; import { createSignal } from 'solid-js';
import { Button } from '@/modules/ui/components/button';
export function useCopy() { export function useCopy() {
const [getIsJustCopied, setIsJustCopied] = createSignal(false); const [getIsJustCopied, setIsJustCopied] = createSignal(false);

View File

@@ -1,5 +1,5 @@
import { buildTimeConfig } from '@/modules/config/config';
import { buildUrl } from '@corentinth/chisels'; import { buildUrl } from '@corentinth/chisels';
import { buildTimeConfig } from '@/modules/config/config';
export function createVitrineUrl({ path, baseUrl = buildTimeConfig.vitrineBaseUrl }: { path: string; baseUrl?: string }): string { export function createVitrineUrl({ path, baseUrl = buildTimeConfig.vitrineBaseUrl }: { path: string; baseUrl?: string }): string {
return buildUrl({ path, baseUrl }); return buildUrl({ path, baseUrl });

View File

@@ -1,5 +1,9 @@
import type { Component } from 'solid-js'; import type { Component } from 'solid-js';
import type { TaggingRule, TaggingRuleForCreation } from '../tagging-rules.types'; import type { TaggingRule, TaggingRuleForCreation } from '../tagging-rules.types';
import { insert, remove, setValue } from '@modular-forms/solid';
import { A } from '@solidjs/router';
import { For, Show } from 'solid-js';
import * as v from 'valibot';
import { useI18n } from '@/modules/i18n/i18n.provider'; import { useI18n } from '@/modules/i18n/i18n.provider';
import { useConfirmModal } from '@/modules/shared/confirm'; import { useConfirmModal } from '@/modules/shared/confirm';
import { createForm } from '@/modules/shared/form/form'; import { createForm } from '@/modules/shared/form/form';
@@ -10,10 +14,6 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
import { Separator } from '@/modules/ui/components/separator'; import { Separator } from '@/modules/ui/components/separator';
import { TextArea } from '@/modules/ui/components/textarea'; import { TextArea } from '@/modules/ui/components/textarea';
import { TextField, TextFieldLabel, TextFieldRoot } from '@/modules/ui/components/textfield'; import { TextField, TextFieldLabel, TextFieldRoot } from '@/modules/ui/components/textfield';
import { insert, remove, setValue } from '@modular-forms/solid';
import { A } from '@solidjs/router';
import { For, Show } from 'solid-js';
import * as v from 'valibot';
import { TAGGING_RULE_FIELDS, TAGGING_RULE_FIELDS_LOCALIZATION_KEYS, TAGGING_RULE_OPERATORS, TAGGING_RULE_OPERATORS_LOCALIZATION_KEYS } from '../tagging-rules.constants'; import { TAGGING_RULE_FIELDS, TAGGING_RULE_FIELDS_LOCALIZATION_KEYS, TAGGING_RULE_OPERATORS, TAGGING_RULE_OPERATORS_LOCALIZATION_KEYS } from '../tagging-rules.constants';
export const TaggingRuleForm: Component<{ export const TaggingRuleForm: Component<{

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
import type { Component, ComponentProps } from 'solid-js'; import type { Component, ComponentProps } from 'solid-js';
import { cn } from '@/modules/shared/style/cn';
import { A } from '@solidjs/router'; import { A } from '@solidjs/router';
import { splitProps } from 'solid-js'; import { splitProps } from 'solid-js';
import { cn } from '@/modules/shared/style/cn';
type TagProps = { type TagProps = {
name?: string; name?: string;

View File

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

View File

@@ -1,7 +1,7 @@
import type { Component } from 'solid-js'; import type { Component } from 'solid-js';
import { createEffect } from 'solid-js';
import { useSession } from '@/modules/auth/auth.services'; import { useSession } from '@/modules/auth/auth.services';
import { buildTimeConfig } from '@/modules/config/config'; import { buildTimeConfig } from '@/modules/config/config';
import { createEffect } from 'solid-js';
import { trackingServices } from '../tracking.services'; import { trackingServices } from '../tracking.services';
export const IdentifyUser: Component = () => { export const IdentifyUser: Component = () => {

View File

@@ -2,10 +2,10 @@ import type { AlertRootProps } from '@kobalte/core/alert';
import type { PolymorphicProps } from '@kobalte/core/polymorphic'; import type { PolymorphicProps } from '@kobalte/core/polymorphic';
import type { VariantProps } from 'class-variance-authority'; import type { VariantProps } from 'class-variance-authority';
import type { ComponentProps, ValidComponent } from 'solid-js'; import type { ComponentProps, ValidComponent } from 'solid-js';
import { cn } from '@/modules/shared/style/cn';
import { Alert as AlertPrimitive } from '@kobalte/core/alert'; import { Alert as AlertPrimitive } from '@kobalte/core/alert';
import { cva } from 'class-variance-authority'; import { cva } from 'class-variance-authority';
import { splitProps } from 'solid-js'; import { splitProps } from 'solid-js';
import { cn } from '@/modules/shared/style/cn';
export const alertVariants = cva( export const alertVariants = cva(
'relative w-full rounded-lg border px-4 py-3 text-sm [&:has(svg)]:pl-11 [&>svg+div]:translate-y-[-3px] [&>svg]:(absolute left-4 top-4 text-foreground)', 'relative w-full rounded-lg border px-4 py-3 text-sm [&:has(svg)]:pl-11 [&>svg+div]:translate-y-[-3px] [&>svg]:(absolute left-4 top-4 text-foreground)',

View File

@@ -1,8 +1,8 @@
import type { VariantProps } from 'class-variance-authority'; import type { VariantProps } from 'class-variance-authority';
import type { ComponentProps } from 'solid-js'; import type { ComponentProps } from 'solid-js';
import { cn } from '@/modules/shared/style/cn';
import { cva } from 'class-variance-authority'; import { cva } from 'class-variance-authority';
import { splitProps } from 'solid-js'; import { splitProps } from 'solid-js';
import { cn } from '@/modules/shared/style/cn';
export const badgeVariants = cva( export const badgeVariants = cva(
'inline-flex items-center rounded-md px-2.5 py-0.5 text-xs font-semibold transition-shadow focus-visible:(outline-none ring-1.5 ring-ring)', 'inline-flex items-center rounded-md px-2.5 py-0.5 text-xs font-semibold transition-shadow focus-visible:(outline-none ring-1.5 ring-ring)',

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