mirror of
https://github.com/papra-hq/papra.git
synced 2025-12-17 03:51:45 -06:00
Compare commits
55 Commits
dev-tools
...
@papra/app
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb652c7166 | ||
|
|
17ca8f8f81 | ||
|
|
f54b8e162a | ||
|
|
6b435bba79 | ||
|
|
8ccdb74834 | ||
|
|
60059c895c | ||
|
|
6e22a93dff | ||
|
|
79c1d3206b | ||
|
|
48a953a584 | ||
|
|
fdb90fa164 | ||
|
|
e9a205c0a3 | ||
|
|
278db63fc8 | ||
|
|
e5ef40f36c | ||
|
|
27c9e39422 | ||
|
|
91d2e236d0 | ||
|
|
d4f72e889a | ||
|
|
759a3ff713 | ||
|
|
34862991fb | ||
|
|
f0876fdc63 | ||
|
|
cb38d66485 | ||
|
|
c28af1407f | ||
|
|
b62ddf2bc4 | ||
|
|
fa7909c62d | ||
|
|
1996b51b4d | ||
|
|
734027f00c | ||
|
|
557cde940c | ||
|
|
26a83052bd | ||
|
|
5aac3f7ba6 | ||
|
|
0ddc2340f0 | ||
|
|
438a31171c | ||
|
|
53bf93f128 | ||
|
|
b400b3f18d | ||
|
|
0627ec25a4 | ||
|
|
72e5a9a4de | ||
|
|
268ac8e358 | ||
|
|
249b3bcfd2 | ||
|
|
d7838b5d57 | ||
|
|
f170ddd817 | ||
|
|
4f53c70854 | ||
|
|
85fa5c4342 | ||
|
|
c5d984a3a0 | ||
|
|
565bd8d7fd | ||
|
|
9b72aa886c | ||
|
|
7410455093 | ||
|
|
dd8f194fd0 | ||
|
|
803c39cbc8 | ||
|
|
096331a4ee | ||
|
|
59ba9465f6 | ||
|
|
a1056702af | ||
|
|
fd44897bca | ||
|
|
332d836d11 | ||
|
|
f613198cbd | ||
|
|
80491a5a58 | ||
|
|
605e21a410 | ||
|
|
dec589b6ed |
8
.changeset/README.md
Normal file
8
.changeset/README.md
Normal file
@@ -0,0 +1,8 @@
|
||||
# Changesets
|
||||
|
||||
Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works
|
||||
with multi-package repos, or single-package repos to help you version and publish your code. You can
|
||||
find the full documentation for it [in our repository](https://github.com/changesets/changesets)
|
||||
|
||||
We have a quick list of common questions to get you started engaging with this project in
|
||||
[our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md)
|
||||
19
.changeset/config.json
Normal file
19
.changeset/config.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"$schema": "https://unpkg.com/@changesets/config@3.1.1/schema.json",
|
||||
"changelog": [
|
||||
"@changesets/changelog-github",
|
||||
{ "repo": "papra-hq/papra"}
|
||||
],
|
||||
"commit": false,
|
||||
"fixed": [
|
||||
["@papra/app-client", "@papra/app-server"]
|
||||
],
|
||||
"access": "public",
|
||||
"baseBranch": "main",
|
||||
"updateInternalDependencies": "patch",
|
||||
"ignore": [],
|
||||
"privatePackages": {
|
||||
"tag": true,
|
||||
"version": true
|
||||
}
|
||||
}
|
||||
4
.github/workflows/ci-apps-papra-server.yaml
vendored
4
.github/workflows/ci-apps-papra-server.yaml
vendored
@@ -26,7 +26,9 @@ jobs:
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm i --frozen-lockfile
|
||||
run: |
|
||||
pnpm i --frozen-lockfile
|
||||
pnpm --filter "@papra/app-server^..." build
|
||||
|
||||
- name: Run linters
|
||||
run: pnpm lint
|
||||
|
||||
41
.github/workflows/ci-packages-api-sdk.yaml
vendored
Normal file
41
.github/workflows/ci-packages-api-sdk.yaml
vendored
Normal file
@@ -0,0 +1,41 @@
|
||||
name: CI - Api SDK
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
ci-packages-api-sdk:
|
||||
name: CI - Api SDK
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: packages/api-sdk
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm i
|
||||
|
||||
- name: Run linters
|
||||
run: pnpm lint
|
||||
|
||||
- name: Type check
|
||||
run: pnpm typecheck
|
||||
|
||||
# - name: Run unit test
|
||||
# run: pnpm test
|
||||
|
||||
- name: Build the app
|
||||
run: pnpm build
|
||||
44
.github/workflows/ci-packages-cli.yaml
vendored
Normal file
44
.github/workflows/ci-packages-cli.yaml
vendored
Normal file
@@ -0,0 +1,44 @@
|
||||
name: CI - CLI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
ci-packages-cli:
|
||||
name: CI - CLI
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: packages/cli
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm i
|
||||
|
||||
- name: Build related packages
|
||||
run: cd ../api-sdk && pnpm build
|
||||
|
||||
- name: Run linters
|
||||
run: pnpm lint
|
||||
|
||||
- name: Type check
|
||||
run: pnpm typecheck
|
||||
|
||||
# - name: Run unit test
|
||||
# run: pnpm test
|
||||
|
||||
- name: Build the app
|
||||
run: pnpm build
|
||||
41
.github/workflows/ci-packages-webhook.yaml
vendored
Normal file
41
.github/workflows/ci-packages-webhook.yaml
vendored
Normal file
@@ -0,0 +1,41 @@
|
||||
name: CI - Webhooks
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
ci-packages-webhooks:
|
||||
name: CI - Webhooks
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: packages/webhooks
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm i
|
||||
|
||||
- name: Run linters
|
||||
run: pnpm lint
|
||||
|
||||
- name: Type check
|
||||
run: pnpm typecheck
|
||||
|
||||
- name: Run unit test
|
||||
run: pnpm test
|
||||
|
||||
- name: Build the app
|
||||
run: pnpm build
|
||||
25
.github/workflows/release-docker.yaml
vendored
25
.github/workflows/release-docker.yaml
vendored
@@ -1,9 +1,12 @@
|
||||
name: Release new versions
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*.*.*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Version to release'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -14,14 +17,6 @@ jobs:
|
||||
name: Release Docker images
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Get release version from tag
|
||||
if: ${{ github.event_name == 'push' }}
|
||||
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_ENV
|
||||
|
||||
- name: Get release version from input
|
||||
if: ${{ github.event_name == 'workflow_dispatch' }}
|
||||
run: echo "RELEASE_VERSION=${{ github.event.inputs.release_version }}" >> $GITHUB_ENV
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
|
||||
@@ -53,9 +48,9 @@ jobs:
|
||||
push: true
|
||||
tags: |
|
||||
corentinth/papra:latest-root
|
||||
corentinth/papra:${{ env.RELEASE_VERSION }}-root
|
||||
corentinth/papra:${{ inputs.version }}-root
|
||||
ghcr.io/papra-hq/papra:latest-root
|
||||
ghcr.io/papra-hq/papra:${{ env.RELEASE_VERSION }}-root
|
||||
ghcr.io/papra-hq/papra:${{ inputs.version }}-root
|
||||
|
||||
- name: Build and push rootless Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
@@ -67,7 +62,7 @@ jobs:
|
||||
tags: |
|
||||
corentinth/papra:latest
|
||||
corentinth/papra:latest-rootless
|
||||
corentinth/papra:${{ env.RELEASE_VERSION }}-rootless
|
||||
corentinth/papra:${{ inputs.version }}-rootless
|
||||
ghcr.io/papra-hq/papra:latest
|
||||
ghcr.io/papra-hq/papra:latest-rootless
|
||||
ghcr.io/papra-hq/papra:${{ env.RELEASE_VERSION }}-rootless
|
||||
ghcr.io/papra-hq/papra:${{ inputs.version }}-rootless
|
||||
53
.github/workflows/release.yml
vendored
Normal file
53
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,53 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
concurrency: ${{ github.workflow }}-${{ github.ref }}
|
||||
|
||||
jobs:
|
||||
release:
|
||||
name: Release
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
id-token: write
|
||||
actions: write
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm i
|
||||
|
||||
- name: Create Release Pull Request
|
||||
id: changesets
|
||||
uses: changesets/action@v1
|
||||
with:
|
||||
# Note: pnpm install after versioning is necessary to refresh lockfile
|
||||
version: pnpm run version
|
||||
publish: pnpm exec changeset publish
|
||||
commit: "chore(release): update versions"
|
||||
title: "chore(release): update versions"
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.CHANGESET_GITHUB_TOKEN }}
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
- name: Trigger Docker build
|
||||
if: steps.changesets.outputs.published == 'true' && contains(fromJson(steps.changesets.outputs.publishedPackages).*.name, '@papra/app-server')
|
||||
run: |
|
||||
VERSION=$(echo '${{ steps.changesets.outputs.publishedPackages }}' | jq -r '.[] | select(.name=="@papra/app-server") | .version')
|
||||
echo "VERSION: $VERSION"
|
||||
gh workflow run release-docker.yaml -f version="$VERSION"
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -60,7 +60,10 @@ pnpm script:generate-i18n-types
|
||||
```
|
||||
|
||||
- This command will update the file [`locales.types.ts`](./apps/papra-client/src/modules/i18n/locale.types.ts) with the new/removed keys.
|
||||
- When developing in papra-client (using `pnpm dev`), the i18n types definition will automatically update when you touch the [`en.yml`](./apps/papra-client/src/locales/en.yml) file, so no need to run the command above.
|
||||
- When developing in papra-client (using `pnpm dev`), **the i18n types definition will automatically update** when you touch the [`en.yml`](./apps/papra-client/src/locales/en.yml) file, so no need to run the command above.
|
||||
|
||||
> [!TIP]
|
||||
> You can use the command `pnpm script:sync-i18n-key-order` to sync the order of the keys in the i18n files, it'll also add the missing keys as comments.
|
||||
|
||||
## Development Setup
|
||||
|
||||
|
||||
@@ -59,11 +59,11 @@ Papra is currently in **beta**. The core functionality is stable and usable, but
|
||||
- **Tags**: Organize your documents with tags.
|
||||
- **Email ingestion**: Send/forward emails to a generated address to automatically import documents.
|
||||
- **Content extraction**: Automatically extract text from images or scanned documents for search.
|
||||
- **Tagging Rules**: Automatically tag documents based on custom rules.
|
||||
- **Folder ingestion**: Automatically import documents from a folder.
|
||||
- **CLI**: Manage your documents from the command line.
|
||||
- **API, SDK and webhooks**: Build your own applications on top of Papra.
|
||||
- *In progress:* **i18n**: Support for multiple languages.
|
||||
- *Coming soon:* **Tagging Rules**: Automatically tag documents based on custom rules.
|
||||
- *Coming soon:* **Folder ingestion**: Automatically import documents from a folder.
|
||||
- *Coming soon:* **SDK and API**: Build your own applications on top of Papra.
|
||||
- *Coming soon:* **CLI**: Manage your documents from the command line.
|
||||
- *Coming soon:* **Document sharing**: Share documents with others.
|
||||
- *Coming soon:* **Document requests**: Generate upload links for people to add documents.
|
||||
- *Coming maybe one day:* **Mobile app**: Access and upload documents on the go.
|
||||
|
||||
27
apps/docs/CHANGELOG.md
Normal file
27
apps/docs/CHANGELOG.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# @papra/docs
|
||||
|
||||
## 0.4.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#322](https://github.com/papra-hq/papra/pull/322) [`f54b8e1`](https://github.com/papra-hq/papra/commit/f54b8e162acd6cfe92241aaa649847fc03ca5852) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Auto computes urls from the provided port
|
||||
|
||||
## 0.4.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#320](https://github.com/papra-hq/papra/pull/320) [`8ccdb74`](https://github.com/papra-hq/papra/commit/8ccdb748349a3cacf38f032fd4d3beebce202487) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added base url configuration in docker compose generator
|
||||
|
||||
## 0.4.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [#295](https://github.com/papra-hq/papra/pull/295) [`438a311`](https://github.com/papra-hq/papra/commit/438a31171c606138c4b7fa299fdd58dcbeaaf298) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added support for custom oauth2 providers
|
||||
|
||||
- [#293](https://github.com/papra-hq/papra/pull/293) [`53bf93f`](https://github.com/papra-hq/papra/commit/53bf93f128b54ad1d3553e18680c87ab23155f8d) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added a papra docker-compose.yml generator
|
||||
|
||||
## 0.3.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#280](https://github.com/papra-hq/papra/pull/280) [`85fa5c4`](https://github.com/papra-hq/papra/commit/85fa5c43424d139f5c2752a3ad644082e61d3d67) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Fix broken lint and added auto link check
|
||||
@@ -1,8 +1,11 @@
|
||||
import { env } from 'node:process';
|
||||
import starlight from '@astrojs/starlight';
|
||||
import { defineConfig } from 'astro/config';
|
||||
import starlightLinksValidator from 'starlight-links-validator';
|
||||
import starlightThemeRapide from 'starlight-theme-rapide';
|
||||
import UnoCSS from 'unocss/astro';
|
||||
import { sidebar } from './src/content/navigation';
|
||||
|
||||
import posthogRawScript from './src/scripts/posthog.script.js?raw';
|
||||
|
||||
const posthogApiKey = env.POSTHOG_API_KEY;
|
||||
@@ -15,19 +18,20 @@ const posthogScript = posthogRawScript.replace('[POSTHOG-API-KEY]', posthogApiKe
|
||||
export default defineConfig({
|
||||
site: 'https://docs.papra.app',
|
||||
integrations: [
|
||||
UnoCSS(),
|
||||
starlight({
|
||||
plugins: [starlightThemeRapide()],
|
||||
plugins: [starlightThemeRapide(), starlightLinksValidator({ exclude: ['http://localhost:1221'] })],
|
||||
title: 'Papra Docs',
|
||||
logo: {
|
||||
dark: './src/assets/logo-dark.svg',
|
||||
light: './src/assets/logo-light.svg',
|
||||
alt: 'Papra Logo',
|
||||
},
|
||||
social: {
|
||||
github: 'https://github.com/papra-hq/papra',
|
||||
blueSky: 'https://bsky.app/profile/papra.app',
|
||||
discord: 'https://papra.app/discord',
|
||||
},
|
||||
social: [
|
||||
{ href: 'https://github.com/papra-hq/papra', icon: 'github', label: 'GitHub' },
|
||||
{ href: 'https://bsky.app/profile/papra.app', icon: 'blueSky', label: 'BlueSky' },
|
||||
{ href: 'https://papra.app/discord', icon: 'discord', label: 'Discord' },
|
||||
],
|
||||
expressiveCode: {
|
||||
themes: ['vitesse-black', 'vitesse-light'],
|
||||
},
|
||||
@@ -37,7 +41,7 @@ export default defineConfig({
|
||||
sidebar,
|
||||
favicon: '/favicon.svg',
|
||||
head: [
|
||||
// Add ICO favicon fallback for Safari.
|
||||
// Add ICO favicon fallback for Safari.
|
||||
{
|
||||
tag: 'link',
|
||||
attrs: {
|
||||
|
||||
1382
apps/docs/package-lock.json
generated
Normal file
1382
apps/docs/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,9 @@
|
||||
{
|
||||
"name": "@papra/docs",
|
||||
"type": "module",
|
||||
"version": "0.3.0",
|
||||
"packageManager": "pnpm@9.15.4",
|
||||
"version": "0.4.2",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.9.0",
|
||||
"description": "Papra documentation website",
|
||||
"author": "Corentin Thomasset <corentinth@proton.me> (https://corentin.tech)",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
@@ -17,10 +18,16 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/starlight": "^0.31.0",
|
||||
"astro": "^5.1.5",
|
||||
"@astrojs/solid-js": "^5.1.0",
|
||||
"@astrojs/starlight": "^0.34.3",
|
||||
"astro": "^5.8.0",
|
||||
"sharp": "^0.32.5",
|
||||
"starlight-theme-rapide": "^0.3.0",
|
||||
"shiki": "^3.4.2",
|
||||
"starlight-links-validator": "^0.16.0",
|
||||
"starlight-theme-rapide": "^0.5.0",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"unocss-preset-animations": "^1.2.1",
|
||||
"yaml": "^2.8.0",
|
||||
"zod-to-json-schema": "^3.24.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -33,6 +40,7 @@
|
||||
"figue": "^2.2.2",
|
||||
"lodash-es": "^4.17.21",
|
||||
"marked": "^15.0.6",
|
||||
"typescript": "^5.7.3"
|
||||
"typescript": "^5.7.3",
|
||||
"unocss": "0.65.0-beta.2"
|
||||
}
|
||||
}
|
||||
|
||||
3
apps/docs/public/_headers
Normal file
3
apps/docs/public/_headers
Normal file
@@ -0,0 +1,3 @@
|
||||
/*
|
||||
X-Frame-Options: DENY
|
||||
X-Content-Type-Options: nosniff
|
||||
@@ -1,4 +1,38 @@
|
||||
:root[data-theme='dark'] {
|
||||
--background: 240 4% 10%;
|
||||
--foreground: 0 0% 98%;
|
||||
|
||||
--card: 240 4% 8%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
|
||||
--popover: 240 4% 8%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
|
||||
--primary: 77 100% 74%;
|
||||
--primary-foreground: 0 0% 9%;
|
||||
|
||||
--secondary: 0 0% 14.9%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
|
||||
--muted: 0 0% 14.9%;
|
||||
--muted-foreground: 0 0% 63.9%;
|
||||
|
||||
--accent: 0 0% 14.9%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
|
||||
--warning: 31 98% 50%;
|
||||
--warning-foreground: 0 0% 98%;
|
||||
|
||||
--border: 345 4% 17%;
|
||||
--input: 0 0% 89.8%;
|
||||
--ring: 0 0% 3.9%;
|
||||
|
||||
--radius: 0.5rem;
|
||||
|
||||
|
||||
--background-color: #0c0d0f!important;
|
||||
--accent-color: #fff!important;
|
||||
--foreground-color: #9ea3a2!important;
|
||||
@@ -55,4 +89,8 @@
|
||||
|
||||
.site-title img {
|
||||
width: 1.8rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
pre.shiki {
|
||||
border-radius: 0.5rem!important;
|
||||
}
|
||||
@@ -33,11 +33,14 @@ const rows = configDetails
|
||||
|
||||
const rawDocumentation = formatDoc(doc);
|
||||
|
||||
// The client baseUrl default value is overridden in the Dockerfiles
|
||||
const defaultOverride = path.join('.') === 'client.baseUrl' ? 'http://localhost:1221' : undefined;
|
||||
|
||||
return {
|
||||
path,
|
||||
env,
|
||||
documentation: rawDocumentation,
|
||||
defaultValue: isEmptyDefaultValue ? undefined : defaultValue,
|
||||
defaultValue: defaultOverride ?? (isEmptyDefaultValue ? undefined : defaultValue),
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ This guide will show you how to setup a Cloudflare Email worker to receive email
|
||||
<Aside type="note">
|
||||
Setting up a Papra Intake Emails with a Cloudflare Email worker requires bit of knowledge about how to setup a CF Email worker and how to configure your Papra instance to receive emails.
|
||||
|
||||
For a simpler solution, you can use the official [OwlRelay integration](/docs/guides/intake-emails-with-papra-email-intake) guide.
|
||||
For a simpler solution, you can use the official [OwlRelay integration](/guides/intake-emails-with-owlrelay) guide.
|
||||
</Aside>
|
||||
|
||||
## Prerequisites
|
||||
@@ -23,7 +23,7 @@ In order to follow this guide, you need:
|
||||
- a publicly accessible Papra instance
|
||||
- basic development skills (git and node.js to setup the Email Worker)
|
||||
|
||||
If you prefer a simpler solution, you can use the official [OwlRelay integration](/docs/guides/intake-emails-with-papra-email-intake) guide.
|
||||
If you prefer a simpler solution, you can use the official [OwlRelay integration](/guides/intake-emails-with-owlrelay) guide.
|
||||
|
||||
## How it works
|
||||
|
||||
|
||||
@@ -91,7 +91,7 @@ environment:
|
||||
|
||||
## Configuration
|
||||
|
||||
You can find the list of all configuration options in the [configuration reference](/docs/configuration-reference), the related variables are prefixed with `INGESTION_FOLDER_`.
|
||||
You can find the list of all configuration options in the [configuration reference](/self-hosting/configuration), the related variables are prefixed with `INGESTION_FOLDER_`.
|
||||
|
||||
|
||||
## Edge cases and behaviors
|
||||
|
||||
181
apps/docs/src/content/docs/03-guides/04-setup-custom-oauth2.mdx
Normal file
181
apps/docs/src/content/docs/03-guides/04-setup-custom-oauth2.mdx
Normal 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"]
|
||||
}
|
||||
]
|
||||
```
|
||||
89
apps/docs/src/content/docs/04-resources/01-cli.mdx
Normal file
89
apps/docs/src/content/docs/04-resources/01-cli.mdx
Normal file
@@ -0,0 +1,89 @@
|
||||
---
|
||||
title: CLI Documentation
|
||||
description: Learn how to use the Papra CLI to interact with your Papra instance from the command line.
|
||||
slug: resources/cli
|
||||
---
|
||||
|
||||
The Papra CLI is a command-line interface tool that helps you interact with the Papra platform from your terminal.
|
||||
|
||||
## Installation
|
||||
|
||||
For the moment, the CLI is only available as an NPM package.
|
||||
|
||||
```bash
|
||||
# using pnpm
|
||||
pnpm i -g @papra/cli
|
||||
|
||||
# or using npm
|
||||
npm i -g @papra/cli
|
||||
|
||||
# or using yarn
|
||||
yarn add -g @papra/cli
|
||||
```
|
||||
|
||||
The CLI will be installed globally, so you can use it from anywhere in your system with the `papra` command.
|
||||
|
||||
## Configuration
|
||||
|
||||
Before using the CLI, you need to configure it with your API credentials.
|
||||
|
||||
### Initial Setup
|
||||
|
||||
To initialize the configuration, run:
|
||||
|
||||
```bash
|
||||
papra config init
|
||||
```
|
||||
|
||||
This command will prompt you for:
|
||||
- **Instance URL**: Your Papra instance URL (e.g., `https://api.papra.app`)
|
||||
- **API Key**: Your personal API key (can be created in your User Settings)
|
||||
|
||||
### Managing Configuration
|
||||
|
||||
You can manage your configuration using the following commands:
|
||||
|
||||
- `papra config list`: View your current configuration
|
||||
- `papra config set api-key`: Set or update your API key
|
||||
- `papra config set api-url`: Set or update your instance URL
|
||||
- `papra config set default-org-id`: Set a default organization ID
|
||||
|
||||
### Organization IDs
|
||||
|
||||
Since Papra supports multiple organizations, you may need to specify the organization ID when importing documents for example. If want, you can set a default organization ID in your configuration.
|
||||
|
||||
```bash
|
||||
papra config set default-org-id <organization-id>
|
||||
papra documents import <file-path>
|
||||
|
||||
# or
|
||||
papra documents import -o <organization-id> <file-path>
|
||||
```
|
||||
|
||||
## Available Commands
|
||||
|
||||
### Importing documents
|
||||
|
||||
The `import` command allows you to import a document into your Papra organization.
|
||||
|
||||
```bash
|
||||
papra documents import -o <organization-id> <file-path>
|
||||
```
|
||||
|
||||
## Getting Help
|
||||
|
||||
For more information about any command, you can use the `--help` flag:
|
||||
|
||||
```bash
|
||||
papra --help
|
||||
papra config --help
|
||||
papra documents --help
|
||||
```
|
||||
|
||||
|
||||
## About the CLI
|
||||
|
||||
The CLI is built using the [citty](https://github.com/unjs/citty) framework and the [Papra TS SDK](https://github.com/papra-hq/papra/tree/main/packages/api-sdk).
|
||||
|
||||
|
||||
|
||||
@@ -51,11 +51,11 @@ In today's digital world, managing countless important documents efficiently and
|
||||
- **Tags**: Organize your documents with tags.
|
||||
- **Email ingestion**: Send/forward emails to a generated address to automatically import documents.
|
||||
- **Content extraction**: Automatically extract text from images or scanned documents for search.
|
||||
- **Tagging Rules**: Automatically tag documents based on custom rules.
|
||||
- **Folder ingestion**: Automatically import documents from a folder.
|
||||
- **API, SDK and webhooks**: Build your own applications on top of Papra.
|
||||
- **CLI**: Manage your documents from the command line.
|
||||
- *In progress:* **i18n**: Support for multiple languages.
|
||||
- *Coming soon:* **Tagging Rules**: Automatically tag documents based on custom rules.
|
||||
- *Coming soon:* **Folder ingestion**: Automatically import documents from a folder.
|
||||
- *Coming soon:* **SDK and API**: Build your own applications on top of Papra.
|
||||
- *Coming soon:* **CLI**: Manage your documents from the command line.
|
||||
- *Coming soon:* **Document sharing**: Share documents with others.
|
||||
- *Coming soon:* **Document requests**: Generate upload links for people to add documents.
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ export const sidebar: StarlightUserConfig['sidebar'] = [
|
||||
items: [
|
||||
{ label: 'Using Docker', slug: 'self-hosting/using-docker' },
|
||||
{ label: 'Using Docker Compose', slug: 'self-hosting/using-docker-compose' },
|
||||
{ label: 'Docker Compose Generator', link: '/docker-compose-generator', badge: { text: 'new', variant: 'note' } },
|
||||
{ label: 'Configuration', slug: 'self-hosting/configuration' },
|
||||
],
|
||||
},
|
||||
@@ -30,11 +31,19 @@ export const sidebar: StarlightUserConfig['sidebar'] = [
|
||||
label: 'Setup Ingestion Folder',
|
||||
slug: 'guides/setup-ingestion-folder',
|
||||
},
|
||||
{
|
||||
label: 'Setup Custom OAuth2 Providers',
|
||||
slug: 'guides/setup-custom-oauth2-providers',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Resources',
|
||||
items: [
|
||||
{
|
||||
label: 'CLI Documentation',
|
||||
slug: 'resources/cli',
|
||||
},
|
||||
{
|
||||
label: 'Security Policy',
|
||||
link: 'https://github.com/papra-hq/papra/blob/main/SECURITY.md',
|
||||
|
||||
453
apps/docs/src/docker-compose-generator/dc-generator.astro
Normal file
453
apps/docs/src/docker-compose-generator/dc-generator.astro
Normal file
@@ -0,0 +1,453 @@
|
||||
---
|
||||
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' });
|
||||
---
|
||||
|
||||
|
||||
<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} />
|
||||
|
||||
<div class="flex items-center gap-2 mt-4">
|
||||
<button class="btn bg-muted mt-0" id="download-button">Download docker-compose.yml</button>
|
||||
<button class="btn bg-muted mt-0" id="copy-button">Copy to clipboard</button>
|
||||
</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');
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
async function updateDockerCompose() {
|
||||
const dockerCompose = getDockerComposeYml();
|
||||
|
||||
const html = await codeToHtml(dockerCompose, { theme: 'vitesse-black', lang: 'yaml' });
|
||||
|
||||
if (dockerComposeOutput) {
|
||||
dockerComposeOutput.innerHTML = html;
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
// 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);
|
||||
|
||||
authSecretInput.value = getRandomString();
|
||||
|
||||
// Initial render
|
||||
updateDockerCompose();
|
||||
|
||||
// Initial setup
|
||||
handleIngestionEnabledChange();
|
||||
handleIntakeEmailEnabledChange();
|
||||
webhookSecretInput.value = getRandomString();
|
||||
</script>
|
||||
16
apps/docs/src/pages/docker-compose-generator.astro
Normal file
16
apps/docs/src/pages/docker-compose-generator.astro
Normal 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>
|
||||
@@ -1,5 +1,10 @@
|
||||
{
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"include": [".astro/types.d.ts", "**/*"],
|
||||
"exclude": ["dist"]
|
||||
"include": [
|
||||
".astro/types.d.ts",
|
||||
"**/*"
|
||||
],
|
||||
"exclude": [
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
|
||||
97
apps/docs/uno.config.ts
Normal file
97
apps/docs/uno.config.ts
Normal 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',
|
||||
},
|
||||
});
|
||||
55
apps/papra-client/CHANGELOG.md
Normal file
55
apps/papra-client/CHANGELOG.md
Normal file
@@ -0,0 +1,55 @@
|
||||
# @papra/app-client
|
||||
|
||||
## 0.6.1
|
||||
|
||||
## 0.6.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [#317](https://github.com/papra-hq/papra/pull/317) [`79c1d32`](https://github.com/papra-hq/papra/commit/79c1d3206b140cf8b3d33ef8bda6098dcf4c9c9c) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added document activity log
|
||||
|
||||
- [#319](https://github.com/papra-hq/papra/pull/319) [`60059c8`](https://github.com/papra-hq/papra/commit/60059c895c4860cbfda69d3c989ad00542def65b) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added pending invitation management page
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#309](https://github.com/papra-hq/papra/pull/309) [`d4f72e8`](https://github.com/papra-hq/papra/commit/d4f72e889a4d39214de998942bc0eb88cd5cee3d) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Disable "Manage subscription" from organization setting by default
|
||||
|
||||
- [#308](https://github.com/papra-hq/papra/pull/308) [`759a3ff`](https://github.com/papra-hq/papra/commit/759a3ff713db8337061418b9c9b122b957479343) Thanks [@CorentinTh](https://github.com/CorentinTh)! - I18n: full support for French language
|
||||
|
||||
- [#312](https://github.com/papra-hq/papra/pull/312) [`e5ef40f`](https://github.com/papra-hq/papra/commit/e5ef40f36c27ea25dc8a79ef2805d673761eec2a) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Fixed an issue with the reset-password page navigation guard that prevented reset
|
||||
|
||||
## 0.5.1
|
||||
|
||||
## 0.5.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [#295](https://github.com/papra-hq/papra/pull/295) [`438a311`](https://github.com/papra-hq/papra/commit/438a31171c606138c4b7fa299fdd58dcbeaaf298) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added support for custom oauth2 providers
|
||||
|
||||
- [#291](https://github.com/papra-hq/papra/pull/291) [`0627ec2`](https://github.com/papra-hq/papra/commit/0627ec25a422b7b820b08740cfc2905f9c55c00e) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added invitation system to add users to an organization
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#296](https://github.com/papra-hq/papra/pull/296) [`0ddc234`](https://github.com/papra-hq/papra/commit/0ddc2340f092cf6fe5bf2175b55fb46db7681c36) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Fix register page description
|
||||
|
||||
## 0.4.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [#280](https://github.com/papra-hq/papra/pull/280) [`85fa5c4`](https://github.com/papra-hq/papra/commit/85fa5c43424d139f5c2752a3ad644082e61d3d67) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added webhook management
|
||||
|
||||
- [#280](https://github.com/papra-hq/papra/pull/280) [`85fa5c4`](https://github.com/papra-hq/papra/commit/85fa5c43424d139f5c2752a3ad644082e61d3d67) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added API keys support
|
||||
|
||||
- [#280](https://github.com/papra-hq/papra/pull/280) [`85fa5c4`](https://github.com/papra-hq/papra/commit/85fa5c43424d139f5c2752a3ad644082e61d3d67) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added document searchable content edit
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#280](https://github.com/papra-hq/papra/pull/280) [`85fa5c4`](https://github.com/papra-hq/papra/commit/85fa5c43424d139f5c2752a3ad644082e61d3d67) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added tag creation button in document page
|
||||
|
||||
- [#280](https://github.com/papra-hq/papra/pull/280) [`85fa5c4`](https://github.com/papra-hq/papra/commit/85fa5c43424d139f5c2752a3ad644082e61d3d67) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Improved tag selector input wrapping
|
||||
|
||||
- [#280](https://github.com/papra-hq/papra/pull/280) [`85fa5c4`](https://github.com/papra-hq/papra/commit/85fa5c43424d139f5c2752a3ad644082e61d3d67) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Properly handle file names without extensions
|
||||
|
||||
- [#280](https://github.com/papra-hq/papra/pull/280) [`85fa5c4`](https://github.com/papra-hq/papra/commit/85fa5c43424d139f5c2752a3ad644082e61d3d67) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Wrap text in document preview
|
||||
|
||||
- [#280](https://github.com/papra-hq/papra/pull/280) [`85fa5c4`](https://github.com/papra-hq/papra/commit/85fa5c43424d139f5c2752a3ad644082e61d3d67) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Excluded deleted documents from doc count
|
||||
@@ -1,8 +1,9 @@
|
||||
{
|
||||
"name": "@papra/papra-app-client",
|
||||
"name": "@papra/app-client",
|
||||
"type": "module",
|
||||
"version": "0.3.0",
|
||||
"packageManager": "pnpm@9.15.4",
|
||||
"version": "0.6.1",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.9.0",
|
||||
"description": "Papra frontend client",
|
||||
"author": "Corentin Thomasset <corentinth@proton.me> (https://corentin.tech)",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
@@ -25,50 +26,52 @@
|
||||
"test:e2e": "playwright test",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"script:get-missing-i18n-keys": "tsx src/scripts/get-missing-i18n-keys.script.ts",
|
||||
"script:generate-i18n-types": "tsx src/scripts/generate-i18n-types.script.ts"
|
||||
"script:generate-i18n-types": "tsx src/scripts/generate-i18n-types.script.ts",
|
||||
"script:sync-i18n-key-order": "tsx src/scripts/sync-i18n-key-order.script.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@corentinth/chisels": "^1.0.2",
|
||||
"@kobalte/core": "^0.13.7",
|
||||
"@corentinth/chisels": "^1.3.1",
|
||||
"@kobalte/core": "^0.13.9",
|
||||
"@kobalte/utils": "^0.9.1",
|
||||
"@modular-forms/solid": "^0.25.0",
|
||||
"@pdfslick/solid": "^2.0.0",
|
||||
"@solid-primitives/storage": "^4.2.1",
|
||||
"@solidjs/router": "^0.14.3",
|
||||
"@tanstack/solid-query": "^5.61.5",
|
||||
"@tanstack/solid-table": "^8.20.5",
|
||||
"@unocss/reset": "^0.64.0",
|
||||
"@modular-forms/solid": "^0.25.1",
|
||||
"@pdfslick/solid": "^2.3.0",
|
||||
"@solid-primitives/storage": "^4.3.2",
|
||||
"@solidjs/router": "^0.14.10",
|
||||
"@tanstack/solid-query": "^5.77.2",
|
||||
"@tanstack/solid-table": "^8.21.3",
|
||||
"@unocss/reset": "^0.64.1",
|
||||
"better-auth": "catalog:",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk-solid": "^1.1.0",
|
||||
"cmdk-solid": "^1.1.2",
|
||||
"date-fns": "^4.1.0",
|
||||
"lodash-es": "^4.17.21",
|
||||
"ofetch": "^1.4.1",
|
||||
"posthog-js": "^1.231.0",
|
||||
"posthog-js": "^1.246.0",
|
||||
"radix3": "^1.1.2",
|
||||
"solid-js": "^1.8.11",
|
||||
"solid-js": "^1.9.7",
|
||||
"solid-sonner": "^0.2.8",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"ts-pattern": "^5.5.0",
|
||||
"unocss-preset-animations": "^1.1.0",
|
||||
"unstorage": "^1.14.4",
|
||||
"ts-pattern": "^5.7.1",
|
||||
"unocss-preset-animations": "^1.2.1",
|
||||
"unstorage": "^1.16.0",
|
||||
"valibot": "1.0.0-beta.10"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@antfu/eslint-config": "catalog:",
|
||||
"@iconify-json/tabler": "^1.1.120",
|
||||
"@playwright/test": "^1.46.1",
|
||||
"@iconify-json/tabler": "^1.2.18",
|
||||
"@playwright/test": "^1.52.0",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/node": "catalog:",
|
||||
"eslint": "catalog:",
|
||||
"jsdom": "^25.0.0",
|
||||
"tsx": "^4.19.1",
|
||||
"jsdom": "^25.0.1",
|
||||
"tinyglobby": "^0.2.14",
|
||||
"tsx": "^4.19.4",
|
||||
"typescript": "catalog:",
|
||||
"unocss": "0.65.0-beta.2",
|
||||
"vite": "^5.0.11",
|
||||
"vite-plugin-solid": "^2.8.2",
|
||||
"vite": "^5.4.19",
|
||||
"vite-plugin-solid": "^2.11.6",
|
||||
"vitest": "catalog:",
|
||||
"yaml": "^2.7.0"
|
||||
"yaml": "^2.8.0"
|
||||
}
|
||||
}
|
||||
|
||||
3
apps/papra-client/public/_headers
Normal file
3
apps/papra-client/public/_headers
Normal file
@@ -0,0 +1,3 @@
|
||||
/*
|
||||
X-Frame-Options: DENY
|
||||
X-Content-Type-Options: nosniff
|
||||
@@ -9,6 +9,7 @@ import { render, Suspense } from 'solid-js/web';
|
||||
import { CommandPaletteProvider } from './modules/command-palette/command-palette.provider';
|
||||
import { ConfigProvider } from './modules/config/config.provider';
|
||||
import { DemoIndicator } from './modules/demo/demo.provider';
|
||||
import { RenameDocumentDialogProvider } from './modules/documents/components/rename-document-button.component';
|
||||
import { I18nProvider } from './modules/i18n/i18n.provider';
|
||||
import { ConfirmModalProvider } from './modules/shared/confirm';
|
||||
import { queryClient } from './modules/shared/query/query-client';
|
||||
@@ -44,9 +45,11 @@ render(
|
||||
>
|
||||
<CommandPaletteProvider>
|
||||
<ConfigProvider>
|
||||
<div class="min-h-screen font-sans text-sm font-400">
|
||||
{props.children}
|
||||
</div>
|
||||
<RenameDocumentDialogProvider>
|
||||
<div class="min-h-screen font-sans text-sm font-400">
|
||||
{props.children}
|
||||
</div>
|
||||
</RenameDocumentDialogProvider>
|
||||
<DemoIndicator />
|
||||
</ConfigProvider>
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
# Authentication
|
||||
|
||||
auth.request-password-reset.title: Reset your password
|
||||
auth.request-password-reset.description: Enter your email to reset your password.
|
||||
auth.request-password-reset.requested: If an account exists for this email, we've sent you an email to reset your password.
|
||||
@@ -38,7 +40,7 @@ auth.login.form.forgot-password.label: Forgot password?
|
||||
auth.login.form.submit: Login
|
||||
|
||||
auth.register.title: Register to Papra
|
||||
auth.register.description: Enter your email or use social login to access your Papra account.
|
||||
auth.register.description: Create an account to start using Papra.
|
||||
auth.register.register-with-email: Register with email
|
||||
auth.register.register-with-provider: Register with {{ provider }}
|
||||
auth.register.providers.google: Google
|
||||
@@ -69,20 +71,256 @@ auth.legal-links.description: By continuing, you acknowledge that you understand
|
||||
auth.legal-links.terms: Terms of Service
|
||||
auth.legal-links.privacy: Privacy Policy
|
||||
|
||||
# User settings
|
||||
|
||||
user.settings.title: User settings
|
||||
user.settings.description: Manage your account settings here.
|
||||
|
||||
user.settings.email.title: Email address
|
||||
user.settings.email.description: Your email address cannot be changed.
|
||||
user.settings.email.label: Email address
|
||||
|
||||
user.settings.name.title: Full name
|
||||
user.settings.name.description: Your full name is displayed to other organization members.
|
||||
user.settings.name.label: Full name
|
||||
user.settings.name.placeholder: Eg. John Doe
|
||||
user.settings.name.update: Update name
|
||||
user.settings.name.updated: Your full name has been updated
|
||||
|
||||
user.settings.logout.title: Logout
|
||||
user.settings.logout.description: Logout from your account. You can login again later.
|
||||
user.settings.logout.button: Logout
|
||||
|
||||
# Organizations
|
||||
|
||||
organizations.list.title: Your organizations
|
||||
organizations.list.description: Organizations are a way to group your documents and manage access to them. You can create multiple organizations and invite your team members to collaborate.
|
||||
organizations.list.create-new: Create new organization
|
||||
|
||||
organizations.details.no-documents.title: No documents
|
||||
organizations.details.no-documents.description: There are no documents in this organization yet. Start by uploading some documents.
|
||||
organizations.details.upload-documents: Upload documents
|
||||
organizations.details.documents-count: documents in total
|
||||
organizations.details.total-size: total size
|
||||
organizations.details.latest-documents: Latest imported documents
|
||||
|
||||
organizations.create.title: Create a new organization
|
||||
organizations.create.description: Your documents will be grouped by organization. You can create multiple organizations to separate your documents, for example, for personal and work documents.
|
||||
organizations.create.back: Back
|
||||
organizations.create.error.max-count-reached: You have reached the maximum number of organizations you can create, if you need to create more, please contact support.
|
||||
organizations.create.form.name.label: Organization name
|
||||
organizations.create.form.name.placeholder: Eg. Acme Inc.
|
||||
organizations.create.form.name.required: Please enter an organization name
|
||||
organizations.create.form.submit: Create organization
|
||||
organizations.create.success: Organization created successfully
|
||||
|
||||
organizations.create-first.title: Create your organization
|
||||
organizations.create-first.description: Your documents will be grouped by organization. You can create multiple organizations to separate your documents, for example, for personal and work documents.
|
||||
organizations.create-first.default-name: My organization
|
||||
organizations.create-first.user-name: "{{ name }}'s organization"
|
||||
|
||||
organization.settings.title: Organization Settings
|
||||
organization.settings.page.title: Organization settings
|
||||
organization.settings.page.description: Manage your organization settings here.
|
||||
organization.settings.name.title: Organization name
|
||||
organization.settings.name.update: Update name
|
||||
organization.settings.name.placeholder: Eg. Acme Inc.
|
||||
organization.settings.name.updated: Organization name updated
|
||||
organization.settings.subscription.title: Subscription
|
||||
organization.settings.subscription.description: Manage your billing, invoices and payment methods.
|
||||
organization.settings.subscription.manage: Manage subscription
|
||||
organization.settings.subscription.error: Failed to get customer portal URL
|
||||
organization.settings.delete.title: Delete organization
|
||||
organization.settings.delete.description: Deleting this organization will permanently remove all data associated with it.
|
||||
organization.settings.delete.confirm.title: Delete organization
|
||||
organization.settings.delete.confirm.message: Are you sure you want to delete this organization? This action cannot be undone, and all data associated with this organization will be permanently removed.
|
||||
organization.settings.delete.confirm.confirm-button: Delete organization
|
||||
organization.settings.delete.confirm.cancel-button: Cancel
|
||||
organization.settings.delete.success: Organization deleted
|
||||
|
||||
organizations.members.title: Members
|
||||
organizations.members.description: Manage your organization members
|
||||
organizations.members.invite-member: Invite member
|
||||
organizations.members.invite-member-disabled-tooltip: Only admins or owners can invite members to the organization
|
||||
organizations.members.remove-from-organization: Remove from organization
|
||||
organizations.members.role: Role
|
||||
organizations.members.roles.owner: Owner
|
||||
organizations.members.roles.admin: Admin
|
||||
organizations.members.roles.member: Member
|
||||
organizations.members.delete.confirm.title: Remove member
|
||||
organizations.members.delete.confirm.message: Are you sure you want to remove this member from the organization?
|
||||
organizations.members.delete.confirm.confirm-button: Remove
|
||||
organizations.members.delete.confirm.cancel-button: Cancel
|
||||
organizations.members.delete.success: Member removed from organization
|
||||
organizations.members.update-role.success: Member role updated
|
||||
organizations.members.table.headers.name: Name
|
||||
organizations.members.table.headers.email: Email
|
||||
organizations.members.table.headers.role: Role
|
||||
organizations.members.table.headers.created: Created
|
||||
organizations.members.table.headers.actions: Actions
|
||||
|
||||
organizations.invite-member.title: Invite member
|
||||
organizations.invite-member.description: Invite a member to your organization
|
||||
organizations.invite-member.form.email.label: Email
|
||||
organizations.invite-member.form.email.placeholder: 'Example: ada@papra.app'
|
||||
organizations.invite-member.form.email.required: Please enter a valid email address
|
||||
organizations.invite-member.form.role.label: Role
|
||||
organizations.invite-member.form.submit: Invite to organization
|
||||
organizations.invite-member.success.message: Member invited
|
||||
organizations.invite-member.success.description: The email has been invited to the organization.
|
||||
organizations.invite-member.error.message: Failed to invite member
|
||||
|
||||
organizations.invitations.title: Invitations
|
||||
organizations.invitations.description: Manage your organization invitations
|
||||
organizations.invitations.list.cta: Invite member
|
||||
organizations.invitations.list.empty.title: No pending invitations
|
||||
organizations.invitations.list.empty.description: You haven't been invited to any organizations yet.
|
||||
organizations.invitations.status.pending: Pending
|
||||
organizations.invitations.status.accepted: Accepted
|
||||
organizations.invitations.status.rejected: Rejected
|
||||
organizations.invitations.status.expired: Expired
|
||||
organizations.invitations.status.cancelled: Cancelled
|
||||
organizations.invitations.resend: Resend invitation
|
||||
organizations.invitations.cancel.title: Cancel invitation
|
||||
organizations.invitations.cancel.description: Are you sure you want to cancel this invitation?
|
||||
organizations.invitations.cancel.confirm: Cancel invitation
|
||||
organizations.invitations.cancel.cancel: Cancel
|
||||
organizations.invitations.resend.title: Resend invitation
|
||||
organizations.invitations.resend.description: Are you sure you want to resend this invitation? This will send a new email to the recipient.
|
||||
organizations.invitations.resend.confirm: Resend invitation
|
||||
organizations.invitations.resend.cancel: Cancel
|
||||
|
||||
invitations.list.title: Invitations
|
||||
invitations.list.description: Manage your organization invitations
|
||||
invitations.list.empty.title: No pending invitations
|
||||
invitations.list.empty.description: You haven't been invited to any organizations yet.
|
||||
invitations.list.headers.organization: Organization
|
||||
invitations.list.headers.status: Status
|
||||
invitations.list.headers.created: Created
|
||||
invitations.list.headers.actions: Actions
|
||||
invitations.list.actions.accept: Accept
|
||||
invitations.list.actions.reject: Reject
|
||||
invitations.list.actions.accept.success.message: Invitation accepted
|
||||
invitations.list.actions.accept.success.description: The invitation has been accepted.
|
||||
invitations.list.actions.reject.success.message: Invitation rejected
|
||||
invitations.list.actions.reject.success.description: The invitation has been rejected.
|
||||
|
||||
# Documents
|
||||
|
||||
documents.list.title: Documents
|
||||
documents.list.no-documents.title: No documents
|
||||
documents.list.no-documents.description: There are no documents in this organization yet. Start by uploading some documents.
|
||||
documents.list.no-results: No documents found
|
||||
|
||||
documents.tabs.info: Info
|
||||
documents.tabs.content: Content
|
||||
documents.tabs.activity: Activity
|
||||
documents.deleted.message: This document has been deleted and will be permanently removed in {{ days }} days.
|
||||
documents.actions.download: Download
|
||||
documents.actions.open-in-new-tab: Open in new tab
|
||||
documents.actions.restore: Restore
|
||||
documents.actions.delete: Delete
|
||||
documents.actions.edit: Edit
|
||||
documents.actions.cancel: Cancel
|
||||
documents.actions.save: Save
|
||||
documents.actions.saving: Saving...
|
||||
documents.content.alert: The content of the document is automatically extracted from the document on upload. It is only used for search and indexing purposes.
|
||||
documents.info.id: ID
|
||||
documents.info.name: Name
|
||||
documents.info.type: Type
|
||||
documents.info.size: Size
|
||||
documents.info.created-at: Created At
|
||||
documents.info.updated-at: Updated At
|
||||
documents.info.never: Never
|
||||
|
||||
documents.rename.title: Rename document
|
||||
documents.rename.form.name.label: Name
|
||||
documents.rename.form.name.placeholder: 'Example: Invoice 2024'
|
||||
documents.rename.form.name.required: Please enter a name for the document
|
||||
documents.rename.form.name.max-length: The name must be less than 255 characters
|
||||
documents.rename.form.submit: Rename document
|
||||
documents.rename.success: Document renamed successfully
|
||||
documents.rename.cancel: Cancel
|
||||
|
||||
import-documents.title.error: '{{ count }} documents failed'
|
||||
import-documents.title.success: '{{ count }} documents imported'
|
||||
import-documents.title.pending: '{{ count }} / {{ total }} documents imported'
|
||||
import-documents.title.none: Import documents
|
||||
import-documents.no-import-in-progress: No document import in progress
|
||||
|
||||
documents.deleted.title: Deleted documents
|
||||
documents.deleted.empty.title: No deleted documents
|
||||
documents.deleted.empty.description: You have no deleted documents. Documents that are deleted will be moved to the trash bin for {{ days }} days.
|
||||
documents.deleted.retention-notice: All deleted documents are stored in the trash bin for {{ days }} days. Passing this delay, the documents will be permanently deleted, and you will not be able to restore them.
|
||||
documents.deleted.deleted-at: Deleted
|
||||
documents.deleted.restoring: Restoring...
|
||||
documents.deleted.deleting: Deleting...
|
||||
|
||||
trash.delete-all.button: Delete all
|
||||
trash.delete-all.confirm.title: Permanently delete all documents?
|
||||
trash.delete-all.confirm.description: Are you sure you want to permanently delete all documents from the trash? This action cannot be undone.
|
||||
trash.delete-all.confirm.label: Delete
|
||||
trash.delete-all.confirm.cancel: Cancel
|
||||
trash.delete.button: Delete
|
||||
trash.delete.confirm.title: Permanently delete document?
|
||||
trash.delete.confirm.description: Are you sure you want to permanently delete this document from the trash? This action cannot be undone.
|
||||
trash.delete.confirm.label: Delete
|
||||
trash.delete.confirm.cancel: Cancel
|
||||
trash.deleted.success.title: Document deleted
|
||||
trash.deleted.success.description: The document has been permanently deleted.
|
||||
|
||||
activity.document.created: The document has been created
|
||||
activity.document.updated.single: The {{ field }} has been updated
|
||||
activity.document.updated.multiple: The {{ fields }} have been updated
|
||||
activity.document.updated: The document has been updated
|
||||
activity.document.deleted: The document has been deleted
|
||||
activity.document.restored: The document has been restored
|
||||
activity.document.tagged: Tag {{ tag }} has been added
|
||||
activity.document.untagged: Tag {{ tag }} has been removed
|
||||
|
||||
activity.document.user.name: by {{ name }}
|
||||
|
||||
activity.load-more: Load more
|
||||
activity.no-more-activities: No more activities for this document
|
||||
|
||||
# Tags
|
||||
|
||||
tags.no-tags.title: No tags yet
|
||||
tags.no-tags.description: This organization has no tags yet. Tags are used to categorize documents. You can add tags to your documents to make them easier to find and organize.
|
||||
tags.no-tags.create-tag: Create tag
|
||||
|
||||
layout.menu.home: Home
|
||||
layout.menu.documents: Documents
|
||||
layout.menu.tags: Tags
|
||||
layout.menu.tagging-rules: Tagging rules
|
||||
layout.menu.integrations: Integrations
|
||||
layout.menu.deleted-documents: Deleted documents
|
||||
layout.menu.organization-settings: Organization settings
|
||||
layout.menu.api-keys: API keys
|
||||
layout.menu.settings: Settings
|
||||
layout.menu.account: Account
|
||||
tags.title: Documents Tags
|
||||
tags.description: Tags are used to categorize documents. You can add tags to your documents to make them easier to find and organize.
|
||||
tags.create: Create tag
|
||||
tags.update: Update tag
|
||||
tags.delete: Delete tag
|
||||
tags.delete.confirm.title: Delete tag
|
||||
tags.delete.confirm.message: Are you sure you want to delete this tag? Deleting a tag will remove it from all documents.
|
||||
tags.delete.confirm.confirm-button: Delete
|
||||
tags.delete.confirm.cancel-button: Cancel
|
||||
tags.delete.success: Tag deleted successfully
|
||||
tags.create.success: Tag "{{ name }}" created successfully.
|
||||
tags.update.success: Tag "{{ name }}" updated successfully.
|
||||
tags.form.name.label: Name
|
||||
tags.form.name.placeholder: Eg. Contracts
|
||||
tags.form.name.required: Please enter a tag name
|
||||
tags.form.name.max-length: Tag name must be less than 64 characters
|
||||
tags.form.color.label: Color
|
||||
tags.form.color.placeholder: 'Eg. #FF0000'
|
||||
tags.form.color.required: Please enter a color
|
||||
tags.form.color.invalid: The hex color is badly formatted.
|
||||
tags.form.description.label: Description
|
||||
tags.form.description.optional: (optional)
|
||||
tags.form.description.placeholder: Eg. All the contracts signed by the company
|
||||
tags.form.description.max-length: Description must be less than 256 characters
|
||||
tags.form.no-description: No description
|
||||
tags.table.headers.tag: Tag
|
||||
tags.table.headers.description: Description
|
||||
tags.table.headers.documents: Documents
|
||||
tags.table.headers.created: Created
|
||||
tags.table.headers.actions: Actions
|
||||
|
||||
# Tagging rules
|
||||
|
||||
tagging-rules.field.name: document name
|
||||
tagging-rules.field.content: document content
|
||||
@@ -121,9 +359,6 @@ tagging-rules.form.conditions.no-conditions.title: No conditions
|
||||
tagging-rules.form.conditions.no-conditions.description: You didn't add any conditions to this rule. This rule will apply its tags to all documents.
|
||||
tagging-rules.form.conditions.no-conditions.confirm: Apply rule without conditions
|
||||
tagging-rules.form.conditions.no-conditions.cancel: Cancel
|
||||
tagging-rules.form.conditions.field.label: Field
|
||||
tagging-rules.form.conditions.operator.label: Operator
|
||||
tagging-rules.form.conditions.value.label: Value
|
||||
tagging-rules.form.conditions.value.placeholder: 'Example: invoice'
|
||||
tagging-rules.form.conditions.value.min-length: Please enter a value for the condition
|
||||
tagging-rules.form.tags.label: Tags
|
||||
@@ -132,41 +367,46 @@ tagging-rules.form.tags.min-length: At least one tag to apply is required
|
||||
tagging-rules.form.tags.add-tag: Create tag
|
||||
tagging-rules.form.submit: Create rule
|
||||
tagging-rules.update.title: Update tagging rule
|
||||
tagging-rules.update.success: Tagging rule updated successfully
|
||||
tagging-rules.update.error: Failed to update tagging rule
|
||||
tagging-rules.update.submit: Update rule
|
||||
tagging-rules.update.cancel: Cancel
|
||||
|
||||
demo.popup.description: This is a demo environment, all data is save to your browser local storage.
|
||||
demo.popup.discord: Join the {{ discordLink }} to get support, propose features or just chat.
|
||||
demo.popup.discord-link-label: Discord server
|
||||
demo.popup.reset: Reset demo data
|
||||
demo.popup.hide: Hide
|
||||
# Intake emails
|
||||
|
||||
trash.delete-all.button: Delete all
|
||||
trash.delete-all.confirm.title: Permanently delete all documents?
|
||||
trash.delete-all.confirm.description: Are you sure you want to permanently delete all documents from the trash? This action cannot be undone.
|
||||
trash.delete-all.confirm.label: Delete
|
||||
trash.delete-all.confirm.cancel: Cancel
|
||||
trash.delete.button: Delete
|
||||
trash.delete.confirm.title: Permanently delete document?
|
||||
trash.delete.confirm.description: Are you sure you want to permanently delete this document from the trash? This action cannot be undone.
|
||||
trash.delete.confirm.label: Delete
|
||||
trash.delete.confirm.cancel: Cancel
|
||||
trash.deleted.success.title: Document deleted
|
||||
trash.deleted.success.description: The document has been permanently deleted.
|
||||
intake-emails.title: Intake Emails
|
||||
intake-emails.description: Intake emails address are used to automatically ingest emails into Papra. Just forward emails to the intake email address and their attachments will be added to your organization's documents.
|
||||
intake-emails.disabled.title: Intake Emails are disabled
|
||||
intake-emails.disabled.description: Intake emails are disabled on this instance. Please contact your administrator to enable them. See the {{ documentation }} for more information.
|
||||
intake-emails.disabled.documentation: documentation
|
||||
intake-emails.info: Only enabled intake emails from allowed origins will be processed. You can enable or disable an intake email at any time.
|
||||
intake-emails.empty.title: No intake emails
|
||||
intake-emails.empty.description: Generate an intake address to easily ingest emails attachments.
|
||||
intake-emails.empty.generate: Generate intake email
|
||||
intake-emails.count: '{{ count }} intake email{{ plural }} for this organization'
|
||||
intake-emails.new: New intake email
|
||||
intake-emails.disabled-label: (Disabled)
|
||||
intake-emails.no-origins: No allowed email origins
|
||||
intake-emails.allowed-origins: Allowed from {{ count }} address{{ plural }}
|
||||
intake-emails.actions.enable: Enable
|
||||
intake-emails.actions.disable: Disable
|
||||
intake-emails.actions.manage-origins: Manage origins addresses
|
||||
intake-emails.actions.delete: Delete
|
||||
intake-emails.delete.confirm.title: Delete intake email?
|
||||
intake-emails.delete.confirm.message: Are you sure you want to delete this intake email? This action cannot be undone.
|
||||
intake-emails.delete.confirm.confirm-button: Delete intake email
|
||||
intake-emails.delete.confirm.cancel-button: Cancel
|
||||
intake-emails.delete.success: Intake email deleted
|
||||
intake-emails.create.success: Intake email created
|
||||
intake-emails.update.success.enabled: Intake email enabled
|
||||
intake-emails.update.success.disabled: Intake email disabled
|
||||
intake-emails.allowed-origins.title: Allowed origins
|
||||
intake-emails.allowed-origins.description: Only emails sent to {{ email }} from these origins will be processed. If no origins are specified, all emails will be discarded.
|
||||
intake-emails.allowed-origins.add.label: Add allowed origin email
|
||||
intake-emails.allowed-origins.add.placeholder: Eg. ada@papra.app
|
||||
intake-emails.allowed-origins.add.button: Add
|
||||
intake-emails.allowed-origins.add.error.exists: This email is already in the allowed origins for this intake email
|
||||
|
||||
import-documents.title.error: '{{ count }} documents failed'
|
||||
import-documents.title.success: '{{ count }} documents imported'
|
||||
import-documents.title.pending: '{{ count }} / {{ total }} documents imported'
|
||||
import-documents.title.none: Import documents
|
||||
import-documents.no-import-in-progress: No document import in progress
|
||||
|
||||
api-errors.document.already_exists: The document already exists
|
||||
api-errors.document.file_too_big: The document file is too big
|
||||
api-errors.intake_email.limit_reached: The maximum number of intake emails for this organization has been reached. Please upgrade your plan to create more intake emails.
|
||||
api-errors.user.max_organization_count_reached: You have reached the maximum number of organizations you can create, if you need to create more, please contact support.
|
||||
api-errors.default: An error occurred while processing your request.
|
||||
# API keys
|
||||
|
||||
api-keys.permissions.documents.title: Documents
|
||||
api-keys.permissions.documents.documents:create: Create documents
|
||||
@@ -186,23 +426,127 @@ api-keys.create.form.name.label: Name
|
||||
api-keys.create.form.name.placeholder: 'Example: My API key'
|
||||
api-keys.create.form.name.required: Please enter a name for the API key
|
||||
api-keys.create.form.permissions.label: Permissions
|
||||
api-keys.create.form.permissions.description: Select the permissions for the API key.
|
||||
api-keys.create.form.permissions.required: Please select at least one permission
|
||||
api-keys.create.form.submit: Create API key
|
||||
api-keys.create.created.title: API key created
|
||||
api-keys.create.created.description: The API key has been created successfully. Save it in a secure location as it will not be displayed again.
|
||||
api-keys.list.title: API keys
|
||||
api-keys.list.description: Manage your API keys here.
|
||||
api-keys.list.delete: Delete
|
||||
api-keys.list.create: Create API key
|
||||
api-keys.list.empty.title: No API keys
|
||||
api-keys.list.empty.description: Create an API key to access the Papra API.
|
||||
api-keys.list.card.last-used: Last used
|
||||
api-keys.list.card.never: Never
|
||||
api-keys.list.card.created: Created
|
||||
api-keys.list.card.delete: Delete
|
||||
api-keys.delete.success: The API key has been deleted successfully
|
||||
api-keys.delete.confirm.title: Delete API key
|
||||
api-keys.delete.confirm.message: Are you sure you want to delete this API key? This action cannot be undone.
|
||||
api-keys.delete.confirm.confirm-button: Delete
|
||||
api-keys.delete.confirm.cancel-button: Cancel
|
||||
|
||||
# Webhooks
|
||||
|
||||
webhooks.list.title: Webhooks
|
||||
webhooks.list.description: Manage your organization webhooks
|
||||
webhooks.list.empty.title: No webhooks
|
||||
webhooks.list.empty.description: Create your first webhook to start receiving events
|
||||
webhooks.list.create: Create webhook
|
||||
webhooks.list.card.last-triggered: Last triggered
|
||||
webhooks.list.card.never: Never
|
||||
webhooks.list.card.created: Created
|
||||
webhooks.create.title: Create webhook
|
||||
webhooks.create.description: Create a new webhook to receive events
|
||||
webhooks.create.success: Webhook created successfully
|
||||
webhooks.create.back: Back
|
||||
webhooks.create.form.submit: Create webhook
|
||||
webhooks.create.form.name.label: Webhook name
|
||||
webhooks.create.form.name.placeholder: Enter webhook name
|
||||
webhooks.create.form.name.required: Name is required
|
||||
webhooks.create.form.url.label: Webhook URL
|
||||
webhooks.create.form.url.placeholder: Enter webhook URL
|
||||
webhooks.create.form.url.required: URL is required
|
||||
webhooks.create.form.url.invalid: URL is invalid
|
||||
webhooks.create.form.secret.label: Secret
|
||||
webhooks.create.form.secret.placeholder: Enter webhook secret
|
||||
webhooks.create.form.events.label: Events
|
||||
webhooks.create.form.events.required: At least one event is required
|
||||
webhooks.update.title: Edit webhook
|
||||
webhooks.update.description: Update your webhook details
|
||||
webhooks.update.success: Webhook updated successfully
|
||||
webhooks.update.submit: Update webhook
|
||||
webhooks.update.cancel: Cancel
|
||||
webhooks.update.form.secret.placeholder: Enter new secret
|
||||
webhooks.update.form.secret.placeholder-redacted: '[Redacted secret]'
|
||||
webhooks.update.form.rotate-secret.button: Rotate secret
|
||||
webhooks.delete.success: Webhook deleted successfully
|
||||
webhooks.delete.confirm.title: Delete webhook
|
||||
webhooks.delete.confirm.message: Are you sure you want to delete this webhook?
|
||||
webhooks.delete.confirm.confirm-button: Delete
|
||||
webhooks.delete.confirm.cancel-button: Cancel
|
||||
|
||||
webhooks.events.documents.document:created.description: Document created
|
||||
webhooks.events.documents.document:deleted.description: Document deleted
|
||||
|
||||
# Navigation
|
||||
|
||||
layout.menu.home: Home
|
||||
layout.menu.documents: Documents
|
||||
layout.menu.tags: Tags
|
||||
layout.menu.tagging-rules: Tagging rules
|
||||
layout.menu.deleted-documents: Deleted documents
|
||||
layout.menu.organization-settings: Settings
|
||||
layout.menu.api-keys: API keys
|
||||
layout.menu.settings: Settings
|
||||
layout.menu.account: Account
|
||||
layout.menu.general-settings: General settings
|
||||
layout.menu.intake-emails: Intake emails
|
||||
layout.menu.webhooks: Webhooks
|
||||
layout.menu.members: Members
|
||||
layout.menu.invitations: Invitations
|
||||
|
||||
layout.theme.light: Light mode
|
||||
layout.theme.dark: Dark mode
|
||||
layout.theme.system: System mode
|
||||
|
||||
layout.search.placeholder: Search...
|
||||
layout.menu.import-document: Import a document
|
||||
|
||||
user-menu.account-settings: Account settings
|
||||
user-menu.api-keys: API keys
|
||||
user-menu.invitations: Invitations
|
||||
user-menu.language: Language
|
||||
user-menu.logout: Logout
|
||||
|
||||
# Command palette
|
||||
|
||||
command-palette.search.placeholder: Search commands or documents
|
||||
command-palette.no-results: No results found
|
||||
command-palette.sections.documents: Documents
|
||||
command-palette.sections.theme: Theme
|
||||
|
||||
# API errors
|
||||
|
||||
api-errors.document.already_exists: The document already exists
|
||||
api-errors.document.file_too_big: The document file is too big
|
||||
api-errors.intake_email.limit_reached: The maximum number of intake emails for this organization has been reached. Please upgrade your plan to create more intake emails.
|
||||
api-errors.user.max_organization_count_reached: You have reached the maximum number of organizations you can create, if you need to create more, please contact support.
|
||||
api-errors.default: An error occurred while processing your request.
|
||||
api-errors.organization.invitation_already_exists: An invitation for this email already exists in this organization.
|
||||
api-errors.user.already_in_organization: This user is already in this organization.
|
||||
api-errors.user.organization_invitation_limit_reached: The maximum number of invitations has been reached for today. Please try again tomorrow.
|
||||
api-errors.demo.not_available: This feature is not available in demo
|
||||
api-errors.tags.already_exists: A tag with this name already exists for this organization
|
||||
|
||||
# Not found
|
||||
|
||||
not-found.title: 404 - Not Found
|
||||
not-found.description: Sorry, the page you are looking for does not seem to exist. Please check the URL and try again.
|
||||
not-found.back-to-home: Go back to home
|
||||
|
||||
# Demo
|
||||
|
||||
demo.popup.description: This is a demo environment, all data is save to your browser local storage.
|
||||
demo.popup.discord: Join the {{ discordLink }} to get support, propose features or just chat.
|
||||
demo.popup.discord-link-label: Discord server
|
||||
demo.popup.reset: Reset demo data
|
||||
demo.popup.hide: Hide
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
# Authentication
|
||||
|
||||
auth.request-password-reset.title: Réinitialiser votre mot de passe
|
||||
auth.request-password-reset.description: Entrez votre email pour réinitialiser votre mot de passe.
|
||||
auth.request-password-reset.requested: Si un compte existe pour cet email, nous vous avons envoyé un email pour réinitialiser votre mot de passe.
|
||||
@@ -38,7 +40,7 @@ auth.login.form.forgot-password.label: Mot de passe oublié ?
|
||||
auth.login.form.submit: Connexion
|
||||
|
||||
auth.register.title: S'inscrire à Papra
|
||||
auth.register.description: Entrez votre email ou utilisez une connexion sociale pour accéder à votre compte Papra.
|
||||
auth.register.description: Créez un compte pour commencer à utiliser Papra.
|
||||
auth.register.register-with-email: S'inscrire avec email
|
||||
auth.register.register-with-provider: S'inscrire avec {{ provider }}
|
||||
auth.register.providers.google: Google
|
||||
@@ -69,31 +71,265 @@ auth.legal-links.description: En continuant, vous reconnaissez que vous comprene
|
||||
auth.legal-links.terms: Conditions d'utilisation
|
||||
auth.legal-links.privacy: Politique de confidentialité
|
||||
|
||||
# User settings
|
||||
|
||||
user.settings.title: Paramètres de l'utilisateur
|
||||
user.settings.description: Gérez vos paramètres de compte ici.
|
||||
|
||||
user.settings.email.title: Adresse email
|
||||
user.settings.email.description: Votre adresse email ne peut pas être modifiée.
|
||||
user.settings.email.label: Adresse email
|
||||
|
||||
user.settings.name.title: Nom complet
|
||||
user.settings.name.description: Votre nom complet est affiché aux autres membres de l'organisation.
|
||||
user.settings.name.label: Nom complet
|
||||
user.settings.name.placeholder: 'Exemple: John Doe'
|
||||
user.settings.name.update: Mettre à jour le nom
|
||||
user.settings.name.updated: Votre nom complet a été mis à jour
|
||||
|
||||
user.settings.logout.title: Déconnexion
|
||||
user.settings.logout.description: Déconnectez-vous de votre compte. Vous pouvez vous reconnecter plus tard.
|
||||
user.settings.logout.button: Déconnexion
|
||||
|
||||
# Organizations
|
||||
|
||||
organizations.list.title: Vos organisations
|
||||
organizations.list.description: Les organisations sont un moyen de grouper vos documents et de gérer l'accès à eux. Vous pouvez créer plusieurs organisations et inviter vos membres de l'équipe à collaborer.
|
||||
organizations.list.create-new: Créer une nouvelle organisation
|
||||
|
||||
organizations.details.no-documents.title: Aucun document
|
||||
organizations.details.no-documents.description: Il n'y a pas de documents dans cette organisation. Commencez par télécharger des documents.
|
||||
organizations.details.upload-documents: Télécharger des documents
|
||||
organizations.details.documents-count: documents en total
|
||||
organizations.details.total-size: taille totale
|
||||
organizations.details.latest-documents: Derniers documents importés
|
||||
|
||||
organizations.create.title: Créer une nouvelle organisation
|
||||
organizations.create.description: Vos documents seront regroupés par organisation. Vous pouvez créer plusieurs organisations pour séparer vos documents, par exemple, pour les documents personnels et professionnels.
|
||||
organizations.create.back: Retour
|
||||
organizations.create.error.max-count-reached: Vous avez atteint le nombre maximum d'organisations que vous pouvez créer, si vous avez besoin de créer plus, veuillez contacter le support.
|
||||
organizations.create.form.name.label: Nom de l'organisation
|
||||
organizations.create.form.name.placeholder: 'Exemple: Acme Inc.'
|
||||
organizations.create.form.name.required: Veuillez entrer un nom pour l'organisation
|
||||
organizations.create.form.submit: Créer l'organisation
|
||||
organizations.create.success: Organisation créée avec succès
|
||||
|
||||
organizations.create-first.title: Créer votre organisation
|
||||
organizations.create-first.description: Vos documents seront regroupés par organisation. Vous pouvez créer plusieurs organisations pour séparer vos documents, par exemple, pour les documents personnels et professionnels.
|
||||
organizations.create-first.default-name: Mon organisation
|
||||
organizations.create-first.user-name: "{{ name }}'s organisation"
|
||||
|
||||
organization.settings.title: Paramètres de l'organisation
|
||||
organization.settings.page.title: Paramètres de l'organisation
|
||||
organization.settings.page.description: Gérez les paramètres de votre organisation ici.
|
||||
organization.settings.name.title: Nom de l'organisation
|
||||
organization.settings.name.update: Modifier le nom
|
||||
organization.settings.name.placeholder: 'Exemple: Acme Inc.'
|
||||
organization.settings.name.updated: Nom de l'organisation mis à jour
|
||||
organization.settings.subscription.title: Subscription
|
||||
organization.settings.subscription.description: Gérez votre facturation, vos factures et vos méthodes de paiement.
|
||||
organization.settings.subscription.manage: Gérer la souscription
|
||||
organization.settings.subscription.error: Échec de la récupération de l'URL du portail client
|
||||
organization.settings.delete.title: Supprimer l'organisation
|
||||
organization.settings.delete.description: Supprimer cette organisation supprimera définitivement toutes les données associées à elle.
|
||||
organization.settings.delete.confirm.title: Supprimer l'organisation
|
||||
organization.settings.delete.confirm.message: Êtes-vous sûr de vouloir supprimer cette organisation ? Cette action est irréversible, et toutes les données associées à cette organisation seront supprimées définitivement.
|
||||
organization.settings.delete.confirm.confirm-button: Supprimer l'organisation
|
||||
organization.settings.delete.confirm.cancel-button: Annuler
|
||||
organization.settings.delete.success: Organisation supprimée
|
||||
|
||||
organizations.members.title: Membres
|
||||
organizations.members.description: Gérez les membres de votre organisation.
|
||||
organizations.members.invite-member: Inviter un membre
|
||||
organizations.members.invite-member-disabled-tooltip: Seuls les administrateurs ou les propriétaires peuvent inviter des membres à l'organisation
|
||||
organizations.members.remove-from-organization: Retirer de l'organisation
|
||||
organizations.members.role: Rôle
|
||||
organizations.members.roles.owner: Propriétaire
|
||||
organizations.members.roles.admin: Admin
|
||||
organizations.members.roles.member: Membre
|
||||
organizations.members.delete.confirm.title: Retirer un membre
|
||||
organizations.members.delete.confirm.message: Êtes-vous sûr de vouloir retirer ce membre de l'organisation ?
|
||||
organizations.members.delete.confirm.confirm-button: Retirer
|
||||
organizations.members.delete.confirm.cancel-button: Annuler
|
||||
organizations.members.delete.success: Membre retiré de l'organisation
|
||||
organizations.members.update-role.success: Rôle du membre mis à jour
|
||||
organizations.members.table.headers.name: Nom
|
||||
organizations.members.table.headers.email: Email
|
||||
organizations.members.table.headers.role: Rôle
|
||||
# organizations.members.table.headers.created: Created
|
||||
organizations.members.table.headers.actions: Actions
|
||||
|
||||
organizations.invite-member.title: Inviter un membre
|
||||
organizations.invite-member.description: Invite un membre à votre organisation
|
||||
organizations.invite-member.form.email.label: Email
|
||||
organizations.invite-member.form.email.placeholder: 'Exemple: ada@papra.app'
|
||||
organizations.invite-member.form.email.required: Veuillez entrer une adresse email valide
|
||||
organizations.invite-member.form.role.label: Rôle
|
||||
organizations.invite-member.form.submit: Inviter à l'organisation
|
||||
organizations.invite-member.success.message: Membre invité
|
||||
organizations.invite-member.success.description: L'email a été invité à l'organisation.
|
||||
organizations.invite-member.error.message: Échec de l'invitation du membre
|
||||
|
||||
organizations.invitations.title: Invitations
|
||||
organizations.invitations.description: Gérez les invitations de votre organisation.
|
||||
organizations.invitations.list.cta: Inviter un membre
|
||||
organizations.invitations.list.empty.title: Aucune invitation en attente
|
||||
organizations.invitations.list.empty.description: Vous n'avez pas été invité à aucune organisation.
|
||||
organizations.invitations.status.pending: En attente
|
||||
organizations.invitations.status.accepted: Accepté
|
||||
organizations.invitations.status.rejected: Refusé
|
||||
organizations.invitations.status.expired: Expiré
|
||||
organizations.invitations.status.cancelled: Annulé
|
||||
organizations.invitations.resend: Renvoyer l'invitation
|
||||
organizations.invitations.cancel.title: Annuler l'invitation
|
||||
organizations.invitations.cancel.description: Êtes-vous sûr de vouloir annuler cette invitation ?
|
||||
organizations.invitations.cancel.confirm: Annuler l'invitation
|
||||
organizations.invitations.cancel.cancel: Annuler
|
||||
organizations.invitations.resend.title: Renvoyer l'invitation
|
||||
organizations.invitations.resend.description: Êtes-vous sûr de vouloir renvoyer cette invitation ? Cela enverra un nouvel email à l'invité.
|
||||
organizations.invitations.resend.confirm: Renvoyer l'invitation
|
||||
organizations.invitations.resend.cancel: Annuler
|
||||
|
||||
invitations.list.title: Invitations
|
||||
invitations.list.description: Gérez les invitations de votre organisation.
|
||||
invitations.list.empty.title: Aucune invitation en attente
|
||||
invitations.list.empty.description: Vous n'avez pas été invité à aucune organisation.
|
||||
invitations.list.headers.organization: Organisation
|
||||
# invitations.list.headers.status: Status
|
||||
invitations.list.headers.created: Créé
|
||||
invitations.list.headers.actions: Actions
|
||||
invitations.list.actions.accept: Accepter
|
||||
invitations.list.actions.reject: Refuser
|
||||
invitations.list.actions.accept.success.message: Invitation acceptée
|
||||
invitations.list.actions.accept.success.description: L'invitation a été acceptée.
|
||||
invitations.list.actions.reject.success.message: Invitation refusée
|
||||
invitations.list.actions.reject.success.description: L'invitation a été refusée.
|
||||
|
||||
# Documents
|
||||
|
||||
documents.list.title: Documents
|
||||
documents.list.no-documents.title: Aucun document
|
||||
documents.list.no-documents.description: Il n'y a pas de documents dans cette organisation. Commencez par télécharger des documents.
|
||||
documents.list.no-results: Aucun document trouvé
|
||||
|
||||
documents.tabs.info: Info
|
||||
documents.tabs.content: Contenu
|
||||
documents.tabs.activity: Activité
|
||||
documents.deleted.message: Ce document a été supprimé et sera supprimé définitivement dans {{ days }} jours.
|
||||
documents.actions.download: Télécharger
|
||||
documents.actions.open-in-new-tab: Ouvrir dans un nouvel onglet
|
||||
documents.actions.restore: Restaurer
|
||||
documents.actions.delete: Supprimer
|
||||
documents.actions.edit: Modifier
|
||||
documents.actions.cancel: Annuler
|
||||
documents.actions.save: Enregistrer
|
||||
documents.actions.saving: Enregistrement...
|
||||
documents.content.alert: Le contenu du document est automatiquement extrait du document lors de l'import. Il est uniquement utilisé pour la recherche et l'indexation.
|
||||
documents.info.id: ID
|
||||
documents.info.name: Nom
|
||||
documents.info.type: Type
|
||||
documents.info.size: Taille
|
||||
documents.info.created-at: Créé le
|
||||
documents.info.updated-at: Mis à jour le
|
||||
documents.info.never: Jamais
|
||||
|
||||
documents.rename.title: Renommer le document
|
||||
documents.rename.form.name.label: Nom
|
||||
documents.rename.form.name.placeholder: 'Exemple: Facture 2024'
|
||||
documents.rename.form.name.required: Veuillez entrer un nom pour le document
|
||||
documents.rename.form.name.max-length: Le nom doit contenir moins de 255 caractères
|
||||
documents.rename.form.submit: Renommer
|
||||
documents.rename.success: Document renommé avec succès
|
||||
documents.rename.cancel: Annuler
|
||||
|
||||
import-documents.title.error: '{{ count }} documents ont échoué'
|
||||
import-documents.title.success: '{{ count }} documents ont été importés'
|
||||
import-documents.title.pending: '{{ count }} / {{ total }} documents importés'
|
||||
import-documents.title.none: Importer des documents
|
||||
import-documents.no-import-in-progress: Aucune importation de documents en cours
|
||||
|
||||
documents.deleted.title: Documents supprimés
|
||||
documents.deleted.empty.title: Aucun document supprimé
|
||||
documents.deleted.empty.description: Vous n'avez pas de documents supprimés. Les documents supprimés seront déplacés dans la corbeille pour {{ days }} jours.
|
||||
documents.deleted.retention-notice: Tous les documents supprimés sont stockés dans la corbeille pour {{ days }} jours. Passé ce délai, les documents seront supprimés définitivement, et vous ne pourrez plus les restaurer.
|
||||
documents.deleted.deleted-at: Supprimé
|
||||
documents.deleted.restoring: Restauration...
|
||||
documents.deleted.deleting: Suppression...
|
||||
|
||||
trash.delete-all.button: Supprimer tous les documents
|
||||
trash.delete-all.confirm.title: Supprimer définitivement tous les documents ?
|
||||
trash.delete-all.confirm.description: Êtes-vous sûr de vouloir supprimer définitivement tous les documents de la corbeille ? Cette action est irréversible.
|
||||
trash.delete-all.confirm.label: Supprimer
|
||||
trash.delete-all.confirm.cancel: Annuler
|
||||
trash.delete.button: Supprimer
|
||||
trash.delete.confirm.title: Supprimer définitivement le document ?
|
||||
trash.delete.confirm.description: Êtes-vous sûr de vouloir supprimer définitivement ce document de la corbeille ? Cette action est irréversible.
|
||||
trash.delete.confirm.label: Supprimer
|
||||
trash.delete.confirm.cancel: Annuler
|
||||
trash.deleted.success.title: Document supprimé
|
||||
trash.deleted.success.description: Le document a été supprimé définitivement.
|
||||
|
||||
activity.document.created: Le document a été créé
|
||||
activity.document.updated.single: Le {{ field }} a été mis à jour
|
||||
activity.document.updated.multiple: Les {{ fields }} ont été mis à jour
|
||||
activity.document.updated: Le document a été mis à jour
|
||||
activity.document.deleted: Le document a été supprimé
|
||||
activity.document.restored: Le document a été restauré
|
||||
activity.document.tagged: Le tag {{ tag }} a été ajouté
|
||||
activity.document.untagged: Le tag {{ tag }} a été supprimé
|
||||
|
||||
activity.document.user.name: par {{ name }}
|
||||
|
||||
activity.load-more: Charger plus
|
||||
activity.no-more-activities: Aucune activité pour ce document
|
||||
|
||||
# Tags
|
||||
|
||||
tags.no-tags.title: Aucun tag
|
||||
tags.no-tags.description: Cette organisation n'a pas de tags. Les tags sont utilisés pour catégoriser les documents. Vous pouvez ajouter des tags à vos documents pour les rendre plus faciles à trouver et à organiser.
|
||||
tags.no-tags.create-tag: Créer un tag
|
||||
|
||||
layout.menu.home: Accueil
|
||||
layout.menu.documents: Documents
|
||||
layout.menu.tags: Tags
|
||||
layout.menu.tagging-rules: Règles de catégorisation
|
||||
layout.menu.integrations: Intégrations
|
||||
layout.menu.deleted-documents: Documents supprimés
|
||||
layout.menu.organization-settings: Paramètres de l'organisation
|
||||
layout.menu.api-keys: API keys
|
||||
layout.menu.settings: Paramètres
|
||||
layout.menu.account: Compte
|
||||
tags.title: Tags de documents
|
||||
tags.description: Les tags sont utilisés pour catégoriser les documents. Vous pouvez ajouter des tags à vos documents pour les rendre plus faciles à trouver et à organiser.
|
||||
tags.create: Créer un tag
|
||||
tags.update: Mettre à jour un tag
|
||||
tags.delete: Supprimer un tag
|
||||
tags.delete.confirm.title: Supprimer un tag
|
||||
tags.delete.confirm.message: Êtes-vous sûr de vouloir supprimer ce tag ? Supprimer un tag supprimera toutes les règles de catégorisation qui l'utilisent.
|
||||
tags.delete.confirm.confirm-button: Supprimer
|
||||
tags.delete.confirm.cancel-button: Annuler
|
||||
tags.delete.success: Tag supprimé avec succès
|
||||
tags.create.success: Tag "{{ name }}" créé avec succès.
|
||||
tags.update.success: Tag "{{ name }}" mis à jour avec succès.
|
||||
tags.form.name.label: Nom
|
||||
tags.form.name.placeholder: 'Exemple: Contrats'
|
||||
tags.form.name.required: Veuillez entrer un nom pour le tag
|
||||
tags.form.name.max-length: Le nom du tag doit contenir moins de 64 caractères
|
||||
tags.form.color.label: Couleur
|
||||
tags.form.color.placeholder: 'Exemple: #FF0000'
|
||||
tags.form.color.required: Veuillez entrer une couleur
|
||||
tags.form.color.invalid: La couleur hexadécimale est mal formatée.
|
||||
tags.form.description.label: Description
|
||||
tags.form.description.optional: (optionnel)
|
||||
tags.form.description.placeholder: "Exemple: Tous les contrats signés par l'entreprise"
|
||||
tags.form.description.max-length: La description doit contenir moins de 256 caractères
|
||||
tags.form.no-description: Aucune description
|
||||
tags.table.headers.tag: Tag
|
||||
tags.table.headers.description: Description
|
||||
tags.table.headers.documents: Documents
|
||||
tags.table.headers.created: Date de création
|
||||
tags.table.headers.actions: Actions
|
||||
|
||||
# Tagging rules
|
||||
|
||||
tagging-rules.field.name: nom du document
|
||||
tagging-rules.field.content: contenu du document
|
||||
|
||||
tagging-rules.operator.equals: égal à
|
||||
tagging-rules.operator.not-equals: différent de
|
||||
tagging-rules.operator.contains: contient
|
||||
tagging-rules.operator.not-contains: ne contient pas
|
||||
tagging-rules.operator.starts-with: commence par
|
||||
tagging-rules.operator.ends-with: finit par
|
||||
|
||||
tagging-rules.list.title: Règles de catégorisation
|
||||
tagging-rules.list.description: Gérez vos règles de catégorisation, pour catégoriser automatiquement les documents en fonction de conditions que vous définissez.
|
||||
tagging-rules.list.demo-warning: 'Note: Cette instance est une démo, les règles de catégorisation ne seront pas appliquées aux documents ajoutés.'
|
||||
@@ -123,9 +359,6 @@ tagging-rules.form.conditions.no-conditions.title: Aucune condition
|
||||
tagging-rules.form.conditions.no-conditions.description: Vous n'avez pas ajouté de conditions à cette règle. Cette règle appliquera ses tags à tous les documents.
|
||||
tagging-rules.form.conditions.no-conditions.confirm: Appliquer la règle sans conditions
|
||||
tagging-rules.form.conditions.no-conditions.cancel: Annuler
|
||||
tagging-rules.form.conditions.field.label: Champ
|
||||
tagging-rules.form.conditions.operator.label: Opérateur
|
||||
tagging-rules.form.conditions.value.label: Valeur
|
||||
tagging-rules.form.conditions.value.placeholder: 'Exemple: facture'
|
||||
tagging-rules.form.conditions.value.min-length: Veuillez entrer une valeur pour la condition
|
||||
tagging-rules.form.tags.label: Tags
|
||||
@@ -134,41 +367,46 @@ tagging-rules.form.tags.min-length: Au moins un tag à appliquer est requis
|
||||
tagging-rules.form.tags.add-tag: Créer un tag
|
||||
tagging-rules.form.submit: Créer la règle
|
||||
tagging-rules.update.title: Mettre à jour la règle de catégorisation
|
||||
tagging-rules.update.success: Règle de catégorisation mise à jour avec succès
|
||||
tagging-rules.update.error: Échec de la mise à jour de la règle de catégorisation
|
||||
tagging-rules.update.submit: Mettre à jour la règle
|
||||
tagging-rules.update.cancel: Annuler
|
||||
|
||||
demo.popup.description: Cette instance est une démo, toutes les données sont sauvegardées dans le stockage local de votre navigateur.
|
||||
demo.popup.discord: Rejoignez le {{ discordLink }} pour obtenir de l'aide, proposer des fonctionnalités ou simplement discuter.
|
||||
demo.popup.discord-link-label: Serveur Discord
|
||||
demo.popup.reset: Réinitialiser les données de la démo
|
||||
demo.popup.hide: Masquer
|
||||
# Intake emails
|
||||
|
||||
trash.delete-all.button: Supprimer tous les documents
|
||||
trash.delete-all.confirm.title: Supprimer définitivement tous les documents ?
|
||||
trash.delete-all.confirm.description: Êtes-vous sûr de vouloir supprimer définitivement tous les documents de la corbeille ? Cette action est irréversible.
|
||||
trash.delete-all.confirm.label: Supprimer
|
||||
trash.delete-all.confirm.cancel: Annuler
|
||||
trash.delete.button: Supprimer
|
||||
trash.delete.confirm.title: Supprimer définitivement le document ?
|
||||
trash.delete.confirm.description: Êtes-vous sûr de vouloir supprimer définitivement ce document de la corbeille ? Cette action est irréversible.
|
||||
trash.delete.confirm.label: Supprimer
|
||||
trash.delete.confirm.cancel: Annuler
|
||||
trash.deleted.success.title: Document supprimé
|
||||
trash.deleted.success.description: Le document a été supprimé définitivement.
|
||||
intake-emails.title: Adresses de réception
|
||||
intake-emails.description: Les adresses de réception sont utilisées pour ingérer automatiquement les emails dans Papra. Il suffit de les envoyer à l'adresse de réception et leurs pièces jointes seront ajoutées à vos documents.
|
||||
intake-emails.disabled.title: Les adresses de réception sont désactivées
|
||||
intake-emails.disabled.description: Les adresses de réception sont désactivées sur cette instance. Veuillez contacter votre administrateur pour les activer. Voir la {{ documentation }} pour plus d'informations.
|
||||
intake-emails.disabled.documentation: documentation
|
||||
intake-emails.info: Seules les adresses de réception activées depuis les origines autorisées seront traitées. Vous pouvez activer ou désactiver une adresse de réception à tout moment.
|
||||
intake-emails.empty.title: Aucune adresse de réception
|
||||
intake-emails.empty.description: Générez une adresse de réception pour ingérer facilement les pièces jointes des emails.
|
||||
intake-emails.empty.generate: Générer une adresse de réception
|
||||
intake-emails.count: '{{ count }} intake email{{ plural }} for this organization'
|
||||
intake-emails.new: Nouvelle adresse de réception
|
||||
intake-emails.disabled-label: (Désactivé)
|
||||
intake-emails.no-origins: Aucune adresse de réception autorisée
|
||||
intake-emails.allowed-origins: Autorisées depuis {{ count }} adresse{{ plural }}
|
||||
intake-emails.actions.enable: Activer
|
||||
intake-emails.actions.disable: Désactiver
|
||||
intake-emails.actions.manage-origins: Gérer les adresses d'origine
|
||||
intake-emails.actions.delete: Supprimer
|
||||
intake-emails.delete.confirm.title: Supprimer l'adresse de réception ?
|
||||
intake-emails.delete.confirm.message: Êtes-vous sûr de vouloir supprimer cette adresse de réception ? Cette action est irréversible.
|
||||
intake-emails.delete.confirm.confirm-button: Supprimer l'adresse de réception
|
||||
intake-emails.delete.confirm.cancel-button: Annuler
|
||||
intake-emails.delete.success: Adresse de réception supprimée
|
||||
intake-emails.create.success: Adresse de réception créée
|
||||
intake-emails.update.success.enabled: Adresse de réception activée
|
||||
intake-emails.update.success.disabled: Adresse de réception désactivée
|
||||
intake-emails.allowed-origins.title: Adresses d'origine autorisées
|
||||
intake-emails.allowed-origins.description: Seuls les emails envoyés à {{ email }} depuis ces adresses d'origine seront traités. Si aucune adresse d'origine n'est spécifiée, tous les emails seront rejetés.
|
||||
intake-emails.allowed-origins.add.label: Ajouter une adresse d'origine autorisée
|
||||
intake-emails.allowed-origins.add.placeholder: 'Exemple: ada@papra.app'
|
||||
intake-emails.allowed-origins.add.button: Ajouter
|
||||
intake-emails.allowed-origins.add.error.exists: Cette adresse email est déjà dans les adresses d'origine autorisées pour cette adresse de réception
|
||||
|
||||
import-documents.title.error: '{{ count }} documents ont échoué'
|
||||
import-documents.title.success: '{{ count }} documents ont été importés'
|
||||
import-documents.title.pending: '{{ count }} / {{ total }} documents importés'
|
||||
import-documents.title.none: Importer des documents
|
||||
import-documents.no-import-in-progress: Aucune importation de documents en cours
|
||||
|
||||
api-errors.document.already_exists: Le document existe déjà
|
||||
api-errors.document.file_too_big: Le fichier du document est trop grand
|
||||
api-errors.intake_email.limit_reached: Le nombre maximum d'emails de réception pour cette organisation a été atteint. Veuillez mettre à niveau votre plan pour créer plus d'emails de réception.
|
||||
api-errors.user.max_organization_count_reached: Vous avez atteint le nombre maximum d'organisations que vous pouvez créer, si vous avez besoin de créer plus, veuillez contacter le support.
|
||||
api-errors.default: Une erreur est survenue lors du traitement de votre requête.
|
||||
# API keys
|
||||
|
||||
api-keys.permissions.documents.title: Documents
|
||||
api-keys.permissions.documents.documents:create: Créer des documents
|
||||
@@ -188,23 +426,127 @@ api-keys.create.form.name.label: Nom
|
||||
api-keys.create.form.name.placeholder: 'Exemple: Ma clé API'
|
||||
api-keys.create.form.name.required: Veuillez entrer un nom pour la clé API
|
||||
api-keys.create.form.permissions.label: Permissions
|
||||
api-keys.create.form.permissions.description: Sélectionnez les permissions pour la clé API.
|
||||
api-keys.create.form.permissions.required: Veuillez sélectionner au moins une permission
|
||||
api-keys.create.form.submit: Créer la clé API
|
||||
api-keys.create.created.title: Clé API créée
|
||||
api-keys.create.created.description: La clé API a été créée avec succès. Enregistrez-la dans un endroit sûr car elle ne sera plus affichée.
|
||||
api-keys.list.title: Clés API
|
||||
api-keys.list.description: Gérez vos clés API ici.
|
||||
api-keys.list.delete: Supprimer
|
||||
api-keys.list.create: Créer une clé API
|
||||
api-keys.list.empty.title: Aucune clé API
|
||||
api-keys.list.empty.description: Créez une clé API pour accéder à l'API de Papra.
|
||||
api-keys.list.card.last-used: Dernière utilisation
|
||||
api-keys.list.card.never: Jamais
|
||||
api-keys.list.card.created: Créée
|
||||
api-keys.list.card.delete: Supprimer
|
||||
api-keys.delete.success: La clé API a été supprimée avec succès
|
||||
api-keys.delete.confirm.title: Supprimer la clé API
|
||||
api-keys.delete.confirm.message: Êtes-vous sûr de vouloir supprimer cette clé API ? Cette action est irréversible.
|
||||
api-keys.delete.confirm.confirm-button: Supprimer
|
||||
api-keys.delete.confirm.cancel-button: Annuler
|
||||
|
||||
# Webhooks
|
||||
|
||||
webhooks.list.title: Webhooks
|
||||
webhooks.list.description: Gérez vos webhooks ici.
|
||||
webhooks.list.empty.title: Aucun webhook
|
||||
webhooks.list.empty.description: Créez votre premier webhook pour commencer à recevoir des événements.
|
||||
webhooks.list.create: Créer un webhook
|
||||
webhooks.list.card.last-triggered: Dernière invocation
|
||||
webhooks.list.card.never: Jamais
|
||||
webhooks.list.card.created: Créée
|
||||
webhooks.create.title: Créer un webhook
|
||||
webhooks.create.description: Créez un webhook pour recevoir des événements lorsque des documents sont ajoutés à votre organisation.
|
||||
webhooks.create.success: Le webhook a été créé avec succès.
|
||||
webhooks.create.back: Retour aux webhooks
|
||||
webhooks.create.form.submit: Créer le webhook
|
||||
webhooks.create.form.name.label: Nom du webhook
|
||||
webhooks.create.form.name.placeholder: Entrez le nom du webhook
|
||||
webhooks.create.form.name.required: Le nom est requis
|
||||
webhooks.create.form.url.label: URL du webhook
|
||||
webhooks.create.form.url.placeholder: Entrez l'URL du webhook
|
||||
webhooks.create.form.url.required: L'URL est requise
|
||||
webhooks.create.form.url.invalid: L'URL est invalide
|
||||
webhooks.create.form.secret.label: Secret
|
||||
webhooks.create.form.secret.placeholder: Entrez le secret du webhook
|
||||
webhooks.create.form.events.label: Événements
|
||||
webhooks.create.form.events.required: Au moins un événement est requis
|
||||
webhooks.update.title: Modifier le webhook
|
||||
webhooks.update.description: Mettez à jour les détails de votre webhook
|
||||
webhooks.update.success: Le webhook a été mis à jour avec succès
|
||||
webhooks.update.submit: Mettre à jour le webhook
|
||||
webhooks.update.cancel: Annuler
|
||||
webhooks.update.form.secret.placeholder: Entrez un nouveau secret
|
||||
webhooks.update.form.secret.placeholder-redacted: '[Secret masqué]'
|
||||
webhooks.update.form.rotate-secret.button: Rotation du secret
|
||||
webhooks.delete.success: Le webhook a été supprimé avec succès
|
||||
webhooks.delete.confirm.title: Supprimer le webhook
|
||||
webhooks.delete.confirm.message: Êtes-vous sûr de vouloir supprimer ce webhook ? Cette action est irréversible.
|
||||
webhooks.delete.confirm.confirm-button: Supprimer
|
||||
webhooks.delete.confirm.cancel-button: Annuler
|
||||
|
||||
webhooks.events.documents.document:created.description: Document créé
|
||||
webhooks.events.documents.document:deleted.description: Document supprimé
|
||||
|
||||
# Navigation
|
||||
|
||||
layout.menu.home: Accueil
|
||||
layout.menu.documents: Documents
|
||||
layout.menu.tags: Tags
|
||||
layout.menu.tagging-rules: Règles de catégorisation
|
||||
layout.menu.deleted-documents: Documents supprimés
|
||||
layout.menu.organization-settings: Paramètres
|
||||
layout.menu.api-keys: API keys
|
||||
layout.menu.settings: Paramètres
|
||||
layout.menu.account: Compte
|
||||
layout.menu.general-settings: Paramètres généraux
|
||||
layout.menu.intake-emails: Adresses de réception
|
||||
layout.menu.webhooks: Webhooks
|
||||
layout.menu.members: Membres
|
||||
layout.menu.invitations: Invitations
|
||||
|
||||
layout.theme.light: Mode clair
|
||||
layout.theme.dark: Mode sombre
|
||||
layout.theme.system: Mode système
|
||||
|
||||
layout.search.placeholder: Rechercher...
|
||||
layout.menu.import-document: Importer un document
|
||||
|
||||
user-menu.account-settings: Paramètres du compte
|
||||
user-menu.api-keys: Clés d'API
|
||||
user-menu.invitations: Invitations
|
||||
user-menu.language: Langue
|
||||
user-menu.logout: Déconnexion
|
||||
|
||||
# Command palette
|
||||
|
||||
command-palette.search.placeholder: Rechercher des commandes ou des documents
|
||||
command-palette.no-results: Aucun résultat trouvé
|
||||
command-palette.sections.documents: Documents
|
||||
command-palette.sections.theme: Thème
|
||||
|
||||
# API errors
|
||||
|
||||
api-errors.document.already_exists: Le document existe déjà
|
||||
api-errors.document.file_too_big: Le fichier du document est trop grand
|
||||
api-errors.intake_email.limit_reached: Le nombre maximum d'emails de réception pour cette organisation a été atteint. Veuillez mettre à niveau votre plan pour créer plus d'emails de réception.
|
||||
api-errors.user.max_organization_count_reached: Vous avez atteint le nombre maximum d'organisations que vous pouvez créer, si vous avez besoin de créer plus, veuillez contacter le support.
|
||||
api-errors.default: Une erreur est survenue lors du traitement de votre requête.
|
||||
api-errors.organization.invitation_already_exists: Une invitation pour cet email existe déjà dans cette organisation.
|
||||
api-errors.user.already_in_organization: Cet utilisateur est déjà dans cette organisation.
|
||||
api-errors.user.organization_invitation_limit_reached: Le nombre maximum d'invitations a été atteint pour aujourd'hui. Veuillez réessayer demain.
|
||||
api-errors.demo.not_available: Cette fonctionnalité n'est pas disponible dans la démo
|
||||
api-errors.tags.already_exists: Un tag avec ce nom existe déjà pour cette organisation
|
||||
|
||||
# Not found
|
||||
|
||||
not-found.title: 404 - Not Found
|
||||
not-found.description: Désolé, la page que vous cherchez n'existe pas. Veuillez vérifier l'URL et réessayer.
|
||||
not-found.back-to-home: Retour à l'accueil
|
||||
|
||||
# Demo
|
||||
|
||||
demo.popup.description: Cette instance est une démo, toutes les données sont sauvegardées dans le stockage local de votre navigateur.
|
||||
demo.popup.discord: Rejoignez le {{ discordLink }} pour obtenir de l'aide, proposer des fonctionnalités ou simplement discuter.
|
||||
demo.popup.discord-link-label: Serveur Discord
|
||||
demo.popup.reset: Réinitialiser la démo
|
||||
demo.popup.hide: Masquer
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { Component } from 'solid-js';
|
||||
import type { LocaleKeys } from '@/modules/i18n/locales.types';
|
||||
import { createSignal, For } from 'solid-js';
|
||||
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||
import { Checkbox, CheckboxControl, CheckboxLabel } from '@/modules/ui/components/checkbox';
|
||||
import { type Component, createSignal, For } from 'solid-js';
|
||||
import { API_KEY_PERMISSIONS } from '../api-keys.constants';
|
||||
|
||||
export const ApiKeyPermissionsPicker: Component<{ permissions: string[]; onChange: (permissions: string[]) => void }> = (props) => {
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
import type { Component } from 'solid-js';
|
||||
import type { ApiKey } from '../api-keys.types';
|
||||
import { A } from '@solidjs/router';
|
||||
import { useMutation, useQuery } from '@tanstack/solid-query';
|
||||
import { format } from 'date-fns';
|
||||
import { For, Match, Show, Suspense, Switch } from 'solid-js';
|
||||
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||
import { useConfirmModal } from '@/modules/shared/confirm';
|
||||
import { queryClient } from '@/modules/shared/query/query-client';
|
||||
import { Button } from '@/modules/ui/components/button';
|
||||
import { EmptyState } from '@/modules/ui/components/empty';
|
||||
import { createToast } from '@/modules/ui/components/sonner';
|
||||
import { A } from '@solidjs/router';
|
||||
import { createMutation, createQuery } from '@tanstack/solid-query';
|
||||
import { format } from 'date-fns';
|
||||
import { type Component, For, Match, Show, Suspense, Switch } from 'solid-js';
|
||||
import { deleteApiKey, fetchApiKeys } from '../api-keys.services';
|
||||
|
||||
export const ApiKeyCard: Component<{ apiKey: ApiKey }> = ({ apiKey }) => {
|
||||
const { t } = useI18n();
|
||||
const { confirm } = useConfirmModal();
|
||||
|
||||
const deleteApiKeyMutation = createMutation(() => ({
|
||||
const deleteApiKeyMutation = useMutation(() => ({
|
||||
mutationFn: deleteApiKey,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['api-keys'] });
|
||||
@@ -84,7 +85,7 @@ export const ApiKeyCard: Component<{ apiKey: ApiKey }> = ({ apiKey }) => {
|
||||
|
||||
export const ApiKeysPage: Component = () => {
|
||||
const { t } = useI18n();
|
||||
const query = createQuery(() => ({
|
||||
const query = useQuery(() => ({
|
||||
queryKey: ['api-keys'],
|
||||
queryFn: () => fetchApiKeys(),
|
||||
}));
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import type { Component } from 'solid-js';
|
||||
import { setValue } from '@modular-forms/solid';
|
||||
import { A } from '@solidjs/router';
|
||||
import { createSignal, Show } from 'solid-js';
|
||||
import * as v from 'valibot';
|
||||
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||
import { createForm } from '@/modules/shared/form/form';
|
||||
import { queryClient } from '@/modules/shared/query/query-client';
|
||||
@@ -5,10 +10,6 @@ import { CopyButton } from '@/modules/shared/utils/copy';
|
||||
import { Button } from '@/modules/ui/components/button';
|
||||
import { createToast } from '@/modules/ui/components/sonner';
|
||||
import { TextField, TextFieldLabel, TextFieldRoot } from '@/modules/ui/components/textfield';
|
||||
import { setValue } from '@modular-forms/solid';
|
||||
import { A } from '@solidjs/router';
|
||||
import { type Component, createSignal, Show } from 'solid-js';
|
||||
import * as v from 'valibot';
|
||||
import { API_KEY_PERMISSIONS_LIST } from '../api-keys.constants';
|
||||
import { createApiKey } from '../api-keys.services';
|
||||
import { ApiKeyPermissionsPicker } from '../components/api-key-permissions-picker.component';
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Config } from '../config/config';
|
||||
import type { SsoProviderConfig } from './auth.types';
|
||||
import { get } from 'lodash-es';
|
||||
import { ssoProviders } from './auth.constants';
|
||||
|
||||
@@ -8,8 +9,15 @@ export function isAuthErrorWithCode({ error, code }: { error: unknown; code: str
|
||||
|
||||
export const isEmailVerificationRequiredError = ({ error }: { error: unknown }) => isAuthErrorWithCode({ error, code: 'EMAIL_NOT_VERIFIED' });
|
||||
|
||||
export function getEnabledSsoProviderConfigs({ config }: { config: Config }) {
|
||||
const enabledSsoProviders = ssoProviders.filter(({ key }) => get(config, `auth.providers.${key}.isEnabled`));
|
||||
export function getEnabledSsoProviderConfigs({ config }: { config: Config }): SsoProviderConfig[] {
|
||||
const enabledSsoProviders: SsoProviderConfig[] = [
|
||||
...ssoProviders.filter(({ key }) => get(config, `auth.providers.${key}.isEnabled`)),
|
||||
...config.auth.providers.customs.map(({ providerId, providerName, providerIconUrl }) => ({
|
||||
key: providerId,
|
||||
name: providerName,
|
||||
icon: providerIconUrl ?? 'i-tabler-login-2',
|
||||
})),
|
||||
];
|
||||
|
||||
return enabledSsoProviders;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { createAuthClient as createBetterAuthClient } from 'better-auth/solid';
|
||||
import type { Config } from '../config/config';
|
||||
|
||||
import type { SsoProviderConfig } from './auth.types';
|
||||
import { genericOAuthClient } from 'better-auth/client/plugins';
|
||||
import { createAuthClient as createBetterAuthClient } from 'better-auth/solid';
|
||||
import { buildTimeConfig } from '../config/config';
|
||||
import { trackingServices } from '../tracking/tracking.services';
|
||||
import { createDemoAuthClient } from './auth.demo.services';
|
||||
@@ -7,6 +10,9 @@ import { createDemoAuthClient } from './auth.demo.services';
|
||||
export function createAuthClient() {
|
||||
const client = createBetterAuthClient({
|
||||
baseURL: buildTimeConfig.baseApiUrl,
|
||||
plugins: [
|
||||
genericOAuthClient(),
|
||||
],
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -38,3 +44,17 @@ export const {
|
||||
} = buildTimeConfig.isDemoMode
|
||||
? createDemoAuthClient()
|
||||
: createAuthClient();
|
||||
|
||||
export async function authWithProvider({ provider, config }: { provider: SsoProviderConfig; config: Config }) {
|
||||
const isCustomProvider = config.auth.providers.customs.some(({ providerId }) => providerId === provider.key);
|
||||
|
||||
if (isCustomProvider) {
|
||||
signIn.oauth2({
|
||||
providerId: provider.key,
|
||||
callbackURL: config.baseUrl,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await signIn.social({ provider: provider.key as 'github' | 'google', callbackURL: config.baseUrl });
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ssoProviders } from './auth.constants';
|
||||
|
||||
export type SsoProviderKey = (typeof ssoProviders)[number]['key'];
|
||||
export type SsoProviderKey = (typeof ssoProviders)[number]['key'] | string & {};
|
||||
export type SsoProviderConfig = { key: SsoProviderKey; name: string; icon: string };
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { Component } from 'solid-js';
|
||||
import { A } from '@solidjs/router';
|
||||
import { useConfig } from '@/modules/config/config.provider';
|
||||
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||
import { createVitrineUrl } from '@/modules/shared/utils/urls';
|
||||
import { Button } from '@/modules/ui/components/button';
|
||||
import { A } from '@solidjs/router';
|
||||
|
||||
export const AuthLegalLinks: Component = () => {
|
||||
const { config } = useConfig();
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { Component, ComponentProps } from 'solid-js';
|
||||
import { splitProps } from 'solid-js';
|
||||
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||
import { cn } from '@/modules/shared/style/cn';
|
||||
import { Button } from '@/modules/ui/components/button';
|
||||
import { type Component, type ComponentProps, splitProps } from 'solid-js';
|
||||
|
||||
const providers = [
|
||||
{
|
||||
|
||||
@@ -1,19 +1,33 @@
|
||||
import type { Component } from 'solid-js';
|
||||
import { createSignal, Match, Switch } from 'solid-js';
|
||||
import { cn } from '@/modules/shared/style/cn';
|
||||
import { Button } from '@/modules/ui/components/button';
|
||||
import { createSignal } from 'solid-js';
|
||||
|
||||
export const SsoProviderButton: Component<{ name: string; icon: string; onClick: () => Promise<void>; label: string }> = (props) => {
|
||||
export const SsoProviderButton: Component<{ name: string; icon?: string; onClick: () => Promise<void>; label: string }> = (props) => {
|
||||
const [getIsLoading, setIsLoading] = createSignal(false);
|
||||
|
||||
const navigateToProvider = async () => {
|
||||
const onClick = async () => {
|
||||
setIsLoading(true);
|
||||
await props.onClick();
|
||||
};
|
||||
|
||||
return (
|
||||
<Button variant="secondary" class="block w-full flex items-center justify-center" onClick={navigateToProvider} disabled={getIsLoading()}>
|
||||
<span class={cn(`mr-2 size-4.5 inline-block`, getIsLoading() ? 'i-tabler-loader-2 animate-spin' : props.icon)} />
|
||||
<Button variant="secondary" class="block w-full flex items-center justify-center gap-2" onClick={onClick} disabled={getIsLoading()}>
|
||||
|
||||
<Switch>
|
||||
<Match when={getIsLoading()}>
|
||||
<span class="i-tabler-loader-2 animate-spin" />
|
||||
</Match>
|
||||
|
||||
<Match when={props.icon?.startsWith('i-')}>
|
||||
<span class={cn(`size-4.5`, props.icon)} />
|
||||
</Match>
|
||||
|
||||
<Match when={props.icon}>
|
||||
<img src={props.icon} alt={props.name} class="size-4.5" />
|
||||
</Match>
|
||||
</Switch>
|
||||
|
||||
{props.label}
|
||||
</Button>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Component } from 'solid-js';
|
||||
import { Navigate } from '@solidjs/router';
|
||||
import { type Component, Suspense } from 'solid-js';
|
||||
import { Suspense } from 'solid-js';
|
||||
import { Dynamic } from 'solid-js/web';
|
||||
import { match } from 'ts-pattern';
|
||||
import { useSession } from '../auth.services';
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { SsoProviderKey } from '../auth.types';
|
||||
import type { Component } from 'solid-js';
|
||||
import type { SsoProviderConfig } from '../auth.types';
|
||||
import { A, useNavigate } from '@solidjs/router';
|
||||
import { createSignal, For, Show } from 'solid-js';
|
||||
import * as v from 'valibot';
|
||||
import { useConfig } from '@/modules/config/config.provider';
|
||||
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||
import { createForm } from '@/modules/shared/form/form';
|
||||
@@ -6,12 +10,9 @@ import { Button } from '@/modules/ui/components/button';
|
||||
import { Checkbox, CheckboxControl, CheckboxLabel } from '@/modules/ui/components/checkbox';
|
||||
import { Separator } from '@/modules/ui/components/separator';
|
||||
import { TextField, TextFieldLabel, TextFieldRoot } from '@/modules/ui/components/textfield';
|
||||
import { A, useNavigate } from '@solidjs/router';
|
||||
import { type Component, createSignal, For, Show } from 'solid-js';
|
||||
import * as v from 'valibot';
|
||||
import { AuthLayout } from '../../ui/layouts/auth-layout.component';
|
||||
import { getEnabledSsoProviderConfigs, isEmailVerificationRequiredError } from '../auth.models';
|
||||
import { signIn } from '../auth.services';
|
||||
import { authWithProvider, signIn } from '../auth.services';
|
||||
import { AuthLegalLinks } from '../components/legal-links.component';
|
||||
import { SsoProviderButton } from '../components/sso-provider-button.component';
|
||||
|
||||
@@ -85,9 +86,11 @@ export const EmailLoginForm: Component = () => {
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Button variant="link" as={A} class="inline p-0! h-auto" href="/request-password-reset">
|
||||
{t('auth.login.form.forgot-password.label')}
|
||||
</Button>
|
||||
<Show when={config.auth.isPasswordResetEnabled}>
|
||||
<Button variant="link" as={A} class="inline p-0! h-auto" href="/request-password-reset">
|
||||
{t('auth.login.form.forgot-password.label')}
|
||||
</Button>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<Button type="submit" class="w-full">{t('auth.login.form.submit')}</Button>
|
||||
@@ -104,8 +107,8 @@ export const LoginPage: Component = () => {
|
||||
|
||||
const [getShowEmailLogin, setShowEmailLogin] = createSignal(false);
|
||||
|
||||
const loginWithProvider = async (provider: { key: SsoProviderKey }) => {
|
||||
await signIn.social({ provider: provider.key, callbackURL: config.baseUrl });
|
||||
const loginWithProvider = async (provider: SsoProviderConfig) => {
|
||||
await authWithProvider({ provider, config });
|
||||
};
|
||||
|
||||
const getHasSsoProviders = () => getEnabledSsoProviderConfigs({ config }).length > 0;
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import type { ssoProviders } from '../auth.constants';
|
||||
import type { Component } from 'solid-js';
|
||||
import type { SsoProviderConfig } from '../auth.types';
|
||||
import { A, useNavigate } from '@solidjs/router';
|
||||
import { createSignal, For, Show } from 'solid-js';
|
||||
import * as v from 'valibot';
|
||||
import { useConfig } from '@/modules/config/config.provider';
|
||||
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||
import { createForm } from '@/modules/shared/form/form';
|
||||
import { Button } from '@/modules/ui/components/button';
|
||||
import { Separator } from '@/modules/ui/components/separator';
|
||||
import { TextField, TextFieldLabel, TextFieldRoot } from '@/modules/ui/components/textfield';
|
||||
import { A, useNavigate } from '@solidjs/router';
|
||||
import { type Component, createSignal, For, Show } from 'solid-js';
|
||||
import * as v from 'valibot';
|
||||
import { AuthLayout } from '../../ui/layouts/auth-layout.component';
|
||||
import { getEnabledSsoProviderConfigs } from '../auth.models';
|
||||
import { signIn, signUp } from '../auth.services';
|
||||
import { authWithProvider, signUp } from '../auth.services';
|
||||
import { AuthLegalLinks } from '../components/legal-links.component';
|
||||
import { SsoProviderButton } from '../components/sso-provider-button.component';
|
||||
|
||||
@@ -132,8 +133,8 @@ export const RegisterPage: Component = () => {
|
||||
|
||||
const [getShowEmailRegister, setShowEmailRegister] = createSignal(false);
|
||||
|
||||
const registerWithProvider = async (provider: typeof ssoProviders[number]) => {
|
||||
await signIn.social({ provider: provider.key });
|
||||
const registerWithProvider = async (provider: SsoProviderConfig) => {
|
||||
await authWithProvider({ provider, config });
|
||||
};
|
||||
|
||||
const getHasSsoProviders = () => getEnabledSsoProviderConfigs({ config }).length > 0;
|
||||
@@ -168,7 +169,7 @@ export const RegisterPage: Component = () => {
|
||||
name={provider.name}
|
||||
icon={provider.icon}
|
||||
onClick={() => registerWithProvider(provider)}
|
||||
label={t('auth.register.register-with-provider', { provider: t(`auth.register.providers.${provider.key}`) })}
|
||||
label={t('auth.register.register-with-provider', { provider: provider.name })}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import type { Component } from 'solid-js';
|
||||
import { buildUrl } from '@corentinth/chisels';
|
||||
import { A, useNavigate } from '@solidjs/router';
|
||||
import { createSignal, onMount } from 'solid-js';
|
||||
import * as v from 'valibot';
|
||||
import { useConfig } from '@/modules/config/config.provider';
|
||||
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||
import { createForm } from '@/modules/shared/form/form';
|
||||
import { Button } from '@/modules/ui/components/button';
|
||||
import { TextField, TextFieldLabel, TextFieldRoot } from '@/modules/ui/components/textfield';
|
||||
import { buildUrl } from '@corentinth/chisels';
|
||||
import { A, useNavigate } from '@solidjs/router';
|
||||
import { type Component, createSignal, onMount } from 'solid-js';
|
||||
import * as v from 'valibot';
|
||||
import { AuthLayout } from '../../ui/layouts/auth-layout.component';
|
||||
import { forgetPassword } from '../auth.services';
|
||||
import { OpenEmailProvider } from '../components/open-email-provider.component';
|
||||
@@ -57,7 +58,7 @@ export const RequestPasswordResetPage: Component = () => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
onMount(() => {
|
||||
if (config.auth.isPasswordResetEnabled) {
|
||||
if (!config.auth.isPasswordResetEnabled) {
|
||||
navigate('/login');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import type { Component } from 'solid-js';
|
||||
import { A, Navigate, useNavigate, useSearchParams } from '@solidjs/router';
|
||||
import { createSignal, onMount } from 'solid-js';
|
||||
import * as v from 'valibot';
|
||||
import { useConfig } from '@/modules/config/config.provider';
|
||||
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||
import { createForm } from '@/modules/shared/form/form';
|
||||
import { Button } from '@/modules/ui/components/button';
|
||||
import { TextField, TextFieldLabel, TextFieldRoot } from '@/modules/ui/components/textfield';
|
||||
import { A, Navigate, useNavigate, useSearchParams } from '@solidjs/router';
|
||||
import { type Component, createSignal } from 'solid-js';
|
||||
import { onMount } from 'solid-js';
|
||||
import * as v from 'valibot';
|
||||
import { AuthLayout } from '../../ui/layouts/auth-layout.component';
|
||||
import { resetPassword } from '../auth.services';
|
||||
|
||||
@@ -62,7 +62,7 @@ export const ResetPasswordPage: Component = () => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
onMount(() => {
|
||||
if (config.auth.isPasswordResetEnabled) {
|
||||
if (!config.auth.isPasswordResetEnabled) {
|
||||
navigate('/login');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import { debounce } from 'lodash-es';
|
||||
import { createContext, createEffect, createSignal, For, on, onCleanup, onMount, Show, useContext } from 'solid-js';
|
||||
import { getDocumentIcon } from '../documents/document.models';
|
||||
import { searchDocuments } from '../documents/documents.services';
|
||||
import { useI18n } from '../i18n/i18n.provider';
|
||||
import { cn } from '../shared/style/cn';
|
||||
import { useThemeStore } from '../theme/theme.store';
|
||||
import { CommandDialog, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandLoading } from '../ui/components/command';
|
||||
@@ -29,9 +30,11 @@ export const CommandPaletteProvider: ParentComponent = (props) => {
|
||||
const [getIsCommandPaletteOpen, setIsCommandPaletteOpen] = createSignal(false);
|
||||
const [getMatchingDocuments, setMatchingDocuments] = createSignal<Document[]>([]);
|
||||
const [getSearchQuery, setSearchQuery] = createSignal('');
|
||||
const params = useParams();
|
||||
const [getIsLoading, setIsLoading] = createSignal(false);
|
||||
|
||||
const params = useParams();
|
||||
const { t } = useI18n();
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
@@ -82,7 +85,7 @@ export const CommandPaletteProvider: ParentComponent = (props) => {
|
||||
options: { label: string; icon: string; action: () => void; forceMatch?: boolean }[];
|
||||
}[] => [
|
||||
{
|
||||
label: 'Documents',
|
||||
label: t('command-palette.sections.documents'),
|
||||
forceMatch: true,
|
||||
options: getMatchingDocuments().map(document => ({
|
||||
label: document.name,
|
||||
@@ -92,20 +95,20 @@ export const CommandPaletteProvider: ParentComponent = (props) => {
|
||||
})),
|
||||
},
|
||||
{
|
||||
label: `Theme`,
|
||||
label: t('command-palette.sections.theme'),
|
||||
options: [
|
||||
{
|
||||
label: 'Switch to light mode',
|
||||
label: t('layout.theme.light'),
|
||||
icon: 'i-tabler-sun',
|
||||
action: () => setColorMode({ mode: 'light' }),
|
||||
},
|
||||
{
|
||||
label: 'Switch to dark mode',
|
||||
label: t('layout.theme.dark'),
|
||||
icon: 'i-tabler-moon',
|
||||
action: () => setColorMode({ mode: 'dark' }),
|
||||
},
|
||||
{
|
||||
label: 'Switch to system',
|
||||
label: t('layout.theme.system'),
|
||||
icon: 'i-tabler-device-laptop',
|
||||
action: () => setColorMode({ mode: 'system' }),
|
||||
},
|
||||
@@ -132,7 +135,7 @@ export const CommandPaletteProvider: ParentComponent = (props) => {
|
||||
onOpenChange={setIsCommandPaletteOpen}
|
||||
>
|
||||
|
||||
<CommandInput onValueChange={setSearchQuery} placeholder="Search commands or documents" />
|
||||
<CommandInput onValueChange={setSearchQuery} placeholder={t('command-palette.search.placeholder')} />
|
||||
<CommandList>
|
||||
<Show when={getIsLoading()}>
|
||||
<CommandLoading>
|
||||
@@ -142,7 +145,7 @@ export const CommandPaletteProvider: ParentComponent = (props) => {
|
||||
<Show when={!getIsLoading()}>
|
||||
<Show when={getMatchingDocuments().length === 0}>
|
||||
<CommandEmpty>
|
||||
No results found.
|
||||
{t('command-palette.no-results')}
|
||||
</CommandEmpty>
|
||||
</Show>
|
||||
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import type { ParentComponent } from 'solid-js';
|
||||
import { createQuery } from '@tanstack/solid-query';
|
||||
import type { Config, RuntimePublicConfig } from './config';
|
||||
import { useQuery } from '@tanstack/solid-query';
|
||||
import { merge } from 'lodash-es';
|
||||
import { createContext, Match, Switch, useContext } from 'solid-js';
|
||||
import { Button } from '../ui/components/button';
|
||||
import { EmptyState } from '../ui/components/empty';
|
||||
import { createToast } from '../ui/components/sonner';
|
||||
import { buildTimeConfig, type Config, type RuntimePublicConfig } from './config';
|
||||
import { buildTimeConfig } from './config';
|
||||
import { fetchPublicConfig } from './config.services';
|
||||
|
||||
const ConfigContext = createContext<{
|
||||
@@ -23,7 +24,7 @@ export function useConfig() {
|
||||
}
|
||||
|
||||
export const ConfigProvider: ParentComponent = (props) => {
|
||||
const query = createQuery(() => ({
|
||||
const query = useQuery(() => ({
|
||||
queryKey: ['config'],
|
||||
queryFn: fetchPublicConfig,
|
||||
}));
|
||||
|
||||
@@ -18,6 +18,11 @@ export const buildTimeConfig = {
|
||||
providers: {
|
||||
github: { isEnabled: asBoolean(import.meta.env.VITE_AUTH_PROVIDERS_GITHUB_IS_ENABLED, false) },
|
||||
google: { isEnabled: asBoolean(import.meta.env.VITE_AUTH_PROVIDERS_GOOGLE_IS_ENABLED, false) },
|
||||
customs: [] as {
|
||||
providerId: string;
|
||||
providerName: string;
|
||||
providerIconUrl: string;
|
||||
}[],
|
||||
},
|
||||
},
|
||||
documents: {
|
||||
@@ -32,6 +37,7 @@ export const buildTimeConfig = {
|
||||
isEnabled: asBoolean(import.meta.env.VITE_INTAKE_EMAILS_IS_ENABLED, false),
|
||||
emailGenerationDomain: asString(import.meta.env.VITE_INTAKE_EMAILS_EMAIL_GENERATION_DOMAIN),
|
||||
},
|
||||
isSubscriptionsEnabled: asBoolean(import.meta.env.VITE_IS_SUBSCRIPTIONS_ENABLED, false),
|
||||
} as const;
|
||||
|
||||
export type Config = typeof buildTimeConfig;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ApiKey } from '../api-keys/api-keys.types';
|
||||
import type { Webhook } from '../webhooks/webhooks.types';
|
||||
import { get } from 'lodash-es';
|
||||
import { FetchError } from 'ofetch';
|
||||
import { createRouter } from 'radix3';
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
tagDocumentStorage,
|
||||
taggingRuleStorage,
|
||||
tagStorage,
|
||||
webhooksStorage,
|
||||
} from './demo.storage';
|
||||
import { findMany, getValues } from './demo.storage.models';
|
||||
|
||||
@@ -565,6 +567,55 @@ const inMemoryApiMock: Record<string, { handler: any }> = {
|
||||
},
|
||||
}),
|
||||
|
||||
...defineHandler({
|
||||
path: '/api/organizations/:organizationId/members',
|
||||
method: 'GET',
|
||||
handler: async ({ params: { organizationId } }) => {
|
||||
return {
|
||||
members: [{
|
||||
id: 'mem_1',
|
||||
user: {
|
||||
id: 'usr_1',
|
||||
email: 'jane.doe@papra.app',
|
||||
name: 'Jane Doe',
|
||||
},
|
||||
role: 'owner',
|
||||
organizationId,
|
||||
}],
|
||||
};
|
||||
},
|
||||
}),
|
||||
|
||||
...defineHandler({
|
||||
path: '/api/organizations/:organizationId/members/invitations',
|
||||
method: 'POST',
|
||||
handler: async () => {
|
||||
throw Object.assign(new FetchError('Not available in demo'), {
|
||||
status: 501,
|
||||
data: {
|
||||
error: {
|
||||
message: 'This feature is not available in demo',
|
||||
code: 'demo.not_available',
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
}),
|
||||
|
||||
...defineHandler({
|
||||
path: '/api/organizations/:organizationId/members/me',
|
||||
method: 'GET',
|
||||
handler: async ({ params: { organizationId } }) => {
|
||||
return {
|
||||
member: {
|
||||
id: 'mem_1',
|
||||
role: 'owner',
|
||||
organizationId,
|
||||
},
|
||||
};
|
||||
},
|
||||
}),
|
||||
|
||||
...defineHandler({
|
||||
path: '/api/api-keys',
|
||||
method: 'GET',
|
||||
@@ -606,6 +657,80 @@ const inMemoryApiMock: Record<string, { handler: any }> = {
|
||||
await apiKeyStorage.removeItem(apiKeyId);
|
||||
},
|
||||
}),
|
||||
|
||||
...defineHandler({
|
||||
path: '/api/invitations/count',
|
||||
method: 'GET',
|
||||
handler: async () => ({ pendingInvitationsCount: 0 }),
|
||||
}),
|
||||
|
||||
...defineHandler({
|
||||
path: '/api/invitations',
|
||||
method: 'GET',
|
||||
handler: async () => ({ invitations: [] }),
|
||||
}),
|
||||
|
||||
...defineHandler({
|
||||
path: '/api/organizations/:organizationId/webhooks',
|
||||
method: 'GET',
|
||||
handler: async ({ params: { organizationId } }) => {
|
||||
const webhooks = await findMany(webhooksStorage, webhook => webhook.organizationId === organizationId);
|
||||
|
||||
return { webhooks };
|
||||
},
|
||||
}),
|
||||
|
||||
...defineHandler({
|
||||
path: '/api/organizations/:organizationId/webhooks',
|
||||
method: 'POST',
|
||||
handler: async ({ params: { organizationId }, body }) => {
|
||||
const webhook: Webhook = {
|
||||
id: createId({ prefix: 'webhook' }),
|
||||
organizationId,
|
||||
name: get(body, 'name'),
|
||||
url: get(body, 'url'),
|
||||
enabled: true,
|
||||
events: get(body, 'events'),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
await webhooksStorage.setItem(webhook.id, webhook);
|
||||
|
||||
return { webhook };
|
||||
},
|
||||
}),
|
||||
|
||||
...defineHandler({
|
||||
path: '/api/organizations/:organizationId/webhooks/:webhookId',
|
||||
method: 'GET',
|
||||
handler: async ({ params: { webhookId } }) => {
|
||||
const webhook = await webhooksStorage.getItem(webhookId);
|
||||
return { webhook };
|
||||
},
|
||||
}),
|
||||
|
||||
...defineHandler({
|
||||
path: '/api/organizations/:organizationId/webhooks/:webhookId',
|
||||
method: 'DELETE',
|
||||
handler: async ({ params: { webhookId } }) => {
|
||||
await webhooksStorage.removeItem(webhookId);
|
||||
},
|
||||
}),
|
||||
|
||||
...defineHandler({
|
||||
path: '/api/organizations/:organizationId/webhooks/:webhookId',
|
||||
method: 'PUT',
|
||||
handler: async ({ params: { webhookId }, body }) => {
|
||||
const webhook = await webhooksStorage.getItem(webhookId);
|
||||
|
||||
assert(webhook, { status: 404 });
|
||||
|
||||
await webhooksStorage.setItem(webhookId, Object.assign(webhook, body, { updatedAt: new Date() }));
|
||||
|
||||
return { webhook };
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
export const router = createRouter({ routes: inMemoryApiMock, strictTrailingSlash: false });
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { HttpClientOptions, ResponseType } from '../shared/http/http-client
|
||||
import { joinUrlPaths } from '@corentinth/chisels';
|
||||
import { router } from './demo-api-mock';
|
||||
|
||||
export async function demoHttpClient<A, R extends ResponseType = 'json'>(options: HttpClientOptions<R>): Promise<MappedResponseType< R, A>> {
|
||||
export async function demoHttpClient<A, R extends ResponseType = 'json'>(options: HttpClientOptions<R>): Promise<MappedResponseType<R, A>> {
|
||||
const path = `/${joinUrlPaths(options.method ?? 'GET', options.url)}`;
|
||||
const matchedRoute = router.lookup(path);
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Component } from 'solid-js';
|
||||
import { A, useNavigate } from '@solidjs/router';
|
||||
import { type Component, createSignal } from 'solid-js';
|
||||
import { createSignal } from 'solid-js';
|
||||
import { Portal } from 'solid-js/web';
|
||||
import { buildTimeConfig } from '../config/config';
|
||||
import { useI18n } from '../i18n/i18n.provider';
|
||||
|
||||
@@ -8,7 +8,7 @@ export async function getValues<T extends StorageValue>(storage: Storage<T>): Pr
|
||||
return values;
|
||||
}
|
||||
|
||||
export async function findOne<T extends StorageValue>(storage: Storage<T>, predicate: (value: T) => boolean): Promise< T | null> {
|
||||
export async function findOne<T extends StorageValue>(storage: Storage<T>, predicate: (value: T) => boolean): Promise<T | null> {
|
||||
const values = await getValues(storage);
|
||||
const found = values.find(predicate);
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { Document } from '../documents/documents.types';
|
||||
import type { Organization } from '../organizations/organizations.types';
|
||||
import type { TaggingRule } from '../tagging-rules/tagging-rules.types';
|
||||
import type { Tag } from '../tags/tags.types';
|
||||
import type { Webhook } from '../webhooks/webhooks.types';
|
||||
import { createStorage, prefixStorage } from 'unstorage';
|
||||
import localStorageDriver from 'unstorage/drivers/localstorage';
|
||||
import { trackingServices } from '../tracking/tracking.services';
|
||||
@@ -18,6 +19,7 @@ export const tagStorage = prefixStorage<Omit<Tag, 'documentsCount'>>(storage, 't
|
||||
export const tagDocumentStorage = prefixStorage<{ documentId: string; tagId: string; id: string }>(storage, 'tagDocuments');
|
||||
export const taggingRuleStorage = prefixStorage<TaggingRule>(storage, 'taggingRules');
|
||||
export const apiKeyStorage = prefixStorage<ApiKey>(storage, 'apiKeys');
|
||||
export const webhooksStorage = prefixStorage<Webhook>(storage, 'webhooks');
|
||||
|
||||
export async function clearDemoStorage() {
|
||||
await storage.clear();
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import type { ParentComponent } from 'solid-js';
|
||||
import type { Document } from '../documents.types';
|
||||
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||
import { promptUploadFiles } from '@/modules/shared/files/upload';
|
||||
import { useI18nApiErrors } from '@/modules/shared/http/composables/i18n-api-errors';
|
||||
import { cn } from '@/modules/shared/style/cn';
|
||||
import { Button } from '@/modules/ui/components/button';
|
||||
import { safely } from '@corentinth/chisels';
|
||||
import { A } from '@solidjs/router';
|
||||
import { throttle } from 'lodash-es';
|
||||
import { createContext, createSignal, For, Match, Show, Switch, useContext } from 'solid-js';
|
||||
import { Portal } from 'solid-js/web';
|
||||
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||
import { promptUploadFiles } from '@/modules/shared/files/upload';
|
||||
import { useI18nApiErrors } from '@/modules/shared/http/composables/i18n-api-errors';
|
||||
import { cn } from '@/modules/shared/style/cn';
|
||||
import { Button } from '@/modules/ui/components/button';
|
||||
import { invalidateOrganizationDocumentsQuery } from '../documents.composables';
|
||||
import { uploadDocument } from '../documents.services';
|
||||
|
||||
@@ -17,7 +17,7 @@ const DocumentUploadContext = createContext<{
|
||||
uploadDocuments: (args: { files: File[]; organizationId: string }) => Promise<void>;
|
||||
}>();
|
||||
|
||||
export function useDocumentUpload({ organizationId }: { organizationId: string }) {
|
||||
export function useDocumentUpload({ getOrganizationId }: { getOrganizationId: () => string }) {
|
||||
const context = useContext(DocumentUploadContext);
|
||||
|
||||
if (!context) {
|
||||
@@ -27,11 +27,11 @@ export function useDocumentUpload({ organizationId }: { organizationId: string }
|
||||
const { uploadDocuments } = context;
|
||||
|
||||
return {
|
||||
uploadDocuments: async ({ files }: { files: File[] }) => uploadDocuments({ files, organizationId }),
|
||||
uploadDocuments: async ({ files }: { files: File[] }) => uploadDocuments({ files, organizationId: getOrganizationId() }),
|
||||
promptImport: async () => {
|
||||
const { files } = await promptUploadFiles();
|
||||
|
||||
await uploadDocuments({ files, organizationId });
|
||||
await uploadDocuments({ files, organizationId: getOrganizationId() });
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -50,7 +50,7 @@ type TaskError = {
|
||||
|
||||
type Task = TaskSuccess | TaskError | {
|
||||
file: File;
|
||||
status: 'pending' | 'uploading' ;
|
||||
status: 'pending' | 'uploading';
|
||||
};
|
||||
|
||||
export const DocumentUploadProvider: ParentComponent = (props) => {
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import type { DropdownMenuSubTriggerProps } from '@kobalte/core/dropdown-menu';
|
||||
import type { Component } from 'solid-js';
|
||||
import type { Document } from '../documents.types';
|
||||
import { A } from '@solidjs/router';
|
||||
import { Button } from '@/modules/ui/components/button';
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/modules/ui/components/dropdown-menu';
|
||||
import { A } from '@solidjs/router';
|
||||
import { useDeleteDocument } from '../documents.composables';
|
||||
import { useRenameDocumentDialog } from './rename-document-button.component';
|
||||
|
||||
export const DocumentManagementDropdown: Component<{ document: Document }> = (props) => {
|
||||
const { deleteDocument } = useDeleteDocument();
|
||||
const { openRenameDialog } = useRenameDocumentDialog();
|
||||
|
||||
const deleteDoc = () => deleteDocument({
|
||||
documentId: props.document.id,
|
||||
@@ -16,6 +18,7 @@ export const DocumentManagementDropdown: Component<{ document: Document }> = (pr
|
||||
});
|
||||
|
||||
return (
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
as={(props: DropdownMenuSubTriggerProps) => (
|
||||
@@ -34,6 +37,18 @@ export const DocumentManagementDropdown: Component<{ document: Document }> = (pr
|
||||
<span>Document details</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
class="cursor-pointer"
|
||||
onClick={() => openRenameDialog({
|
||||
documentId: props.document.id,
|
||||
organizationId: props.document.organizationId,
|
||||
documentName: props.document.name,
|
||||
})}
|
||||
>
|
||||
<div class="i-tabler-pencil size-4 mr-2"></div>
|
||||
<span>Rename document</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
class="cursor-pointer text-red"
|
||||
onClick={() => deleteDoc()}
|
||||
@@ -43,5 +58,6 @@ export const DocumentManagementDropdown: Component<{ document: Document }> = (pr
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { Component } from 'solid-js';
|
||||
import type { Document } from '../documents.types';
|
||||
import { useQuery } from '@tanstack/solid-query';
|
||||
import { createResource, Match, Suspense, Switch } from 'solid-js';
|
||||
import { Card } from '@/modules/ui/components/card';
|
||||
import { createQuery } from '@tanstack/solid-query';
|
||||
import { type Component, createResource, Match, Suspense, Switch } from 'solid-js';
|
||||
import { fetchDocumentFile } from '../documents.services';
|
||||
import { PdfViewer } from './pdf-viewer.component';
|
||||
|
||||
@@ -34,7 +35,7 @@ export const DocumentPreview: Component<{ document: Document }> = (props) => {
|
||||
const getIsImage = () => imageMimeType.includes(props.document.mimeType);
|
||||
const getIsPdf = () => pdfMimeType.includes(props.document.mimeType);
|
||||
|
||||
const query = createQuery(() => ({
|
||||
const query = useQuery(() => ({
|
||||
queryKey: ['organizations', props.document.organizationId, 'documents', props.document.id, 'file'],
|
||||
queryFn: () => fetchDocumentFile({ documentId: props.document.id, organizationId: props.document.organizationId }),
|
||||
}));
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { Component } from 'solid-js';
|
||||
import { useParams } from '@solidjs/router';
|
||||
import { createSignal } from 'solid-js';
|
||||
import { promptUploadFiles } from '@/modules/shared/files/upload';
|
||||
import { queryClient } from '@/modules/shared/query/query-client';
|
||||
import { cn } from '@/modules/shared/style/cn';
|
||||
import { Button } from '@/modules/ui/components/button';
|
||||
import { useParams } from '@solidjs/router';
|
||||
import { createSignal } from 'solid-js';
|
||||
import { uploadDocument } from '../documents.services';
|
||||
|
||||
export const DocumentUploadArea: Component<{ organizationId?: string }> = (props) => {
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import type { Tag } from '@/modules/tags/tags.types';
|
||||
import type { TooltipTriggerProps } from '@kobalte/core/tooltip';
|
||||
import type { ColumnDef } from '@tanstack/solid-table';
|
||||
import type { Accessor, Component, Setter } from 'solid-js';
|
||||
import type { Document } from '../documents.types';
|
||||
import type { Tag } from '@/modules/tags/tags.types';
|
||||
import { formatBytes } from '@corentinth/chisels';
|
||||
import { A } from '@solidjs/router';
|
||||
import { createSolidTable, flexRender, getCoreRowModel, getPaginationRowModel } from '@tanstack/solid-table';
|
||||
import { For, Match, Show, Switch } from 'solid-js';
|
||||
import { timeAgo } from '@/modules/shared/date/time-ago';
|
||||
import { cn } from '@/modules/shared/style/cn';
|
||||
import { TagLink } from '@/modules/tags/components/tag.component';
|
||||
@@ -9,10 +14,6 @@ import { Button } from '@/modules/ui/components/button';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/modules/ui/components/select';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/modules/ui/components/table';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/modules/ui/components/tooltip';
|
||||
import { formatBytes } from '@corentinth/chisels';
|
||||
import { A } from '@solidjs/router';
|
||||
import { createSolidTable, flexRender, getCoreRowModel, getPaginationRowModel } from '@tanstack/solid-table';
|
||||
import { type Accessor, type Component, For, Match, type Setter, Show, Switch } from 'solid-js';
|
||||
import { getDocumentIcon, getDocumentNameExtension, getDocumentNameWithoutExtension } from '../document.models';
|
||||
import { DocumentManagementDropdown } from './document-management-dropdown.component';
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Component } from 'solid-js';
|
||||
import { cn } from '@/modules/shared/style/cn';
|
||||
import { createSignal, onCleanup } from 'solid-js';
|
||||
import { cn } from '@/modules/shared/style/cn';
|
||||
|
||||
export const GlobalDropArea: Component<{ onFilesDrop?: (args: { files: File[] }) => void }> = (props) => {
|
||||
const [isDragging, setIsDragging] = createSignal(false);
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import type { Component, ParentComponent } from 'solid-js';
|
||||
import { setValue } from '@modular-forms/solid';
|
||||
import { useMutation } from '@tanstack/solid-query';
|
||||
import { createContext, createEffect, createSignal, useContext } from 'solid-js';
|
||||
import * as v from 'valibot';
|
||||
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||
import { createForm } from '@/modules/shared/form/form';
|
||||
import { Button } from '@/modules/ui/components/button';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/modules/ui/components/dialog';
|
||||
import { createToast } from '@/modules/ui/components/sonner';
|
||||
import { TextField, TextFieldLabel, TextFieldRoot } from '@/modules/ui/components/textfield';
|
||||
import { getDocumentNameExtension, getDocumentNameWithoutExtension } from '../document.models';
|
||||
import { invalidateOrganizationDocumentsQuery } from '../documents.composables';
|
||||
import { updateDocument } from '../documents.services';
|
||||
|
||||
export const RenameDocumentDialog: Component<{
|
||||
documentId: string;
|
||||
organizationId: string;
|
||||
documentName: string;
|
||||
isOpen: boolean;
|
||||
setIsOpen: (isOpen: boolean) => void;
|
||||
}> = (props) => {
|
||||
const { t } = useI18n();
|
||||
|
||||
const renameDocumentMutation = useMutation(() => ({
|
||||
mutationFn: ({ name }: { name: string }) => updateDocument({ documentId: props.documentId, organizationId: props.organizationId, name }),
|
||||
onSuccess: async () => {
|
||||
createToast({
|
||||
message: t('documents.rename.success'),
|
||||
type: 'success',
|
||||
});
|
||||
|
||||
props.setIsOpen(false);
|
||||
|
||||
await invalidateOrganizationDocumentsQuery({ organizationId: props.organizationId });
|
||||
},
|
||||
|
||||
}));
|
||||
|
||||
const { Form, Field, form } = createForm({
|
||||
schema: v.object({
|
||||
name: v.pipe(
|
||||
v.string(),
|
||||
v.trim(),
|
||||
v.maxLength(255, t('documents.rename.form.name.max-length')),
|
||||
v.minLength(1, t('documents.rename.form.name.required')),
|
||||
),
|
||||
}),
|
||||
initialValues: {
|
||||
name: getDocumentNameWithoutExtension({ name: props.documentName }),
|
||||
},
|
||||
onSubmit: async ({ name }) => {
|
||||
const extension = getDocumentNameExtension({ name: props.documentName });
|
||||
const newName = extension ? `${name}.${extension}` : name;
|
||||
|
||||
await renameDocumentMutation.mutateAsync({ name: newName });
|
||||
},
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
setValue(form, 'name', getDocumentNameWithoutExtension({ name: props.documentName }));
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={props.setIsOpen} open={props.isOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('documents.rename.title')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<Form>
|
||||
<Field name="name">
|
||||
{(field, inputProps) => (
|
||||
<TextFieldRoot>
|
||||
<TextFieldLabel class="sr-only" for="name">{t('documents.rename.form.name.label')}</TextFieldLabel>
|
||||
<TextField {...inputProps} value={field.value} id="name" placeholder={t('documents.rename.form.name.placeholder')} />
|
||||
{field.error && <div class="text-red-500 text-sm">{field.error}</div>}
|
||||
</TextFieldRoot>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<div class="flex justify-end mt-4 gap-2">
|
||||
<Button type="button" variant="secondary" onClick={() => props.setIsOpen(false)}>
|
||||
{t('documents.rename.cancel')}
|
||||
</Button>
|
||||
<Button type="submit">{t('documents.rename.form.submit')}</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
const context = createContext<{
|
||||
openRenameDialog: (args: { documentId: string; organizationId: string; documentName: string }) => void;
|
||||
}>();
|
||||
|
||||
export function useRenameDocumentDialog() {
|
||||
const renameDialogContext = useContext(context);
|
||||
|
||||
if (!renameDialogContext) {
|
||||
throw new Error('useRenameDocumentDialog must be used within a RenameDocumentDialogProvider');
|
||||
}
|
||||
|
||||
return renameDialogContext;
|
||||
}
|
||||
|
||||
export const RenameDocumentDialogProvider: ParentComponent = (props) => {
|
||||
const [getIsRenameDialogOpen, setIsRenameDialogOpen] = createSignal(false);
|
||||
const [getDocumentId, setDocumentId] = createSignal<string | undefined>(undefined);
|
||||
const [getOrganizationId, setOrganizationId] = createSignal<string | undefined>(undefined);
|
||||
const [getDocumentName, setDocumentName] = createSignal<string | undefined>(undefined);
|
||||
|
||||
return (
|
||||
<context.Provider
|
||||
value={{
|
||||
openRenameDialog: ({ documentId, organizationId, documentName }) => {
|
||||
setIsRenameDialogOpen(true);
|
||||
setDocumentId(documentId);
|
||||
setOrganizationId(organizationId);
|
||||
setDocumentName(documentName);
|
||||
},
|
||||
}}
|
||||
>
|
||||
<RenameDocumentDialog
|
||||
documentId={getDocumentId() ?? ''}
|
||||
organizationId={getOrganizationId() ?? ''}
|
||||
documentName={getDocumentName() ?? ''}
|
||||
isOpen={getIsRenameDialogOpen()}
|
||||
setIsOpen={setIsRenameDialogOpen}
|
||||
/>
|
||||
|
||||
{props.children}
|
||||
</context.Provider>
|
||||
);
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { DocumentActivityEvent } from './documents.types';
|
||||
import { addDays, differenceInDays } from 'date-fns';
|
||||
|
||||
export const iconByFileType = {
|
||||
@@ -79,3 +80,16 @@ export function getDocumentNameExtension({ name }: { name: string }) {
|
||||
|
||||
return dotSplittedName[dotCount];
|
||||
}
|
||||
|
||||
export const documentActivityIcon: Record<DocumentActivityEvent, string> = {
|
||||
created: 'i-tabler-file-plus',
|
||||
updated: 'i-tabler-file-diff',
|
||||
deleted: 'i-tabler-file-x',
|
||||
restored: 'i-tabler-file-check',
|
||||
tagged: 'i-tabler-tag',
|
||||
untagged: 'i-tabler-tag-off',
|
||||
} as const;
|
||||
|
||||
export function getDocumentActivityIcon({ event }: { event: DocumentActivityEvent }) {
|
||||
return documentActivityIcon[event] ?? 'i-tabler-file';
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
export const DOCUMENT_ACTIVITY_EVENTS = {
|
||||
CREATED: 'created',
|
||||
UPDATED: 'updated',
|
||||
DELETED: 'deleted',
|
||||
RESTORED: 'restored',
|
||||
TAGGED: 'tagged',
|
||||
UNTAGGED: 'untagged',
|
||||
} as const;
|
||||
|
||||
export const DOCUMENT_ACTIVITY_EVENT_LIST = Object.values(DOCUMENT_ACTIVITY_EVENTS);
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { AsDto } from '../shared/http/http-client.types';
|
||||
import type { Document } from './documents.types';
|
||||
import type { Document, DocumentActivity } from './documents.types';
|
||||
import { apiClient } from '../shared/http/api-client';
|
||||
import { coerceDates, getFormData } from '../shared/http/http-client.models';
|
||||
|
||||
@@ -194,18 +194,45 @@ export async function updateDocument({
|
||||
documentId,
|
||||
organizationId,
|
||||
content,
|
||||
name,
|
||||
}: {
|
||||
documentId: string;
|
||||
organizationId: string;
|
||||
content: string;
|
||||
content?: string;
|
||||
name?: string;
|
||||
}) {
|
||||
const { document } = await apiClient<{ document: AsDto<Document> }>({
|
||||
method: 'PATCH',
|
||||
path: `/api/organizations/${organizationId}/documents/${documentId}`,
|
||||
body: { content },
|
||||
body: { content, name },
|
||||
});
|
||||
|
||||
return {
|
||||
document: coerceDates(document),
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchDocumentActivities({
|
||||
documentId,
|
||||
organizationId,
|
||||
pageIndex,
|
||||
pageSize,
|
||||
}: {
|
||||
documentId: string;
|
||||
organizationId: string;
|
||||
pageIndex: number;
|
||||
pageSize: number;
|
||||
}) {
|
||||
const { activities } = await apiClient<{ activities: AsDto<DocumentActivity>[] }>({
|
||||
method: 'GET',
|
||||
path: `/api/organizations/${organizationId}/documents/${documentId}/activity`,
|
||||
query: {
|
||||
pageIndex,
|
||||
pageSize,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
activities: activities.map(coerceDates),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { Tag } from '../tags/tags.types';
|
||||
import type { User } from '../users/users.types';
|
||||
import type { DOCUMENT_ACTIVITY_EVENTS } from './documents.constants';
|
||||
|
||||
export type Document = {
|
||||
id: string;
|
||||
@@ -14,3 +16,17 @@ export type Document = {
|
||||
content: string;
|
||||
tags: Tag[];
|
||||
};
|
||||
|
||||
export type DocumentActivityEvent = (typeof DOCUMENT_ACTIVITY_EVENTS)[keyof typeof DOCUMENT_ACTIVITY_EVENTS];
|
||||
|
||||
export type DocumentActivity = {
|
||||
id: string;
|
||||
documentId: string;
|
||||
event: DocumentActivityEvent;
|
||||
eventData: Record<string, unknown>;
|
||||
userId?: string;
|
||||
createdAt: Date;
|
||||
updatedAt?: Date;
|
||||
tag?: Pick<Tag, 'id' | 'name' | 'color' | 'description'>;
|
||||
user?: Pick<User, 'id' | 'name'>;
|
||||
};
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { Component } from 'solid-js';
|
||||
import type { Document } from '../documents.types';
|
||||
import { useParams } from '@solidjs/router';
|
||||
import { keepPreviousData, useMutation, useQuery } from '@tanstack/solid-query';
|
||||
import { createSignal, Show, Suspense } from 'solid-js';
|
||||
import { useConfig } from '@/modules/config/config.provider';
|
||||
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||
import { useConfirmModal } from '@/modules/shared/confirm';
|
||||
@@ -7,15 +11,13 @@ import { queryClient } from '@/modules/shared/query/query-client';
|
||||
import { Alert, AlertDescription } from '@/modules/ui/components/alert';
|
||||
import { Button } from '@/modules/ui/components/button';
|
||||
import { createToast } from '@/modules/ui/components/sonner';
|
||||
import { useParams } from '@solidjs/router';
|
||||
import { createMutation, createQuery, keepPreviousData } from '@tanstack/solid-query';
|
||||
import { type Component, createSignal, Show, Suspense } from 'solid-js';
|
||||
import { DocumentsPaginatedList } from '../components/documents-list.component';
|
||||
import { useRestoreDocument } from '../documents.composables';
|
||||
import { deleteAllTrashDocuments, deleteTrashDocument, fetchOrganizationDeletedDocuments } from '../documents.services';
|
||||
|
||||
const RestoreDocumentButton: Component<{ document: Document }> = (props) => {
|
||||
const { getIsRestoring, restore } = useRestoreDocument();
|
||||
const { t } = useI18n();
|
||||
|
||||
return (
|
||||
<Button
|
||||
@@ -25,11 +27,11 @@ const RestoreDocumentButton: Component<{ document: Document }> = (props) => {
|
||||
isLoading={getIsRestoring()}
|
||||
>
|
||||
{ getIsRestoring()
|
||||
? (<>Restoring...</>)
|
||||
? (<>{t('documents.deleted.restoring')}</>)
|
||||
: (
|
||||
<>
|
||||
<div class="i-tabler-refresh size-4 mr-2" />
|
||||
Restore
|
||||
{t('documents.actions.restore')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
@@ -40,7 +42,7 @@ const PermanentlyDeleteTrashDocumentButton: Component<{ document: Document; orga
|
||||
const { confirm } = useConfirmModal();
|
||||
const { t } = useI18n();
|
||||
|
||||
const deleteMutation = createMutation(() => ({
|
||||
const deleteMutation = useMutation(() => ({
|
||||
mutationFn: async () => {
|
||||
await deleteTrashDocument({ documentId: props.document.id, organizationId: props.organizationId });
|
||||
},
|
||||
@@ -81,7 +83,7 @@ const PermanentlyDeleteTrashDocumentButton: Component<{ document: Document; orga
|
||||
class="text-red-500 hover:text-red-600"
|
||||
>
|
||||
{deleteMutation.isPending
|
||||
? (<>Deleting...</>)
|
||||
? (<>{t('documents.deleted.deleting')}</>)
|
||||
: (
|
||||
<>
|
||||
<div class="i-tabler-trash size-4 mr-2" />
|
||||
@@ -96,7 +98,7 @@ const DeleteAllTrashDocumentsButton: Component<{ organizationId: string }> = (pr
|
||||
const { confirm } = useConfirmModal();
|
||||
const { t } = useI18n();
|
||||
|
||||
const deleteAllMutation = createMutation(() => ({
|
||||
const deleteAllMutation = useMutation(() => ({
|
||||
mutationFn: async () => {
|
||||
await deleteAllTrashDocuments({ organizationId: props.organizationId });
|
||||
},
|
||||
@@ -132,7 +134,7 @@ const DeleteAllTrashDocumentsButton: Component<{ organizationId: string }> = (pr
|
||||
class="text-red-500 hover:text-red-600"
|
||||
>
|
||||
{deleteAllMutation.isPending
|
||||
? (<>Deleting...</>)
|
||||
? (<>{t('documents.deleted.deleting')}</>)
|
||||
: (
|
||||
<>
|
||||
<div class="i-tabler-trash size-4 mr-2" />
|
||||
@@ -147,8 +149,9 @@ export const DeletedDocumentsPage: Component = () => {
|
||||
const [getPagination, setPagination] = createSignal({ pageIndex: 0, pageSize: 100 });
|
||||
const params = useParams();
|
||||
const { config } = useConfig();
|
||||
const { t } = useI18n();
|
||||
|
||||
const query = createQuery(() => ({
|
||||
const query = useQuery(() => ({
|
||||
queryKey: ['organizations', params.organizationId, 'documents', 'deleted', getPagination()],
|
||||
queryFn: () => fetchOrganizationDeletedDocuments({
|
||||
organizationId: params.organizationId,
|
||||
@@ -159,16 +162,12 @@ export const DeletedDocumentsPage: Component = () => {
|
||||
|
||||
return (
|
||||
<div class="p-6 mt-4 pb-32">
|
||||
<h1 class="text-2xl font-bold">Deleted documents</h1>
|
||||
<h1 class="text-2xl font-bold">{t('documents.deleted.title')}</h1>
|
||||
|
||||
<Alert variant="muted" class="my-4 flex items-center gap-6 xl:gap-4">
|
||||
<div class="i-tabler-info-circle size-10 xl:size-8 text-primary flex-shrink-0 hidden sm:block" />
|
||||
<AlertDescription>
|
||||
All deleted documents are stored in the trash bin for
|
||||
{' '}
|
||||
{config.documents.deletedDocumentsRetentionDays}
|
||||
{' '}
|
||||
days. Passing this delay, the documents will be permanently deleted, and you will not be able to restore them.
|
||||
{t('documents.deleted.retention-notice', { days: config.documents.deletedDocumentsRetentionDays })}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
@@ -176,13 +175,9 @@ export const DeletedDocumentsPage: Component = () => {
|
||||
<Show when={query.data?.documents.length === 0}>
|
||||
<div class="flex flex-col items-center justify-center gap-2 pt-24 mx-auto max-w-md text-center">
|
||||
<div class="i-tabler-trash text-primary size-12" aria-hidden="true" />
|
||||
<div class="text-xl font-medium">No deleted documents</div>
|
||||
<div class="text-sm text-muted-foreground">
|
||||
You have no deleted documents. Documents that are deleted will be moved to the trash bin for
|
||||
{' '}
|
||||
{config.documents.deletedDocumentsRetentionDays}
|
||||
{' '}
|
||||
days.
|
||||
<div class="text-xl font-medium">{t('documents.deleted.empty.title')}</div>
|
||||
<div class="text-sm text-muted-foreground">
|
||||
{t('documents.deleted.empty.description', { days: config.documents.deletedDocumentsRetentionDays })}
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
@@ -202,7 +197,7 @@ export const DeletedDocumentsPage: Component = () => {
|
||||
id: 'deletion',
|
||||
cell: data => (
|
||||
<div class="text-muted-foreground hidden sm:block">
|
||||
Deleted
|
||||
{t('documents.deleted.deleted-at')}
|
||||
{' '}
|
||||
<span class="text-foreground font-bold" title={data.row.original.deletedAt?.toLocaleString()}>{timeAgo({ date: data.row.original.deletedAt! })}</span>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import type { Component, JSX } from 'solid-js';
|
||||
import type { DocumentActivity } from '../documents.types';
|
||||
import { formatBytes, safely } from '@corentinth/chisels';
|
||||
import { A, useNavigate, useParams, useSearchParams } from '@solidjs/router';
|
||||
import { createQueries, useInfiniteQuery } from '@tanstack/solid-query';
|
||||
import { createEffect, createSignal, For, Match, Show, Suspense, Switch } from 'solid-js';
|
||||
import { useConfig } from '@/modules/config/config.provider';
|
||||
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||
import { timeAgo } from '@/modules/shared/date/time-ago';
|
||||
import { downloadFile } from '@/modules/shared/files/download';
|
||||
import { queryClient } from '@/modules/shared/query/query-client';
|
||||
import { cn } from '@/modules/shared/style/cn';
|
||||
import { DocumentTagPicker } from '@/modules/tags/components/tag-picker.component';
|
||||
import { TagLink } from '@/modules/tags/components/tag.component';
|
||||
import { CreateTagModal } from '@/modules/tags/pages/tags.page';
|
||||
import { addTagToDocument, removeTagFromDocument } from '@/modules/tags/tags.services';
|
||||
import { Alert, AlertDescription } from '@/modules/ui/components/alert';
|
||||
@@ -13,15 +21,11 @@ import { createToast } from '@/modules/ui/components/sonner';
|
||||
import { Tabs, TabsContent, TabsIndicator, TabsList, TabsTrigger } from '@/modules/ui/components/tabs';
|
||||
import { TextArea } from '@/modules/ui/components/textarea';
|
||||
import { TextFieldRoot } from '@/modules/ui/components/textfield';
|
||||
import { formatBytes, safely } from '@corentinth/chisels';
|
||||
import { useNavigate, useParams } from '@solidjs/router';
|
||||
import { createQueries } from '@tanstack/solid-query';
|
||||
import { type Component, For, type JSX, Show, Suspense } from 'solid-js';
|
||||
import { createSignal } from 'solid-js';
|
||||
import { DocumentPreview } from '../components/document-preview.component';
|
||||
import { getDaysBeforePermanentDeletion } from '../document.models';
|
||||
import { useRenameDocumentDialog } from '../components/rename-document-button.component';
|
||||
import { getDaysBeforePermanentDeletion, getDocumentActivityIcon } from '../document.models';
|
||||
import { useDeleteDocument, useRestoreDocument } from '../documents.composables';
|
||||
import { fetchDocument, fetchDocumentFile, updateDocument } from '../documents.services';
|
||||
import { fetchDocument, fetchDocumentActivities, fetchDocumentFile, updateDocument } from '../documents.services';
|
||||
import '@pdfslick/solid/dist/pdf_viewer.css';
|
||||
|
||||
type KeyValueItem = {
|
||||
@@ -50,13 +54,78 @@ const KeyValues: Component<{ data?: KeyValueItem[] }> = (props) => {
|
||||
);
|
||||
};
|
||||
|
||||
const ActivityItem: Component<{ activity: DocumentActivity }> = (props) => {
|
||||
const { t, te } = useI18n();
|
||||
const params = useParams();
|
||||
|
||||
return (
|
||||
<div class="border-b py-3 flex items-center gap-2">
|
||||
<div>
|
||||
<div class={cn(getDocumentActivityIcon({ event: props.activity.event }), 'size-6 text-muted-foreground')} />
|
||||
</div>
|
||||
<div>
|
||||
<Switch fallback={<span class="text-sm">{t(`activity.document.${props.activity.event}`)}</span>}>
|
||||
<Match when={['tagged', 'untagged'].includes(props.activity.event)}>
|
||||
<span class="text-sm flex items-baseline gap-1">
|
||||
{te(`activity.document.${props.activity.event}`, { tag: props.activity.tag ? <TagLink {...props.activity.tag} organizationId={params.organizationId} class="text-xs" /> : undefined })}
|
||||
</span>
|
||||
</Match>
|
||||
|
||||
<Match when={props.activity.event === 'updated' && (props.activity.eventData.updatedFields as string[]).length === 1}>
|
||||
<span class="text-sm flex items-baseline gap-1">
|
||||
{te(`activity.document.updated.single`, {
|
||||
field: <span class="font-bold">{(props.activity.eventData.updatedFields as string[])[0]}</span>,
|
||||
})}
|
||||
</span>
|
||||
</Match>
|
||||
|
||||
<Match when={props.activity.event === 'updated' && (props.activity.eventData.updatedFields as string[]).length > 1}>
|
||||
<span class="text-sm flex items-baseline gap-1">
|
||||
{te(`activity.document.updated.multiple`, { fields: (props.activity.eventData.updatedFields as string[]).join(', ') })}
|
||||
</span>
|
||||
</Match>
|
||||
|
||||
</Switch>
|
||||
|
||||
<div class="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<span title={props.activity.createdAt.toLocaleString()}>{timeAgo({ date: props.activity.createdAt })}</span>
|
||||
<Show when={props.activity.user}>
|
||||
{getUser => (
|
||||
<span>{te('activity.document.user.name', { name: <A href={`/organizations/${params.organizationId}/members`} class="underline hover:text-primary transition">{getUser().name}</A> })}</span>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const tabs = ['info', 'content', 'activity'] as const;
|
||||
type Tab = typeof tabs[number];
|
||||
|
||||
export const DocumentPage: Component = () => {
|
||||
const { t } = useI18n();
|
||||
const params = useParams();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const { deleteDocument } = useDeleteDocument();
|
||||
const { restore, getIsRestoring } = useRestoreDocument();
|
||||
const navigate = useNavigate();
|
||||
const { config } = useConfig();
|
||||
const { openRenameDialog } = useRenameDocumentDialog();
|
||||
|
||||
const getInitialTab = (): Tab => {
|
||||
const tab = searchParams.tab;
|
||||
if (tab && typeof tab === 'string' && tabs.includes(tab as Tab)) {
|
||||
return tab as Tab;
|
||||
}
|
||||
return 'info';
|
||||
};
|
||||
|
||||
const [getTab, setTab] = createSignal<Tab>(getInitialTab());
|
||||
|
||||
createEffect(() => {
|
||||
setSearchParams({ tab: getTab() }, { replace: true });
|
||||
});
|
||||
|
||||
const queries = createQueries(() => ({
|
||||
queries: [
|
||||
@@ -71,6 +140,30 @@ export const DocumentPage: Component = () => {
|
||||
],
|
||||
}));
|
||||
|
||||
const activityPageSize = 20;
|
||||
const activityQuery = useInfiniteQuery(() => ({
|
||||
enabled: getTab() === 'activity',
|
||||
queryKey: ['organizations', params.organizationId, 'documents', params.documentId, 'activity'],
|
||||
queryFn: async ({ pageParam }) => {
|
||||
const { activities } = await fetchDocumentActivities({
|
||||
documentId: params.documentId,
|
||||
organizationId: params.organizationId,
|
||||
pageIndex: pageParam,
|
||||
pageSize: activityPageSize,
|
||||
});
|
||||
|
||||
return activities;
|
||||
},
|
||||
getNextPageParam: (lastPage, _pages, lastPageParam) => {
|
||||
if (lastPage.length < activityPageSize) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return lastPageParam + 1;
|
||||
},
|
||||
initialPageParam: 0,
|
||||
}));
|
||||
|
||||
const deleteDoc = async () => {
|
||||
if (!queries[0].data) {
|
||||
return;
|
||||
@@ -137,7 +230,21 @@ export const DocumentPage: Component = () => {
|
||||
{getDocument => (
|
||||
<div class="flex gap-4 md:pr-6">
|
||||
<div class="flex-1">
|
||||
<h1 class="text-xl font-semibold">{getDocument().name}</h1>
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="flex items-center gap-2 group bg-transparent! px-0"
|
||||
onClick={() => openRenameDialog({
|
||||
documentId: getDocument().id,
|
||||
organizationId: params.organizationId,
|
||||
documentName: getDocument().name,
|
||||
})}
|
||||
>
|
||||
<h1 class="text-xl font-semibold">
|
||||
{getDocument().name}
|
||||
</h1>
|
||||
|
||||
<div class="i-tabler-pencil size-4 text-muted-foreground group-hover:text-foreground transition-colors"></div>
|
||||
</Button>
|
||||
<p class="text-sm text-muted-foreground mb-6">{getDocument().id}</p>
|
||||
|
||||
<div class="flex gap-2 mb-2">
|
||||
@@ -147,7 +254,7 @@ export const DocumentPage: Component = () => {
|
||||
size="sm"
|
||||
>
|
||||
<div class="i-tabler-download size-4 mr-2"></div>
|
||||
Download
|
||||
{t('documents.actions.download')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
@@ -156,7 +263,7 @@ export const DocumentPage: Component = () => {
|
||||
size="sm"
|
||||
>
|
||||
<div class="i-tabler-eye size-4 mr-2"></div>
|
||||
Open in new tab
|
||||
{t('documents.actions.open-in-new-tab')}
|
||||
</Button>
|
||||
|
||||
{getDocument().isDeleted
|
||||
@@ -168,7 +275,7 @@ export const DocumentPage: Component = () => {
|
||||
isLoading={getIsRestoring()}
|
||||
>
|
||||
<div class="i-tabler-refresh size-4 mr-2"></div>
|
||||
Restore
|
||||
{t('documents.actions.restore')}
|
||||
</Button>
|
||||
)
|
||||
: (
|
||||
@@ -178,7 +285,7 @@ export const DocumentPage: Component = () => {
|
||||
onClick={deleteDoc}
|
||||
>
|
||||
<div class="i-tabler-trash size-4 mr-2"></div>
|
||||
Delete
|
||||
{t('documents.actions.delete')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -218,56 +325,67 @@ export const DocumentPage: Component = () => {
|
||||
|
||||
{getDocument().isDeleted && (
|
||||
<Alert variant="destructive" class="mt-6">
|
||||
This document has been deleted and will be permanently removed in
|
||||
{' '}
|
||||
{getDaysBeforePermanentDeletion({
|
||||
{t('documents.deleted.message', { days: getDaysBeforePermanentDeletion({
|
||||
document: getDocument(),
|
||||
deletedDocumentsRetentionDays: config.documents.deletedDocumentsRetentionDays,
|
||||
})}
|
||||
{' '}
|
||||
days.
|
||||
}) ?? 0 })}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Separator class="my-3" />
|
||||
|
||||
<Tabs defaultValue="info" class="w-full" value="content">
|
||||
<Tabs value={getTab()} onChange={setTab} class="w-full">
|
||||
<TabsList class="w-full h-8">
|
||||
<TabsTrigger value="info">Info</TabsTrigger>
|
||||
<TabsTrigger value="content">Content</TabsTrigger>
|
||||
<TabsTrigger value="info">{t('documents.tabs.info')}</TabsTrigger>
|
||||
<TabsTrigger value="content">{t('documents.tabs.content')}</TabsTrigger>
|
||||
<TabsTrigger value="activity">{t('documents.tabs.activity')}</TabsTrigger>
|
||||
<TabsIndicator />
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="info">
|
||||
<KeyValues data={[
|
||||
{
|
||||
label: 'ID',
|
||||
label: t('documents.info.id'),
|
||||
value: getDocument().id,
|
||||
icon: 'i-tabler-id',
|
||||
},
|
||||
{
|
||||
label: 'Name',
|
||||
value: getDocument().name,
|
||||
label: t('documents.info.name'),
|
||||
value: (
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="flex items-center gap-2 group bg-transparent! p-0 h-auto"
|
||||
onClick={() => openRenameDialog({
|
||||
documentId: getDocument().id,
|
||||
organizationId: params.organizationId,
|
||||
documentName: getDocument().name,
|
||||
})}
|
||||
>
|
||||
{getDocument().name}
|
||||
|
||||
<div class="i-tabler-pencil size-4 text-muted-foreground group-hover:text-foreground transition-colors"></div>
|
||||
</Button>
|
||||
),
|
||||
icon: 'i-tabler-file-text',
|
||||
},
|
||||
{
|
||||
label: 'Type',
|
||||
label: t('documents.info.type'),
|
||||
value: getDocument().mimeType,
|
||||
icon: 'i-tabler-file-unknown',
|
||||
},
|
||||
{
|
||||
label: 'Size',
|
||||
label: t('documents.info.size'),
|
||||
value: formatBytes({ bytes: getDocument().originalSize, base: 1000 }),
|
||||
icon: 'i-tabler-weight',
|
||||
},
|
||||
{
|
||||
label: 'Created At',
|
||||
label: t('documents.info.created-at'),
|
||||
value: timeAgo({ date: getDocument().createdAt }),
|
||||
icon: 'i-tabler-calendar',
|
||||
},
|
||||
{
|
||||
label: 'Updated At',
|
||||
value: getDocument().updatedAt ? timeAgo({ date: getDocument().updatedAt! }) : <span class="text-muted-foreground">Never</span>,
|
||||
label: t('documents.info.updated-at'),
|
||||
value: getDocument().updatedAt ? timeAgo({ date: getDocument().updatedAt! }) : <span class="text-muted-foreground">{t('documents.info.never')}</span>,
|
||||
icon: 'i-tabler-calendar',
|
||||
},
|
||||
]}
|
||||
@@ -284,14 +402,14 @@ export const DocumentPage: Component = () => {
|
||||
<div class="flex justify-end">
|
||||
<Button variant="outline" onClick={handleEdit}>
|
||||
<div class="i-tabler-edit size-4 mr-2" />
|
||||
Edit
|
||||
{t('documents.actions.edit')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Alert variant="muted" class="my-4 flex items-center gap-2">
|
||||
<div class="i-tabler-info-circle size-8 flex-shrink-0" />
|
||||
<AlertDescription>
|
||||
The content of the document is automatically extracted from the document on upload. It is only used for search and indexing purposes.
|
||||
{t('documents.content.alert')}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
@@ -307,15 +425,49 @@ export const DocumentPage: Component = () => {
|
||||
</TextFieldRoot>
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={handleCancel} disabled={isSaving()}>
|
||||
Cancel
|
||||
{t('documents.actions.cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={isSaving()}>
|
||||
{isSaving() ? 'Saving...' : 'Save'}
|
||||
{isSaving() ? t('documents.actions.saving') : t('documents.actions.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</TabsContent>
|
||||
<TabsContent value="activity">
|
||||
<Show when={activityQuery.data?.pages}>
|
||||
{getActivitiesPages => (
|
||||
<div class="flex flex-col">
|
||||
<For each={getActivitiesPages() ?? []}>
|
||||
{activities => (
|
||||
<For each={activities}>
|
||||
{activity => (
|
||||
<ActivityItem activity={activity} />
|
||||
)}
|
||||
</For>
|
||||
)}
|
||||
</For>
|
||||
|
||||
<Show
|
||||
when={activityQuery.hasNextPage}
|
||||
fallback={(
|
||||
<div class="text-sm text-muted-foreground text-center py-4">
|
||||
{t('activity.no-more-activities')}
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => activityQuery.fetchNextPage()}
|
||||
isLoading={activityQuery.isFetchingNextPage}
|
||||
>
|
||||
{t('activity.load-more')}
|
||||
</Button>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import { fetchOrganization } from '@/modules/organizations/organizations.services';
|
||||
import { Tag } from '@/modules/tags/components/tag.component';
|
||||
import { fetchTags } from '@/modules/tags/tags.services';
|
||||
import type { Component } from 'solid-js';
|
||||
import { useParams, useSearchParams } from '@solidjs/router';
|
||||
import { createQueries, keepPreviousData } from '@tanstack/solid-query';
|
||||
import { castArray } from 'lodash-es';
|
||||
import { type Component, createSignal, For, Show, Suspense } from 'solid-js';
|
||||
import { createSignal, For, Show, Suspense } from 'solid-js';
|
||||
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||
import { fetchOrganization } from '@/modules/organizations/organizations.services';
|
||||
import { Tag } from '@/modules/tags/components/tag.component';
|
||||
import { fetchTags } from '@/modules/tags/tags.services';
|
||||
import { DocumentUploadArea } from '../components/document-upload-area.component';
|
||||
import { createdAtColumn, DocumentsPaginatedList, standardActionsColumn, tagsColumn } from '../components/documents-list.component';
|
||||
import { fetchOrganizationDocuments } from '../documents.services';
|
||||
|
||||
export const DocumentsPage: Component = () => {
|
||||
const params = useParams();
|
||||
const { t } = useI18n();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [getPagination, setPagination] = createSignal({ pageIndex: 0, pageSize: 100 });
|
||||
|
||||
@@ -50,11 +53,11 @@ export const DocumentsPage: Component = () => {
|
||||
? (
|
||||
<>
|
||||
<h2 class="text-xl font-bold ">
|
||||
No documents
|
||||
{t('documents.list.no-documents.title')}
|
||||
</h2>
|
||||
|
||||
<p class="text-muted-foreground mt-1 mb-6">
|
||||
There are no documents in this organization yet. Start by uploading some documents.
|
||||
{t('documents.list.no-documents.description')}
|
||||
</p>
|
||||
|
||||
<DocumentUploadArea />
|
||||
@@ -64,7 +67,7 @@ export const DocumentsPage: Component = () => {
|
||||
: (
|
||||
<>
|
||||
<h2 class="text-lg font-semibold mb-4">
|
||||
Documents
|
||||
{t('documents.list.title')}
|
||||
</h2>
|
||||
<Show when={hasFilters()}>
|
||||
<div class="flex flex-wrap gap-2 mb-4">
|
||||
@@ -82,7 +85,7 @@ export const DocumentsPage: Component = () => {
|
||||
|
||||
<Show when={hasFilters() && query[0].data?.documentsCount === 0}>
|
||||
<p class="text-muted-foreground mt-1 mb-6">
|
||||
No documents found
|
||||
{t('documents.list.no-results')}
|
||||
</p>
|
||||
</Show>
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { glob } from 'tinyglobby';
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
const rawLocales = import.meta.glob('../../locales/*.yml', { eager: true });
|
||||
@@ -28,4 +30,41 @@ describe('locales', () => {
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test('all keys in en.yml must be used in the app (dynamic keys are manually excluded)', async () => {
|
||||
const srcFileNames = await glob(['src/**/*.{ts,tsx}', '!src/**/*.test.*', '!src/modules/i18n/locales.types.ts'], { cwd: process.cwd() });
|
||||
|
||||
// Exclude keys that are used in dynamic contexts
|
||||
const dynamicKeysMatchers = [
|
||||
/^api-errors\./, // api-errors.document.already_exists
|
||||
/^auth\.register\.providers\.[a-z0-9:]+$/, // auth.register.providers.google
|
||||
/^webhooks\.events\.documents\.[a-z0-9:]+.description$/, // webhooks.events.organization.organization:created
|
||||
/^api-keys\.permissions\.[a-z0-9:]+\.[a-z0-9:]+$/, // api-keys.permissions.documents.documents:delete
|
||||
/^organizations\.members\.roles\.[a-z0-9]+$/, // organizations.members.roles.admin
|
||||
/^activity\.document\.[a-z0-9:]+$/, // activity.document.created
|
||||
/^organizations\.invitations\.status\.[a-z0-9:]+$/, // organizations.invitations.status.pending
|
||||
];
|
||||
|
||||
const keys = new Set(
|
||||
Object
|
||||
.keys(defaultLocal)
|
||||
.filter(key => !dynamicKeysMatchers.some(matcher => matcher.test(key))),
|
||||
);
|
||||
|
||||
for (const srcFileName of srcFileNames) {
|
||||
const fileContent = await readFile(srcFileName, 'utf-8');
|
||||
|
||||
for (const key of keys) {
|
||||
if (fileContent.includes(key)) {
|
||||
keys.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
if (keys.size === 0) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
expect([...keys]).to.eql([], 'Unused keys found in en.yml, please remove them (or add them to the dynamic keys matchers in locales.test.ts if they are used in dynamic contexts)');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -68,19 +68,223 @@ export type LocaleKeys =
|
||||
| 'auth.legal-links.description'
|
||||
| 'auth.legal-links.terms'
|
||||
| 'auth.legal-links.privacy'
|
||||
| 'user.settings.title'
|
||||
| 'user.settings.description'
|
||||
| 'user.settings.email.title'
|
||||
| 'user.settings.email.description'
|
||||
| 'user.settings.email.label'
|
||||
| 'user.settings.name.title'
|
||||
| 'user.settings.name.description'
|
||||
| 'user.settings.name.label'
|
||||
| 'user.settings.name.placeholder'
|
||||
| 'user.settings.name.update'
|
||||
| 'user.settings.name.updated'
|
||||
| 'user.settings.logout.title'
|
||||
| 'user.settings.logout.description'
|
||||
| 'user.settings.logout.button'
|
||||
| 'organizations.list.title'
|
||||
| 'organizations.list.description'
|
||||
| 'organizations.list.create-new'
|
||||
| 'organizations.details.no-documents.title'
|
||||
| 'organizations.details.no-documents.description'
|
||||
| 'organizations.details.upload-documents'
|
||||
| 'organizations.details.documents-count'
|
||||
| 'organizations.details.total-size'
|
||||
| 'organizations.details.latest-documents'
|
||||
| 'organizations.create.title'
|
||||
| 'organizations.create.description'
|
||||
| 'organizations.create.back'
|
||||
| 'organizations.create.error.max-count-reached'
|
||||
| 'organizations.create.form.name.label'
|
||||
| 'organizations.create.form.name.placeholder'
|
||||
| 'organizations.create.form.name.required'
|
||||
| 'organizations.create.form.submit'
|
||||
| 'organizations.create.success'
|
||||
| 'organizations.create-first.title'
|
||||
| 'organizations.create-first.description'
|
||||
| 'organizations.create-first.default-name'
|
||||
| 'organizations.create-first.user-name'
|
||||
| 'organization.settings.title'
|
||||
| 'organization.settings.page.title'
|
||||
| 'organization.settings.page.description'
|
||||
| 'organization.settings.name.title'
|
||||
| 'organization.settings.name.update'
|
||||
| 'organization.settings.name.placeholder'
|
||||
| 'organization.settings.name.updated'
|
||||
| 'organization.settings.subscription.title'
|
||||
| 'organization.settings.subscription.description'
|
||||
| 'organization.settings.subscription.manage'
|
||||
| 'organization.settings.subscription.error'
|
||||
| 'organization.settings.delete.title'
|
||||
| 'organization.settings.delete.description'
|
||||
| 'organization.settings.delete.confirm.title'
|
||||
| 'organization.settings.delete.confirm.message'
|
||||
| 'organization.settings.delete.confirm.confirm-button'
|
||||
| 'organization.settings.delete.confirm.cancel-button'
|
||||
| 'organization.settings.delete.success'
|
||||
| 'organizations.members.title'
|
||||
| 'organizations.members.description'
|
||||
| 'organizations.members.invite-member'
|
||||
| 'organizations.members.invite-member-disabled-tooltip'
|
||||
| 'organizations.members.remove-from-organization'
|
||||
| 'organizations.members.role'
|
||||
| 'organizations.members.roles.owner'
|
||||
| 'organizations.members.roles.admin'
|
||||
| 'organizations.members.roles.member'
|
||||
| 'organizations.members.delete.confirm.title'
|
||||
| 'organizations.members.delete.confirm.message'
|
||||
| 'organizations.members.delete.confirm.confirm-button'
|
||||
| 'organizations.members.delete.confirm.cancel-button'
|
||||
| 'organizations.members.delete.success'
|
||||
| 'organizations.members.update-role.success'
|
||||
| 'organizations.members.table.headers.name'
|
||||
| 'organizations.members.table.headers.email'
|
||||
| 'organizations.members.table.headers.role'
|
||||
| 'organizations.members.table.headers.created'
|
||||
| 'organizations.members.table.headers.actions'
|
||||
| 'organizations.invite-member.title'
|
||||
| 'organizations.invite-member.description'
|
||||
| 'organizations.invite-member.form.email.label'
|
||||
| 'organizations.invite-member.form.email.placeholder'
|
||||
| 'organizations.invite-member.form.email.required'
|
||||
| 'organizations.invite-member.form.role.label'
|
||||
| 'organizations.invite-member.form.submit'
|
||||
| 'organizations.invite-member.success.message'
|
||||
| 'organizations.invite-member.success.description'
|
||||
| 'organizations.invite-member.error.message'
|
||||
| 'organizations.invitations.title'
|
||||
| 'organizations.invitations.description'
|
||||
| 'organizations.invitations.list.cta'
|
||||
| 'organizations.invitations.list.empty.title'
|
||||
| 'organizations.invitations.list.empty.description'
|
||||
| 'organizations.invitations.status.pending'
|
||||
| 'organizations.invitations.status.accepted'
|
||||
| 'organizations.invitations.status.rejected'
|
||||
| 'organizations.invitations.status.expired'
|
||||
| 'organizations.invitations.status.cancelled'
|
||||
| 'organizations.invitations.resend'
|
||||
| 'organizations.invitations.cancel.title'
|
||||
| 'organizations.invitations.cancel.description'
|
||||
| 'organizations.invitations.cancel.confirm'
|
||||
| 'organizations.invitations.cancel.cancel'
|
||||
| 'organizations.invitations.resend.title'
|
||||
| 'organizations.invitations.resend.description'
|
||||
| 'organizations.invitations.resend.confirm'
|
||||
| 'organizations.invitations.resend.cancel'
|
||||
| 'invitations.list.title'
|
||||
| 'invitations.list.description'
|
||||
| 'invitations.list.empty.title'
|
||||
| 'invitations.list.empty.description'
|
||||
| 'invitations.list.headers.organization'
|
||||
| 'invitations.list.headers.status'
|
||||
| 'invitations.list.headers.created'
|
||||
| 'invitations.list.headers.actions'
|
||||
| 'invitations.list.actions.accept'
|
||||
| 'invitations.list.actions.reject'
|
||||
| 'invitations.list.actions.accept.success.message'
|
||||
| 'invitations.list.actions.accept.success.description'
|
||||
| 'invitations.list.actions.reject.success.message'
|
||||
| 'invitations.list.actions.reject.success.description'
|
||||
| 'documents.list.title'
|
||||
| 'documents.list.no-documents.title'
|
||||
| 'documents.list.no-documents.description'
|
||||
| 'documents.list.no-results'
|
||||
| 'documents.tabs.info'
|
||||
| 'documents.tabs.content'
|
||||
| 'documents.tabs.activity'
|
||||
| 'documents.deleted.message'
|
||||
| 'documents.actions.download'
|
||||
| 'documents.actions.open-in-new-tab'
|
||||
| 'documents.actions.restore'
|
||||
| 'documents.actions.delete'
|
||||
| 'documents.actions.edit'
|
||||
| 'documents.actions.cancel'
|
||||
| 'documents.actions.save'
|
||||
| 'documents.actions.saving'
|
||||
| 'documents.content.alert'
|
||||
| 'documents.info.id'
|
||||
| 'documents.info.name'
|
||||
| 'documents.info.type'
|
||||
| 'documents.info.size'
|
||||
| 'documents.info.created-at'
|
||||
| 'documents.info.updated-at'
|
||||
| 'documents.info.never'
|
||||
| 'documents.rename.title'
|
||||
| 'documents.rename.form.name.label'
|
||||
| 'documents.rename.form.name.placeholder'
|
||||
| 'documents.rename.form.name.required'
|
||||
| 'documents.rename.form.name.max-length'
|
||||
| 'documents.rename.form.submit'
|
||||
| 'documents.rename.success'
|
||||
| 'documents.rename.cancel'
|
||||
| 'import-documents.title.error'
|
||||
| 'import-documents.title.success'
|
||||
| 'import-documents.title.pending'
|
||||
| 'import-documents.title.none'
|
||||
| 'import-documents.no-import-in-progress'
|
||||
| 'documents.deleted.title'
|
||||
| 'documents.deleted.empty.title'
|
||||
| 'documents.deleted.empty.description'
|
||||
| 'documents.deleted.retention-notice'
|
||||
| 'documents.deleted.deleted-at'
|
||||
| 'documents.deleted.restoring'
|
||||
| 'documents.deleted.deleting'
|
||||
| 'trash.delete-all.button'
|
||||
| 'trash.delete-all.confirm.title'
|
||||
| 'trash.delete-all.confirm.description'
|
||||
| 'trash.delete-all.confirm.label'
|
||||
| 'trash.delete-all.confirm.cancel'
|
||||
| 'trash.delete.button'
|
||||
| 'trash.delete.confirm.title'
|
||||
| 'trash.delete.confirm.description'
|
||||
| 'trash.delete.confirm.label'
|
||||
| 'trash.delete.confirm.cancel'
|
||||
| 'trash.deleted.success.title'
|
||||
| 'trash.deleted.success.description'
|
||||
| 'activity.document.created'
|
||||
| 'activity.document.updated.single'
|
||||
| 'activity.document.updated.multiple'
|
||||
| 'activity.document.updated'
|
||||
| 'activity.document.deleted'
|
||||
| 'activity.document.restored'
|
||||
| 'activity.document.tagged'
|
||||
| 'activity.document.untagged'
|
||||
| 'activity.document.user.name'
|
||||
| 'activity.load-more'
|
||||
| 'activity.no-more-activities'
|
||||
| 'tags.no-tags.title'
|
||||
| 'tags.no-tags.description'
|
||||
| 'tags.no-tags.create-tag'
|
||||
| 'layout.menu.home'
|
||||
| 'layout.menu.documents'
|
||||
| 'layout.menu.tags'
|
||||
| 'layout.menu.tagging-rules'
|
||||
| 'layout.menu.integrations'
|
||||
| 'layout.menu.deleted-documents'
|
||||
| 'layout.menu.organization-settings'
|
||||
| 'layout.menu.api-keys'
|
||||
| 'layout.menu.settings'
|
||||
| 'layout.menu.account'
|
||||
| 'tags.title'
|
||||
| 'tags.description'
|
||||
| 'tags.create'
|
||||
| 'tags.update'
|
||||
| 'tags.delete'
|
||||
| 'tags.delete.confirm.title'
|
||||
| 'tags.delete.confirm.message'
|
||||
| 'tags.delete.confirm.confirm-button'
|
||||
| 'tags.delete.confirm.cancel-button'
|
||||
| 'tags.delete.success'
|
||||
| 'tags.create.success'
|
||||
| 'tags.update.success'
|
||||
| 'tags.form.name.label'
|
||||
| 'tags.form.name.placeholder'
|
||||
| 'tags.form.name.required'
|
||||
| 'tags.form.name.max-length'
|
||||
| 'tags.form.color.label'
|
||||
| 'tags.form.color.placeholder'
|
||||
| 'tags.form.color.required'
|
||||
| 'tags.form.color.invalid'
|
||||
| 'tags.form.description.label'
|
||||
| 'tags.form.description.optional'
|
||||
| 'tags.form.description.placeholder'
|
||||
| 'tags.form.description.max-length'
|
||||
| 'tags.form.no-description'
|
||||
| 'tags.table.headers.tag'
|
||||
| 'tags.table.headers.description'
|
||||
| 'tags.table.headers.documents'
|
||||
| 'tags.table.headers.created'
|
||||
| 'tags.table.headers.actions'
|
||||
| 'tagging-rules.field.name'
|
||||
| 'tagging-rules.field.content'
|
||||
| 'tagging-rules.operator.equals'
|
||||
@@ -118,9 +322,6 @@ export type LocaleKeys =
|
||||
| 'tagging-rules.form.conditions.no-conditions.description'
|
||||
| 'tagging-rules.form.conditions.no-conditions.confirm'
|
||||
| 'tagging-rules.form.conditions.no-conditions.cancel'
|
||||
| 'tagging-rules.form.conditions.field.label'
|
||||
| 'tagging-rules.form.conditions.operator.label'
|
||||
| 'tagging-rules.form.conditions.value.label'
|
||||
| 'tagging-rules.form.conditions.value.placeholder'
|
||||
| 'tagging-rules.form.conditions.value.min-length'
|
||||
| 'tagging-rules.form.tags.label'
|
||||
@@ -129,37 +330,41 @@ export type LocaleKeys =
|
||||
| 'tagging-rules.form.tags.add-tag'
|
||||
| 'tagging-rules.form.submit'
|
||||
| 'tagging-rules.update.title'
|
||||
| 'tagging-rules.update.success'
|
||||
| 'tagging-rules.update.error'
|
||||
| 'tagging-rules.update.submit'
|
||||
| 'tagging-rules.update.cancel'
|
||||
| 'demo.popup.description'
|
||||
| 'demo.popup.discord'
|
||||
| 'demo.popup.discord-link-label'
|
||||
| 'demo.popup.reset'
|
||||
| 'demo.popup.hide'
|
||||
| 'trash.delete-all.button'
|
||||
| 'trash.delete-all.confirm.title'
|
||||
| 'trash.delete-all.confirm.description'
|
||||
| 'trash.delete-all.confirm.label'
|
||||
| 'trash.delete-all.confirm.cancel'
|
||||
| 'trash.delete.button'
|
||||
| 'trash.delete.confirm.title'
|
||||
| 'trash.delete.confirm.description'
|
||||
| 'trash.delete.confirm.label'
|
||||
| 'trash.delete.confirm.cancel'
|
||||
| 'trash.deleted.success.title'
|
||||
| 'trash.deleted.success.description'
|
||||
| 'import-documents.title.error'
|
||||
| 'import-documents.title.success'
|
||||
| 'import-documents.title.pending'
|
||||
| 'import-documents.title.none'
|
||||
| 'import-documents.no-import-in-progress'
|
||||
| 'api-errors.document.already_exists'
|
||||
| 'api-errors.document.file_too_big'
|
||||
| 'api-errors.intake_email.limit_reached'
|
||||
| 'api-errors.user.max_organization_count_reached'
|
||||
| 'api-errors.default'
|
||||
| 'intake-emails.title'
|
||||
| 'intake-emails.description'
|
||||
| 'intake-emails.disabled.title'
|
||||
| 'intake-emails.disabled.description'
|
||||
| 'intake-emails.disabled.documentation'
|
||||
| 'intake-emails.info'
|
||||
| 'intake-emails.empty.title'
|
||||
| 'intake-emails.empty.description'
|
||||
| 'intake-emails.empty.generate'
|
||||
| 'intake-emails.count'
|
||||
| 'intake-emails.new'
|
||||
| 'intake-emails.disabled-label'
|
||||
| 'intake-emails.no-origins'
|
||||
| 'intake-emails.allowed-origins'
|
||||
| 'intake-emails.actions.enable'
|
||||
| 'intake-emails.actions.disable'
|
||||
| 'intake-emails.actions.manage-origins'
|
||||
| 'intake-emails.actions.delete'
|
||||
| 'intake-emails.delete.confirm.title'
|
||||
| 'intake-emails.delete.confirm.message'
|
||||
| 'intake-emails.delete.confirm.confirm-button'
|
||||
| 'intake-emails.delete.confirm.cancel-button'
|
||||
| 'intake-emails.delete.success'
|
||||
| 'intake-emails.create.success'
|
||||
| 'intake-emails.update.success.enabled'
|
||||
| 'intake-emails.update.success.disabled'
|
||||
| 'intake-emails.allowed-origins.title'
|
||||
| 'intake-emails.allowed-origins.description'
|
||||
| 'intake-emails.allowed-origins.add.label'
|
||||
| 'intake-emails.allowed-origins.add.placeholder'
|
||||
| 'intake-emails.allowed-origins.add.button'
|
||||
| 'intake-emails.allowed-origins.add.error.exists'
|
||||
| 'api-keys.permissions.documents.title'
|
||||
| 'api-keys.permissions.documents.documents:create'
|
||||
| 'api-keys.permissions.documents.documents:read'
|
||||
@@ -178,23 +383,105 @@ export type LocaleKeys =
|
||||
| 'api-keys.create.form.name.placeholder'
|
||||
| 'api-keys.create.form.name.required'
|
||||
| 'api-keys.create.form.permissions.label'
|
||||
| 'api-keys.create.form.permissions.description'
|
||||
| 'api-keys.create.form.permissions.required'
|
||||
| 'api-keys.create.form.submit'
|
||||
| 'api-keys.create.created.title'
|
||||
| 'api-keys.create.created.description'
|
||||
| 'api-keys.list.title'
|
||||
| 'api-keys.list.description'
|
||||
| 'api-keys.list.delete'
|
||||
| 'api-keys.list.create'
|
||||
| 'api-keys.list.empty.title'
|
||||
| 'api-keys.list.empty.description'
|
||||
| 'api-keys.list.card.last-used'
|
||||
| 'api-keys.list.card.never'
|
||||
| 'api-keys.list.card.created'
|
||||
| 'api-keys.list.card.delete'
|
||||
| 'api-keys.delete.success'
|
||||
| 'api-keys.delete.confirm.title'
|
||||
| 'api-keys.delete.confirm.message'
|
||||
| 'api-keys.delete.confirm.confirm-button'
|
||||
| 'api-keys.delete.confirm.cancel-button';
|
||||
| 'api-keys.delete.confirm.cancel-button'
|
||||
| 'webhooks.list.title'
|
||||
| 'webhooks.list.description'
|
||||
| 'webhooks.list.empty.title'
|
||||
| 'webhooks.list.empty.description'
|
||||
| 'webhooks.list.create'
|
||||
| 'webhooks.list.card.last-triggered'
|
||||
| 'webhooks.list.card.never'
|
||||
| 'webhooks.list.card.created'
|
||||
| 'webhooks.create.title'
|
||||
| 'webhooks.create.description'
|
||||
| 'webhooks.create.success'
|
||||
| 'webhooks.create.back'
|
||||
| 'webhooks.create.form.submit'
|
||||
| 'webhooks.create.form.name.label'
|
||||
| 'webhooks.create.form.name.placeholder'
|
||||
| 'webhooks.create.form.name.required'
|
||||
| 'webhooks.create.form.url.label'
|
||||
| 'webhooks.create.form.url.placeholder'
|
||||
| 'webhooks.create.form.url.required'
|
||||
| 'webhooks.create.form.url.invalid'
|
||||
| 'webhooks.create.form.secret.label'
|
||||
| 'webhooks.create.form.secret.placeholder'
|
||||
| 'webhooks.create.form.events.label'
|
||||
| 'webhooks.create.form.events.required'
|
||||
| 'webhooks.update.title'
|
||||
| 'webhooks.update.description'
|
||||
| 'webhooks.update.success'
|
||||
| 'webhooks.update.submit'
|
||||
| 'webhooks.update.cancel'
|
||||
| 'webhooks.update.form.secret.placeholder'
|
||||
| 'webhooks.update.form.secret.placeholder-redacted'
|
||||
| 'webhooks.update.form.rotate-secret.button'
|
||||
| 'webhooks.delete.success'
|
||||
| 'webhooks.delete.confirm.title'
|
||||
| 'webhooks.delete.confirm.message'
|
||||
| 'webhooks.delete.confirm.confirm-button'
|
||||
| 'webhooks.delete.confirm.cancel-button'
|
||||
| 'webhooks.events.documents.document:created.description'
|
||||
| 'webhooks.events.documents.document:deleted.description'
|
||||
| 'layout.menu.home'
|
||||
| 'layout.menu.documents'
|
||||
| 'layout.menu.tags'
|
||||
| 'layout.menu.tagging-rules'
|
||||
| 'layout.menu.deleted-documents'
|
||||
| 'layout.menu.organization-settings'
|
||||
| 'layout.menu.api-keys'
|
||||
| 'layout.menu.settings'
|
||||
| 'layout.menu.account'
|
||||
| 'layout.menu.general-settings'
|
||||
| 'layout.menu.intake-emails'
|
||||
| 'layout.menu.webhooks'
|
||||
| 'layout.menu.members'
|
||||
| 'layout.menu.invitations'
|
||||
| 'layout.theme.light'
|
||||
| 'layout.theme.dark'
|
||||
| 'layout.theme.system'
|
||||
| 'layout.search.placeholder'
|
||||
| 'layout.menu.import-document'
|
||||
| 'user-menu.account-settings'
|
||||
| 'user-menu.api-keys'
|
||||
| 'user-menu.invitations'
|
||||
| 'user-menu.language'
|
||||
| 'user-menu.logout'
|
||||
| 'command-palette.search.placeholder'
|
||||
| 'command-palette.no-results'
|
||||
| 'command-palette.sections.documents'
|
||||
| 'command-palette.sections.theme'
|
||||
| 'api-errors.document.already_exists'
|
||||
| 'api-errors.document.file_too_big'
|
||||
| 'api-errors.intake_email.limit_reached'
|
||||
| 'api-errors.user.max_organization_count_reached'
|
||||
| 'api-errors.default'
|
||||
| 'api-errors.organization.invitation_already_exists'
|
||||
| 'api-errors.user.already_in_organization'
|
||||
| 'api-errors.user.organization_invitation_limit_reached'
|
||||
| 'api-errors.demo.not_available'
|
||||
| 'api-errors.tags.already_exists'
|
||||
| 'not-found.title'
|
||||
| 'not-found.description'
|
||||
| 'not-found.back-to-home'
|
||||
| 'demo.popup.description'
|
||||
| 'demo.popup.discord'
|
||||
| 'demo.popup.discord-link-label'
|
||||
| 'demo.popup.reset'
|
||||
| 'demo.popup.hide';
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import type { DialogTriggerProps } from '@kobalte/core/dialog';
|
||||
import type { Component, JSX } from 'solid-js';
|
||||
import type { IntakeEmail } from '../intake-emails.types';
|
||||
import { safely } from '@corentinth/chisels';
|
||||
import { useParams } from '@solidjs/router';
|
||||
import { useQuery } from '@tanstack/solid-query';
|
||||
import { createSignal, For, Show, Suspense } from 'solid-js';
|
||||
import * as v from 'valibot';
|
||||
import { useConfig } from '@/modules/config/config.provider';
|
||||
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||
import { useConfirmModal } from '@/modules/shared/confirm';
|
||||
import { createForm } from '@/modules/shared/form/form';
|
||||
import { isHttpErrorWithCode } from '@/modules/shared/http/http-errors';
|
||||
@@ -13,16 +20,11 @@ import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, Di
|
||||
import { EmptyState } from '@/modules/ui/components/empty';
|
||||
import { createToast } from '@/modules/ui/components/sonner';
|
||||
import { TextField, TextFieldLabel, TextFieldRoot } from '@/modules/ui/components/textfield';
|
||||
import { safely } from '@corentinth/chisels';
|
||||
import { useParams } from '@solidjs/router';
|
||||
import { createQuery } from '@tanstack/solid-query';
|
||||
import { type Component, For, type JSX, Show, Suspense } from 'solid-js';
|
||||
import { createSignal } from 'solid-js';
|
||||
import * as v from 'valibot';
|
||||
import { createIntakeEmail, deleteIntakeEmail, fetchIntakeEmails, updateIntakeEmail } from '../intake-emails.services';
|
||||
|
||||
const AllowedOriginsDialog: Component<{ children: (props: DialogTriggerProps) => JSX.Element; intakeEmails: IntakeEmail }> = (props) => {
|
||||
const [getAllowedOrigins, setAllowedOrigins] = createSignal([...props.intakeEmails.allowedOrigins]);
|
||||
const { t } = useI18n();
|
||||
|
||||
const update = async () => {
|
||||
await updateIntakeEmail({
|
||||
@@ -47,7 +49,7 @@ const AllowedOriginsDialog: Component<{ children: (props: DialogTriggerProps) =>
|
||||
}),
|
||||
onSubmit: async ({ email }) => {
|
||||
if (getAllowedOrigins().includes(email)) {
|
||||
throw new Error('This email is already in the allowed origins for this intake email');
|
||||
throw new Error(t('intake-emails.allowed-origins.add.error.exists'));
|
||||
}
|
||||
|
||||
setAllowedOrigins(origins => [...origins, email]);
|
||||
@@ -67,13 +69,9 @@ const AllowedOriginsDialog: Component<{ children: (props: DialogTriggerProps) =>
|
||||
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Allowed origins</DialogTitle>
|
||||
<DialogTitle>{t('intake-emails.allowed-origins.title')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Only emails sent to
|
||||
{' '}
|
||||
<span class="font-medium text-primary">{props.intakeEmails.emailAddress}</span>
|
||||
{' '}
|
||||
from these origins will be processed. If no origins are specified, all emails will be discarded.
|
||||
{t('intake-emails.allowed-origins.description', { email: props.intakeEmails.emailAddress })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -81,13 +79,13 @@ const AllowedOriginsDialog: Component<{ children: (props: DialogTriggerProps) =>
|
||||
<Field name="email">
|
||||
{(field, inputProps) => (
|
||||
<TextFieldRoot class="flex flex-col gap-1 mb-4 mt-4">
|
||||
<TextFieldLabel for="email">Add allowed origin email</TextFieldLabel>
|
||||
<TextFieldLabel for="email">{t('intake-emails.allowed-origins.add.label')}</TextFieldLabel>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<TextField type="email" id="email" placeholder="Eg. ada@papra.app" {...inputProps} autoFocus value={field.value} aria-invalid={Boolean(field.error)} />
|
||||
<TextField type="email" id="email" placeholder={t('intake-emails.allowed-origins.add.placeholder')} {...inputProps} autoFocus value={field.value} aria-invalid={Boolean(field.error)} />
|
||||
<Button type="submit">
|
||||
<div class="i-tabler-plus size-4 mr-2" />
|
||||
Add
|
||||
{t('intake-emails.allowed-origins.add.button')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -130,22 +128,39 @@ const AllowedOriginsDialog: Component<{ children: (props: DialogTriggerProps) =>
|
||||
|
||||
export const IntakeEmailsPage: Component = () => {
|
||||
const { config } = useConfig();
|
||||
const { t, te } = useI18n();
|
||||
|
||||
if (!config.intakeEmails.isEnabled) {
|
||||
return (
|
||||
<Card class="p-6">
|
||||
<h2 class="text-base font-bold">Intake Emails</h2>
|
||||
<div class="p-6 max-w-screen-md mx-auto mt-10">
|
||||
<h1 class="text-xl font-semibold">{t('intake-emails.title')}</h1>
|
||||
|
||||
<p class="text-muted-foreground mt-1">
|
||||
Intake emails are disabled on this instance. Please contact your administrator to enable them.
|
||||
{t('intake-emails.description')}
|
||||
</p>
|
||||
</Card>
|
||||
<Card class="px-6 py-4 mt-4 flex items-center gap-4">
|
||||
<div class="i-tabler-mail-off size-12 text-muted-foreground flex-shrink-0" />
|
||||
<div>
|
||||
<h2 class="text-base font-bold text-muted-foreground">{t('intake-emails.disabled.title')}</h2>
|
||||
<p class="text-muted-foreground mt-1">
|
||||
{te('intake-emails.disabled.description', {
|
||||
documentation: (
|
||||
<a href="https://docs.papra.app/guides/intake-emails-with-owlrelay/" target="_blank" class="text-primary">
|
||||
{t('intake-emails.disabled.documentation')}
|
||||
</a>
|
||||
),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const params = useParams();
|
||||
const { confirm } = useConfirmModal();
|
||||
|
||||
const query = createQuery(() => ({
|
||||
const query = useQuery(() => ({
|
||||
queryKey: ['organizations', params.organizationId, 'intake-emails'],
|
||||
queryFn: () => fetchIntakeEmails({ organizationId: params.organizationId }),
|
||||
}));
|
||||
@@ -155,7 +170,7 @@ export const IntakeEmailsPage: Component = () => {
|
||||
|
||||
if (isHttpErrorWithCode({ error, code: 'intake_email.limit_reached' })) {
|
||||
createToast({
|
||||
message: 'The maximum number of intake emails for this organization has been reached. Please upgrade your plan to create more intake emails.',
|
||||
message: t('api-errors.intake_email.limit_reached'),
|
||||
type: 'error',
|
||||
});
|
||||
|
||||
@@ -169,20 +184,20 @@ export const IntakeEmailsPage: Component = () => {
|
||||
await query.refetch();
|
||||
|
||||
createToast({
|
||||
message: 'Intake email created',
|
||||
message: t('intake-emails.create.success'),
|
||||
type: 'success',
|
||||
});
|
||||
};
|
||||
|
||||
const deleteEmail = async ({ intakeEmailId }: { intakeEmailId: string }) => {
|
||||
const confirmed = await confirm({
|
||||
title: 'Delete intake email?',
|
||||
message: 'Are you sure you want to delete this intake email? This action cannot be undone.',
|
||||
title: t('intake-emails.delete.confirm.title'),
|
||||
message: t('intake-emails.delete.confirm.message'),
|
||||
cancelButton: {
|
||||
text: 'Cancel',
|
||||
text: t('intake-emails.delete.confirm.cancel-button'),
|
||||
},
|
||||
confirmButton: {
|
||||
text: 'Delete intake email',
|
||||
text: t('intake-emails.delete.confirm.confirm-button'),
|
||||
variant: 'destructive',
|
||||
},
|
||||
});
|
||||
@@ -195,7 +210,7 @@ export const IntakeEmailsPage: Component = () => {
|
||||
await query.refetch();
|
||||
|
||||
createToast({
|
||||
message: 'Intake email deleted',
|
||||
message: t('intake-emails.delete.success'),
|
||||
type: 'success',
|
||||
});
|
||||
};
|
||||
@@ -205,27 +220,25 @@ export const IntakeEmailsPage: Component = () => {
|
||||
await query.refetch();
|
||||
|
||||
createToast({
|
||||
message: `Intake email ${isEnabled ? 'enabled' : 'disabled'}`,
|
||||
message: isEnabled ? t('intake-emails.update.success.enabled') : t('intake-emails.update.success.disabled'),
|
||||
type: 'success',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Card class="p-6">
|
||||
|
||||
<h2 class="text-base font-bold">Intake Emails</h2>
|
||||
<div class="p-6 max-w-screen-md mx-auto mt-10">
|
||||
<h1 class="text-xl font-semibold">{t('intake-emails.title')}</h1>
|
||||
|
||||
<p class="text-muted-foreground mt-1">
|
||||
Intake emails address are used to automatically ingest emails into Papra. Just forward emails to the intake email address and their attachments will be added to your organization's documents.
|
||||
{t('intake-emails.description')}
|
||||
</p>
|
||||
|
||||
<Alert variant="default" class="mt-4 flex items-center gap-4 xl:gap-4 text-muted-foreground">
|
||||
<div class="i-tabler-info-circle size-10 xl:size-8 text-primary flex-shrink-0 " />
|
||||
|
||||
<AlertDescription>
|
||||
Only enabled intake emails from allowed origins will be processed. You can enable or disable an intake email at any time.
|
||||
{t('intake-emails.info')}
|
||||
</AlertDescription>
|
||||
|
||||
</Alert>
|
||||
|
||||
<Suspense>
|
||||
@@ -236,14 +249,14 @@ export const IntakeEmailsPage: Component = () => {
|
||||
fallback={(
|
||||
<div class="mt-4 py-8 border-2 border-dashed rounded-lg text-center">
|
||||
<EmptyState
|
||||
title="No intake emails"
|
||||
description="Generate an intake address to easily ingest emails attachments."
|
||||
title={t('intake-emails.empty.title')}
|
||||
description={t('intake-emails.empty.description')}
|
||||
class="pt-0"
|
||||
icon="i-tabler-mail"
|
||||
cta={(
|
||||
<Button variant="secondary" onClick={createEmail}>
|
||||
<div class="i-tabler-plus size-4 mr-2" />
|
||||
Generate intake email
|
||||
{t('intake-emails.empty.generate')}
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
@@ -252,19 +265,22 @@ export const IntakeEmailsPage: Component = () => {
|
||||
>
|
||||
<div class="mt-4 mb-4 flex items-center justify-between">
|
||||
<div class="text-muted-foreground">
|
||||
{`${intakeEmails().length} intake email${intakeEmails().length > 1 ? 's' : ''} for this organization`}
|
||||
{t('intake-emails.count', {
|
||||
count: intakeEmails().length,
|
||||
plural: intakeEmails().length > 1 ? 's' : '',
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Button onClick={createEmail}>
|
||||
<div class="i-tabler-plus size-4 mr-2" />
|
||||
New intake email
|
||||
{t('intake-emails.new')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<For each={intakeEmails()}>
|
||||
{intakeEmail => (
|
||||
<div class="flex items-center justify-between border rounded-lg p-4">
|
||||
<div class="flex items-center justify-between border rounded-lg p-4 bg-card">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="bg-muted size-9 rounded-lg flex items-center justify-center">
|
||||
<div class={cn('i-tabler-mail size-5', intakeEmail.isEnabled ? 'text-primary' : 'text-muted-foreground')} />
|
||||
@@ -275,9 +291,8 @@ export const IntakeEmailsPage: Component = () => {
|
||||
{intakeEmail.emailAddress}
|
||||
|
||||
<Show when={!intakeEmail.isEnabled}>
|
||||
<span class="text-muted-foreground text-xs ml-2">(Disabled)</span>
|
||||
<span class="text-muted-foreground text-xs ml-2">{t('intake-emails.disabled-label')}</span>
|
||||
</Show>
|
||||
|
||||
</div>
|
||||
|
||||
<Show
|
||||
@@ -285,14 +300,16 @@ export const IntakeEmailsPage: Component = () => {
|
||||
fallback={(
|
||||
<div class="text-xs text-warning flex items-center gap-1.5">
|
||||
<div class="i-tabler-alert-triangle size-3.75" />
|
||||
No allowed email origins
|
||||
{t('intake-emails.no-origins')}
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<div class="text-xs text-muted-foreground flex items-center gap-2">
|
||||
{`Allowed from ${intakeEmail.allowedOrigins.length} address${intakeEmail.allowedOrigins.length > 1 ? 'es' : ''}`}
|
||||
{t('intake-emails.allowed-origins', {
|
||||
count: intakeEmail.allowedOrigins.length,
|
||||
plural: intakeEmail.allowedOrigins.length > 1 ? 'es' : '',
|
||||
})}
|
||||
</div>
|
||||
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
@@ -303,7 +320,7 @@ export const IntakeEmailsPage: Component = () => {
|
||||
onClick={() => updateEmail({ intakeEmailId: intakeEmail.id, isEnabled: !intakeEmail.isEnabled })}
|
||||
>
|
||||
<div class="i-tabler-power size-4 mr-2" />
|
||||
{intakeEmail.isEnabled ? 'Disable' : 'Enable'}
|
||||
{intakeEmail.isEnabled ? t('intake-emails.actions.disable') : t('intake-emails.actions.enable')}
|
||||
</Button>
|
||||
|
||||
<AllowedOriginsDialog intakeEmails={intakeEmail}>
|
||||
@@ -315,7 +332,7 @@ export const IntakeEmailsPage: Component = () => {
|
||||
class="flex items-center gap-2 leading-none"
|
||||
>
|
||||
<div class="i-tabler-edit size-4" />
|
||||
Manage origins addresses
|
||||
{t('intake-emails.actions.manage-origins')}
|
||||
</Button>
|
||||
)}
|
||||
</AllowedOriginsDialog>
|
||||
@@ -327,21 +344,17 @@ export const IntakeEmailsPage: Component = () => {
|
||||
class="text-red"
|
||||
>
|
||||
<div class="i-tabler-trash size-4 mr-2" />
|
||||
|
||||
Delete
|
||||
{t('intake-emails.actions.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
|
||||
</div>
|
||||
|
||||
</Show>
|
||||
|
||||
)}
|
||||
</Show>
|
||||
</Suspense>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -1,28 +1,33 @@
|
||||
import type { Component } from 'solid-js';
|
||||
import { safely } from '@corentinth/chisels';
|
||||
import * as v from 'valibot';
|
||||
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||
import { createForm } from '@/modules/shared/form/form';
|
||||
import { isHttpErrorWithCode } from '@/modules/shared/http/http-errors';
|
||||
import { Button } from '@/modules/ui/components/button';
|
||||
import { TextField, TextFieldLabel, TextFieldRoot } from '@/modules/ui/components/textfield';
|
||||
import { safely } from '@corentinth/chisels';
|
||||
import * as v from 'valibot';
|
||||
import { organizationNameSchema } from '../organizations.schemas';
|
||||
|
||||
export const CreateOrganizationForm: Component<{
|
||||
onSubmit: (args: { organizationName: string }) => Promise<void>;
|
||||
initialOrganizationName?: string;
|
||||
}> = (props) => {
|
||||
const { t } = useI18n();
|
||||
const { form, Form, Field } = createForm({
|
||||
onSubmit: async ({ organizationName }) => {
|
||||
const [, error] = await safely(props.onSubmit({ organizationName }));
|
||||
|
||||
if (isHttpErrorWithCode({ error, code: 'user.max_organization_count_reached' })) {
|
||||
throw new Error('You have reached the maximum number of organizations you can create, if you need to create more, please contact support.');
|
||||
throw new Error(t('organizations.create.error.max-count-reached'));
|
||||
}
|
||||
|
||||
throw error;
|
||||
},
|
||||
schema: v.object({
|
||||
organizationName: organizationNameSchema,
|
||||
organizationName: v.pipe(
|
||||
organizationNameSchema,
|
||||
v.nonEmpty(t('organizations.create.form.name.required')),
|
||||
),
|
||||
}),
|
||||
initialValues: {
|
||||
organizationName: props.initialOrganizationName,
|
||||
@@ -35,8 +40,8 @@ export const CreateOrganizationForm: Component<{
|
||||
<Field name="organizationName">
|
||||
{(field, inputProps) => (
|
||||
<TextFieldRoot class="flex flex-col gap-1 mb-6">
|
||||
<TextFieldLabel for="organizationName">Organization name</TextFieldLabel>
|
||||
<TextField type="text" id="organizationName" placeholder="Eg. Acme Inc." {...inputProps} autoFocus value={field.value} aria-invalid={Boolean(field.error)} />
|
||||
<TextFieldLabel for="organizationName">{t('organizations.create.form.name.label')}</TextFieldLabel>
|
||||
<TextField type="text" id="organizationName" placeholder={t('organizations.create.form.name.placeholder')} {...inputProps} autoFocus value={field.value} aria-invalid={Boolean(field.error)} />
|
||||
{field.error && <div class="text-red-500 text-sm">{field.error}</div>}
|
||||
</TextFieldRoot>
|
||||
)}
|
||||
@@ -44,7 +49,7 @@ export const CreateOrganizationForm: Component<{
|
||||
|
||||
<div class="flex justify-end">
|
||||
<Button type="submit" isLoading={form.submitting} class="w-full">
|
||||
Create organization
|
||||
{t('organizations.create.form.submit')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ParentComponent } from 'solid-js';
|
||||
import type { Organization } from '../organizations.types';
|
||||
import { makePersisted } from '@solid-primitives/storage';
|
||||
import { createQuery } from '@tanstack/solid-query';
|
||||
import { useQuery } from '@tanstack/solid-query';
|
||||
import { createContext, createSignal, Show, useContext } from 'solid-js';
|
||||
import { fetchOrganizations } from '../organizations.services';
|
||||
|
||||
@@ -24,7 +24,7 @@ export function useCurrentOrganization() {
|
||||
export const CurrentOrganizationProvider: ParentComponent = (props) => {
|
||||
const [getCurrentOrganizationId, setCurrentOrganizationId] = makePersisted(createSignal<string | null>(null), { name: 'papra_current_organization_id', storage: localStorage });
|
||||
|
||||
const query = createQuery(() => ({
|
||||
const query = useQuery(() => ({
|
||||
queryKey: ['organizations'],
|
||||
queryFn: fetchOrganizations,
|
||||
}));
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
import { useNavigate, useParams } from '@solidjs/router';
|
||||
import { useQuery } from '@tanstack/solid-query';
|
||||
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||
import { queryClient } from '@/modules/shared/query/query-client';
|
||||
import { createToast } from '@/modules/ui/components/sonner';
|
||||
import { useNavigate } from '@solidjs/router';
|
||||
import { createOrganization, deleteOrganization, updateOrganization } from './organizations.services';
|
||||
import { ORGANIZATION_ROLES } from './organizations.constants';
|
||||
import { createOrganization, deleteOrganization, getMembership, updateOrganization } from './organizations.services';
|
||||
|
||||
export function useCreateOrganization() {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useI18n();
|
||||
|
||||
return {
|
||||
createOrganization: async ({ organizationName }: { organizationName: string }) => {
|
||||
const { organization } = await createOrganization({ name: organizationName });
|
||||
|
||||
createToast({ type: 'success', message: 'Organization created' });
|
||||
createToast({ type: 'success', message: t('organizations.create.success') });
|
||||
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ['organizations'],
|
||||
@@ -50,3 +54,29 @@ export function useDeleteOrganization() {
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function useCurrentUserRole({ organizationId }: { organizationId?: string } = {}) {
|
||||
const params = useParams();
|
||||
|
||||
const getOrganizationId = () => organizationId ?? params.organizationId;
|
||||
|
||||
const query = useQuery(() => ({
|
||||
queryKey: ['organizations', getOrganizationId(), 'members', 'me'],
|
||||
queryFn: () => getMembership({ organizationId: getOrganizationId() }),
|
||||
}));
|
||||
|
||||
const getRole = () => query.data?.member.role;
|
||||
const getIsMember = () => getRole() === ORGANIZATION_ROLES.MEMBER;
|
||||
const getIsAdmin = () => getRole() === ORGANIZATION_ROLES.ADMIN;
|
||||
const getIsOwner = () => getRole() === ORGANIZATION_ROLES.OWNER;
|
||||
const getIsAtLeastAdmin = () => getIsAdmin() || getIsOwner();
|
||||
|
||||
return {
|
||||
query,
|
||||
getRole,
|
||||
getIsMember,
|
||||
getIsAdmin,
|
||||
getIsOwner,
|
||||
getIsAtLeastAdmin,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,8 +1,16 @@
|
||||
import type { AsDto } from '../shared/http/http-client.types';
|
||||
import type { Organization } from './organizations.types';
|
||||
import type { Organization, OrganizationInvitation, OrganizationMember, OrganizationMemberRole } from './organizations.types';
|
||||
import { apiClient } from '../shared/http/api-client';
|
||||
import { coerceDates } from '../shared/http/http-client.models';
|
||||
|
||||
export async function inviteOrganizationMember({ organizationId, email, role }: { organizationId: string; email: string; role: OrganizationMemberRole }) {
|
||||
await apiClient({
|
||||
path: `/api/organizations/${organizationId}/members/invitations`,
|
||||
method: 'POST',
|
||||
body: { email, role },
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchOrganizations() {
|
||||
const { organizations } = await apiClient<{ organizations: AsDto<Organization>[] }>({
|
||||
path: '/api/organizations',
|
||||
@@ -55,3 +63,55 @@ export async function deleteOrganization({ organizationId }: { organizationId: s
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchOrganizationMembers({ organizationId }: { organizationId: string }) {
|
||||
const { members } = await apiClient<{ members: AsDto<OrganizationMember>[] }>({
|
||||
path: `/api/organizations/${organizationId}/members`,
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
return {
|
||||
members: members.map(({ user, ...rest }) => coerceDates({ user: coerceDates(user), ...rest })),
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchOrganizationInvitations({ organizationId }: { organizationId: string }) {
|
||||
const { invitations } = await apiClient<{ invitations: AsDto<OrganizationInvitation>[] }>({
|
||||
path: `/api/organizations/${organizationId}/members/invitations`,
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
return {
|
||||
invitations: invitations.map(coerceDates),
|
||||
};
|
||||
}
|
||||
|
||||
export async function removeOrganizationMember({ organizationId, memberId }: { organizationId: string; memberId: string }) {
|
||||
await apiClient({
|
||||
path: `/api/organizations/${organizationId}/members/${memberId}`,
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
export async function getMembership({ organizationId }: { organizationId: string }) {
|
||||
const { member } = await apiClient<{ member: AsDto<OrganizationMember> }>({
|
||||
path: `/api/organizations/${organizationId}/members/me`,
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
return {
|
||||
member: coerceDates(member),
|
||||
};
|
||||
}
|
||||
|
||||
export async function updateOrganizationMemberRole({ organizationId, memberId, role }: { organizationId: string; memberId: string; role: OrganizationMemberRole }) {
|
||||
const { member } = await apiClient<{ member: AsDto<OrganizationMember> }>({
|
||||
path: `/api/organizations/${organizationId}/members/${memberId}`,
|
||||
method: 'PATCH',
|
||||
body: { role },
|
||||
});
|
||||
|
||||
return {
|
||||
member: coerceDates(member),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,29 @@
|
||||
import type { User } from 'better-auth/types';
|
||||
import type { ORGANIZATION_INVITATION_STATUS_LIST } from './organizations.constants';
|
||||
|
||||
export type Organization = {
|
||||
id: string;
|
||||
name: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
||||
export type OrganizationMember = {
|
||||
id: string;
|
||||
organizationId: string;
|
||||
user: User;
|
||||
role: OrganizationMemberRole;
|
||||
};
|
||||
|
||||
export type OrganizationMemberRole = 'owner' | 'admin' | 'member';
|
||||
|
||||
export type OrganizationInvitationStatus = typeof ORGANIZATION_INVITATION_STATUS_LIST[number];
|
||||
|
||||
export type OrganizationInvitation = {
|
||||
id: string;
|
||||
organizationId: string;
|
||||
email: string;
|
||||
status: OrganizationInvitationStatus;
|
||||
role: OrganizationMemberRole;
|
||||
createdAt: Date;
|
||||
};
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useCurrentUser } from '@/modules/users/composables/useCurrentUser';
|
||||
import type { Component } from 'solid-js';
|
||||
import { useNavigate } from '@solidjs/router';
|
||||
import { createQuery } from '@tanstack/solid-query';
|
||||
import { type Component, createEffect, on } from 'solid-js';
|
||||
import { useQuery } from '@tanstack/solid-query';
|
||||
import { createEffect, on } from 'solid-js';
|
||||
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||
import { useCurrentUser } from '@/modules/users/composables/useCurrentUser';
|
||||
import { CreateOrganizationForm } from '../components/create-organization-form.component';
|
||||
import { useCreateOrganization } from '../organizations.composables';
|
||||
import { fetchOrganizations } from '../organizations.services';
|
||||
@@ -10,24 +12,25 @@ export const CreateFirstOrganizationPage: Component = () => {
|
||||
const { createOrganization } = useCreateOrganization();
|
||||
const { user } = useCurrentUser();
|
||||
const navigate = useNavigate();
|
||||
const { t } = useI18n();
|
||||
|
||||
const getOrganizationName = () => {
|
||||
const { name } = user;
|
||||
|
||||
if (name && name.length > 0) {
|
||||
return `${name}'s organization`;
|
||||
return t('organizations.create-first.user-name', { name });
|
||||
}
|
||||
|
||||
return `My organization`;
|
||||
return t('organizations.create-first.default-name');
|
||||
};
|
||||
|
||||
const queries = createQuery(() => ({
|
||||
const query = useQuery(() => ({
|
||||
queryKey: ['organizations'],
|
||||
queryFn: fetchOrganizations,
|
||||
}));
|
||||
|
||||
createEffect(on(
|
||||
() => queries.data?.organizations,
|
||||
() => query.data?.organizations,
|
||||
(orgs) => {
|
||||
if (orgs && orgs.length > 0) {
|
||||
navigate('/organizations/create');
|
||||
@@ -39,11 +42,11 @@ export const CreateFirstOrganizationPage: Component = () => {
|
||||
<div>
|
||||
<div class="max-w-md mx-auto pt-12 sm:pt-24 px-6">
|
||||
<h1 class="text-xl font-bold">
|
||||
Create your organization
|
||||
{t('organizations.create-first.title')}
|
||||
</h1>
|
||||
|
||||
<p class="text-muted-foreground mb-6">
|
||||
Your documents will be grouped by organization. You can create multiple organizations to separate your documents, for example, for personal and work documents.
|
||||
{t('organizations.create-first.description')}
|
||||
</p>
|
||||
|
||||
<CreateOrganizationForm onSubmit={createOrganization} initialOrganizationName={getOrganizationName()} />
|
||||
|
||||
@@ -1,27 +1,28 @@
|
||||
import type { Component } from 'solid-js';
|
||||
import { Button } from '@/modules/ui/components/button';
|
||||
import { A } from '@solidjs/router';
|
||||
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||
import { Button } from '@/modules/ui/components/button';
|
||||
import { CreateOrganizationForm } from '../components/create-organization-form.component';
|
||||
import { useCreateOrganization } from '../organizations.composables';
|
||||
|
||||
export const CreateOrganizationPage: Component = () => {
|
||||
const { t } = useI18n();
|
||||
const { createOrganization } = useCreateOrganization();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div class="max-w-md mx-auto pt-12 sm:pt-24 px-6">
|
||||
|
||||
<Button as={A} href="/" class="mb-4" variant="outline">
|
||||
<div class="i-tabler-arrow-left mr-2"></div>
|
||||
Back
|
||||
{t('organizations.create.back')}
|
||||
</Button>
|
||||
|
||||
<h1 class="text-xl font-bold">
|
||||
Create a new organization
|
||||
{t('organizations.create.title')}
|
||||
</h1>
|
||||
|
||||
<p class="text-muted-foreground mb-6">
|
||||
Your documents will be grouped by organization. You can create multiple organizations to separate your documents, for example, for personal and work documents.
|
||||
{t('organizations.create.description')}
|
||||
</p>
|
||||
|
||||
<CreateOrganizationForm onSubmit={createOrganization} />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -1,15 +1,18 @@
|
||||
import type { Component } from 'solid-js';
|
||||
import { formatBytes } from '@corentinth/chisels';
|
||||
import { useParams } from '@solidjs/router';
|
||||
import { createQueries, keepPreviousData } from '@tanstack/solid-query';
|
||||
import { createSignal, Show, Suspense } from 'solid-js';
|
||||
import { DocumentUploadArea } from '@/modules/documents/components/document-upload-area.component';
|
||||
import { createdAtColumn, DocumentsPaginatedList, standardActionsColumn, tagsColumn } from '@/modules/documents/components/documents-list.component';
|
||||
import { useUploadDocuments } from '@/modules/documents/documents.composables';
|
||||
import { fetchOrganizationDocuments, getOrganizationDocumentsStats } from '@/modules/documents/documents.services';
|
||||
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||
import { Button } from '@/modules/ui/components/button';
|
||||
import { formatBytes } from '@corentinth/chisels';
|
||||
import { useParams } from '@solidjs/router';
|
||||
import { createQueries, keepPreviousData } from '@tanstack/solid-query';
|
||||
import { type Component, createSignal, Show, Suspense } from 'solid-js';
|
||||
|
||||
export const OrganizationPage: Component = () => {
|
||||
const params = useParams();
|
||||
const { t } = useI18n();
|
||||
const [getPagination, setPagination] = createSignal({ pageIndex: 0, pageSize: 100 });
|
||||
|
||||
const query = createQueries(() => ({
|
||||
@@ -38,11 +41,11 @@ export const OrganizationPage: Component = () => {
|
||||
? (
|
||||
<>
|
||||
<h2 class="text-xl font-bold ">
|
||||
No documents
|
||||
{t('organizations.details.no-documents.title')}
|
||||
</h2>
|
||||
|
||||
<p class="text-muted-foreground mt-1 mb-6">
|
||||
There are no documents in this organization yet. Start by uploading some documents.
|
||||
{t('organizations.details.no-documents.description')}
|
||||
</p>
|
||||
|
||||
<DocumentUploadArea />
|
||||
@@ -56,7 +59,7 @@ export const OrganizationPage: Component = () => {
|
||||
<Button onClick={promptImport} class="h-auto items-start flex-col gap-4 py-4 px-6">
|
||||
<div class="i-tabler-upload size-6"></div>
|
||||
|
||||
Upload documents
|
||||
{t('organizations.details.upload-documents')}
|
||||
</Button>
|
||||
|
||||
<Show when={query[1].data?.organizationStats}>
|
||||
@@ -68,7 +71,7 @@ export const OrganizationPage: Component = () => {
|
||||
{organizationStats().documentsCount}
|
||||
</span>
|
||||
<span class="text-muted-foreground">
|
||||
documents in total
|
||||
{t('organizations.details.documents-count')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -79,7 +82,7 @@ export const OrganizationPage: Component = () => {
|
||||
{formatBytes({ bytes: organizationStats().documentsSize, base: 1000 })}
|
||||
</span>
|
||||
<span class="text-muted-foreground">
|
||||
total size
|
||||
{t('organizations.details.total-size')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -89,7 +92,7 @@ export const OrganizationPage: Component = () => {
|
||||
</div>
|
||||
|
||||
<h2 class="text-lg font-semibold mb-4">
|
||||
Latest imported documents
|
||||
{t('organizations.details.latest-documents')}
|
||||
</h2>
|
||||
|
||||
<DocumentsPaginatedList
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import type { Component } from 'solid-js';
|
||||
import type { Organization } from '../organizations.types';
|
||||
import { safely } from '@corentinth/chisels';
|
||||
import { useParams } from '@solidjs/router';
|
||||
import { useQuery } from '@tanstack/solid-query';
|
||||
import { createSignal, Show, Suspense } from 'solid-js';
|
||||
import * as v from 'valibot';
|
||||
import { buildTimeConfig } from '@/modules/config/config';
|
||||
import { useConfig } from '@/modules/config/config.provider';
|
||||
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||
import { useConfirmModal } from '@/modules/shared/confirm';
|
||||
import { createForm } from '@/modules/shared/form/form';
|
||||
import { getCustomerPortalUrl } from '@/modules/subscriptions/subscriptions.services';
|
||||
@@ -7,11 +15,6 @@ import { Button } from '@/modules/ui/components/button';
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/modules/ui/components/card';
|
||||
import { createToast } from '@/modules/ui/components/sonner';
|
||||
import { TextField, TextFieldLabel, TextFieldRoot } from '@/modules/ui/components/textfield';
|
||||
import { safely } from '@corentinth/chisels';
|
||||
import { useParams } from '@solidjs/router';
|
||||
import { createQuery } from '@tanstack/solid-query';
|
||||
import { type Component, createSignal, Show, Suspense } from 'solid-js';
|
||||
import * as v from 'valibot';
|
||||
import { useDeleteOrganization, useUpdateOrganization } from '../organizations.composables';
|
||||
import { organizationNameSchema } from '../organizations.schemas';
|
||||
import { fetchOrganization } from '../organizations.services';
|
||||
@@ -19,24 +22,25 @@ import { fetchOrganization } from '../organizations.services';
|
||||
const DeleteOrganizationCard: Component<{ organization: Organization }> = (props) => {
|
||||
const { deleteOrganization } = useDeleteOrganization();
|
||||
const { confirm } = useConfirmModal();
|
||||
const { t } = useI18n();
|
||||
|
||||
const handleDelete = async () => {
|
||||
const confirmed = await confirm({
|
||||
title: 'Delete organization',
|
||||
message: 'Are you sure you want to delete this organization? This action cannot be undone, and all data associated with this organization will be permanently removed.',
|
||||
title: t('organization.settings.delete.confirm.title'),
|
||||
message: t('organization.settings.delete.confirm.message'),
|
||||
confirmButton: {
|
||||
text: 'Delete organization',
|
||||
text: t('organization.settings.delete.confirm.confirm-button'),
|
||||
variant: 'destructive',
|
||||
},
|
||||
cancelButton: {
|
||||
text: 'Cancel',
|
||||
text: t('organization.settings.delete.confirm.cancel-button'),
|
||||
},
|
||||
});
|
||||
|
||||
if (confirmed) {
|
||||
await deleteOrganization({ organizationId: props.organization.id });
|
||||
|
||||
createToast({ type: 'success', message: 'Organization deleted' });
|
||||
createToast({ type: 'success', message: t('organization.settings.delete.success') });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -44,15 +48,15 @@ const DeleteOrganizationCard: Component<{ organization: Organization }> = (props
|
||||
<div>
|
||||
<Card class="border-destructive">
|
||||
<CardHeader class="border-b">
|
||||
<CardTitle>Delete organization</CardTitle>
|
||||
<CardTitle>{t('organization.settings.delete.title')}</CardTitle>
|
||||
<CardDescription>
|
||||
Deleting this organization will permanently remove all data associated with it.
|
||||
{t('organization.settings.delete.description')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardFooter class="pt-6">
|
||||
<Button onClick={handleDelete} variant="destructive">
|
||||
Delete organization
|
||||
{t('organization.settings.delete.confirm.confirm-button')}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
@@ -61,7 +65,14 @@ const DeleteOrganizationCard: Component<{ organization: Organization }> = (props
|
||||
};
|
||||
|
||||
export const SubscriptionCard: Component<{ organization: Organization }> = (props) => {
|
||||
const { config } = useConfig();
|
||||
|
||||
if (!config.isSubscriptionsEnabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [getIsLoading, setIsLoading] = createSignal(false);
|
||||
const { t } = useI18n();
|
||||
|
||||
const goToCustomerPortal = async () => {
|
||||
setIsLoading(true);
|
||||
@@ -69,7 +80,7 @@ export const SubscriptionCard: Component<{ organization: Organization }> = (prop
|
||||
const [result, error] = await safely(getCustomerPortalUrl({ organizationId: props.organization.id }));
|
||||
|
||||
if (error) {
|
||||
createToast({ type: 'error', message: 'Failed to get customer portal URL' });
|
||||
createToast({ type: 'error', message: t('organization.settings.subscription.error') });
|
||||
setIsLoading(false);
|
||||
|
||||
return;
|
||||
@@ -85,13 +96,13 @@ export const SubscriptionCard: Component<{ organization: Organization }> = (prop
|
||||
return (
|
||||
<Card class="flex flex-col sm:flex-row justify-between gap-4 sm:items-center p-6 ">
|
||||
<div>
|
||||
<div class="font-semibold">Subscription</div>
|
||||
<div class="font-semibold">{t('organization.settings.subscription.title')}</div>
|
||||
<div class="text-sm text-muted-foreground">
|
||||
Manage your billing, invoices and payment methods.
|
||||
{t('organization.settings.subscription.description')}
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={goToCustomerPortal} isLoading={getIsLoading()} class="flex-shrink-0" disabled={buildTimeConfig.isDemoMode}>
|
||||
Manage subscription
|
||||
{t('organization.settings.subscription.manage')}
|
||||
</Button>
|
||||
</Card>
|
||||
);
|
||||
@@ -99,6 +110,7 @@ export const SubscriptionCard: Component<{ organization: Organization }> = (prop
|
||||
|
||||
const UpdateOrganizationNameCard: Component<{ organization: Organization }> = (props) => {
|
||||
const { updateOrganization } = useUpdateOrganization();
|
||||
const { t } = useI18n();
|
||||
|
||||
const { form, Form, Field } = createForm({
|
||||
schema: v.object({
|
||||
@@ -113,7 +125,7 @@ const UpdateOrganizationNameCard: Component<{ organization: Organization }> = (p
|
||||
organizationName: organizationName.trim(),
|
||||
});
|
||||
|
||||
createToast({ type: 'success', message: 'Organization name updated' });
|
||||
createToast({ type: 'success', message: t('organization.settings.name.updated') });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -121,24 +133,22 @@ const UpdateOrganizationNameCard: Component<{ organization: Organization }> = (p
|
||||
<div>
|
||||
<Card>
|
||||
<CardHeader class="border-b">
|
||||
<CardTitle>Organization name</CardTitle>
|
||||
|
||||
<CardTitle>{t('organization.settings.name.title')}</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<Form>
|
||||
<CardContent class="pt-6 ">
|
||||
|
||||
<Field name="organizationName">
|
||||
{(field, inputProps) => (
|
||||
<TextFieldRoot class="flex flex-col gap-1">
|
||||
<TextFieldLabel for="organizationName" class="sr-only">
|
||||
Organization name
|
||||
{t('organization.settings.name.title')}
|
||||
</TextFieldLabel>
|
||||
<div class="flex gap-2 flex-col sm:flex-row">
|
||||
<TextField type="text" id="organizationName" placeholder="Eg. Acme Inc." {...inputProps} autoFocus value={field.value} aria-invalid={Boolean(field.error)} />
|
||||
<TextField type="text" id="organizationName" placeholder={t('organization.settings.name.placeholder')} {...inputProps} autoFocus value={field.value} aria-invalid={Boolean(field.error)} />
|
||||
|
||||
<Button type="submit" isLoading={form.submitting} class="flex-shrink-0" disabled={field.value?.trim() === props.organization.name}>
|
||||
Update name
|
||||
{t('organization.settings.name.update')}
|
||||
</Button>
|
||||
</div>
|
||||
{field.error && <div class="text-red-500 text-sm">{field.error}</div>}
|
||||
@@ -148,7 +158,6 @@ const UpdateOrganizationNameCard: Component<{ organization: Organization }> = (p
|
||||
|
||||
<div class="text-red-500 text-sm">{form.response.message}</div>
|
||||
</CardContent>
|
||||
|
||||
</Form>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -157,24 +166,25 @@ const UpdateOrganizationNameCard: Component<{ organization: Organization }> = (p
|
||||
|
||||
export const OrganizationsSettingsPage: Component = () => {
|
||||
const params = useParams();
|
||||
const { t } = useI18n();
|
||||
|
||||
const query = createQuery(() => ({
|
||||
const query = useQuery(() => ({
|
||||
queryKey: ['organizations', params.organizationId],
|
||||
queryFn: () => fetchOrganization({ organizationId: params.organizationId }),
|
||||
}));
|
||||
|
||||
return (
|
||||
<div class="p-6 mt-4 pb-32 mx-auto max-w-xl">
|
||||
<div class="p-6 mt-10 pb-32 mx-auto max-w-screen-md w-full">
|
||||
<Suspense>
|
||||
<Show when={query.data?.organization}>
|
||||
{ getOrganization => (
|
||||
<>
|
||||
<h1 class="text-xl font-semibold mb-2">
|
||||
Organization settings
|
||||
{t('organization.settings.page.title')}
|
||||
</h1>
|
||||
|
||||
<p class="text-muted-foreground">
|
||||
Manage your organization settings here.
|
||||
{t('organization.settings.page.description')}
|
||||
</p>
|
||||
|
||||
<div class="mt-6 flex flex-col gap-6">
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
import type { Component } from 'solid-js';
|
||||
import { A, useNavigate } from '@solidjs/router';
|
||||
import { createQuery } from '@tanstack/solid-query';
|
||||
import { type Component, createEffect, For, on } from 'solid-js';
|
||||
import { useQuery } from '@tanstack/solid-query';
|
||||
import { createEffect, For, on } from 'solid-js';
|
||||
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||
import { fetchOrganizations } from '../organizations.services';
|
||||
|
||||
export const OrganizationsPage: Component = () => {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useI18n();
|
||||
|
||||
const queries = createQuery(() => ({
|
||||
const query = useQuery(() => ({
|
||||
queryKey: ['organizations'],
|
||||
queryFn: fetchOrganizations,
|
||||
}));
|
||||
|
||||
createEffect(on(
|
||||
() => queries.data?.organizations,
|
||||
() => query.data?.organizations,
|
||||
(orgs) => {
|
||||
if (orgs && orgs.length === 0) {
|
||||
navigate('/organizations/first');
|
||||
@@ -23,15 +26,15 @@ export const OrganizationsPage: Component = () => {
|
||||
return (
|
||||
<div class="p-6 mt-4 pb-32 max-w-5xl mx-auto">
|
||||
<h2 class="text-xl font-bold mb-2">
|
||||
Your organizations
|
||||
{t('organizations.list.title')}
|
||||
</h2>
|
||||
|
||||
<p class="text-muted-foreground mb-6">
|
||||
Organizations are a way to group your documents and manage access to them. You can create multiple organizations and invite your team members to collaborate.
|
||||
{t('organizations.list.description')}
|
||||
</p>
|
||||
|
||||
<div class="grid gap-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
<For each={queries.data?.organizations}>
|
||||
<For each={query.data?.organizations}>
|
||||
{organization => (
|
||||
<A
|
||||
href={`/organizations/${organization.id}`}
|
||||
@@ -42,7 +45,6 @@ export const OrganizationsPage: Component = () => {
|
||||
</div>
|
||||
|
||||
<div class="p-4">
|
||||
|
||||
<div class="w-full text-left font-bold truncate block">
|
||||
{organization.name}
|
||||
</div>
|
||||
@@ -55,7 +57,7 @@ export const OrganizationsPage: Component = () => {
|
||||
<div class="i-tabler-plus size-16 text-muted-foreground op-50 group-hover:(text-primary op-100) transition" />
|
||||
|
||||
<div class="font-bold block text-muted-foreground">
|
||||
Create new organization
|
||||
{t('organizations.list.create-new')}
|
||||
</div>
|
||||
</A>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { buildTimeConfig } from '@/modules/config/config';
|
||||
import type { HttpClientOptions, ResponseType } from './http-client';
|
||||
import { safely } from '@corentinth/chisels';
|
||||
import { httpClient, type HttpClientOptions, type ResponseType } from './http-client';
|
||||
import { buildTimeConfig } from '@/modules/config/config';
|
||||
import { httpClient } from './http-client';
|
||||
import { isHttpErrorWithStatusCode } from './http-errors';
|
||||
|
||||
export async function apiClient<T, R extends ResponseType = 'json'>({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { LocaleKeys } from '@/modules/i18n/locales.types';
|
||||
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||
import { get } from 'lodash-es';
|
||||
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||
|
||||
export function useI18nApiErrors({ t = useI18n().t }: { t?: ReturnType<typeof useI18n>['t'] } = {}) {
|
||||
const getTranslationFromApiErrorCode = ({ code }: { code: string }) => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { FetchOptions, ResponseType } from 'ofetch';
|
||||
import { ofetch } from 'ofetch';
|
||||
import { buildTimeConfig } from '@/modules/config/config';
|
||||
import { demoHttpClient } from '@/modules/demo/demo-http-client';
|
||||
import { ofetch } from 'ofetch';
|
||||
|
||||
export { ResponseType };
|
||||
export type HttpClientOptions<R extends ResponseType = 'json'> = Omit<FetchOptions<R>, 'baseURL'> & { url: string; baseUrl?: string };
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
import type { Component } from 'solid-js';
|
||||
import { Button } from '@/modules/ui/components/button';
|
||||
import { A } from '@solidjs/router';
|
||||
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||
import { Button } from '@/modules/ui/components/button';
|
||||
|
||||
export const NotFoundPage: Component = () => {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<div class="h-screen flex flex-col items-center justify-center p-6">
|
||||
|
||||
<div class="flex items-center flex-row sm:gap-24">
|
||||
<div class="max-w-350px">
|
||||
<h1 class="text-xl mr-4 py-2">404 - Not Found</h1>
|
||||
<h1 class="text-xl mr-4 py-2">{t('not-found.title')}</h1>
|
||||
<p class="text-muted-foreground">
|
||||
Sorry, the page you are looking for does seem to exist. Please check the URL and try again.
|
||||
{t('not-found.description')}
|
||||
</p>
|
||||
<Button as={A} href="/" class="mt-4" variant="default">
|
||||
<div class="i-tabler-arrow-left mr-2"></div>
|
||||
Go back to home
|
||||
{t('not-found.back-to-home')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ComponentProps, ParentComponent } from 'solid-js';
|
||||
import { Button } from '@/modules/ui/components/button';
|
||||
import { createSignal } from 'solid-js';
|
||||
import { Button } from '@/modules/ui/components/button';
|
||||
|
||||
export function useCopy() {
|
||||
const [getIsJustCopied, setIsJustCopied] = createSignal(false);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { buildTimeConfig } from '@/modules/config/config';
|
||||
import { buildUrl } from '@corentinth/chisels';
|
||||
import { buildTimeConfig } from '@/modules/config/config';
|
||||
|
||||
export function createVitrineUrl({ path, baseUrl = buildTimeConfig.vitrineBaseUrl }: { path: string; baseUrl?: string }): string {
|
||||
return buildUrl({ path, baseUrl });
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import type { Component } from 'solid-js';
|
||||
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 { useConfirmModal } from '@/modules/shared/confirm';
|
||||
import { createForm } from '@/modules/shared/form/form';
|
||||
@@ -9,10 +14,6 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
|
||||
import { Separator } from '@/modules/ui/components/separator';
|
||||
import { TextArea } from '@/modules/ui/components/textarea';
|
||||
import { TextField, TextFieldLabel, TextFieldRoot } from '@/modules/ui/components/textfield';
|
||||
import { insert, remove, setValue } from '@modular-forms/solid';
|
||||
import { A } from '@solidjs/router';
|
||||
import { type Component, 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';
|
||||
|
||||
export const TaggingRuleForm: Component<{
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { Component } from 'solid-js';
|
||||
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 { 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 { createTaggingRule } from '../tagging-rules.services';
|
||||
|
||||
@@ -12,7 +12,7 @@ export const CreateTaggingRulePage: Component = () => {
|
||||
const params = useParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const createTaggingRuleMutation = createMutation(() => ({
|
||||
const createTaggingRuleMutation = useMutation(() => ({
|
||||
mutationFn: async ({ taggingRule }: { taggingRule: TaggingRuleForCreation }) => {
|
||||
await createTaggingRule({ taggingRule, organizationId: params.organizationId });
|
||||
},
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import type { Component } from 'solid-js';
|
||||
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 { useI18n } from '@/modules/i18n/i18n.provider';
|
||||
import { queryClient } from '@/modules/shared/query/query-client';
|
||||
import { Alert } from '@/modules/ui/components/alert';
|
||||
import { Button } from '@/modules/ui/components/button';
|
||||
import { EmptyState } from '@/modules/ui/components/empty';
|
||||
import { A, useParams } from '@solidjs/router';
|
||||
import { createMutation, createQuery } from '@tanstack/solid-query';
|
||||
import { type Component, For, Match, Show, Switch } from 'solid-js';
|
||||
import { deleteTaggingRule, fetchTaggingRules } from '../tagging-rules.services';
|
||||
|
||||
const TaggingRuleCard: Component<{ taggingRule: TaggingRule }> = (props) => {
|
||||
@@ -27,7 +28,7 @@ const TaggingRuleCard: Component<{ taggingRule: TaggingRule }> = (props) => {
|
||||
return t('tagging-rules.list.card.conditions', { count });
|
||||
};
|
||||
|
||||
const deleteTaggingRuleMutation = createMutation(() => ({
|
||||
const deleteTaggingRuleMutation = useMutation(() => ({
|
||||
mutationFn: async () => {
|
||||
await deleteTaggingRule({ organizationId: props.taggingRule.organizationId, taggingRuleId: props.taggingRule.id });
|
||||
},
|
||||
@@ -81,7 +82,7 @@ export const TaggingRulesPage: Component = () => {
|
||||
const { config } = useConfig();
|
||||
const params = useParams();
|
||||
|
||||
const query = createQuery(() => ({
|
||||
const query = useQuery(() => ({
|
||||
queryKey: ['organizations', params.organizationId, 'tagging-rules'],
|
||||
queryFn: () => fetchTaggingRules({ organizationId: params.organizationId }),
|
||||
}));
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import type { Component } from 'solid-js';
|
||||
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 { queryClient } from '@/modules/shared/query/query-client';
|
||||
import { createToast } from '@/modules/ui/components/sonner';
|
||||
import { useNavigate, useParams } from '@solidjs/router';
|
||||
import { createMutation, createQuery } from '@tanstack/solid-query';
|
||||
import { type Component, Show } from 'solid-js';
|
||||
import { TaggingRuleForm } from '../components/tagging-rule-form.component';
|
||||
import { getTaggingRule, updateTaggingRule } from '../tagging-rules.services';
|
||||
|
||||
@@ -13,12 +14,12 @@ export const UpdateTaggingRulePage: Component = () => {
|
||||
const params = useParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const query = createQuery(() => ({
|
||||
const query = useQuery(() => ({
|
||||
queryKey: ['organizations', params.organizationId, 'tagging-rules', params.taggingRuleId],
|
||||
queryFn: () => getTaggingRule({ organizationId: params.organizationId, taggingRuleId: params.taggingRuleId }),
|
||||
}));
|
||||
|
||||
const updateTaggingRuleMutation = createMutation(() => ({
|
||||
const updateTaggingRuleMutation = useMutation(() => ({
|
||||
mutationFn: async ({ taggingRule }: { taggingRule: TaggingRuleForCreation }) => {
|
||||
await updateTaggingRule({ organizationId: params.organizationId, taggingRuleId: params.taggingRuleId, taggingRule });
|
||||
},
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user