Compare commits

...

34 Commits

Author SHA1 Message Date
Corentin Thomasset
249b3bcfd2 chore(release): update versions (#285)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-05-13 22:44:37 +02:00
Corentin Thomasset
d7838b5d57 chore(release): remove commitMode from release job configuration (#284) 2025-05-13 20:14:08 +00:00
Corentin Thomasset
f170ddd817 chore(release): use PAT for release PR creation (#283) 2025-05-13 20:05:57 +00:00
Corentin Thomasset
4f53c70854 chore(release): update permissions for release job (#281) 2025-05-13 16:44:47 +00:00
Corentin Thomasset
85fa5c4342 chore(version): added changeset for versioning (#280) 2025-05-13 13:48:55 +02:00
Corentin Thomasset
c5d984a3a0 refactor(docker): build transitive dependencies (#277) 2025-05-08 20:47:57 +02:00
Corentin Thomasset
565bd8d7fd feat(webhooks): added webhook management and logic (#276) 2025-05-08 18:52:11 +02:00
Corentin Thomasset
9b72aa886c feat(cli): added cli documentation (#275) 2025-05-02 23:30:33 +02:00
Corentin Thomasset
7410455093 feat(cli): setup base cli (#274) 2025-05-02 00:20:57 +02:00
riskpoint-per
dd8f194fd0 feat(server): add azure blob storage support (#261)
* add azure blob storage support

* set stream to nodejs.readable

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

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

* fix lock file

* bugfixes

* fix lint issues

---------

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

* feat(b2): fix order of tsconfig entries

* feat(b2): fix accidental responseType change

* fix(b2): remove unnecessary try-catches

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

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

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-04-24 21:17:37 +00:00
Corentin Thomasset
6287aaa973 feat(i18n): auto generate i18n type in dev mode (#254) 2025-04-24 20:03:21 +00:00
Corentin Thomasset
cc2edc59b0 feat(server): added api-keys (#248) 2025-04-24 21:13:56 +02:00
Corentin Thomasset
9cba84e38b refactor(client, services): updated API responses to use AsDto and date coercion (#250) 2025-04-22 22:35:48 +02:00
Corentin Thomasset
5fe401778d feat(documents): enhance document page with alert for content extraction details (#249) 2025-04-22 19:26:48 +00:00
Joshua Anderson
38aa1ea7f1 feat(documents): add document searchable content view and edit (#230)
* feat(documents): add tab for viewing content

* feat(documents): allow editing document content

* fix(demo): add content to demo document api

* refactor(documents): fix api structure for updating documents

* refactor(documents): return updated document after change

* refactor(documents): use correct api validation

* refactor(documents): move update to repository

* refactor(documents): limit height of content view

* refactor(documents): use new validation schemas
2025-04-22 21:14:57 +02:00
Corentin Thomasset
ab98c1b255 refactor(validation): mutualized resources ids for route validation (#241) 2025-04-19 16:27:51 +02:00
Corentin Thomasset
265f06f8b7 docs(CONTRIBUTING): updated guidelines for PRs (#240) 2025-04-18 21:47:59 +00:00
Corentin Thomasset
a787b7915c chore(github): added CODEOWNERS file (#239) 2025-04-18 21:40:37 +00:00
Joshua Anderson
0ba6a09923 fix(documents): fix padding when tags input wraps (#227)
* fix(documents): fix padding when tags input wraps

* fix(documents): use padding for y not just b
2025-04-18 22:58:50 +02:00
Joshua Anderson
6880bfd41c feat(documents): wrap txt document preview (#231)
* feat(documents): wrap txt document preview

* Update apps/papra-client/src/modules/documents/components/document-preview.component.tsx

---------

Co-authored-by: Corentin Thomasset <corentin.thomasset74@gmail.com>
2025-04-18 22:43:48 +02:00
Corentin Thomasset
21a2c95e56 fix(tags): exclude deleted documents from tags docs count (#234) 2025-04-18 08:43:32 +00:00
Joshua Anderson
19e2083a71 feat(documents): add create tag button on document page (#220)
* feat(documents): fix tag selection input width

* feat(documents): add create tag button on document page

also added i18n just for the tag button, to be used for future localizing
2025-04-17 23:09:47 +02:00
Corentin Thomasset
e6b2d9fb2d refactor(documents): add functions to retrieve document name and extension (#217) 2025-04-16 23:07:54 +02:00
255 changed files with 13473 additions and 1583 deletions

8
.changeset/README.md Normal file
View File

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

19
.changeset/config.json Normal file
View File

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

View File

@@ -38,11 +38,12 @@ jobs:
- name: Run unit test
run: pnpm test
- name: Build the app
run: pnpm build
# Ensure locales types are up to date
# Ensure locales types are up to date, must be run before building the app
- name: Check locales types
run: |
pnpm script:generate-i18n-types
git diff --exit-code -- src/modules/i18n/locales.types.ts > /dev/null || (echo "Locales types are outdated, please run 'pnpm script:generate-i18n-types' and commit the changes." && exit 1)
git diff --exit-code -- src/modules/i18n/locales.types.ts > /dev/null || (echo "Locales types are outdated, please run 'pnpm script:generate-i18n-types' and commit the changes." && exit 1)
- name: Build the app
run: pnpm build

View File

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

View File

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

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

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

View File

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

View File

@@ -3,7 +3,7 @@ name: Release new versions
on:
push:
tags:
- 'v*.*.*'
- '@papra/app-server@*'
permissions:
contents: read
@@ -15,13 +15,8 @@ jobs:
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
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/tags/@papra/app-server@}" >> $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

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

@@ -0,0 +1,43 @@
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
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 }}

1
CODEOWNERS Normal file
View File

@@ -0,0 +1 @@
* @papra-hq/papra-maintainers

View File

@@ -22,11 +22,11 @@ We follow a **GitHub Flow** model where all PRs should target the `main` branch,
- Each PR should be small and atomic. Please avoid solving multiple unrelated issues in a single PR.
- Ensure that the **CI is green** before submitting. Some of the following checks are automatically run for each package: linting, type checking, testing, and building.
- PRs without a corresponding issue are welcome.
- If your PR fixes an issue, please reference the issue number in the PR description.
- If your PR adds a new feature, please include tests and update the documentation if necessary.
- Be prepared to address feedback and iterate on your PR.
- Resolving merge conflicts is part of the PR author's responsibility.
- Draft PRs are welcome to get feedback early on your work but only when requested, they'll not be reviewed.
### Branching
@@ -60,7 +60,7 @@ 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.
- Will developing you can use `pnpm script:generate-i18n-types:watch` to automatically update the types when you touch the [`en.yml`](./apps/papra-client/src/locales/en.yml) file.
- 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.
## Development Setup

View File

@@ -59,9 +59,9 @@ 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.
- *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.

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

@@ -0,0 +1,7 @@
# @papra/docs
## 0.3.1
### Patch Changes
- [#280](https://github.com/papra-hq/papra/pull/280) [`85fa5c4`](https://github.com/papra-hq/papra/commit/85fa5c43424d139f5c2752a3ad644082e61d3d67) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Fix broken lint and added auto link check

View File

@@ -1,6 +1,7 @@
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 { sidebar } from './src/content/navigation';
import posthogRawScript from './src/scripts/posthog.script.js?raw';
@@ -16,18 +17,18 @@ export default defineConfig({
site: 'https://docs.papra.app',
integrations: [
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'],
},

View File

@@ -1,8 +1,9 @@
{
"name": "@papra/docs",
"type": "module",
"version": "0.3.0",
"packageManager": "pnpm@9.15.4",
"version": "0.3.1",
"private": true,
"packageManager": "pnpm@10.9.0",
"description": "Papra documentation website",
"author": "Corentin Thomasset <corentinth@proton.me> (https://corentin.tech)",
"license": "AGPL-3.0-or-later",
@@ -17,10 +18,11 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@astrojs/starlight": "^0.31.0",
"astro": "^5.1.5",
"@astrojs/starlight": "^0.34.2",
"astro": "^5.7.10",
"sharp": "^0.32.5",
"starlight-theme-rapide": "^0.3.0",
"starlight-links-validator": "^0.16.0",
"starlight-theme-rapide": "^0.5.0",
"zod-to-json-schema": "^3.24.5"
},
"devDependencies": {

View File

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

View File

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

View File

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

View File

@@ -51,9 +51,9 @@ 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.
- *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.

View File

@@ -35,6 +35,10 @@ export const sidebar: StarlightUserConfig['sidebar'] = [
{
label: 'Resources',
items: [
{
label: 'CLI Documentation',
slug: 'resources/cli',
},
{
label: 'Security Policy',
link: 'https://github.com/papra-hq/papra/blob/main/SECURITY.md',

View File

@@ -0,0 +1,23 @@
# @papra/app-client
## 0.4.0
### Minor Changes
- [#280](https://github.com/papra-hq/papra/pull/280) [`85fa5c4`](https://github.com/papra-hq/papra/commit/85fa5c43424d139f5c2752a3ad644082e61d3d67) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added webhook management
- [#280](https://github.com/papra-hq/papra/pull/280) [`85fa5c4`](https://github.com/papra-hq/papra/commit/85fa5c43424d139f5c2752a3ad644082e61d3d67) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added API keys support
- [#280](https://github.com/papra-hq/papra/pull/280) [`85fa5c4`](https://github.com/papra-hq/papra/commit/85fa5c43424d139f5c2752a3ad644082e61d3d67) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added document searchable content edit
### Patch Changes
- [#280](https://github.com/papra-hq/papra/pull/280) [`85fa5c4`](https://github.com/papra-hq/papra/commit/85fa5c43424d139f5c2752a3ad644082e61d3d67) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added tag creation button in document page
- [#280](https://github.com/papra-hq/papra/pull/280) [`85fa5c4`](https://github.com/papra-hq/papra/commit/85fa5c43424d139f5c2752a3ad644082e61d3d67) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Improved tag selector input wrapping
- [#280](https://github.com/papra-hq/papra/pull/280) [`85fa5c4`](https://github.com/papra-hq/papra/commit/85fa5c43424d139f5c2752a3ad644082e61d3d67) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Properly handle file names without extensions
- [#280](https://github.com/papra-hq/papra/pull/280) [`85fa5c4`](https://github.com/papra-hq/papra/commit/85fa5c43424d139f5c2752a3ad644082e61d3d67) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Wrap text in document preview
- [#280](https://github.com/papra-hq/papra/pull/280) [`85fa5c4`](https://github.com/papra-hq/papra/commit/85fa5c43424d139f5c2752a3ad644082e61d3d67) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Excluded deleted documents from doc count

View File

@@ -1,8 +1,9 @@
{
"name": "@papra/papra-app-client",
"name": "@papra/app-client",
"type": "module",
"version": "0.3.0",
"packageManager": "pnpm@9.15.4",
"version": "0.4.0",
"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,8 +26,7 @@
"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:watch": "tsx watch --include src/locales/en.yml src/scripts/generate-i18n-types.script.ts"
"script:generate-i18n-types": "tsx src/scripts/generate-i18n-types.script.ts"
},
"dependencies": {
"@corentinth/chisels": "^1.0.2",
@@ -64,6 +64,7 @@
"@types/node": "catalog:",
"eslint": "catalog:",
"jsdom": "^25.0.0",
"tinyglobby": "^0.2.13",
"tsx": "^4.19.1",
"typescript": "catalog:",
"unocss": "0.65.0-beta.2",

View File

@@ -1,219 +1,245 @@
auth:
request-password-reset:
title: Reset your password
description: Enter your email to reset your password.
requested: If an account exists for this email, we've sent you an email to reset your password.
back-to-login: Back to login
form:
email:
label: Email
placeholder: 'Example: ada@papra.app'
required: Please enter your email address
invalid: This email address is invalid
submit: Request password reset
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.
auth.request-password-reset.back-to-login: Back to login
auth.request-password-reset.form.email.label: Email
auth.request-password-reset.form.email.placeholder: 'Example: ada@papra.app'
auth.request-password-reset.form.email.required: Please enter your email address
auth.request-password-reset.form.email.invalid: This email address is invalid
auth.request-password-reset.form.submit: Request password reset
reset-password:
title: Reset your password
description: Enter your new password to reset your password.
reset: Your password has been reset.
back-to-login: Back to login
form:
new-password:
label: New password
placeholder: 'Example: **********'
required: Please enter your new password
min-length: Password must be at least {{ minLength }} characters
max-length: Password must be less than {{ maxLength }} characters
submit: Reset password
auth.reset-password.title: Reset your password
auth.reset-password.description: Enter your new password to reset your password.
auth.reset-password.reset: Your password has been reset.
auth.reset-password.back-to-login: Back to login
auth.reset-password.form.new-password.label: New password
auth.reset-password.form.new-password.placeholder: 'Example: **********'
auth.reset-password.form.new-password.required: Please enter your new password
auth.reset-password.form.new-password.min-length: Password must be at least {{ minLength }} characters
auth.reset-password.form.new-password.max-length: Password must be less than {{ maxLength }} characters
auth.reset-password.form.submit: Reset password
email-provider:
open: Open {{ provider }}
auth.email-provider.open: Open {{ provider }}
login:
title: Login to Papra
description: Enter your email or use social login to access your Papra account.
login-with-provider: Login with {{ provider }}
no-account: Don't have an account?
register: Register
form:
email:
label: Email
placeholder: 'Example: ada@papra.app'
required: Please enter your email address
invalid: This email address is invalid
password:
label: Password
placeholder: Set a password
required: Please enter your password
remember-me:
label: Remember me
forgot-password:
label: Forgot password?
submit: Login
auth.login.title: Login to Papra
auth.login.description: Enter your email or use social login to access your Papra account.
auth.login.login-with-provider: Login with {{ provider }}
auth.login.no-account: Don't have an account?
auth.login.register: Register
auth.login.form.email.label: Email
auth.login.form.email.placeholder: 'Example: ada@papra.app'
auth.login.form.email.required: Please enter your email address
auth.login.form.email.invalid: This email address is invalid
auth.login.form.password.label: Password
auth.login.form.password.placeholder: Set a password
auth.login.form.password.required: Please enter your password
auth.login.form.remember-me.label: Remember me
auth.login.form.forgot-password.label: Forgot password?
auth.login.form.submit: Login
register:
title: Register to Papra
description: Enter your email or use social login to access your Papra account.
register-with-email: Register with email
register-with-provider: Register with {{ provider }}
providers:
google: Google
github: GitHub
have-account: Already have an account?
login: Login
registration-disabled:
title: Registration is disabled
description: The creation of new accounts is currently disabled on this instance of Papra. Only users with existing accounts can log in. If you think this is a mistake, please contact the administrator of this instance.
form:
email:
label: Email
placeholder: 'Example: ada@papra.app'
required: Please enter your email address
invalid: This email address is invalid
password:
label: Password
placeholder: Set a password
required: Please enter your password
min-length: Password must be at least {{ minLength }} characters
max-length: Password must be less than {{ maxLength }} characters
name:
label: Name
placeholder: 'Example: Ada Lovelace'
required: Please enter your name
max-length: Name must be less than {{ maxLength }} characters
submit: Register
email-validation-required:
title: Verify your email
description: A verification email has been sent to your email address. Please verify your email address by clicking the link in the email.
legal-links:
description: By continuing, you acknowledge that you understand and agree to the {{ terms }} and {{ privacy }}.
terms: Terms of Service
privacy: Privacy Policy
auth.register.title: Register to Papra
auth.register.description: Enter your email or use social login to access your Papra account.
auth.register.register-with-email: Register with email
auth.register.register-with-provider: Register with {{ provider }}
auth.register.providers.google: Google
auth.register.providers.github: GitHub
auth.register.have-account: Already have an account?
auth.register.login: Login
auth.register.registration-disabled.title: Registration is disabled
auth.register.registration-disabled.description: The creation of new accounts is currently disabled on this instance of Papra. Only users with existing accounts can log in. If you think this is a mistake, please contact the administrator of this instance.
auth.register.form.email.label: Email
auth.register.form.email.placeholder: 'Example: ada@papra.app'
auth.register.form.email.required: Please enter your email address
auth.register.form.email.invalid: This email address is invalid
auth.register.form.password.label: Password
auth.register.form.password.placeholder: Set a password
auth.register.form.password.required: Please enter your password
auth.register.form.password.min-length: Password must be at least {{ minLength }} characters
auth.register.form.password.max-length: Password must be less than {{ maxLength }} characters
auth.register.form.name.label: Name
auth.register.form.name.placeholder: 'Example: Ada Lovelace'
auth.register.form.name.required: Please enter your name
auth.register.form.name.max-length: Name must be less than {{ maxLength }} characters
auth.register.form.submit: Register
tags:
no-tags:
title: No tags yet
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.
create-tag: Create tag
auth.email-validation-required.title: Verify your email
auth.email-validation-required.description: A verification email has been sent to your email address. Please verify your email address by clicking the link in the email.
layout:
menu:
home: Home
documents: Documents
tags: Tags
tagging-rules: Tagging rules
integrations: Integrations
deleted-documents: Deleted documents
organization-settings: Organization settings
auth.legal-links.description: By continuing, you acknowledge that you understand and agree to the {{ terms }} and {{ privacy }}.
auth.legal-links.terms: Terms of Service
auth.legal-links.privacy: Privacy Policy
tagging-rules:
field:
name: document name
content: document content
operator:
equals: equals
not-equals: not equals
contains: contains
not-contains: not contains
starts-with: starts with
ends-with: ends with
list:
title: Tagging rules
description: Manage your organization's tagging rules, to automatically tag documents based on conditions you define.
demo-warning: 'Note: As this is a demo environment (with no server), tagging rules will not be applied to newly added documents.'
no-tagging-rules:
title: No tagging rules
description: Create a tagging rule to automatically tag your added documents based on conditions you define.
create-tagging-rule: Create tagging rule
card:
no-conditions: No conditions
one-condition: 1 condition
conditions: '{{ count }} conditions'
delete: Delete rule
edit: Edit rule
create:
title: Create tagging rule
success: Tagging rule created successfully
error: Failed to create tagging rule
submit: Create rule
form:
name:
label: Name
placeholder: 'Example: Tag invoices'
min-length: Please enter a name for the rule
max-length: The name must be less than 64 characters
description:
label: Description
placeholder: 'Example: Tag documents with "invoice" in the name'
max-length: The description must be less than 256 characters
conditions:
label: Conditions
description: Define the conditions that must be met for the rule to apply. All conditions must be met for the rule to apply.
add-condition: Add condition
no-conditions:
title: No conditions
description: You didn't add any conditions to this rule. This rule will apply its tags to all documents.
confirm: Apply rule without conditions
cancel: Cancel
field:
label: Field
operator:
label: Operator
value:
label: Value
placeholder: 'Example: invoice'
min-length: Please enter a value for the condition
tags:
label: Tags
description: Select the tags to apply to the added documents that match the conditions
min-length: At least one tag to apply is required
add-tag: Create tag
submit: Create rule
update:
title: Update tagging rule
success: Tagging rule updated successfully
error: Failed to update tagging rule
submit: Update rule
cancel: Cancel
demo:
popup:
description: This is a demo environment, all data is save to your browser local storage.
discord: Join the {{ discordLink }} to get support, propose features or just chat.
discord-link-label: Discord server
reset: Reset demo data
hide: Hide
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
trash:
delete-all:
button: Delete all
confirm:
title: Permanently delete all documents?
description: Are you sure you want to permanently delete all documents from the trash? This action cannot be undone.
label: Delete
cancel: Cancel
delete:
button: Delete
confirm:
title: Permanently delete document?
description: Are you sure you want to permanently delete this document from the trash? This action cannot be undone.
label: Delete
cancel: Cancel
deleted:
success:
title: Document deleted
description: The document has been permanently deleted.
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
import-documents:
title:
error: '{{ count }} documents failed'
success: '{{ count }} documents imported'
pending: '{{ count }} / {{ total }} documents imported'
none: Import documents
no-import-in-progress: No document import in progress
tagging-rules.field.name: document name
tagging-rules.field.content: document content
tagging-rules.operator.equals: equals
tagging-rules.operator.not-equals: not equals
tagging-rules.operator.contains: contains
tagging-rules.operator.not-contains: not contains
tagging-rules.operator.starts-with: starts with
tagging-rules.operator.ends-with: ends with
tagging-rules.list.title: Tagging rules
tagging-rules.list.description: Manage your organization's tagging rules, to automatically tag documents based on conditions you define.
tagging-rules.list.demo-warning: 'Note: As this is a demo environment (with no server), tagging rules will not be applied to newly added documents.'
tagging-rules.list.no-tagging-rules.title: No tagging rules
tagging-rules.list.no-tagging-rules.description: Create a tagging rule to automatically tag your added documents based on conditions you define.
tagging-rules.list.no-tagging-rules.create-tagging-rule: Create tagging rule
tagging-rules.list.card.no-conditions: No conditions
tagging-rules.list.card.one-condition: 1 condition
tagging-rules.list.card.conditions: '{{ count }} conditions'
tagging-rules.list.card.delete: Delete rule
tagging-rules.list.card.edit: Edit rule
tagging-rules.create.title: Create tagging rule
tagging-rules.create.success: Tagging rule created successfully
tagging-rules.create.error: Failed to create tagging rule
tagging-rules.create.submit: Create rule
tagging-rules.form.name.label: Name
tagging-rules.form.name.placeholder: 'Example: Tag invoices'
tagging-rules.form.name.min-length: Please enter a name for the rule
tagging-rules.form.name.max-length: The name must be less than 64 characters
tagging-rules.form.description.label: Description
tagging-rules.form.description.placeholder: "Example: Tag documents with 'invoice' in the name"
tagging-rules.form.description.max-length: The description must be less than 256 characters
tagging-rules.form.conditions.label: Conditions
tagging-rules.form.conditions.description: Define the conditions that must be met for the rule to apply. All conditions must be met for the rule to apply.
tagging-rules.form.conditions.add-condition: Add condition
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.value.placeholder: 'Example: invoice'
tagging-rules.form.conditions.value.min-length: Please enter a value for the condition
tagging-rules.form.tags.label: Tags
tagging-rules.form.tags.description: Select the tags to apply to the added documents that match the conditions
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.error: Failed to update tagging rule
tagging-rules.update.submit: Update rule
tagging-rules.update.cancel: Cancel
api-errors:
document.already_exists: The document already exists
document.file_too_big: The document file is too big
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.
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.
default: An error occurred while processing your request.
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
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.
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.permissions.documents.title: Documents
api-keys.permissions.documents.documents:create: Create documents
api-keys.permissions.documents.documents:read: Read documents
api-keys.permissions.documents.documents:update: Update documents
api-keys.permissions.documents.documents:delete: Delete documents
api-keys.permissions.tags.title: Tags
api-keys.permissions.tags.tags:create: Create tags
api-keys.permissions.tags.tags:read: Read tags
api-keys.permissions.tags.tags:update: Update tags
api-keys.permissions.tags.tags:delete: Delete tags
api-keys.create.title: Create API key
api-keys.create.description: Create a new API key to access the Papra API.
api-keys.create.success: The API key has been created successfully.
api-keys.create.back: Back to API keys
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.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.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.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.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

View File

@@ -1,37 +1,202 @@
auth:
login:
title: Connexion à Papra
description: Entrez votre adresse e-mail ou utilisez un service tiers pour accéder à votre compte Papra.
login-with-provider: Connexion via {{ provider }}
no-account: Vous n'avez pas de compte ?
register: S'inscrire
email-validation-required:
title: Vérifiez votre adresse e-mail
description: Un e-mail de vérification a été envoyé à votre adresse. Veuillez vérifier votre adresse en cliquant sur le lien dans l'e-mail.
legal-links:
description: En continuant, vous reconnaissez que vous comprenez et acceptez les {{ terms }} et la {{ privacy }}.
terms: Conditions d'utilisation
privacy: Politique de confidentialité
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.
auth.request-password-reset.back-to-login: Retour à la connexion
auth.request-password-reset.form.email.label: Email
auth.request-password-reset.form.email.placeholder: 'Exemple: ada@papra.app'
auth.request-password-reset.form.email.required: Veuillez entrer votre adresse email
auth.request-password-reset.form.email.invalid: Cette adresse email est invalide
auth.request-password-reset.form.submit: Réinitialiser le mot de passe
tags:
no-tags:
title: Aucun tag pour le moment
description: Cette organisation n'a pas encore 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.
create-tag: Créer un tag
auth.reset-password.title: Réinitialiser votre mot de passe
auth.reset-password.description: Entrez votre nouveau mot de passe pour réinitialiser votre mot de passe.
auth.reset-password.reset: Votre mot de passe a été réinitialisé.
auth.reset-password.back-to-login: Retour à la connexion
auth.reset-password.form.new-password.label: Nouveau mot de passe
auth.reset-password.form.new-password.placeholder: 'Exemple: **********'
auth.reset-password.form.new-password.required: Veuillez entrer votre nouveau mot de passe
auth.reset-password.form.new-password.min-length: Le mot de passe doit contenir au moins {{ minLength }} caractères
auth.reset-password.form.new-password.max-length: Le mot de passe doit contenir moins de {{ maxLength }} caractères
auth.reset-password.form.submit: Réinitialiser le mot de passe
layout:
menu:
home: Accueil
documents: Documents
tags: Tags
integrations: Intégrations
deleted-documents: Documents supprimés
organization-settings: Paramètres de l'organisation
auth.email-provider.open: Ouvrir {{ provider }}
demo:
popup:
description: Ceci est un environnement de démo, toutes les données sont enregistrées dans le local storage de votre navigateur.
discord: Rejoignez le {{ discordLink }} pour obtenir de l'aide, proposer des fonctionnalités ou discuter avec l'équipe.
discord-link-label: Serveur Discord
reset: Réinitialiser la démo
hide: Masquer
auth.login.title: Connexion à Papra
auth.login.description: Entrez votre email ou utilisez une connexion sociale pour accéder à votre compte Papra.
auth.login.login-with-provider: Connexion avec {{ provider }}
auth.login.no-account: Je n'ai pas de compte
auth.login.register: S'inscrire
auth.login.form.email.label: Email
auth.login.form.email.placeholder: 'Exemple: ada@papra.app'
auth.login.form.email.required: Veuillez entrer votre adresse email
auth.login.form.email.invalid: Cette adresse email est invalide
auth.login.form.password.label: Mot de passe
auth.login.form.password.placeholder: Définir un mot de passe
auth.login.form.password.required: Veuillez entrer votre mot de passe
auth.login.form.remember-me.label: Se souvenir de moi
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.register-with-email: S'inscrire avec email
auth.register.register-with-provider: S'inscrire avec {{ provider }}
auth.register.providers.google: Google
auth.register.providers.github: GitHub
auth.register.have-account: Je possède déjà un compte
auth.register.login: Connexion
auth.register.registration-disabled.title: Inscription désactivée
auth.register.registration-disabled.description: La création de nouveaux comptes est actuellement désactivée sur cette instance de Papra. Seuls les utilisateurs avec un compte existant peuvent se connecter. Si vous pensez que c'est une erreur, veuillez contacter l'administrateur de cette instance.
auth.register.form.email.label: Email
auth.register.form.email.placeholder: 'Exemple: ada@papra.app'
auth.register.form.email.required: Veuillez entrer votre adresse email
auth.register.form.email.invalid: Cette adresse email est invalide
auth.register.form.password.label: Mot de passe
auth.register.form.password.placeholder: Définir un mot de passe
auth.register.form.password.required: Veuillez entrer votre mot de passe
auth.register.form.password.min-length: Le mot de passe doit contenir au moins {{ minLength }} caractères
auth.register.form.password.max-length: Le mot de passe doit contenir moins de {{ maxLength }} caractères
auth.register.form.name.label: Nom
auth.register.form.name.placeholder: 'Exemple: Ada Lovelace'
auth.register.form.name.required: Veuillez entrer votre nom
auth.register.form.name.max-length: Le nom doit contenir moins de {{ maxLength }} caractères
auth.register.form.submit: S'inscrire
auth.email-validation-required.title: Vérifier votre email
auth.email-validation-required.description: Un email de vérification a été envoyé à votre adresse email. Veuillez vérifier votre adresse email en cliquant sur le lien dans l'email.
auth.legal-links.description: En continuant, vous reconnaissez que vous comprenez et acceptez les {{ terms }} et {{ privacy }}.
auth.legal-links.terms: Conditions d'utilisation
auth.legal-links.privacy: Politique de confidentialité
tags.no-tags.title: Aucun tag
tags.no-tags.description: Cette organisation n'a pas de tags. Les tags sont utilisés pour catégoriser les documents. Vous pouvez ajouter des tags à vos documents pour les rendre plus faciles à trouver et à organiser.
tags.no-tags.create-tag: Créer un tag
layout.menu.home: Accueil
layout.menu.documents: Documents
layout.menu.tags: Tags
layout.menu.tagging-rules: Règles de catégorisation
layout.menu.deleted-documents: Documents supprimés
layout.menu.organization-settings: Paramètres
layout.menu.api-keys: API keys
layout.menu.settings: Paramètres
layout.menu.account: Compte
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.'
tagging-rules.list.no-tagging-rules.title: Aucune règle de catégorisation
tagging-rules.list.no-tagging-rules.description: Créez une règle de catégorisation pour catégoriser automatiquement vos documents en fonction de conditions que vous définissez.
tagging-rules.list.no-tagging-rules.create-tagging-rule: Créer une règle de catégorisation
tagging-rules.list.card.no-conditions: Aucune condition
tagging-rules.list.card.one-condition: 1 condition
tagging-rules.list.card.conditions: '{{ count }} conditions'
tagging-rules.list.card.delete: Supprimer la règle
tagging-rules.list.card.edit: Modifier la règle
tagging-rules.create.title: Créer une règle de catégorisation
tagging-rules.create.success: Règle de catégorisation créée avec succès
tagging-rules.create.error: Échec de la création de la règle de catégorisation
tagging-rules.create.submit: Créer la règle
tagging-rules.form.name.label: Nom
tagging-rules.form.name.placeholder: 'Exemple: Catégoriser les factures'
tagging-rules.form.name.min-length: Veuillez entrer un nom pour la règle
tagging-rules.form.name.max-length: Le nom doit contenir moins de 64 caractères
tagging-rules.form.description.label: Description
tagging-rules.form.description.placeholder: "Exemple: Catégoriser les documents avec 'facture' dans le nom"
tagging-rules.form.description.max-length: La description doit contenir moins de 256 caractères
tagging-rules.form.conditions.label: Conditions
tagging-rules.form.conditions.description: Définissez les conditions que doivent remplir la règle pour qu'elle s'applique. Toutes les conditions doivent être remplies pour que la règle s'applique.
tagging-rules.form.conditions.add-condition: Ajouter une condition
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.value.placeholder: 'Exemple: facture'
tagging-rules.form.conditions.value.min-length: Veuillez entrer une valeur pour la condition
tagging-rules.form.tags.label: Tags
tagging-rules.form.tags.description: Sélectionnez les tags à appliquer aux documents ajoutés qui correspondent aux conditions
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.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
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.
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.permissions.documents.title: Documents
api-keys.permissions.documents.documents:create: Créer des documents
api-keys.permissions.documents.documents:read: Lire des documents
api-keys.permissions.documents.documents:update: Mettre à jour des documents
api-keys.permissions.documents.documents:delete: Supprimer des documents
api-keys.permissions.tags.title: Tags
api-keys.permissions.tags.tags:create: Créer des tags
api-keys.permissions.tags.tags:read: Lire des tags
api-keys.permissions.tags.tags:update: Mettre à jour des tags
api-keys.permissions.tags.tags:delete: Supprimer des tags
api-keys.create.title: Créer une clé API
api-keys.create.description: Créer une nouvelle clé API pour accéder à l'API de Papra.
api-keys.create.success: La clé API a été créée avec succès.
api-keys.create.back: Retour aux clés API
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.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.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.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

View File

@@ -0,0 +1,28 @@
// export const API_KEY_PERMISSIONS = {
// documents: {
// create: 'documents:create',
// },
// } as const;
export const API_KEY_PERMISSIONS = [
{
section: 'documents',
permissions: [
'documents:create',
'documents:read',
'documents:update',
'documents:delete',
],
},
{
section: 'tags',
permissions: [
'tags:create',
'tags:read',
'tags:update',
'tags:delete',
],
},
] as const;
export const API_KEY_PERMISSIONS_LIST = API_KEY_PERMISSIONS.flatMap(permission => permission.permissions);

View File

@@ -0,0 +1,56 @@
import type { ApiKey } from './api-keys.types';
import { apiClient } from '../shared/http/api-client';
import { coerceDates } from '../shared/http/http-client.models';
export async function createApiKey({
name,
permissions,
organizationIds,
allOrganizations,
expiresAt,
}: {
name: string;
permissions: string[];
organizationIds: string[];
allOrganizations: boolean;
expiresAt?: Date;
}) {
const { apiKey, token } = await apiClient<{
apiKey: ApiKey;
token: string;
}>({
path: '/api/api-keys',
method: 'POST',
body: {
name,
permissions,
organizationIds,
allOrganizations,
expiresAt,
},
});
return {
apiKey: coerceDates(apiKey),
token,
};
}
export async function fetchApiKeys() {
const { apiKeys } = await apiClient<{
apiKeys: ApiKey[];
}>({
path: '/api/api-keys',
});
return {
apiKeys: apiKeys.map(coerceDates),
};
}
export async function deleteApiKey({ apiKeyId }: { apiKeyId: string }) {
await apiClient({
path: `/api/api-keys/${apiKeyId}`,
method: 'DELETE',
});
}

View File

@@ -0,0 +1,12 @@
export type ApiKey = {
id: string;
name: string;
permissions: string[];
organizationIds: string[];
allOrganizations: boolean;
expiresAt?: Date;
prefix: string;
lastUsedAt?: Date;
createdAt: Date;
updatedAt: Date;
};

View File

@@ -0,0 +1,75 @@
import type { LocaleKeys } from '@/modules/i18n/locales.types';
import type { Component } from 'solid-js';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { Checkbox, CheckboxControl, CheckboxLabel } from '@/modules/ui/components/checkbox';
import { createSignal, For } from 'solid-js';
import { API_KEY_PERMISSIONS } from '../api-keys.constants';
export const ApiKeyPermissionsPicker: Component<{ permissions: string[]; onChange: (permissions: string[]) => void }> = (props) => {
const [permissions, setPermissions] = createSignal<string[]>(props.permissions);
const { t } = useI18n();
const getPermissionsSections = () => {
return API_KEY_PERMISSIONS.map(section => ({
...section,
title: t(`api-keys.permissions.${section.section}.title`),
permissions: section.permissions.map((permission) => {
const [prefix, suffix] = permission.split(':');
return {
name: permission,
prefix,
suffix,
description: t(`api-keys.permissions.${section.section}.${permission}` as LocaleKeys),
};
}),
}));
};
const isPermissionSelected = (permission: string) => {
return permissions().includes(permission);
};
const togglePermission = (permission: string) => {
setPermissions((prev) => {
if (prev.includes(permission)) {
return prev.filter(p => p !== permission);
}
return [...prev, permission];
});
props.onChange(permissions());
};
return (
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<For each={getPermissionsSections()}>
{section => (
<div>
<p class="text-muted-foreground text-xs">{section.title}</p>
<div class="pl-4 flex flex-col gap-4 mt-4">
<For each={section.permissions}>
{permission => (
<Checkbox
class="flex items-center gap-2"
checked={isPermissionSelected(permission.name)}
onChange={() => togglePermission(permission.name)}
>
<CheckboxControl />
<div class="flex flex-col gap-1">
<CheckboxLabel class="text-sm leading-none">
{permission.description}
</CheckboxLabel>
</div>
</Checkbox>
)}
</For>
</div>
</div>
)}
</For>
</div>
);
};

View File

@@ -0,0 +1,140 @@
import type { Component } from 'solid-js';
import type { ApiKey } from '../api-keys.types';
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 { 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(() => ({
mutationFn: deleteApiKey,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['api-keys'] });
createToast({
message: t('api-keys.delete.success'),
});
},
}));
const handleDelete = async () => {
const confirmed = await confirm({
title: t('api-keys.delete.confirm.title'),
message: t('api-keys.delete.confirm.message'),
confirmButton: {
text: t('api-keys.delete.confirm.confirm-button'),
variant: 'destructive',
},
cancelButton: {
text: t('api-keys.delete.confirm.cancel-button'),
},
});
if (!confirmed) {
return;
}
deleteApiKeyMutation.mutate({ apiKeyId: apiKey.id });
};
return (
<div class="bg-card rounded-lg border p-4 flex items-center gap-4">
<div class="rounded-lg bg-muted p-2">
<div class="i-tabler-key text-muted-foreground size-5 text-primary" />
</div>
<div class="flex-1">
<h2 class="text-sm font-medium leading-tight">{apiKey.name}</h2>
<p class="text-muted-foreground text-xs font-mono">{`${apiKey.prefix}...`}</p>
</div>
<div>
<p class="text-muted-foreground text-xs">
{t('api-keys.list.card.last-used')}
{' '}
{apiKey.lastUsedAt ? format(apiKey.lastUsedAt, 'MMM d, yyyy') : t('api-keys.list.card.never')}
</p>
<p class="text-muted-foreground text-xs">
{t('api-keys.list.card.created')}
{' '}
{format(apiKey.createdAt, 'MMM d, yyyy')}
</p>
</div>
<div class="flex items-center gap-2">
<Button
variant="outline"
size="icon"
isLoading={deleteApiKeyMutation.isPending}
onClick={handleDelete}
>
<div class="i-tabler-trash text-muted-foreground size-4" />
</Button>
</div>
</div>
);
};
export const ApiKeysPage: Component = () => {
const { t } = useI18n();
const query = createQuery(() => ({
queryKey: ['api-keys'],
queryFn: () => fetchApiKeys(),
}));
return (
<div class="p-6 mt-12 pb-32 mx-auto max-w-xl w-full">
<div class="border-b pb-4 flex items-center justify-between">
<div>
<h1 class="text-2xl font-semibold mb-1">{t('api-keys.list.title')}</h1>
<p class="text-muted-foreground">{t('api-keys.list.description')}</p>
</div>
<div>
<Show when={query.data?.apiKeys?.length}>
<Button as={A} href="/api-keys/create" class="gap-2">
<div class="i-tabler-plus size-4" />
{t('api-keys.list.create')}
</Button>
</Show>
</div>
</div>
<Suspense>
<Switch>
<Match when={query.data?.apiKeys?.length === 0}>
<EmptyState
title={t('api-keys.list.empty.title')}
description={t('api-keys.list.empty.description')}
icon="i-tabler-key"
cta={(
<Button as={A} href="/api-keys/create" class="gap-2">
<div class="i-tabler-plus size-4" />
{t('api-keys.list.create')}
</Button>
)}
/>
</Match>
<Match when={query.data?.apiKeys?.length}>
<div class="mt-6 flex flex-col gap-2">
<For each={query.data?.apiKeys}>
{apiKey => (
<ApiKeyCard apiKey={apiKey} />
)}
</For>
</div>
</Match>
</Switch>
</Suspense>
</div>
);
};

View File

@@ -0,0 +1,118 @@
import type { Component } from 'solid-js';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { createForm } from '@/modules/shared/form/form';
import { queryClient } from '@/modules/shared/query/query-client';
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 { 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';
export const CreateApiKeyPage: Component = () => {
const { t } = useI18n();
const [getToken, setToken] = createSignal<string | null>(null);
const { form, Form, Field } = createForm({
onSubmit: async ({ name, permissions }) => {
const { token } = await createApiKey({
name,
permissions,
organizationIds: [],
allOrganizations: false,
});
await queryClient.invalidateQueries({ queryKey: ['api-keys'] });
setToken(token);
createToast({
type: 'success',
message: t('api-keys.create.success'),
});
},
schema: v.object({
name: v.pipe(
v.string(),
v.nonEmpty(t('api-keys.create.form.name.required')),
),
permissions: v.pipe(
v.array(v.picklist(API_KEY_PERMISSIONS_LIST as string[])),
v.nonEmpty(t('api-keys.create.form.permissions.required')),
),
}),
initialValues: {
name: '',
permissions: API_KEY_PERMISSIONS_LIST,
},
});
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-bold">{t('api-keys.create.title')}</h1>
<p class="text-sm text-muted-foreground">{t('api-keys.create.description')}</p>
</div>
<Show when={getToken()}>
<div class="bg-card border p-6 rounded-md mt-6">
<h2 class="text-lg font-semibold mb-2">{t('api-keys.create.created.title')}</h2>
<p class="text-sm text-muted-foreground mb-4">{t('api-keys.create.created.description')}</p>
<TextFieldRoot class="flex items-center gap-2 space-y-0">
<TextField type="text" placeholder={t('api-keys.create.form.name.placeholder')} value={getToken() ?? ''} />
<CopyButton text={getToken() ?? ''} />
</TextFieldRoot>
</div>
<div class="flex justify-end mt-6">
<Button type="button" variant="secondary" as={A} href="/api-keys">
{t('api-keys.create.back')}
</Button>
</div>
</Show>
<Show when={!getToken()}>
<Form>
<Field name="name">
{(field, inputProps) => (
<TextFieldRoot class="flex flex-col mb-6">
<TextFieldLabel for="name">{t('api-keys.create.form.name.label')}</TextFieldLabel>
<TextField type="text" id="name" placeholder={t('api-keys.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>
)}
</Field>
<Field name="permissions" type="string[]">
{field => (
<div>
<p class="text-sm font-bold">{t('api-keys.create.form.permissions.label')}</p>
<div class="p-6 pb-8 border rounded-md mt-2">
<ApiKeyPermissionsPicker permissions={field.value ?? []} onChange={permissions => setValue(form, 'permissions', permissions)} />
</div>
{field.error && <div class="text-red-500 text-sm">{field.error}</div>}
</div>
)}
</Field>
<div class="flex justify-end mt-6">
<Button type="submit" isLoading={form.submitting}>
{t('api-keys.create.form.submit')}
</Button>
</div>
</Form>
</Show>
</div>
);
};

View File

@@ -1,7 +1,8 @@
import type { Component, ComponentProps } 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';
import { splitProps } from 'solid-js';
const providers = [
{

View File

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

View File

@@ -1,3 +1,4 @@
import type { Component } from 'solid-js';
import type { SsoProviderKey } from '../auth.types';
import { useConfig } from '@/modules/config/config.provider';
import { useI18n } from '@/modules/i18n/i18n.provider';
@@ -7,7 +8,7 @@ import { Checkbox, CheckboxControl, CheckboxLabel } from '@/modules/ui/component
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 { 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';

View File

@@ -1,3 +1,4 @@
import type { Component } from 'solid-js';
import type { ssoProviders } from '../auth.constants';
import { useConfig } from '@/modules/config/config.provider';
import { useI18n } from '@/modules/i18n/i18n.provider';
@@ -6,7 +7,7 @@ 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 { createSignal, For, Show } from 'solid-js';
import * as v from 'valibot';
import { AuthLayout } from '../../ui/layouts/auth-layout.component';
import { getEnabledSsoProviderConfigs } from '../auth.models';

View File

@@ -1,3 +1,4 @@
import type { Component } from 'solid-js';
import { useConfig } from '@/modules/config/config.provider';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { createForm } from '@/modules/shared/form/form';
@@ -5,7 +6,7 @@ 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 { createSignal, onMount } from 'solid-js';
import * as v from 'valibot';
import { AuthLayout } from '../../ui/layouts/auth-layout.component';
import { forgetPassword } from '../auth.services';

View File

@@ -1,11 +1,11 @@
import type { Component } from 'solid-js';
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 { createSignal, onMount } from 'solid-js';
import * as v from 'valibot';
import { AuthLayout } from '../../ui/layouts/auth-layout.component';
import { resetPassword } from '../auth.services';

View File

@@ -1,11 +1,12 @@
import type { ParentComponent } from 'solid-js';
import type { Config, RuntimePublicConfig } from './config';
import { createQuery } 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<{

View File

@@ -1,10 +1,29 @@
import type { ApiKey } from '../api-keys/api-keys.types';
import { get } from 'lodash-es';
import { FetchError } from 'ofetch';
import { createRouter } from 'radix3';
import { defineHandler } from './demo-api-mock.models';
import { documentFileStorage, documentStorage, organizationStorage, tagDocumentStorage, taggingRuleStorage, tagStorage } from './demo.storage';
import {
apiKeyStorage,
documentFileStorage,
documentStorage,
organizationStorage,
tagDocumentStorage,
taggingRuleStorage,
tagStorage,
} from './demo.storage';
import { findMany, getValues } from './demo.storage.models';
const corpus = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
function randomString({ length = 10 }: { length?: number } = {}) {
return Array.from({ length }, () => corpus[Math.floor(Math.random() * corpus.length)]).join('');
}
function createId({ prefix }: { prefix: string }) {
return `${prefix}_${randomString({ length: 24 })}`;
}
function assert(condition: unknown, { message = 'Error', status }: { message?: string; status?: number } = {}): asserts condition {
if (!condition) {
throw Object.assign(new FetchError(message), { status });
@@ -114,12 +133,13 @@ const inMemoryApiMock: Record<string, { handler: any }> = {
assert(file, { status: 400 });
const document = {
id: `doc_${Math.random().toString(36).slice(2)}`,
id: createId({ prefix: 'doc' }),
organizationId,
name: file.name,
originalName: file.name,
originalSize: file.size,
mimeType: file.type,
content: '',
createdAt: new Date(),
updatedAt: new Date(),
tags: [],
@@ -310,7 +330,7 @@ const inMemoryApiMock: Record<string, { handler: any }> = {
assert(organization, { status: 403 });
const tag = {
id: `tag_${Math.random().toString(36).slice(2)}`,
id: createId({ prefix: 'tag' }),
organizationId,
name: get(body, 'name'),
color: get(body, 'color'),
@@ -372,7 +392,7 @@ const inMemoryApiMock: Record<string, { handler: any }> = {
assert(tagId, { status: 400 });
const tagDocument = {
id: `tagDoc_${Math.random().toString(36).slice(2)}`,
id: createId({ prefix: 'tagDoc' }),
tagId,
documentId,
createdAt: new Date(),
@@ -411,7 +431,7 @@ const inMemoryApiMock: Record<string, { handler: any }> = {
method: 'POST',
handler: async ({ body }) => {
const organization = {
id: `org_${Math.random().toString(36).slice(2)}`,
id: createId({ prefix: 'org' }),
name: get(body, 'name'),
createdAt: new Date(),
updatedAt: new Date(),
@@ -475,7 +495,7 @@ const inMemoryApiMock: Record<string, { handler: any }> = {
method: 'POST',
handler: async ({ params: { organizationId }, body }) => {
const taggingRule = {
id: `tr_${Math.random().toString(36).slice(2)}`,
id: createId({ prefix: 'tr' }),
organizationId,
name: get(body, 'name'),
description: get(body, 'description'),
@@ -544,6 +564,48 @@ const inMemoryApiMock: Record<string, { handler: any }> = {
await documentStorage.removeItem(key);
},
}),
...defineHandler({
path: '/api/api-keys',
method: 'GET',
handler: async () => {
const apiKeys = await getValues(apiKeyStorage);
return { apiKeys };
},
}),
...defineHandler({
path: '/api/api-keys',
method: 'POST',
handler: async ({ body }) => {
const token = `ppapi_${randomString({ length: 64 })}`;
const apiKey = {
id: createId({ prefix: 'apiKey' }),
name: get(body, 'name'),
permissions: get(body, 'permissions'),
organizationIds: get(body, 'organizationIds'),
allOrganizations: get(body, 'allOrganizations'),
expiresAt: get(body, 'expiresAt'),
createdAt: new Date(),
updatedAt: new Date(),
prefix: token.slice(0, 11),
} as ApiKey;
await apiKeyStorage.setItem(apiKey.id, apiKey);
return { apiKey, token };
},
}),
...defineHandler({
path: '/api/api-keys/:apiKeyId',
method: 'DELETE',
handler: async ({ params: { apiKeyId } }) => {
await apiKeyStorage.removeItem(apiKeyId);
},
}),
};
export const router = createRouter({ routes: inMemoryApiMock, strictTrailingSlash: false });

View File

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

View File

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

View File

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

View File

@@ -1,3 +1,4 @@
import type { ApiKey } from '../api-keys/api-keys.types';
import type { Document } from '../documents/documents.types';
import type { Organization } from '../organizations/organizations.types';
import type { TaggingRule } from '../tagging-rules/tagging-rules.types';
@@ -16,6 +17,7 @@ export const documentFileStorage = prefixStorage(storage, 'documentFiles');
export const tagStorage = prefixStorage<Omit<Tag, 'documentsCount'>>(storage, 'tags');
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 async function clearDemoStorage() {
await storage.clear();

View File

@@ -50,7 +50,7 @@ type TaskError = {
type Task = TaskSuccess | TaskError | {
file: File;
status: 'pending' | 'uploading' ;
status: 'pending' | 'uploading';
};
export const DocumentUploadProvider: ParentComponent = (props) => {

View File

@@ -1,7 +1,8 @@
import type { Component } from 'solid-js';
import type { Document } from '../documents.types';
import { Card } from '@/modules/ui/components/card';
import { createQuery } from '@tanstack/solid-query';
import { type Component, createResource, Match, Suspense, Switch } from 'solid-js';
import { createResource, Match, Suspense, Switch } from 'solid-js';
import { fetchDocumentFile } from '../documents.services';
import { PdfViewer } from './pdf-viewer.component';
@@ -24,7 +25,7 @@ const TextFromBlob: Component<{ blob: Blob }> = (props) => {
return (
<Card class="p-6 overflow-auto max-h-800px max-w-full text-xs">
<Suspense>
<pre>{txt()}</pre>
<pre class="break-words whitespace-pre-wrap">{txt()}</pre>
</Suspense>
</Card>
);

View File

@@ -1,6 +1,7 @@
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 { timeAgo } from '@/modules/shared/date/time-ago';
import { cn } from '@/modules/shared/style/cn';
@@ -12,8 +13,8 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/modules/ui/components
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 } from '../document.models';
import { For, Match, Show, Switch } from 'solid-js';
import { getDocumentIcon, getDocumentNameExtension, getDocumentNameWithoutExtension } from '../document.models';
import { DocumentManagementDropdown } from './document-management-dropdown.component';
type Pagination = {
@@ -83,15 +84,11 @@ export const DocumentsPaginatedList: Component<{
href={`/organizations/${data.row.original.organizationId}/documents/${data.row.original.id}`}
class="font-bold truncate block hover:underline"
>
{data.row.original.name.split('.').slice(0, -1).join('.')}
{getDocumentNameWithoutExtension({ name: data.row.original.name })}
</A>
<div class="text-xs text-muted-foreground lh-tight">
{formatBytes({ bytes: data.row.original.originalSize, base: 1000 })}
{' '}
-
{' '}
{data.row.original.name.split('.').pop()?.toUpperCase()}
{[formatBytes({ bytes: data.row.original.originalSize, base: 1000 }), getDocumentNameExtension({ name: data.row.original.name })].filter(Boolean).join(' - ')}
{' '}
-
{' '}

View File

@@ -1,7 +1,7 @@
import { icons as tablerIconSet } from '@iconify-json/tabler';
import { values } from 'lodash-es';
import { describe, expect, test } from 'vitest';
import { getDaysBeforePermanentDeletion, getDocumentIcon, iconByFileType } from './document.models';
import { getDaysBeforePermanentDeletion, getDocumentIcon, getDocumentNameExtension, getDocumentNameWithoutExtension, iconByFileType } from './document.models';
describe('files models', () => {
describe('iconByFileType', () => {
@@ -101,4 +101,24 @@ describe('files models', () => {
expect(daysBeforeDeletion).to.eql(undefined);
});
});
describe('getDocumentNameWithoutExtension', () => {
test('get the document name without the extension', () => {
expect(getDocumentNameWithoutExtension({ name: 'document.txt' })).to.eql('document');
expect(getDocumentNameWithoutExtension({ name: 'document' })).to.eql('document');
expect(getDocumentNameWithoutExtension({ name: '.document' })).to.eql('.document');
expect(getDocumentNameWithoutExtension({ name: '.document.txt' })).to.eql('.document');
expect(getDocumentNameWithoutExtension({ name: 'document.test.txt' })).to.eql('document.test');
});
});
describe('getDocumentNameExtension', () => {
test('get the document name extension', () => {
expect(getDocumentNameExtension({ name: 'document.txt' })).to.eql('txt');
expect(getDocumentNameExtension({ name: 'document' })).to.eql(undefined);
expect(getDocumentNameExtension({ name: '.document' })).to.eql(undefined);
expect(getDocumentNameExtension({ name: '.document.txt' })).to.eql('txt');
expect(getDocumentNameExtension({ name: 'document.test.txt' })).to.eql('txt');
});
});
});

View File

@@ -49,3 +49,33 @@ export function getDaysBeforePermanentDeletion({ document, deletedDocumentsReten
return daysBeforeDeletion;
}
export function getDocumentNameWithoutExtension({ name }: { name: string }) {
const dotSplittedName = name.split('.');
const dotCount = dotSplittedName.length - 1;
if (dotCount === 0) {
return name;
}
if (dotCount === 1 && name.startsWith('.')) {
return name;
}
return dotSplittedName.slice(0, -1).join('.');
}
export function getDocumentNameExtension({ name }: { name: string }) {
const dotSplittedName = name.split('.');
const dotCount = dotSplittedName.length - 1;
if (dotCount === 0) {
return undefined;
}
if (dotCount === 1 && name.startsWith('.')) {
return undefined;
}
return dotSplittedName[dotCount];
}

View File

@@ -1,6 +1,7 @@
import type { AsDto } from '../shared/http/http-client.types';
import type { Document } from './documents.types';
import { apiClient } from '../shared/http/api-client';
import { getFormData } from '../shared/http/http-client.models';
import { coerceDates, getFormData } from '../shared/http/http-client.models';
export async function uploadDocument({
file,
@@ -9,18 +10,14 @@ export async function uploadDocument({
file: File;
organizationId: string;
}) {
const { document } = await apiClient<{ document: Document }>({
const { document } = await apiClient<{ document: AsDto<Document> }>({
method: 'POST',
path: `/api/organizations/${organizationId}/documents`,
body: getFormData({ file }),
});
return {
document: {
...document,
createdAt: new Date(document.createdAt),
updatedAt: document.updatedAt ? new Date(document.updatedAt) : undefined,
},
document: coerceDates(document),
};
}
@@ -40,7 +37,7 @@ export async function fetchOrganizationDocuments({
const {
documents,
documentsCount,
} = await apiClient<{ documents: Document[]; documentsCount: number }>({
} = await apiClient<{ documents: AsDto<Document>[]; documentsCount: number }>({
method: 'GET',
path: `/api/organizations/${organizationId}/documents`,
query: {
@@ -52,11 +49,7 @@ export async function fetchOrganizationDocuments({
return {
documentsCount,
documents: documents.map(document => ({
...document,
createdAt: new Date(document.createdAt),
updatedAt: document.updatedAt ? new Date(document.updatedAt) : undefined,
})),
documents: documents.map(coerceDates),
};
}
@@ -72,7 +65,7 @@ export async function fetchOrganizationDeletedDocuments({
const {
documents,
documentsCount,
} = await apiClient<{ documents: Document[]; documentsCount: number }>({
} = await apiClient<{ documents: AsDto<Document>[]; documentsCount: number }>({
method: 'GET',
path: `/api/organizations/${organizationId}/documents/deleted`,
query: {
@@ -83,11 +76,7 @@ export async function fetchOrganizationDeletedDocuments({
return {
documentsCount,
documents: documents.map(document => ({
...document,
createdAt: new Date(document.createdAt),
updatedAt: document.updatedAt ? new Date(document.updatedAt) : undefined,
})),
documents: documents.map(coerceDates),
};
}
@@ -124,17 +113,13 @@ export async function fetchDocument({
documentId: string;
organizationId: string;
}) {
const { document } = await apiClient<{ document: Document }>({
const { document } = await apiClient<{ document: AsDto<Document> }>({
method: 'GET',
path: `/api/organizations/${organizationId}/documents/${documentId}`,
});
return {
document: {
...document,
createdAt: new Date(document.createdAt),
updatedAt: document.updatedAt ? new Date(document.updatedAt) : undefined,
},
document: coerceDates(document),
};
}
@@ -167,7 +152,7 @@ export async function searchDocuments({
}) {
const {
documents,
} = await apiClient<{ documents: Document[] }>({
} = await apiClient<{ documents: AsDto<Document>[] }>({
method: 'GET',
path: `/api/organizations/${organizationId}/documents/search`,
query: {
@@ -178,11 +163,7 @@ export async function searchDocuments({
});
return {
documents: documents.map(document => ({
...document,
createdAt: new Date(document.createdAt),
updatedAt: document.updatedAt ? new Date(document.updatedAt) : undefined,
})),
documents: documents.map(coerceDates),
};
}
@@ -208,3 +189,23 @@ export async function deleteTrashDocument({ documentId, organizationId }: { docu
path: `/api/organizations/${organizationId}/documents/trash/${documentId}`,
});
}
export async function updateDocument({
documentId,
organizationId,
content,
}: {
documentId: string;
organizationId: string;
content: string;
}) {
const { document } = await apiClient<{ document: AsDto<Document> }>({
method: 'PATCH',
path: `/api/organizations/${organizationId}/documents/${documentId}`,
body: { content },
});
return {
document: coerceDates(document),
};
}

View File

@@ -11,5 +11,6 @@ export type Document = {
isDeleted?: boolean;
deletedAt?: Date;
deletedBy?: string;
content: string;
tags: Tag[];
};

View File

@@ -1,3 +1,4 @@
import type { Component } from 'solid-js';
import type { Document } from '../documents.types';
import { useConfig } from '@/modules/config/config.provider';
import { useI18n } from '@/modules/i18n/i18n.provider';
@@ -9,7 +10,7 @@ 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 { 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';

View File

@@ -1,19 +1,27 @@
import type { Component, JSX } 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 { DocumentTagPicker } from '@/modules/tags/components/tag-picker.component';
import { CreateTagModal } from '@/modules/tags/pages/tags.page';
import { addTagToDocument, removeTagFromDocument } from '@/modules/tags/tags.services';
import { Alert } from '@/modules/ui/components/alert';
import { Alert, AlertDescription } from '@/modules/ui/components/alert';
import { Button } from '@/modules/ui/components/button';
import { Separator } from '@/modules/ui/components/separator';
import { formatBytes } from '@corentinth/chisels';
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, For, Show, Suspense } from 'solid-js';
import { DocumentPreview } from '../components/document-preview.component';
import { getDaysBeforePermanentDeletion } from '../document.models';
import { useDeleteDocument, useRestoreDocument } from '../documents.composables';
import { fetchDocument, fetchDocumentFile } from '../documents.services';
import { fetchDocument, fetchDocumentFile, updateDocument } from '../documents.services';
import '@pdfslick/solid/dist/pdf_viewer.css';
type KeyValueItem = {
@@ -43,6 +51,7 @@ const KeyValues: Component<{ data?: KeyValueItem[] }> = (props) => {
};
export const DocumentPage: Component = () => {
const { t } = useI18n();
const params = useParams();
const { deleteDocument } = useDeleteDocument();
const { restore, getIsRestoring } = useRestoreDocument();
@@ -82,6 +91,44 @@ export const DocumentPage: Component = () => {
const getDataUrl = () => queries[1].data ? URL.createObjectURL(queries[1].data) : undefined;
const [isEditing, setIsEditing] = createSignal(false);
const [editedContent, setEditedContent] = createSignal('');
const [isSaving, setIsSaving] = createSignal(false);
const handleEdit = () => {
setEditedContent(queries[0].data?.document.content ?? '');
setIsEditing(true);
};
const handleCancel = () => {
setIsEditing(false);
setEditedContent('');
};
const handleSave = async () => {
if (!queries[0].data?.document) {
return;
}
setIsSaving(true);
const [, error] = await safely(updateDocument({
documentId: queries[0].data.document.id,
organizationId: params.organizationId,
content: editedContent(),
}));
setIsSaving(false);
setIsEditing(false);
if (error) {
createToast({ type: 'error', message: 'Failed to update document content' });
return;
}
createToast({ type: 'success', message: 'Document content updated' });
await queryClient.invalidateQueries({
queryKey: ['organizations', params.organizationId, 'documents', params.documentId],
});
};
return (
<div class="p-6 flex gap-6 h-full flex-col md:flex-row max-w-7xl mx-auto">
<Suspense>
@@ -136,25 +183,38 @@ export const DocumentPage: Component = () => {
)}
</div>
<DocumentTagPicker
organizationId={params.organizationId}
tagIds={getDocument().tags.map(tag => tag.id)}
onTagAdded={async ({ tag }) => {
await addTagToDocument({
documentId: params.documentId,
organizationId: params.organizationId,
tagId: tag.id,
});
}}
<div class="flex gap-2 sm:items-center sm:flex-row flex-col">
<div class="flex-1">
<DocumentTagPicker
organizationId={params.organizationId}
tagIds={getDocument().tags.map(tag => tag.id)}
onTagAdded={async ({ tag }) => {
await addTagToDocument({
documentId: params.documentId,
organizationId: params.organizationId,
tagId: tag.id,
});
}}
onTagRemoved={async ({ tag }) => {
await removeTagFromDocument({
documentId: params.documentId,
organizationId: params.organizationId,
tagId: tag.id,
});
}}
/>
onTagRemoved={async ({ tag }) => {
await removeTagFromDocument({
documentId: params.documentId,
organizationId: params.organizationId,
tagId: tag.id,
});
}}
/>
</div>
<CreateTagModal organizationId={params.organizationId}>
{params => (
<Button variant="outline" {...params}>
<div class="i-tabler-plus size-4 mr-2"></div>
{t('tagging-rules.form.tags.add-tag')}
</Button>
)}
</CreateTagModal>
</div>
{getDocument().isDeleted && (
<Alert variant="destructive" class="mt-6">
@@ -169,41 +229,95 @@ export const DocumentPage: Component = () => {
</Alert>
)}
<Separator class="my-6" />
<Separator class="my-3" />
<Tabs defaultValue="info" class="w-full">
<TabsList class="w-full h-8">
<TabsTrigger value="info">Info</TabsTrigger>
<TabsTrigger value="content">Content</TabsTrigger>
<TabsIndicator />
</TabsList>
<TabsContent value="info">
<KeyValues data={[
{
label: 'ID',
value: getDocument().id,
icon: 'i-tabler-id',
},
{
label: 'Name',
value: getDocument().name,
icon: 'i-tabler-file-text',
},
{
label: 'Type',
value: getDocument().mimeType,
icon: 'i-tabler-file-unknown',
},
{
label: 'Size',
value: formatBytes({ bytes: getDocument().originalSize, base: 1000 }),
icon: 'i-tabler-weight',
},
{
label: '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>,
icon: 'i-tabler-calendar',
},
]}
/>
</TabsContent>
<TabsContent value="content">
<Show
when={isEditing()}
fallback={(
<div class="flex flex-col gap-2">
<div class="whitespace-pre-wrap font-mono text-sm bg-muted p-4 rounded-md max-h-[400px] overflow-auto">
{queries[0].data?.document.content}
</div>
<div class="flex justify-end">
<Button variant="outline" onClick={handleEdit}>
<div class="i-tabler-edit size-4 mr-2" />
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.
</AlertDescription>
</Alert>
</div>
)}
>
<div class="flex flex-col gap-2">
<TextFieldRoot>
<TextArea
value={editedContent()}
onInput={e => setEditedContent(e.currentTarget.value)}
class="font-mono min-h-[200px]"
/>
</TextFieldRoot>
<div class="flex justify-end gap-2">
<Button variant="outline" onClick={handleCancel} disabled={isSaving()}>
Cancel
</Button>
<Button onClick={handleSave} disabled={isSaving()}>
{isSaving() ? 'Saving...' : 'Save'}
</Button>
</div>
</div>
</Show>
</TabsContent>
</Tabs>
<KeyValues data={[
{
label: 'ID',
value: getDocument().id,
icon: 'i-tabler-id',
},
{
label: 'Name',
value: getDocument().name,
icon: 'i-tabler-file-text',
},
{
label: 'Type',
value: getDocument().mimeType,
icon: 'i-tabler-file-unknown',
},
{
label: 'Size',
value: formatBytes({ bytes: getDocument().originalSize, base: 1000 }),
icon: 'i-tabler-weight',
},
{
label: '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>,
icon: 'i-tabler-calendar',
},
]}
/>
</div>
</div>
)}

View File

@@ -1,10 +1,11 @@
import type { Component } from 'solid-js';
import { fetchOrganization } from '@/modules/organizations/organizations.services';
import { Tag } from '@/modules/tags/components/tag.component';
import { fetchTags } from '@/modules/tags/tags.services';
import { useParams, useSearchParams } from '@solidjs/router';
import { 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 { DocumentUploadArea } from '../components/document-upload-area.component';
import { createdAtColumn, DocumentsPaginatedList, standardActionsColumn, tagsColumn } from '../components/documents-list.component';
import { fetchOrganizationDocuments } from '../documents.services';

View File

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

View File

@@ -2,7 +2,7 @@ import type { Accessor, ParentComponent, Setter } from 'solid-js';
import type { LocaleKeys } from './locales.types';
import { makePersisted } from '@solid-primitives/storage';
import { createContext, createEffect, createResource, createSignal, Show, useContext } from 'solid-js';
import defaultDict from '../../locales/en.yml?flattened';
import defaultDict from '../../locales/en.yml';
import { locales } from './i18n.constants';
import { createFragmentTranslator, createTranslator, findMatchingLocale } from './i18n.models';
@@ -28,7 +28,7 @@ export function useI18n() {
}
async function fetchDictionary(locale: Locale): Promise<Dictionary> {
const { default: dict } = await import(`../../locales/${locale}.yml?flattened`);
const { default: dict } = await import(`../../locales/${locale}.yml`);
return {
...defaultDict,

View File

@@ -1,10 +1,14 @@
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, query: '?flattened' });
const { en: defaultLocal, ...locales } = Object.fromEntries(
const rawLocales = import.meta.glob('../../locales/*.yml', { eager: true });
const locales = Object.fromEntries(
Object.entries(rawLocales).map(([key, value]: [string, any]) => [key.replace('../../locales/', '').replace('.yml', ''), value.default]),
);
const { en: defaultLocal } = locales;
describe('locales', () => {
for (const [locale, translations] of Object.entries(locales)) {
describe(locale, () => {
@@ -26,4 +30,38 @@ 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
];
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)');
});
});

File diff suppressed because one or more lines are too long

View File

@@ -1,9 +1,10 @@
import type { AsDto } from '../shared/http/http-client.types';
import type { IntakeEmail } from './intake-emails.types';
import { apiClient } from '../shared/http/api-client';
import { coerceDates } from '../shared/http/http-client.models';
export async function fetchIntakeEmails({ organizationId }: { organizationId: string }) {
const { intakeEmails } = await apiClient<{ intakeEmails: IntakeEmail[] }>({
const { intakeEmails } = await apiClient<{ intakeEmails: AsDto<IntakeEmail>[] }>({
path: `/api/organizations/${organizationId}/intake-emails`,
method: 'GET',
});
@@ -14,7 +15,7 @@ export async function fetchIntakeEmails({ organizationId }: { organizationId: st
}
export async function createIntakeEmail({ organizationId }: { organizationId: string }) {
const { intakeEmail } = await apiClient<{ intakeEmail: IntakeEmail }>({
const { intakeEmail } = await apiClient<{ intakeEmail: AsDto<IntakeEmail> }>({
path: `/api/organizations/${organizationId}/intake-emails`,
method: 'POST',
});
@@ -42,7 +43,7 @@ export async function updateIntakeEmail({
isEnabled?: boolean;
allowedOrigins?: string[];
}) {
const { intakeEmail } = await apiClient<{ intakeEmail: IntakeEmail }>({
const { intakeEmail } = await apiClient<{ intakeEmail: AsDto<IntakeEmail> }>({
path: `/api/organizations/${organizationId}/intake-emails/${intakeEmailId}`,
method: 'PUT',
body: {

View File

@@ -6,5 +6,5 @@ export type IntakeEmail = {
allowedOrigins: string[];
createdAt: Date;
updatedAt: Date | undefined;
updatedAt: Date;
};

View File

@@ -1,4 +1,5 @@
import type { DialogTriggerProps } from '@kobalte/core/dialog';
import type { Component, JSX } from 'solid-js';
import type { IntakeEmail } from '../intake-emails.types';
import { useConfig } from '@/modules/config/config.provider';
import { useConfirmModal } from '@/modules/shared/confirm';
@@ -16,8 +17,7 @@ import { TextField, TextFieldLabel, TextFieldRoot } from '@/modules/ui/component
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 { createSignal, For, Show, Suspense } from 'solid-js';
import * as v from 'valibot';
import { createIntakeEmail, deleteIntakeEmail, fetchIntakeEmails, updateIntakeEmail } from '../intake-emails.services';
@@ -211,9 +211,9 @@ export const IntakeEmailsPage: Component = () => {
};
return (
<Card class="p-6">
<div class="p-6 max-w-screen-md mx-auto mt-10">
<h2 class="text-base font-bold">Intake Emails</h2>
<h1 class="text-xl font-semibold">Intake Emails</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.
@@ -264,7 +264,7 @@ export const IntakeEmailsPage: Component = () => {
<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')} />
@@ -342,6 +342,6 @@ export const IntakeEmailsPage: Component = () => {
)}
</Show>
</Suspense>
</Card>
</div>
);
};

View File

@@ -1,65 +1,51 @@
import type { AsDto } from '../shared/http/http-client.types';
import type { Organization } from './organizations.types';
import { apiClient } from '../shared/http/api-client';
import { coerceDates } from '../shared/http/http-client.models';
export async function fetchOrganizations() {
const { organizations } = await apiClient<{ organizations: Organization[] }>({
const { organizations } = await apiClient<{ organizations: AsDto<Organization>[] }>({
path: '/api/organizations',
method: 'GET',
});
return {
organizations: organizations.map(organization => ({
...organization,
createdAt: new Date(organization.createdAt),
updatedAt: organization.updatedAt ? new Date(organization.updatedAt) : undefined,
})),
organizations: organizations.map(coerceDates),
};
}
export async function createOrganization({ name }: { name: string }) {
const { organization } = await apiClient<{ organization: Organization }>({
const { organization } = await apiClient<{ organization: AsDto<Organization> }>({
path: '/api/organizations',
method: 'POST',
body: { name },
});
return {
organization: {
...organization,
createdAt: new Date(organization.createdAt),
updatedAt: organization.updatedAt ? new Date(organization.updatedAt) : undefined,
},
organization: coerceDates(organization),
};
}
export async function updateOrganization({ organizationId, name }: { organizationId: string; name: string }) {
const { organization } = await apiClient<{ organization: Organization }>({
const { organization } = await apiClient<{ organization: AsDto<Organization> }>({
path: `/api/organizations/${organizationId}`,
method: 'PUT',
body: { name },
});
return {
organization: {
...organization,
createdAt: new Date(organization.createdAt),
updatedAt: organization.updatedAt ? new Date(organization.updatedAt) : undefined,
},
organization: coerceDates(organization),
};
}
export async function fetchOrganization({ organizationId }: { organizationId: string }) {
const { organization } = await apiClient<{ organization: Organization }>({
const { organization } = await apiClient<{ organization: AsDto<Organization> }>({
path: `/api/organizations/${organizationId}`,
method: 'GET',
});
return {
organization: {
...organization,
createdAt: new Date(organization.createdAt),
updatedAt: organization.updatedAt ? new Date(organization.updatedAt) : undefined,
},
organization: coerceDates(organization),
};
}

View File

@@ -2,5 +2,5 @@ export type Organization = {
id: string;
name: string;
createdAt: Date;
updatedAt?: Date;
updatedAt: Date;
};

View File

@@ -1,7 +1,8 @@
import type { Component } from 'solid-js';
import { useCurrentUser } from '@/modules/users/composables/useCurrentUser';
import { useNavigate } from '@solidjs/router';
import { createQuery } from '@tanstack/solid-query';
import { type Component, createEffect, on } from 'solid-js';
import { createEffect, on } from 'solid-js';
import { CreateOrganizationForm } from '../components/create-organization-form.component';
import { useCreateOrganization } from '../organizations.composables';
import { fetchOrganizations } from '../organizations.services';

View File

@@ -1,3 +1,4 @@
import type { Component } from 'solid-js';
import { DocumentUploadArea } from '@/modules/documents/components/document-upload-area.component';
import { createdAtColumn, DocumentsPaginatedList, standardActionsColumn, tagsColumn } from '@/modules/documents/components/documents-list.component';
import { useUploadDocuments } from '@/modules/documents/documents.composables';
@@ -6,7 +7,7 @@ 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';
import { createSignal, Show, Suspense } from 'solid-js';
export const OrganizationPage: Component = () => {
const params = useParams();

View File

@@ -1,3 +1,4 @@
import type { Component } from 'solid-js';
import type { Organization } from '../organizations.types';
import { buildTimeConfig } from '@/modules/config/config';
import { useConfirmModal } from '@/modules/shared/confirm';
@@ -10,7 +11,7 @@ import { TextField, TextFieldLabel, TextFieldRoot } from '@/modules/ui/component
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 { createSignal, Show, Suspense } from 'solid-js';
import * as v from 'valibot';
import { useDeleteOrganization, useUpdateOrganization } from '../organizations.composables';
import { organizationNameSchema } from '../organizations.schemas';
@@ -164,7 +165,7 @@ export const OrganizationsSettingsPage: Component = () => {
}));
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 => (

View File

@@ -1,6 +1,7 @@
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 { createEffect, For, on } from 'solid-js';
import { fetchOrganizations } from '../organizations.services';
export const OrganizationsPage: Component = () => {

View File

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

View File

@@ -24,6 +24,8 @@ describe('http-client models', () => {
createdAt: '2021-01-01T00:00:00.000Z',
updatedAt: '2021-01-02T00:00:00.000Z',
deletedAt: '2021-01-03T00:00:00.000Z',
expiresAt: '2021-01-04T00:00:00.000Z',
lastUsedAt: '2021-01-05T00:00:00.000Z',
foo: 'bar',
baz: 'qux',
};
@@ -34,6 +36,8 @@ describe('http-client models', () => {
createdAt: new Date('2021-01-01T00:00:00.000Z'),
updatedAt: new Date('2021-01-02T00:00:00.000Z'),
deletedAt: new Date('2021-01-03T00:00:00.000Z'),
expiresAt: new Date('2021-01-04T00:00:00.000Z'),
lastUsedAt: new Date('2021-01-05T00:00:00.000Z'),
foo: 'bar',
baz: 'qux',
});

View File

@@ -24,5 +24,7 @@ export function coerceDates<T extends Record<string, any>>(obj: T): CoerceDates<
...('createdAt' in obj ? { createdAt: toDate(obj.createdAt) } : {}),
...('updatedAt' in obj ? { updatedAt: toDate(obj.updatedAt) } : {}),
...('deletedAt' in obj ? { deletedAt: toDate(obj.deletedAt) } : {}),
...('expiresAt' in obj ? { expiresAt: toDate(obj.expiresAt) } : {}),
...('lastUsedAt' in obj ? { lastUsedAt: toDate(obj.lastUsedAt) } : {}),
};
}

View File

@@ -0,0 +1,5 @@
import type { Expand } from '@corentinth/chisels';
export type AsDto<T> = Expand<{
[K in keyof T]: T[K] extends Date ? string : T[K];
}>;

View File

@@ -1,3 +1,4 @@
import type { Component } from 'solid-js';
import type { TaggingRule, TaggingRuleForCreation } from '../tagging-rules.types';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { useConfirmModal } from '@/modules/shared/confirm';
@@ -11,7 +12,7 @@ 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 { 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';

View File

@@ -1,3 +1,4 @@
import type { Component } from 'solid-js';
import type { TaggingRule } from '../tagging-rules.types';
import { useConfig } from '@/modules/config/config.provider';
import { useI18n } from '@/modules/i18n/i18n.provider';
@@ -7,7 +8,7 @@ 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 { For, Match, Show, Switch } from 'solid-js';
import { deleteTaggingRule, fetchTaggingRules } from '../tagging-rules.services';
const TaggingRuleCard: Component<{ taggingRule: TaggingRule }> = (props) => {

View File

@@ -1,10 +1,11 @@
import type { Component } from 'solid-js';
import type { TaggingRuleForCreation } from '../tagging-rules.types';
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 { Show } from 'solid-js';
import { TaggingRuleForm } from '../components/tagging-rule-form.component';
import { getTaggingRule, updateTaggingRule } from '../tagging-rules.services';

View File

@@ -1,13 +1,15 @@
import type { AsDto } from '../shared/http/http-client.types';
import type { TaggingRule, TaggingRuleForCreation } from './tagging-rules.types';
import { apiClient } from '../shared/http/api-client';
import { coerceDates } from '../shared/http/http-client.models';
export async function fetchTaggingRules({ organizationId }: { organizationId: string }) {
const { taggingRules } = await apiClient<{ taggingRules: TaggingRule[] }>({
const { taggingRules } = await apiClient<{ taggingRules: AsDto<TaggingRule>[] }>({
path: `/api/organizations/${organizationId}/tagging-rules`,
method: 'GET',
});
return { taggingRules };
return { taggingRules: taggingRules.map(coerceDates) };
}
export async function createTaggingRule({ taggingRule, organizationId }: { taggingRule: TaggingRuleForCreation; organizationId: string }) {
@@ -26,12 +28,12 @@ export async function deleteTaggingRule({ organizationId, taggingRuleId }: { org
}
export async function getTaggingRule({ organizationId, taggingRuleId }: { organizationId: string; taggingRuleId: string }) {
const { taggingRule } = await apiClient<{ taggingRule: TaggingRule }>({
const { taggingRule } = await apiClient<{ taggingRule: AsDto<TaggingRule> }>({
path: `/api/organizations/${organizationId}/tagging-rules/${taggingRuleId}`,
method: 'GET',
});
return { taggingRule };
return { taggingRule: coerceDates(taggingRule) };
}
export async function updateTaggingRule({ organizationId, taggingRuleId, taggingRule }: { organizationId: string; taggingRuleId: string; taggingRule: TaggingRuleForCreation }) {

View File

@@ -1,7 +1,8 @@
import type { Component } from 'solid-js';
import type { Tag } from '../tags.types';
import { Combobox, ComboboxContent, ComboboxInput, ComboboxItem, ComboboxTrigger } from '@/modules/ui/components/combobox';
import { createQuery } from '@tanstack/solid-query';
import { type Component, createSignal, For } from 'solid-js';
import { createSignal, For } from 'solid-js';
import { fetchTags } from '../tags.services';
import { Tag as TagComponent } from './tag.component';
@@ -49,13 +50,13 @@ export const DocumentTagPicker: Component<{
>
<ComboboxTrigger displayMultipleState={state => (
<span class="flex flex-wrap items-center gap-1">
<span class="flex flex-wrap items-center gap-1 flex-1">
<For each={state.selectedOptions() as Tag[]}>
{tag => (
<TagComponent name={tag.name} color={tag.color} class="text-xs my-1" closable onClose={() => state.remove(tag)} />
)}
</For>
<ComboboxInput />
<ComboboxInput class="py-2" />
</span>

View File

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

View File

@@ -1,4 +1,5 @@
import type { DialogTriggerProps } from '@kobalte/core/dialog';
import type { Component, JSX } from 'solid-js';
import type { Tag as TagType } from '../tags.types';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { useConfirmModal } from '@/modules/shared/confirm';
@@ -15,7 +16,7 @@ import { TextField, TextFieldLabel, TextFieldRoot } from '@/modules/ui/component
import { getValues } from '@modular-forms/solid';
import { A, useParams } from '@solidjs/router';
import { createQuery } from '@tanstack/solid-query';
import { type Component, createSignal, For, type JSX, Show, Suspense } from 'solid-js';
import { createSignal, For, Show, Suspense } from 'solid-js';
import * as v from 'valibot';
import { Tag } from '../components/tag.component';
import { createTag, deleteTag, fetchTags, updateTag } from '../tags.services';

View File

@@ -1,9 +1,10 @@
import type { AsDto } from '../shared/http/http-client.types';
import type { Tag } from './tags.types';
import { apiClient } from '../shared/http/api-client';
import { coerceDates } from '../shared/http/http-client.models';
export async function fetchTags({ organizationId }: { organizationId: string }) {
const { tags } = await apiClient<{ tags: Tag[] }>({
const { tags } = await apiClient<{ tags: AsDto<Tag>[] }>({
path: `/api/organizations/${organizationId}/tags`,
method: 'GET',
});
@@ -14,7 +15,7 @@ export async function fetchTags({ organizationId }: { organizationId: string })
}
export async function createTag({ organizationId, name, color, description }: { organizationId: string; name: string; color: string; description: string }) {
const { tag } = await apiClient<{ tag: Tag }>({
const { tag } = await apiClient<{ tag: AsDto<Tag> }>({
path: `/api/organizations/${organizationId}/tags`,
method: 'POST',
body: { name, color, description },
@@ -26,7 +27,7 @@ export async function createTag({ organizationId, name, color, description }: {
}
export async function updateTag({ organizationId, tagId, name, color, description }: { organizationId: string; tagId: string; name: string; color: string; description: string }) {
const { tag } = await apiClient<{ tag: Tag }>({
const { tag } = await apiClient<{ tag: AsDto<Tag> }>({
path: `/api/organizations/${organizationId}/tags/${tagId}`,
method: 'PUT',
body: { name, color, description },

View File

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

View File

@@ -1,5 +1,6 @@
import type { Component } from 'solid-js';
import { useCurrentMatches } from '@solidjs/router';
import { type Component, createEffect, on } from 'solid-js';
import { createEffect, on } from 'solid-js';
import { trackingServices } from '../tracking.services';
export const PageViewTracker: Component = () => {

View File

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

View File

@@ -140,7 +140,7 @@ export function DropdownMenuShortcut(props: ComponentProps<'span'>) {
);
}
type dropdownMenuSubTriggerProps<T extends ValidComponent = 'div'> = ParentProps< DropdownMenuSubTriggerProps<T> & { class?: string }>;
type dropdownMenuSubTriggerProps<T extends ValidComponent = 'div'> = ParentProps<DropdownMenuSubTriggerProps<T> & { class?: string }>;
export function DropdownMenuSubTrigger<T extends ValidComponent = 'div'>(props: PolymorphicProps<T, dropdownMenuSubTriggerProps<T>>) {
const [local, rest] = splitProps(props as dropdownMenuSubTriggerProps, [
@@ -201,7 +201,7 @@ export function DropdownMenuSubContent<T extends ValidComponent = 'div'>(props:
);
}
type dropdownMenuCheckboxItemProps<T extends ValidComponent = 'div'> = ParentProps< DropdownMenuCheckboxItemProps<T> & { class?: string } >;
type dropdownMenuCheckboxItemProps<T extends ValidComponent = 'div'> = ParentProps<DropdownMenuCheckboxItemProps<T> & { class?: string }>;
export function DropdownMenuCheckboxItem<T extends ValidComponent = 'div'>(props: PolymorphicProps<T, dropdownMenuCheckboxItemProps<T>>) {
const [local, rest] = splitProps(props as dropdownMenuCheckboxItemProps, [

View File

@@ -1,5 +1,6 @@
import type { Component, ComponentProps, JSX } from 'solid-js';
import { cn } from '@/modules/shared/style/cn';
import { type Component, type ComponentProps, type JSX, splitProps } from 'solid-js';
import { splitProps } from 'solid-js';
export const EmptyState: Component<{
title: JSX.Element;

View File

@@ -138,9 +138,9 @@ export function NumberFieldDecrementTrigger<T extends ValidComponent = 'button',
);
}
type numberFieldIncrementTriggerProps<T extends ValidComponent = 'button'> = VoidProps< NumberFieldIncrementTriggerProps<T> & { class?: string }>;
type numberFieldIncrementTriggerProps<T extends ValidComponent = 'button'> = VoidProps<NumberFieldIncrementTriggerProps<T> & { class?: string }>;
export function NumberFieldIncrementTrigger< T extends ValidComponent = 'button',>(props: PolymorphicProps<T, numberFieldIncrementTriggerProps<T>>) {
export function NumberFieldIncrementTrigger<T extends ValidComponent = 'button',>(props: PolymorphicProps<T, numberFieldIncrementTriggerProps<T>>) {
const [local, rest] = splitProps(props as numberFieldIncrementTriggerProps, ['class']);
return (

View File

@@ -1,5 +1,6 @@
import type { ComponentProps } from 'solid-js';
import { cn } from '@/modules/shared/style/cn';
import { type ComponentProps, splitProps } from 'solid-js';
import { splitProps } from 'solid-js';
export function Skeleton(props: ComponentProps<'div'>) {
const [local, rest] = splitProps(props, ['class']);

View File

@@ -1,5 +1,6 @@
import type { ComponentProps } from 'solid-js';
import { cn } from '@/modules/shared/style/cn';
import { type ComponentProps, splitProps } from 'solid-js';
import { splitProps } from 'solid-js';
export function Table(props: ComponentProps<'table'>) {
const [local, rest] = splitProps(props, ['class']);

View File

@@ -3,9 +3,10 @@ import type {
TooltipContentProps,
TooltipRootProps,
} from '@kobalte/core/tooltip';
import type { ValidComponent } from 'solid-js';
import { cn } from '@/modules/shared/style/cn';
import { Tooltip as TooltipPrimitive } from '@kobalte/core/tooltip';
import { mergeProps, splitProps, type ValidComponent } from 'solid-js';
import { mergeProps, splitProps } from 'solid-js';
export const TooltipTrigger = TooltipPrimitive.Trigger;

View File

@@ -1,11 +1,12 @@
import type { Component, ParentComponent } from 'solid-js';
import { signOut } from '@/modules/auth/auth.services';
import { cn } from '@/modules/shared/style/cn';
import { useThemeStore } from '@/modules/theme/theme.store';
import { Button } from '@/modules/ui/components/button';
import { A, useNavigate } from '@solidjs/router';
import { type Component, For, type ParentComponent, Show, Suspense } from 'solid-js';
import { For, Show, Suspense } from 'solid-js';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '../components/dropdown-menu';
import { Sheet, SheetContent, SheetTrigger } from '../components/sheet';

View File

@@ -1,33 +0,0 @@
import type { ParentComponent } from 'solid-js';
import { A, useParams } from '@solidjs/router';
import { Button } from '../components/button';
export const IntegrationsLayout: ParentComponent = (props) => {
const params = useParams();
return (
<div class="p-6 mt-4 pb-32 mx-auto max-w-5xl">
<div class="border-b mb-6">
<h1 class="text-xl font-bold">
Integrations
</h1>
<p class="text-muted-foreground mt-1">
Manage your organization's integrations
</p>
<div class="flex gap-2 mt-4">
<Button as={A} href={`/organizations/${params.organizationId}/intake-emails`} variant="ghost" activeClass="border-b border-primary text-foreground!" class="text-muted-foreground rounded-b-none">
Intake Emails
</Button>
<Button as={A} href={`/organizations/${params.organizationId}/api-keys`} variant="ghost" activeClass="border-b border-primary text-foreground!" class="text-muted-foreground rounded-b-none">
API Keys
</Button>
</div>
</div>
{props.children}
</div>
);
};

View File

@@ -0,0 +1,53 @@
import type { ParentComponent } from 'solid-js';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { A, useParams } from '@solidjs/router';
import { Button } from '../components/button';
import { SideNav } from './sidenav.layout';
export const OrganizationSettingsLayout: ParentComponent = (props) => {
const params = useParams();
const { t } = useI18n();
const getNavigationItems = () => [
{
label: t('layout.menu.general-settings'),
href: `/organizations/${params.organizationId}/settings`,
icon: 'i-tabler-settings',
},
{
label: t('layout.menu.intake-emails'),
href: `/organizations/${params.organizationId}/settings/intake-emails`,
icon: 'i-tabler-mail',
},
{
label: t('layout.menu.webhooks'),
href: `/organizations/${params.organizationId}/settings/webhooks`,
icon: 'i-tabler-webhook',
},
];
return (
<div class="flex flex-row h-screen min-h-0">
<div class="w-350px border-r border-r-border flex-shrink-0 hidden md:block bg-card">
<SideNav
mainMenu={getNavigationItems()}
header={() => (
<div class="pl-6 py-3 border-b border-b-border flex items-center gap-1">
<Button variant="ghost" size="icon" class="text-muted-foreground" as={A} href="/">
<div class="i-tabler-arrow-left size-5"></div>
</Button>
<h1 class="text-base font-bold">
Organization Settings
</h1>
</div>
)}
/>
</div>
<div class="flex-1 min-h-0 flex flex-col">
{props.children}
</div>
</div>
);
};

View File

@@ -1,13 +1,14 @@
import type { Organization } from '@/modules/organizations/organizations.types';
import { DocumentUploadProvider } from '@/modules/documents/components/document-import-status.component';
import type { Component, ParentComponent } from 'solid-js';
import { DocumentUploadProvider } from '@/modules/documents/components/document-import-status.component';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { fetchOrganization, fetchOrganizations } from '@/modules/organizations/organizations.services';
import { useNavigate, useParams } from '@solidjs/router';
import { createQueries, createQuery } from '@tanstack/solid-query';
import { get } from 'lodash-es';
import { type Component, createEffect, on, type ParentComponent } from 'solid-js';
import { createEffect, on } from 'solid-js';
import {
Select,
SelectContent,
@@ -44,11 +45,6 @@ const OrganizationLayoutSideNav: Component = () => {
icon: 'i-tabler-list-check',
href: `/organizations/${params.organizationId}/tagging-rules`,
},
{
label: t('layout.menu.integrations'),
icon: 'i-tabler-link',
href: `/organizations/${params.organizationId}/intake-emails`,
},
];

View File

@@ -1,34 +1,47 @@
import type { ParentComponent } from 'solid-js';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { A } from '@solidjs/router';
import { Button } from '../components/button';
import { SideNav } from './sidenav.layout';
export const SettingsLayout: ParentComponent = (props) => {
const navigation = [
const { t } = useI18n();
const getMainMenuItems = () => [
{
name: 'Account',
href: '/settings/account',
label: t('layout.menu.account'),
icon: 'i-tabler-user',
href: '/settings',
},
{
name: 'Billing & Plan',
href: '/settings/billing',
label: t('layout.menu.api-keys'),
icon: 'i-tabler-key',
href: '/api-keys',
},
];
return (
<div class="p-6 max-w-5xl mx-auto mt-4 pb-32">
<div class="flex justify-between items-center mb-2">
<h1 class="text-2xl font-bold">Settings</h1>
</div>
<div class="flex flex-row h-screen min-h-0">
<div class="w-350px border-r border-r-border flex-shrink-0 hidden md:block bg-card">
<div class="flex gap-6 mb-6 border-b">
{navigation.map(item => (
<Button as={A} href={item.href} variant="ghost" class="px-0 border-b border-transparent text-muted-foreground rounded-b-none !bg-transparent" activeClass="!text-foreground !border-foreground">
{item.name}
</Button>
))}
</div>
<SideNav
mainMenu={getMainMenuItems()}
header={() => (
<div class="pl-6 py-3 border-b border-b-border flex items-center gap-1">
<Button variant="ghost" size="icon" class="text-muted-foreground" as={A} href="/">
<div class="i-tabler-arrow-left size-5"></div>
</Button>
<h1 class="text-lg font-bold">
{t('layout.menu.settings')}
</h1>
</div>
)}
/>
{props.children}
</div>
<div class="flex-1 min-h-0 flex flex-col">
{props.children}
</div>
</div>
);
};

View File

@@ -1,17 +1,18 @@
import type { TooltipTriggerProps } from '@kobalte/core/tooltip';
import type { Component, ComponentProps, JSX, ParentComponent } from 'solid-js';
import { signOut } from '@/modules/auth/auth.services';
import { useCommandPalette } from '@/modules/command-palette/command-palette.provider';
import { useConfig } from '@/modules/config/config.provider';
import { useDocumentUpload } from '@/modules/documents/components/document-import-status.component';
import { GlobalDropArea } from '@/modules/documents/components/global-drop-area.component';
import { GlobalDropArea } from '@/modules/documents/components/global-drop-area.component';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { cn } from '@/modules/shared/style/cn';
import { useThemeStore } from '@/modules/theme/theme.store';
import { Button } from '@/modules/ui/components/button';
import { A, useNavigate, useParams } from '@solidjs/router';
import { type Component, type ComponentProps, type JSX, type ParentComponent, Show, Suspense } from 'solid-js';
import { Show, Suspense } from 'solid-js';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger } from '../components/dropdown-menu';
import { Sheet, SheetContent, SheetTrigger } from '../components/sheet';
import { Tooltip, TooltipContent, TooltipTrigger } from '../components/tooltip';
@@ -26,12 +27,10 @@ type MenuItem = {
const MenuItemButton: Component<MenuItem> = (props) => {
return (
<Button class="block" variant="ghost" {...(props.onClick ? { onClick: props.onClick } : { as: A, href: props.href, activeClass: 'bg-accent/50! text-accent-foreground! truncate', end: true } as ComponentProps<typeof Button>)}>
<div class="flex items-center gap-2 dark:text-muted-foreground truncate">
<div class={cn(props.icon, 'size-5')}></div>
<div>{props.label}</div>
{props.badge && <div class="ml-auto">{props.badge}</div>}
</div>
<Button class="justify-start items-center gap-2 dark:text-muted-foreground truncate" variant="ghost" {...(props.onClick ? { onClick: props.onClick } : { as: A, href: props.href, activeClass: 'bg-accent/50! text-accent-foreground! truncate', end: true } as ComponentProps<typeof Button>)}>
<div class={cn(props.icon, 'size-5 text-muted-foreground opacity-50')}></div>
<div>{props.label}</div>
{props.badge && <div class="ml-auto">{props.badge}</div>}
</Button>
);
};
@@ -242,6 +241,11 @@ export const SidenavLayout: ParentComponent<{
Account settings
</DropdownMenuItem>
<DropdownMenuItem class="flex items-center gap-2 cursor-pointer" as={A} href="/api-keys">
<div class="i-tabler-key size-4 text-muted-foreground"></div>
API keys
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger class="flex items-center gap-2 cursor-pointer">
<div class="i-tabler-language size-4 text-muted-foreground"></div>

View File

@@ -1,12 +1,13 @@
import type { Component } from 'solid-js';
import { signOut } from '@/modules/auth/auth.services';
import { createForm } from '@/modules/shared/form/form';
import { Button } from '@/modules/ui/components/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/modules/ui/components/card';
import { createToast } from '@/modules/ui/components/sonner';
import { TextField, TextFieldLabel, TextFieldRoot } from '@/modules/ui/components/textfield';
import { A, useNavigate } from '@solidjs/router';
import { useNavigate } from '@solidjs/router';
import { createQuery } from '@tanstack/solid-query';
import { type Component, createSignal, Show, Suspense } from 'solid-js';
import { createSignal, Show, Suspense } from 'solid-js';
import * as v from 'valibot';
import { useUpdateCurrentUser } from '../users.composables';
import { nameSchema } from '../users.schemas';
@@ -127,18 +128,15 @@ export const UserSettingsPage: Component = () => {
}));
return (
<div class="p-6 mt-4 pb-32 mx-auto max-w-xl">
<div class="p-6 mt-12 pb-32 mx-auto max-w-xl">
<Suspense>
<Show when={query.data?.user}>
{getUser => (
<>
<Button as={A} href="/" variant="outline" class="mb-4">
<div class="i-tabler-arrow-left size-4 mr-2"></div>
Back
</Button>
<h1 class="text-xl font-semibold mb-2">User settings</h1>
<p class="text-muted-foreground">Manage your account settings here.</p>
<div class="border-b pb-4">
<h1 class="text-2xl font-semibold mb-1">User settings</h1>
<p class="text-muted-foreground">Manage your account settings here.</p>
</div>
<div class="mt-6 flex flex-col gap-6">
<UserEmailCard email={getUser().email} />

View File

@@ -0,0 +1,79 @@
import type { LocaleKeys } from '@/modules/i18n/locales.types';
import type { Component } from 'solid-js';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { Checkbox, CheckboxControl, CheckboxLabel } from '@/modules/ui/components/checkbox';
import { createSignal, For } from 'solid-js';
import { WEBHOOK_EVENTS } from '../webhooks.constants';
type WebhookEvent = typeof WEBHOOK_EVENTS[number]['events'][number];
type WebhookSection = typeof WEBHOOK_EVENTS[number];
export const WebhookEventsPicker: Component<{ events: WebhookEvent[]; onChange: (events: WebhookEvent[]) => void }> = (props) => {
const [events, setEvents] = createSignal<WebhookEvent[]>(props.events);
const { t } = useI18n();
const getEventsSections = () => {
return WEBHOOK_EVENTS.map((section: WebhookSection) => ({
...section,
title: t(`webhooks.events.${section.section}.title` as LocaleKeys),
events: section.events.map((event: WebhookEvent) => {
const [prefix, suffix] = event.split(':');
return {
name: event,
prefix,
suffix,
description: t(`webhooks.events.${section.section}.${event}.description`),
};
}),
}));
};
const isEventSelected = (event: WebhookEvent) => {
return events().includes(event);
};
const toggleEvent = (event: WebhookEvent) => {
setEvents((prev) => {
if (prev.includes(event)) {
return prev.filter(e => e !== event);
}
return [...prev, event];
});
props.onChange(events());
};
return (
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<For each={getEventsSections()}>
{section => (
<div>
<p class="text-muted-foreground text-xs">{section.title}</p>
<div class="pl-4 flex flex-col gap-4 mt-4">
<For each={section.events}>
{event => (
<Checkbox
class="flex items-start gap-2"
checked={isEventSelected(event.name)}
onChange={() => toggleEvent(event.name)}
>
<CheckboxControl />
<div class="flex flex-col gap-1">
<CheckboxLabel class="text-sm leading-none">
<div class="font-semibold">{event.description}</div>
<div class="text-muted-foreground text-xs mt-1">{event.name}</div>
</CheckboxLabel>
</div>
</Checkbox>
)}
</For>
</div>
</div>
)}
</For>
</div>
);
};

View File

@@ -0,0 +1,151 @@
import type { Component } from 'solid-js';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { createForm } from '@/modules/shared/form/form';
import { queryClient } from '@/modules/shared/query/query-client';
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, useNavigate, useParams } from '@solidjs/router';
import * as v from 'valibot';
import { WebhookEventsPicker } from '../components/webhook-events-picker.component';
import { WEBHOOK_EVENT_NAMES } from '../webhooks.constants';
import { createWebhook } from '../webhooks.services';
export const CreateWebhookPage: Component = () => {
const { t } = useI18n();
const params = useParams();
const navigate = useNavigate();
const { form, Form, Field } = createForm({
onSubmit: async ({ name, url, secret, enabled, events }) => {
await createWebhook({
name,
url,
secret,
enabled,
events,
organizationId: params.organizationId,
});
await queryClient.invalidateQueries({ queryKey: ['webhooks', params.organizationId] });
createToast({
type: 'success',
message: t('webhooks.create.success'),
});
navigate(`/organizations/${params.organizationId}/settings/webhooks`);
},
schema: v.object({
name: v.pipe(
v.string(),
v.nonEmpty(t('webhooks.create.form.name.required')),
),
url: v.pipe(
v.string(),
v.nonEmpty(t('webhooks.create.form.url.required')),
v.url(t('webhooks.create.form.url.invalid')),
),
secret: v.optional(v.string()),
enabled: v.optional(v.boolean()),
events: v.pipe(
v.array(v.picklist(WEBHOOK_EVENT_NAMES)),
v.nonEmpty(t('webhooks.create.form.events.required')),
),
}),
initialValues: {
name: '',
url: '',
secret: '',
enabled: true,
events: [],
},
});
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-bold">{t('webhooks.create.title')}</h1>
<p class="text-sm text-muted-foreground">{t('webhooks.create.description')}</p>
</div>
<Form>
<Field name="name">
{(field, inputProps) => (
<TextFieldRoot class="flex flex-col mb-6">
<TextFieldLabel for="name">{t('webhooks.create.form.name.label')}</TextFieldLabel>
<TextField
type="text"
id="name"
placeholder={t('webhooks.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>
)}
</Field>
<Field name="url">
{(field, inputProps) => (
<TextFieldRoot class="flex flex-col mb-6">
<TextFieldLabel for="url">{t('webhooks.create.form.url.label')}</TextFieldLabel>
<TextField
type="url"
id="url"
placeholder={t('webhooks.create.form.url.placeholder')}
{...inputProps}
value={field.value}
aria-invalid={Boolean(field.error)}
/>
{field.error && <div class="text-red-500 text-sm">{field.error}</div>}
</TextFieldRoot>
)}
</Field>
<Field name="secret">
{(field, inputProps) => (
<TextFieldRoot class="flex flex-col mb-6">
<TextFieldLabel for="secret">{t('webhooks.create.form.secret.label')}</TextFieldLabel>
<TextField
type="password"
id="secret"
placeholder={t('webhooks.create.form.secret.placeholder')}
{...inputProps}
value={field.value}
aria-invalid={Boolean(field.error)}
/>
{field.error && <div class="text-red-500 text-sm">{field.error}</div>}
</TextFieldRoot>
)}
</Field>
<Field name="events" type="string[]">
{field => (
<div>
<p class="text-sm font-bold">{t('webhooks.create.form.events.label')}</p>
<div class="p-6 pb-8 border rounded-md mt-2">
<WebhookEventsPicker events={field.value ?? []} onChange={events => setValue(form, 'events', events)} />
</div>
{field.error && <div class="text-red-500 text-sm">{field.error}</div>}
</div>
)}
</Field>
<div class="flex justify-end mt-6">
<Button type="button" variant="secondary" as={A} href={`/organizations/${params.organizationId}/settings/webhooks`}>
{t('webhooks.create.back')}
</Button>
<Button type="submit" class="ml-2" isLoading={form.submitting}>
{t('webhooks.create.form.submit')}
</Button>
</div>
</Form>
</div>
);
};

View File

@@ -0,0 +1,196 @@
import type { Component } from 'solid-js';
import type { Webhook } from '../webhooks.types';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { createForm } from '@/modules/shared/form/form';
import { queryClient } from '@/modules/shared/query/query-client';
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, useNavigate, useParams } from '@solidjs/router';
import { createQuery } from '@tanstack/solid-query';
import { createSignal, Show, Suspense } from 'solid-js';
import * as v from 'valibot';
import { WebhookEventsPicker } from '../components/webhook-events-picker.component';
import { WEBHOOK_EVENT_NAMES } from '../webhooks.constants';
import { fetchWebhook, updateWebhook } from '../webhooks.services';
export const EditWebhookForm: Component<{ webhook: Webhook }> = (props) => {
const { t } = useI18n();
const params = useParams();
const navigate = useNavigate();
const [rotateSecret, setRotateSecret] = createSignal(false);
const { form, Form, Field } = createForm({
onSubmit: async ({ name, url, secret, enabled, events }) => {
const updateData = {
name,
url,
enabled,
events,
};
// Only include secret if rotation was requested
if (rotateSecret() && secret) {
Object.assign(updateData, { secret });
}
await updateWebhook({
webhookId: params.webhookId,
organizationId: params.organizationId,
input: updateData,
});
await queryClient.invalidateQueries({ queryKey: ['webhooks', params.organizationId] });
await queryClient.invalidateQueries({ queryKey: ['webhook', params.organizationId, params.webhookId] });
createToast({
type: 'success',
message: t('webhooks.update.success'),
});
navigate(`/organizations/${params.organizationId}/settings/webhooks`);
},
schema: v.object({
name: v.pipe(
v.string(),
v.nonEmpty(t('webhooks.create.form.name.required')),
),
url: v.pipe(
v.string(),
v.nonEmpty(t('webhooks.create.form.url.required')),
v.url(t('webhooks.create.form.url.invalid')),
),
secret: v.optional(v.string()),
enabled: v.optional(v.boolean()),
events: v.pipe(
v.array(v.picklist(WEBHOOK_EVENT_NAMES)),
v.nonEmpty(t('webhooks.create.form.events.required')),
),
}),
initialValues: {
name: props.webhook.name,
url: props.webhook.url,
enabled: props.webhook.enabled,
events: props.webhook.events,
},
});
return (
<Form>
<Field name="name">
{(field, inputProps) => (
<TextFieldRoot class="flex flex-col mb-6">
<TextFieldLabel for="name">{t('webhooks.create.form.name.label')}</TextFieldLabel>
<TextField
type="text"
id="name"
placeholder={t('webhooks.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>
)}
</Field>
<Field name="url">
{(field, inputProps) => (
<TextFieldRoot class="flex flex-col mb-6">
<TextFieldLabel for="url">{t('webhooks.create.form.url.label')}</TextFieldLabel>
<TextField
type="url"
id="url"
placeholder={t('webhooks.create.form.url.placeholder')}
{...inputProps}
value={field.value}
aria-invalid={Boolean(field.error)}
/>
{field.error && <div class="text-red-500 text-sm">{field.error}</div>}
</TextFieldRoot>
)}
</Field>
<div class="mb-6">
<Field name="secret">
{(field, inputProps) => (
<TextFieldRoot class="flex flex-col mt-4">
<TextFieldLabel for="secret">{t('webhooks.create.form.secret.label')}</TextFieldLabel>
<div class="flex items-center gap-2">
<TextField
type="password"
id="secret"
placeholder={rotateSecret() ? t('webhooks.update.form.secret.placeholder') : t('webhooks.update.form.secret.placeholder-redacted')}
{...inputProps}
value={field.value}
aria-invalid={Boolean(field.error)}
disabled={!rotateSecret()}
/>
<Show when={!rotateSecret()}>
<Button type="button" variant="secondary" onClick={() => setRotateSecret(true)} class="flex-shrink-0">
{t('webhooks.update.form.rotate-secret.button')}
</Button>
</Show>
</div>
{field.error && <div class="text-red-500 text-sm">{field.error}</div>}
</TextFieldRoot>
)}
</Field>
</div>
<Field name="events" type="string[]">
{field => (
<div>
<p class="text-sm font-bold">{t('webhooks.create.form.events.label')}</p>
<div class="p-6 pb-8 border rounded-md mt-2">
<WebhookEventsPicker events={field.value ?? []} onChange={events => setValue(form, 'events', events)} />
</div>
{field.error && <div class="text-red-500 text-sm">{field.error}</div>}
</div>
)}
</Field>
<div class="flex justify-end mt-6">
<Button type="button" variant="secondary" as={A} href={`/organizations/${params.organizationId}/settings/webhooks`}>
{t('webhooks.update.cancel')}
</Button>
<Button type="submit" class="ml-2" isLoading={form.submitting}>
{t('webhooks.update.submit')}
</Button>
</div>
</Form>
);
};
export const EditWebhookPage: Component = () => {
const { t } = useI18n();
const params = useParams();
const webhookQuery = createQuery(() => ({
queryKey: ['webhook', params.organizationId, params.webhookId],
queryFn: () => fetchWebhook({
organizationId: params.organizationId,
webhookId: params.webhookId,
}),
}));
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-bold">{t('webhooks.update.title')}</h1>
<p class="text-sm text-muted-foreground">{t('webhooks.update.description')}</p>
</div>
<Suspense>
<Show when={webhookQuery.data?.webhook}>
{getWebhook => <EditWebhookForm webhook={getWebhook()} />}
</Show>
</Suspense>
</div>
);
};

View File

@@ -0,0 +1,157 @@
import type { Component } from 'solid-js';
import type { Webhook } from '../webhooks.types';
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, useParams } from '@solidjs/router';
import { createMutation, createQuery } from '@tanstack/solid-query';
import { format } from 'date-fns';
import { For, Match, Show, Suspense, Switch } from 'solid-js';
import { deleteWebhook, fetchWebhooks } from '../webhooks.services';
export const WebhookCard: Component<{ webhook: Webhook }> = ({ webhook }) => {
const { t } = useI18n();
const { confirm } = useConfirmModal();
const params = useParams();
const deleteWebhookMutation = createMutation(() => ({
mutationFn: deleteWebhook,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['webhooks', params.organizationId] });
createToast({
message: t('webhooks.delete.success'),
});
},
}));
const handleDelete = async () => {
const confirmed = await confirm({
title: t('webhooks.delete.confirm.title'),
message: t('webhooks.delete.confirm.message'),
confirmButton: {
text: t('webhooks.delete.confirm.confirm-button'),
variant: 'destructive',
},
cancelButton: {
text: t('webhooks.delete.confirm.cancel-button'),
},
});
if (!confirmed) {
return;
}
deleteWebhookMutation.mutate({ webhookId: webhook.id, organizationId: params.organizationId });
};
return (
<div class="bg-card rounded-lg border p-4 flex items-center gap-4">
<div class="rounded-lg bg-muted p-2">
<div class="i-tabler-webhook text-muted-foreground size-5 text-primary" />
</div>
<div class="flex-1 flex flex-col gap-1 overflow-hidden">
<h2 class="text-sm font-medium leading-tight">{webhook.name}</h2>
<p class="text-muted-foreground text-xs font-mono truncate">{webhook.url}</p>
</div>
<div>
<p class="text-muted-foreground text-xs">
{t('webhooks.list.card.last-triggered')}
{' '}
{webhook.lastTriggeredAt ? format(webhook.lastTriggeredAt, 'MMM d, yyyy') : t('webhooks.list.card.never')}
</p>
<p class="text-muted-foreground text-xs">
{t('webhooks.list.card.created')}
{' '}
{format(webhook.createdAt, 'MMM d, yyyy')}
</p>
</div>
<div class="flex items-center gap-2">
<Button
variant="outline"
size="icon"
as={A}
href={`/organizations/${params.organizationId}/settings/webhooks/${webhook.id}`}
>
<div class="i-tabler-edit text-muted-foreground size-4" />
</Button>
<Button
variant="outline"
size="icon"
isLoading={deleteWebhookMutation.isPending}
onClick={handleDelete}
>
<div class="i-tabler-trash text-muted-foreground size-4" />
</Button>
</div>
</div>
);
};
export const WebhooksPage: Component = () => {
const { t } = useI18n();
const params = useParams();
const query = createQuery(() => ({
queryKey: ['webhooks', params.organizationId],
queryFn: () => fetchWebhooks({ organizationId: params.organizationId }),
}));
return (
<div class="p-6 mt-10 pb-32 mx-auto max-w-screen-md w-full">
<div class="flex gap-4 items-center justify-between">
<div>
<h1 class="text-xl font-semibold mb-2">
{t('webhooks.list.title')}
</h1>
<p class="text-muted-foreground">
{t('webhooks.list.description')}
</p>
</div>
<Show when={query.data?.webhooks?.length}>
<Button as={A} href={`/organizations/${params.organizationId}/settings/webhooks/create`} class="gap-2">
<div class="i-tabler-plus size-4" />
{t('webhooks.list.create')}
</Button>
</Show>
</div>
<Suspense>
<Switch>
<Match when={query.data?.webhooks?.length === 0}>
<div class="mt-4 py-8 border-2 border-dashed rounded-lg text-center">
<EmptyState
title={t('webhooks.list.empty.title')}
description={t('webhooks.list.empty.description')}
icon="i-tabler-webhook"
class="p-0"
cta={(
<Button as={A} href={`/organizations/${params.organizationId}/settings/webhooks/create`} class="gap-2">
<div class="i-tabler-plus size-4" />
{t('webhooks.list.create')}
</Button>
)}
/>
</div>
</Match>
<Match when={query.data?.webhooks?.length}>
<div class="mt-6 flex flex-col gap-2">
<For each={query.data?.webhooks}>
{webhook => (
<WebhookCard webhook={webhook} />
)}
</For>
</div>
</Match>
</Switch>
</Suspense>
</div>
);
};

View File

@@ -0,0 +1,12 @@
export const WEBHOOK_EVENTS = [
{
section: 'documents',
events: [
'document:created',
'document:deleted',
],
},
] as const;
export const WEBHOOK_EVENT_NAMES = WEBHOOK_EVENTS.flatMap(event => event.events);

View File

@@ -0,0 +1,62 @@
import type { CreateWebhookInput, UpdateWebhookInput, Webhook } from './webhooks.types';
import { apiClient } from '../shared/http/api-client';
import { coerceDates } from '../shared/http/http-client.models';
export async function createWebhook({ organizationId, ...input }: CreateWebhookInput) {
const { webhook } = await apiClient<{
webhook: Webhook;
}>({
path: `/api/organizations/${organizationId}/webhooks`,
method: 'POST',
body: input,
});
return {
webhook: coerceDates(webhook),
};
}
export async function fetchWebhooks({ organizationId }: { organizationId: string }) {
const { webhooks } = await apiClient<{
webhooks: Webhook[];
}>({
path: `/api/organizations/${organizationId}/webhooks`,
});
return {
webhooks: webhooks.map(coerceDates),
};
}
export async function fetchWebhook({ webhookId, organizationId }: { webhookId: string; organizationId: string }) {
const { webhook } = await apiClient<{
webhook: Webhook;
}>({
path: `/api/organizations/${organizationId}/webhooks/${webhookId}`,
});
return {
webhook: coerceDates(webhook),
};
}
export async function updateWebhook({ webhookId, organizationId, input }: { webhookId: string; organizationId: string; input: UpdateWebhookInput }) {
const { webhook } = await apiClient<{
webhook: Webhook;
}>({
path: `/api/organizations/${organizationId}/webhooks/${webhookId}`,
method: 'PUT',
body: input,
});
return {
webhook: coerceDates(webhook),
};
}
export async function deleteWebhook({ webhookId, organizationId }: { webhookId: string; organizationId: string }) {
await apiClient({
path: `/api/organizations/${organizationId}/webhooks/${webhookId}`,
method: 'DELETE',
});
}

View File

@@ -0,0 +1,34 @@
import type { WEBHOOK_EVENT_NAMES } from './webhooks.constants';
export type WebhookEvent = (typeof WEBHOOK_EVENT_NAMES)[number];
export type Webhook = {
id: string;
name: string;
url: string;
secret?: string;
enabled: boolean;
events: WebhookEvent[];
organizationId: string;
createdAt: Date;
updatedAt: Date;
lastTriggeredAt?: Date;
lastError?: string;
};
export type CreateWebhookInput = {
name: string;
url: string;
secret?: string;
enabled?: boolean;
events?: WebhookEvent[];
organizationId: string;
};
export type UpdateWebhookInput = {
name?: string;
url?: string;
secret?: string;
enabled?: boolean;
events?: WebhookEvent[];
};

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