Compare commits

...

32 Commits

Author SHA1 Message Date
Corentin Thomasset
1996b51b4d chore(release): update versions (#292)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-05-24 18:17:41 +02:00
Corentin Thomasset
734027f00c feat(docs): updated feature list statuses (#300) 2025-05-24 18:08:32 +02:00
Corentin Thomasset
557cde940c feat(organizations): added member role update functionality (#299) 2025-05-24 17:13:32 +02:00
Corentin Thomasset
26a83052bd fix(intake-emails): enhance disabled intake-email state (#298) 2025-05-24 12:27:09 +00:00
Corentin Thomasset
5aac3f7ba6 fix(demo): added missing routes in demo (#297) 2025-05-24 12:16:05 +00:00
Corentin Thomasset
0ddc2340f0 fix(locales): update registration page description (#296) 2025-05-24 11:24:44 +00:00
Corentin Thomasset
438a31171c feat(auth): added support for custom oauth2 providers (#295) 2025-05-24 03:12:39 +02:00
Corentin Thomasset
53bf93f128 feat(doc): added a papra docker compose generator (#293) 2025-05-23 21:24:08 +00:00
Corentin Thomasset
b400b3f18d feat(database): ensure local database directory en boot (#294) 2025-05-23 22:21:33 +02:00
Corentin Thomasset
0627ec25a4 feat(organizations): add permission check for invitation (#291) 2025-05-21 23:06:43 +02:00
Corentin Thomasset
72e5a9a4de feat(invitations): added organizations invitations and multi-user (#289) 2025-05-21 21:53:56 +02:00
Corentin Thomasset
268ac8e358 chore(release): update Docker release workflow to use version input parameter (#286) 2025-05-14 13:11:19 +02:00
Corentin Thomasset
249b3bcfd2 chore(release): update versions (#285)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-05-13 22:44:37 +02:00
Corentin Thomasset
d7838b5d57 chore(release): remove commitMode from release job configuration (#284) 2025-05-13 20:14:08 +00:00
Corentin Thomasset
f170ddd817 chore(release): use PAT for release PR creation (#283) 2025-05-13 20:05:57 +00:00
Corentin Thomasset
4f53c70854 chore(release): update permissions for release job (#281) 2025-05-13 16:44:47 +00:00
Corentin Thomasset
85fa5c4342 chore(version): added changeset for versioning (#280) 2025-05-13 13:48:55 +02:00
Corentin Thomasset
c5d984a3a0 refactor(docker): build transitive dependencies (#277) 2025-05-08 20:47:57 +02:00
Corentin Thomasset
565bd8d7fd feat(webhooks): added webhook management and logic (#276) 2025-05-08 18:52:11 +02:00
Corentin Thomasset
9b72aa886c feat(cli): added cli documentation (#275) 2025-05-02 23:30:33 +02:00
Corentin Thomasset
7410455093 feat(cli): setup base cli (#274) 2025-05-02 00:20:57 +02:00
riskpoint-per
dd8f194fd0 feat(server): add azure blob storage support (#261)
* add azure blob storage support

* set stream to nodejs.readable

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

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

* fix lock file

* bugfixes

* fix lint issues

---------

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

* feat(b2): fix order of tsconfig entries

* feat(b2): fix accidental responseType change

* fix(b2): remove unnecessary try-catches

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

8
.changeset/README.md Normal file
View File

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

19
.changeset/config.json Normal file
View File

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

View File

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

View File

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

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

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

View File

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

View File

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

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

@@ -0,0 +1,52 @@
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 }}
- name: Trigger Docker build
if: steps.changesets.outputs.published == 'true' && contains(fromJson(steps.changesets.outputs.publishedPackages).*.name, '@papra/app-server')
run: |
VERSION=$(echo '${{ steps.changesets.outputs.publishedPackages }}' | jq -r '.[] | select(.name=="@papra/app-server") | .version')
echo "VERSION: $VERSION"
gh workflow run release-docker.yaml -f version="$VERSION"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -59,11 +59,11 @@ Papra is currently in **beta**. The core functionality is stable and usable, but
- **Tags**: Organize your documents with tags.
- **Email ingestion**: Send/forward emails to a generated address to automatically import documents.
- **Content extraction**: Automatically extract text from images or scanned documents for search.
- **Tagging Rules**: Automatically tag documents based on custom rules.
- **Folder ingestion**: Automatically import documents from a folder.
- **CLI**: Manage your documents from the command line.
- **API, SDK and webhooks**: Build your own applications on top of Papra.
- *In progress:* **i18n**: Support for multiple languages.
- *Coming soon:* **Tagging Rules**: Automatically tag documents based on custom rules.
- *Coming soon:* **Folder ingestion**: Automatically import documents from a folder.
- *Coming soon:* **SDK and API**: Build your own applications on top of Papra.
- *Coming soon:* **CLI**: Manage your documents from the command line.
- *Coming soon:* **Document sharing**: Share documents with others.
- *Coming soon:* **Document requests**: Generate upload links for people to add documents.
- *Coming maybe one day:* **Mobile app**: Access and upload documents on the go.

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

@@ -0,0 +1,15 @@
# @papra/docs
## 0.4.0
### Minor Changes
- [#295](https://github.com/papra-hq/papra/pull/295) [`438a311`](https://github.com/papra-hq/papra/commit/438a31171c606138c4b7fa299fdd58dcbeaaf298) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added support for custom oauth2 providers
- [#293](https://github.com/papra-hq/papra/pull/293) [`53bf93f`](https://github.com/papra-hq/papra/commit/53bf93f128b54ad1d3553e18680c87ab23155f8d) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added a papra docker-compose.yml generator
## 0.3.1
### Patch Changes
- [#280](https://github.com/papra-hq/papra/pull/280) [`85fa5c4`](https://github.com/papra-hq/papra/commit/85fa5c43424d139f5c2752a3ad644082e61d3d67) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Fix broken lint and added auto link check

View File

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

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -12,6 +12,7 @@ export const sidebar: StarlightUserConfig['sidebar'] = [
items: [
{ label: 'Using Docker', slug: 'self-hosting/using-docker' },
{ label: 'Using Docker Compose', slug: 'self-hosting/using-docker-compose' },
{ label: 'Docker Compose Generator', link: '/docker-compose-generator', badge: { text: 'new', variant: 'note' } },
{ label: 'Configuration', slug: 'self-hosting/configuration' },
],
},
@@ -30,11 +31,19 @@ export const sidebar: StarlightUserConfig['sidebar'] = [
label: 'Setup Ingestion Folder',
slug: 'guides/setup-ingestion-folder',
},
{
label: 'Setup Custom OAuth2 Providers',
slug: 'guides/setup-custom-oauth2-providers',
},
],
},
{
label: 'Resources',
items: [
{
label: 'CLI Documentation',
slug: 'resources/cli',
},
{
label: 'Security Policy',
link: 'https://github.com/papra-hq/papra/blob/main/SECURITY.md',

View File

@@ -0,0 +1,363 @@
---
import { codeToHtml } from 'shiki';
const images = {
GitHub: 'ghcr.io/papra-hq/papra',
DockerHub: 'corentinth/papra',
};
const defaultDockerCompose = `
services:
papra:
image: ghcr.io/papra-hq/papra:latest
container_name: papra
restart: unless-stopped
ports:
- 1221:1221
environment:
- AUTH_SECRET=change-me
volumes:
- ./app-data:/app/app-data
user: 1000:1000
`.trim();
const dcHtml = await codeToHtml(defaultDockerCompose, { theme: 'vitesse-black', lang: 'yaml' });
---
<p>This tool will help you generate a custom docker-compose.yml file for Papra, tailored to your needs. You can personalize the service name, the port, the auth secret, and the source image.</p>
<h2 class="mt-8 mb-2">General settings</h2>
<div class="flex items-center gap-2 mt-1">
<label for="port" class="min-w-32">External port</label>
<input id="port" class="input-field" value="1221" type="number" min="1024" max="65535" placeholder="eg: 1221" />
</div>
<div class="flex items-center gap-2 mt-1">
<label for="source" class="min-w-32">Image source</label>
<select class="input-field mt-0" id="source">
{Object.entries(images).map(([registry, imageName]) => <option class="bg-background" value={imageName}>{`${registry} - ${imageName}`}</option>)}
</select>
</div>
<div class="flex items-center gap-2 mt-1">
<label for="service-name" class="min-w-32">Service Name</label>
<input id="service-name" class="input-field" value="papra" type="text" placeholder="eg: papra" />
</div>
<div class="flex items-center gap-2 mt-1">
<label
for="auth-secret"
class="min-w-32"
>
Auth secret
</label>
<div class="flex items-center gap-2 mt-0 w-full">
<input class="input-field font-mono" id="auth-secret" type="text" placeholder="eg: 1234567890" />
<button class="btn bg-muted" id="refresh-secret"> Refresh </button>
</div>
</div>
<div class="flex items-center gap-2 mt-1">
<label for="volume-path" class="min-w-32">Volume path</label>
<input id="volume-path" class="input-field" value="./app-data" type="text" placeholder="eg: ./app-data" />
</div>
<div class="flex items-center gap-2 mt-1">
<label for="privileged-mode" class="min-w-32">Privileged mode</label>
<div class="flex items-center gap-2 mt-0 w-full">
<select class="input-field mt-0" id="privileged-mode">
<option value="false" class="bg-background">Rootless</option>
<option value="true" class="bg-background">Root</option>
</select>
</div>
</div>
<h2 class="mt-8 mb-2">Ingestion folder</h2>
<div class="flex items-center gap-2 mt-1">
<label for="ingestion-enabled" class="min-w-32">Enable ingestion</label>
<div class="flex items-center gap-2 mt-0 w-full">
<select class="input-field mt-0" id="ingestion-enabled">
<option value="false" class="bg-background">Disabled</option>
<option value="true" class="bg-background">Enabled</option>
</select>
</div>
</div>
<div class="flex items-center gap-2 mt-1" id="ingestion-path-container" style="display: none;">
<label for="ingestion-path" class="min-w-32">Ingestion path</label>
<input id="ingestion-path" class="input-field" value="./ingestion" type="text" placeholder="eg: ./ingestion" />
</div>
<h2 class="mt-8 mb-2">Intake emails</h2>
<div class="flex items-center gap-2 mt-1">
<label for="intake-email-enabled" class="min-w-32">Enabled</label>
<div class="flex items-center gap-2 mt-0 w-full">
<select class="input-field mt-0" id="intake-email-enabled">
<option value="false" class="bg-background">Disabled</option>
<option value="true" class="bg-background">Enabled</option>
</select>
</div>
</div>
<div class="flex items-center gap-2 mt-1" id="intake-email-driver-container" style="display: none;">
<label for="intake-email-driver" class="min-w-32">Driver</label>
<div class="flex items-center gap-2 mt-0 w-full">
<select class="input-field mt-0" id="intake-email-driver">
<option value="owlrelay" class="bg-background">OwlRelay</option>
<option value="random-username" class="bg-background">Cloudflare Email Worker</option>
</select>
</div>
</div>
<div id="intake-email-owlrelay-config" style="display: none;" class="mt-1">
<div class="flex items-center gap-2 mt-1">
<label for="intake-email-owlrelay-api-key" class="min-w-32">API Key</label>
<input id="intake-email-owlrelay-api-key" class="input-field" type="text" placeholder="owrl_*****" />
</div>
<div class="flex items-center gap-2 mt-1">
<label for="intake-email-owlrelay-webhook-url" class="min-w-32">Webhook URL</label>
<input id="intake-email-owlrelay-webhook-url" class="input-field" type="text" placeholder="https://your-instance.com/api/intake-emails/ingest" />
</div>
</div>
<div id="intake-email-cf-worker-config" style="display: none;" class="mt-1">
<div class="flex items-center gap-2 mt-1">
<label for="intake-email-cf-email-domain" class="min-w-32">Email domain</label>
<input id="intake-email-cf-email-domain" class="input-field" type="text" placeholder="papra.email" />
</div>
</div>
<div class="flex items-center gap-2 mt-1" id="intake-email-webhook-secret-container" style="display: none;">
<label for="intake-email-webhook-secret" class="min-w-32">Webhook secret</label>
<div class="flex items-center gap-2 mt-0 w-full">
<input class="input-field font-mono" id="intake-email-webhook-secret" type="text" placeholder="a-random-key" />
<button class="btn bg-muted" id="refresh-webhook-secret">Refresh</button>
</div>
</div>
<div id="docker-compose-output" class="mt-12" set:html={dcHtml} />
<div class="flex items-center gap-2 mt-4">
<button class="btn bg-muted mt-0" id="download-button">Download docker-compose.yml</button>
<button class="btn bg-muted mt-0" id="copy-button">Copy to clipboard</button>
</div>
<script>
import { codeToHtml } from 'shiki';
import { stringify } from 'yaml';
const portInput = document.getElementById('port') as HTMLInputElement;
const sourceSelect = document.getElementById('source') as HTMLSelectElement;
const serviceNameInput = document.getElementById('service-name') as HTMLInputElement;
const authSecretInput = document.getElementById('auth-secret') as HTMLInputElement;
const refreshSecretButton = document.getElementById('refresh-secret');
const copyButton = document.getElementById('copy-button');
const dockerComposeOutput = document.getElementById('docker-compose-output');
const downloadButton = document.getElementById('download-button');
const volumePathInput = document.getElementById('volume-path') as HTMLInputElement;
const privilegedModeSelect = document.getElementById('privileged-mode') as HTMLSelectElement;
const ingestionEnabledSelect = document.getElementById('ingestion-enabled') as HTMLSelectElement;
const ingestionPathInput = document.getElementById('ingestion-path') as HTMLInputElement;
const ingestionPathContainer = document.getElementById('ingestion-path-container') as HTMLDivElement;
const intakeEmailEnabledSelect = document.getElementById('intake-email-enabled') as HTMLSelectElement;
const intakeDriverSelect = document.getElementById('intake-email-driver') as HTMLSelectElement;
const owlrelayConfig = document.getElementById('intake-email-owlrelay-config') as HTMLDivElement;
const cfWorkerConfig = document.getElementById('intake-email-cf-worker-config') as HTMLDivElement;
const owlrelayApiKeyInput = document.getElementById('intake-email-owlrelay-api-key') as HTMLInputElement;
const owlrelayWebhookUrlInput = document.getElementById('intake-email-owlrelay-webhook-url') as HTMLInputElement;
const cfEmailDomainInput = document.getElementById('intake-email-cf-email-domain') as HTMLInputElement;
const webhookSecretInput = document.getElementById('intake-email-webhook-secret') as HTMLInputElement;
const refreshWebhookSecretButton = document.getElementById('refresh-webhook-secret');
function getRandomString() {
const alphabet = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
return Array.from({ length: 48 }, () => alphabet[Math.floor(Math.random() * alphabet.length)]).join('');
}
function copyToClipboard(text: string) {
navigator.clipboard.writeText(text);
}
function getDockerComposeYml() {
const serviceName = serviceNameInput.value;
const isRootless = privilegedModeSelect.value === 'false';
const image = sourceSelect.value;
const port = portInput.value;
const authSecret = authSecretInput.value;
const volumePath = volumePathInput.value;
const isIngestionEnabled = ingestionEnabledSelect.value === 'true';
const ingestionPath = ingestionPathInput.value;
const intakeEmailEnabled = intakeEmailEnabledSelect.value === 'true';
const intakeDriver = intakeDriverSelect.value;
const webhookSecret = webhookSecretInput.value;
const version = isRootless ? 'latest' : 'latest-root';
const fullImage = `${image}:${version}`;
const environment = [
`AUTH_SECRET=${authSecret}`,
isIngestionEnabled && 'INGESTION_FOLDER_IS_ENABLED=true',
intakeEmailEnabled && 'INTAKE_EMAILS_IS_ENABLED=true',
intakeEmailEnabled && `INTAKE_EMAILS_DRIVER=${intakeDriver}`,
intakeEmailEnabled && `INTAKE_EMAILS_WEBHOOK_SECRET=${webhookSecret}`,
intakeEmailEnabled && intakeDriver === 'owlrelay' && owlrelayApiKeyInput.value && `OWLRELAY_API_KEY=${owlrelayApiKeyInput.value}`,
intakeEmailEnabled && intakeDriver === 'owlrelay' && owlrelayWebhookUrlInput.value && `OWLRELAY_WEBHOOK_URL=${owlrelayWebhookUrlInput.value}`,
intakeEmailEnabled && intakeDriver === 'random-username' && cfEmailDomainInput.value && `INTAKE_EMAILS_EMAIL_GENERATION_DOMAIN=${cfEmailDomainInput.value}`,
].flat().filter(Boolean);
const volumes = [
`${volumePath}:/app/app-data`,
isIngestionEnabled && `${ingestionPath}:/app/ingestion`,
].filter(Boolean);
const dc = {
services: {
[serviceName]: {
image: fullImage,
container_name: serviceName,
restart: 'unless-stopped',
ports: [`${port}:1221`],
environment,
volumes,
...(isRootless && {
user: '1000:1000',
}),
},
},
};
return stringify(dc);
}
async function updateDockerCompose() {
const dockerCompose = getDockerComposeYml();
const html = await codeToHtml(dockerCompose, { theme: 'vitesse-black', lang: 'yaml' });
if (dockerComposeOutput) {
dockerComposeOutput.innerHTML = html;
}
}
function handleCopy() {
const dockerCompose = getDockerComposeYml();
copyToClipboard(dockerCompose);
if (copyButton) {
copyButton.textContent = 'Copied!';
}
setTimeout(() => {
if (copyButton) {
copyButton.textContent = 'Copy to clipboard';
}
}, 1000);
}
function handleRefreshSecret() {
authSecretInput.value = getRandomString();
updateDockerCompose();
}
function handleDownload() {
const dockerCompose = getDockerComposeYml();
const blob = new Blob([dockerCompose], { type: 'text/yaml' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'docker-compose.yml';
a.click();
}
function handleIngestionEnabledChange() {
const isEnabled = ingestionEnabledSelect.value === 'true';
ingestionPathContainer.style.display = isEnabled ? 'flex' : 'none';
updateDockerCompose();
}
function handleIntakeEmailEnabledChange() {
const isEnabled = intakeEmailEnabledSelect.value === 'true';
const driverContainer = document.getElementById('intake-email-driver-container');
const webhookSecretContainer = document.getElementById('intake-email-webhook-secret-container');
if (driverContainer) {
driverContainer.style.display = isEnabled ? 'flex' : 'none';
}
if (webhookSecretContainer) {
webhookSecretContainer.style.display = isEnabled ? 'flex' : 'none';
}
if (!isEnabled) {
// Reset driver-specific configs when disabled
if (owlrelayConfig) {
owlrelayConfig.style.display = 'none';
}
if (cfWorkerConfig) {
cfWorkerConfig.style.display = 'none';
}
} else {
// Show the appropriate driver config
handleIntakeDriverChange();
}
updateDockerCompose();
}
function handleIntakeDriverChange() {
const driver = intakeDriverSelect.value;
const isEnabled = intakeEmailEnabledSelect.value === 'true';
if (!isEnabled) {
return;
}
if (owlrelayConfig) {
owlrelayConfig.style.display = driver === 'owlrelay' ? 'block' : 'none';
}
if (cfWorkerConfig) {
cfWorkerConfig.style.display = driver === 'random-username' ? 'block' : 'none';
}
updateDockerCompose();
}
function handleRefreshWebhookSecret() {
webhookSecretInput.value = getRandomString();
updateDockerCompose();
}
// Add event listeners
portInput.addEventListener('input', updateDockerCompose);
sourceSelect.addEventListener('change', updateDockerCompose);
serviceNameInput.addEventListener('input', updateDockerCompose);
authSecretInput.addEventListener('input', updateDockerCompose);
refreshSecretButton?.addEventListener('click', handleRefreshSecret);
copyButton?.addEventListener('click', handleCopy);
downloadButton?.addEventListener('click', handleDownload);
volumePathInput.addEventListener('input', updateDockerCompose);
privilegedModeSelect.addEventListener('change', updateDockerCompose);
ingestionEnabledSelect.addEventListener('change', handleIngestionEnabledChange);
ingestionPathInput.addEventListener('input', updateDockerCompose);
intakeEmailEnabledSelect.addEventListener('change', handleIntakeEmailEnabledChange);
intakeDriverSelect.addEventListener('change', handleIntakeDriverChange);
owlrelayApiKeyInput.addEventListener('input', updateDockerCompose);
owlrelayWebhookUrlInput.addEventListener('input', updateDockerCompose);
cfEmailDomainInput.addEventListener('input', updateDockerCompose);
webhookSecretInput.addEventListener('input', updateDockerCompose);
refreshWebhookSecretButton?.addEventListener('click', handleRefreshWebhookSecret);
authSecretInput.value = getRandomString();
// Initial render
updateDockerCompose();
// Initial setup
handleIngestionEnabledChange();
handleIntakeEmailEnabledChange();
webhookSecretInput.value = getRandomString();
</script>

View File

@@ -0,0 +1,15 @@
---
import StarlightPage from '@astrojs/starlight/components/StarlightPage.astro';
import DockerComposeGeneratorComp from '../docker-compose-generator/dc-generator.astro';
---
<StarlightPage
frontmatter={{
title: 'Papra docker-compose.yml generator',
description: 'Generate a custom docker-compose.yml file for Papra, tailored to your needs.',
tableOfContents: false,
}}
>
<DockerComposeGeneratorComp />
</StarlightPage>

View File

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

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

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

View File

@@ -0,0 +1,35 @@
# @papra/app-client
## 0.5.0
### Minor Changes
- [#295](https://github.com/papra-hq/papra/pull/295) [`438a311`](https://github.com/papra-hq/papra/commit/438a31171c606138c4b7fa299fdd58dcbeaaf298) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added support for custom oauth2 providers
- [#291](https://github.com/papra-hq/papra/pull/291) [`0627ec2`](https://github.com/papra-hq/papra/commit/0627ec25a422b7b820b08740cfc2905f9c55c00e) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added invitation system to add users to an organization
### Patch Changes
- [#296](https://github.com/papra-hq/papra/pull/296) [`0ddc234`](https://github.com/papra-hq/papra/commit/0ddc2340f092cf6fe5bf2175b55fb46db7681c36) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Fix register page description
## 0.4.0
### Minor Changes
- [#280](https://github.com/papra-hq/papra/pull/280) [`85fa5c4`](https://github.com/papra-hq/papra/commit/85fa5c43424d139f5c2752a3ad644082e61d3d67) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added webhook management
- [#280](https://github.com/papra-hq/papra/pull/280) [`85fa5c4`](https://github.com/papra-hq/papra/commit/85fa5c43424d139f5c2752a3ad644082e61d3d67) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added API keys support
- [#280](https://github.com/papra-hq/papra/pull/280) [`85fa5c4`](https://github.com/papra-hq/papra/commit/85fa5c43424d139f5c2752a3ad644082e61d3d67) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added document searchable content edit
### Patch Changes
- [#280](https://github.com/papra-hq/papra/pull/280) [`85fa5c4`](https://github.com/papra-hq/papra/commit/85fa5c43424d139f5c2752a3ad644082e61d3d67) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added tag creation button in document page
- [#280](https://github.com/papra-hq/papra/pull/280) [`85fa5c4`](https://github.com/papra-hq/papra/commit/85fa5c43424d139f5c2752a3ad644082e61d3d67) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Improved tag selector input wrapping
- [#280](https://github.com/papra-hq/papra/pull/280) [`85fa5c4`](https://github.com/papra-hq/papra/commit/85fa5c43424d139f5c2752a3ad644082e61d3d67) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Properly handle file names without extensions
- [#280](https://github.com/papra-hq/papra/pull/280) [`85fa5c4`](https://github.com/papra-hq/papra/commit/85fa5c43424d139f5c2752a3ad644082e61d3d67) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Wrap text in document preview
- [#280](https://github.com/papra-hq/papra/pull/280) [`85fa5c4`](https://github.com/papra-hq/papra/commit/85fa5c43424d139f5c2752a3ad644082e61d3d67) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Excluded deleted documents from doc count

View File

@@ -1,8 +1,9 @@
{
"name": "@papra/papra-app-client",
"name": "@papra/app-client",
"type": "module",
"version": "0.3.0",
"packageManager": "pnpm@9.15.4",
"version": "0.5.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",
@@ -63,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

@@ -38,7 +38,7 @@ auth.login.form.forgot-password.label: Forgot password?
auth.login.form.submit: Login
auth.register.title: Register to Papra
auth.register.description: Enter your email or use social login to access your Papra account.
auth.register.description: Create an account to start using Papra.
auth.register.register-with-email: Register with email
auth.register.register-with-provider: Register with {{ provider }}
auth.register.providers.google: Google
@@ -77,13 +77,16 @@ layout.menu.home: Home
layout.menu.documents: Documents
layout.menu.tags: Tags
layout.menu.tagging-rules: Tagging rules
layout.menu.integrations: Integrations
layout.menu.deleted-documents: Deleted documents
layout.menu.organization-settings: Organization settings
layout.menu.organization-settings: Settings
layout.menu.api-keys: API keys
layout.menu.settings: Settings
layout.menu.account: Account
layout.menu.general-settings: General settings
layout.menu.intake-emails: Intake emails
layout.menu.webhooks: Webhooks
layout.menu.members: Members
layout.menu.invitations: Invitations
tagging-rules.field.name: document name
tagging-rules.field.content: document content
tagging-rules.operator.equals: equals
@@ -121,9 +124,6 @@ tagging-rules.form.conditions.no-conditions.title: No conditions
tagging-rules.form.conditions.no-conditions.description: You didn't add any conditions to this rule. This rule will apply its tags to all documents.
tagging-rules.form.conditions.no-conditions.confirm: Apply rule without conditions
tagging-rules.form.conditions.no-conditions.cancel: Cancel
tagging-rules.form.conditions.field.label: Field
tagging-rules.form.conditions.operator.label: Operator
tagging-rules.form.conditions.value.label: Value
tagging-rules.form.conditions.value.placeholder: 'Example: invoice'
tagging-rules.form.conditions.value.min-length: Please enter a value for the condition
tagging-rules.form.tags.label: Tags
@@ -132,7 +132,6 @@ tagging-rules.form.tags.min-length: At least one tag to apply is required
tagging-rules.form.tags.add-tag: Create tag
tagging-rules.form.submit: Create rule
tagging-rules.update.title: Update tagging rule
tagging-rules.update.success: Tagging rule updated successfully
tagging-rules.update.error: Failed to update tagging rule
tagging-rules.update.submit: Update rule
tagging-rules.update.cancel: Cancel
@@ -167,6 +166,10 @@ api-errors.document.file_too_big: The document file is too big
api-errors.intake_email.limit_reached: The maximum number of intake emails for this organization has been reached. Please upgrade your plan to create more intake emails.
api-errors.user.max_organization_count_reached: You have reached the maximum number of organizations you can create, if you need to create more, please contact support.
api-errors.default: An error occurred while processing your request.
api-errors.organization.invitation_already_exists: An invitation for this email already exists in this organization.
api-errors.user.already_in_organization: This user is already in this organization.
api-errors.user.organization_invitation_limit_reached: The maximum number of invitations has been reached for today. Please try again tomorrow.
api-errors.demo.not_available: This feature is not available in demo
api-keys.permissions.documents.title: Documents
api-keys.permissions.documents.documents:create: Create documents
@@ -186,23 +189,103 @@ api-keys.create.form.name.label: Name
api-keys.create.form.name.placeholder: 'Example: My API key'
api-keys.create.form.name.required: Please enter a name for the API key
api-keys.create.form.permissions.label: Permissions
api-keys.create.form.permissions.description: Select the permissions for the API key.
api-keys.create.form.permissions.required: Please select at least one permission
api-keys.create.form.submit: Create API key
api-keys.create.created.title: API key created
api-keys.create.created.description: The API key has been created successfully. Save it in a secure location as it will not be displayed again.
api-keys.list.title: API keys
api-keys.list.description: Manage your API keys here.
api-keys.list.delete: Delete
api-keys.list.create: Create API key
api-keys.list.empty.title: No API keys
api-keys.list.empty.description: Create an API key to access the Papra API.
api-keys.list.card.last-used: Last used
api-keys.list.card.never: Never
api-keys.list.card.created: Created
api-keys.list.card.delete: Delete
api-keys.delete.success: The API key has been deleted successfully
api-keys.delete.confirm.title: Delete API key
api-keys.delete.confirm.message: Are you sure you want to delete this API key? This action cannot be undone.
api-keys.delete.confirm.confirm-button: Delete
api-keys.delete.confirm.cancel-button: Cancel
webhooks.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
organizations.members.title: Members
organizations.members.description: Manage your organization members
organizations.members.invite-member: Invite member
organizations.members.invite-member-disabled-tooltip: Only admins or owners can invite members to the organization
organizations.members.remove-from-organization: Remove from organization
organizations.members.role: Role
organizations.members.roles.owner: Owner
organizations.members.roles.admin: Admin
organizations.members.roles.member: Member
organizations.members.delete.confirm.title: Remove member
organizations.members.delete.confirm.message: Are you sure you want to remove this member from the organization?
organizations.members.delete.confirm.confirm-button: Remove
organizations.members.delete.confirm.cancel-button: Cancel
organizations.members.delete.success: Member removed from organization
organizations.members.update-role.success: Member role updated
organizations.invite-member.title: Invite member
organizations.invite-member.description: Invite a member to your organization
organizations.invite-member.form.email.label: Email
organizations.invite-member.form.email.placeholder: 'Example: ada@papra.app'
organizations.invite-member.form.email.required: Please enter a valid email address
organizations.invite-member.form.role.label: Role
organizations.invite-member.form.submit: Invite to organization
organizations.invite-member.success.message: Member invited
organizations.invite-member.success.description: The email has been invited to the organization.
organizations.invite-member.error.message: Failed to invite member
invitations.list.title: Invitations
invitations.list.description: Manage your organization invitations
invitations.list.empty.title: No pending invitations
invitations.list.empty.description: You haven't been invited to any organizations yet.
invitations.list.headers.organization: Organization
invitations.list.headers.created: Created
invitations.list.headers.actions: Actions
invitations.list.actions.accept: Accept
invitations.list.actions.reject: Reject
invitations.list.actions.accept.success.message: Invitation accepted
invitations.list.actions.accept.success.description: The invitation has been accepted.
invitations.list.actions.reject.success.message: Invitation rejected
invitations.list.actions.reject.success.description: The invitation has been rejected.

View File

@@ -38,7 +38,7 @@ auth.login.form.forgot-password.label: Mot de passe oublié ?
auth.login.form.submit: Connexion
auth.register.title: S'inscrire à Papra
auth.register.description: Entrez votre email ou utilisez une connexion sociale pour accéder à votre compte Papra.
auth.register.description: Créez un compte pour commencer à utiliser Papra.
auth.register.register-with-email: S'inscrire avec email
auth.register.register-with-provider: S'inscrire avec {{ provider }}
auth.register.providers.google: Google
@@ -77,9 +77,8 @@ layout.menu.home: Accueil
layout.menu.documents: Documents
layout.menu.tags: Tags
layout.menu.tagging-rules: Règles de catégorisation
layout.menu.integrations: Intégrations
layout.menu.deleted-documents: Documents supprimés
layout.menu.organization-settings: Paramètres de l'organisation
layout.menu.organization-settings: Paramètres
layout.menu.api-keys: API keys
layout.menu.settings: Paramètres
layout.menu.account: Compte
@@ -123,9 +122,6 @@ tagging-rules.form.conditions.no-conditions.title: Aucune condition
tagging-rules.form.conditions.no-conditions.description: Vous n'avez pas ajouté de conditions à cette règle. Cette règle appliquera ses tags à tous les documents.
tagging-rules.form.conditions.no-conditions.confirm: Appliquer la règle sans conditions
tagging-rules.form.conditions.no-conditions.cancel: Annuler
tagging-rules.form.conditions.field.label: Champ
tagging-rules.form.conditions.operator.label: Opérateur
tagging-rules.form.conditions.value.label: Valeur
tagging-rules.form.conditions.value.placeholder: 'Exemple: facture'
tagging-rules.form.conditions.value.min-length: Veuillez entrer une valeur pour la condition
tagging-rules.form.tags.label: Tags
@@ -134,7 +130,6 @@ tagging-rules.form.tags.min-length: Au moins un tag à appliquer est requis
tagging-rules.form.tags.add-tag: Créer un tag
tagging-rules.form.submit: Créer la règle
tagging-rules.update.title: Mettre à jour la règle de catégorisation
tagging-rules.update.success: Règle de catégorisation mise à jour avec succès
tagging-rules.update.error: Échec de la mise à jour de la règle de catégorisation
tagging-rules.update.submit: Mettre à jour la règle
tagging-rules.update.cancel: Annuler
@@ -188,21 +183,18 @@ api-keys.create.form.name.label: Nom
api-keys.create.form.name.placeholder: 'Exemple: Ma clé API'
api-keys.create.form.name.required: Veuillez entrer un nom pour la clé API
api-keys.create.form.permissions.label: Permissions
api-keys.create.form.permissions.description: Sélectionnez les permissions pour la clé API.
api-keys.create.form.permissions.required: Veuillez sélectionner au moins une permission
api-keys.create.form.submit: Créer la clé API
api-keys.create.created.title: Clé API créée
api-keys.create.created.description: La clé API a été créée avec succès. Enregistrez-la dans un endroit sûr car elle ne sera plus affichée.
api-keys.list.title: Clés API
api-keys.list.description: Gérez vos clés API ici.
api-keys.list.delete: Supprimer
api-keys.list.create: Créer une clé API
api-keys.list.empty.title: Aucune clé API
api-keys.list.empty.description: Créez une clé API pour accéder à l'API de Papra.
api-keys.list.card.last-used: Dernière utilisation
api-keys.list.card.never: Jamais
api-keys.list.card.created: Créée
api-keys.list.card.delete: Supprimer
api-keys.delete.success: La clé API a été supprimée avec succès
api-keys.delete.confirm.title: Supprimer la clé API
api-keys.delete.confirm.message: Êtes-vous sûr de vouloir supprimer cette clé API ? Cette action est irréversible.

View File

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

View File

@@ -1,14 +1,15 @@
import type { Component } from 'solid-js';
import type { ApiKey } from '../api-keys.types';
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 { useI18n } from '@/modules/i18n/i18n.provider';
import { useConfirmModal } from '@/modules/shared/confirm';
import { queryClient } from '@/modules/shared/query/query-client';
import { Button } from '@/modules/ui/components/button';
import { EmptyState } from '@/modules/ui/components/empty';
import { createToast } from '@/modules/ui/components/sonner';
import { A } from '@solidjs/router';
import { createMutation, createQuery } from '@tanstack/solid-query';
import { format } from 'date-fns';
import { type Component, For, Match, Show, Suspense, Switch } from 'solid-js';
import { deleteApiKey, fetchApiKeys } from '../api-keys.services';
export const ApiKeyCard: Component<{ apiKey: ApiKey }> = ({ apiKey }) => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,8 @@
import type { SsoProviderKey } from '../auth.types';
import type { Component } from 'solid-js';
import type { SsoProviderConfig } from '../auth.types';
import { A, useNavigate } from '@solidjs/router';
import { createSignal, For, Show } from 'solid-js';
import * as v from 'valibot';
import { useConfig } from '@/modules/config/config.provider';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { createForm } from '@/modules/shared/form/form';
@@ -6,12 +10,9 @@ import { Button } from '@/modules/ui/components/button';
import { Checkbox, CheckboxControl, CheckboxLabel } from '@/modules/ui/components/checkbox';
import { Separator } from '@/modules/ui/components/separator';
import { TextField, TextFieldLabel, TextFieldRoot } from '@/modules/ui/components/textfield';
import { A, useNavigate } from '@solidjs/router';
import { type Component, createSignal, For, Show } from 'solid-js';
import * as v from 'valibot';
import { AuthLayout } from '../../ui/layouts/auth-layout.component';
import { getEnabledSsoProviderConfigs, isEmailVerificationRequiredError } from '../auth.models';
import { signIn } from '../auth.services';
import { authWithProvider, signIn } from '../auth.services';
import { AuthLegalLinks } from '../components/legal-links.component';
import { SsoProviderButton } from '../components/sso-provider-button.component';
@@ -104,8 +105,8 @@ export const LoginPage: Component = () => {
const [getShowEmailLogin, setShowEmailLogin] = createSignal(false);
const loginWithProvider = async (provider: { key: SsoProviderKey }) => {
await signIn.social({ provider: provider.key, callbackURL: config.baseUrl });
const loginWithProvider = async (provider: SsoProviderConfig) => {
await authWithProvider({ provider, config });
};
const getHasSsoProviders = () => getEnabledSsoProviderConfigs({ config }).length > 0;

View File

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

View File

@@ -1,12 +1,13 @@
import type { Component } from 'solid-js';
import { buildUrl } from '@corentinth/chisels';
import { A, useNavigate } from '@solidjs/router';
import { createSignal, onMount } from 'solid-js';
import * as v from 'valibot';
import { useConfig } from '@/modules/config/config.provider';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { createForm } from '@/modules/shared/form/form';
import { Button } from '@/modules/ui/components/button';
import { TextField, TextFieldLabel, TextFieldRoot } from '@/modules/ui/components/textfield';
import { buildUrl } from '@corentinth/chisels';
import { A, useNavigate } from '@solidjs/router';
import { type Component, createSignal, onMount } from 'solid-js';
import * as v from 'valibot';
import { AuthLayout } from '../../ui/layouts/auth-layout.component';
import { forgetPassword } from '../auth.services';
import { OpenEmailProvider } from '../components/open-email-provider.component';

View File

@@ -1,12 +1,12 @@
import type { Component } from 'solid-js';
import { A, Navigate, useNavigate, useSearchParams } from '@solidjs/router';
import { createSignal, onMount } from 'solid-js';
import * as v from 'valibot';
import { useConfig } from '@/modules/config/config.provider';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { createForm } from '@/modules/shared/form/form';
import { Button } from '@/modules/ui/components/button';
import { TextField, TextFieldLabel, TextFieldRoot } from '@/modules/ui/components/textfield';
import { A, Navigate, useNavigate, useSearchParams } from '@solidjs/router';
import { type Component, createSignal } from 'solid-js';
import { onMount } from 'solid-js';
import * as v from 'valibot';
import { AuthLayout } from '../../ui/layouts/auth-layout.component';
import { resetPassword } from '../auth.services';

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

@@ -18,6 +18,11 @@ export const buildTimeConfig = {
providers: {
github: { isEnabled: asBoolean(import.meta.env.VITE_AUTH_PROVIDERS_GITHUB_IS_ENABLED, false) },
google: { isEnabled: asBoolean(import.meta.env.VITE_AUTH_PROVIDERS_GOOGLE_IS_ENABLED, false) },
customs: [] as {
providerId: string;
providerName: string;
providerIconUrl: string;
}[],
},
},
documents: {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,9 +1,9 @@
import type { DropdownMenuSubTriggerProps } from '@kobalte/core/dropdown-menu';
import type { Component } from 'solid-js';
import type { Document } from '../documents.types';
import { A } from '@solidjs/router';
import { Button } from '@/modules/ui/components/button';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/modules/ui/components/dropdown-menu';
import { A } from '@solidjs/router';
import { useDeleteDocument } from '../documents.composables';
export const DocumentManagementDropdown: Component<{ document: Document }> = (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 { Card } from '@/modules/ui/components/card';
import { fetchDocumentFile } from '../documents.services';
import { PdfViewer } from './pdf-viewer.component';

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,8 @@
import type { Component } from 'solid-js';
import type { Document } from '../documents.types';
import { useParams } from '@solidjs/router';
import { createMutation, createQuery, keepPreviousData } from '@tanstack/solid-query';
import { createSignal, Show, Suspense } from 'solid-js';
import { useConfig } from '@/modules/config/config.provider';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { useConfirmModal } from '@/modules/shared/confirm';
@@ -7,9 +11,6 @@ import { queryClient } from '@/modules/shared/query/query-client';
import { Alert, AlertDescription } from '@/modules/ui/components/alert';
import { Button } from '@/modules/ui/components/button';
import { createToast } from '@/modules/ui/components/sonner';
import { useParams } from '@solidjs/router';
import { createMutation, createQuery, keepPreviousData } from '@tanstack/solid-query';
import { type Component, createSignal, Show, Suspense } from 'solid-js';
import { DocumentsPaginatedList } from '../components/documents-list.component';
import { useRestoreDocument } from '../documents.composables';
import { deleteAllTrashDocuments, deleteTrashDocument, fetchOrganizationDeletedDocuments } from '../documents.services';

View File

@@ -1,3 +1,8 @@
import type { Component, JSX } from 'solid-js';
import { formatBytes, safely } from '@corentinth/chisels';
import { useNavigate, useParams } from '@solidjs/router';
import { createQueries } from '@tanstack/solid-query';
import { createSignal, For, Show, Suspense } from 'solid-js';
import { useConfig } from '@/modules/config/config.provider';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { timeAgo } from '@/modules/shared/date/time-ago';
@@ -13,11 +18,6 @@ import { createToast } from '@/modules/ui/components/sonner';
import { Tabs, TabsContent, TabsIndicator, TabsList, TabsTrigger } from '@/modules/ui/components/tabs';
import { TextArea } from '@/modules/ui/components/textarea';
import { TextFieldRoot } from '@/modules/ui/components/textfield';
import { formatBytes, safely } from '@corentinth/chisels';
import { useNavigate, useParams } from '@solidjs/router';
import { createQueries } from '@tanstack/solid-query';
import { type Component, For, type JSX, Show, Suspense } from 'solid-js';
import { createSignal } from 'solid-js';
import { DocumentPreview } from '../components/document-preview.component';
import { getDaysBeforePermanentDeletion } from '../document.models';
import { useDeleteDocument, useRestoreDocument } from '../documents.composables';
@@ -231,7 +231,7 @@ export const DocumentPage: Component = () => {
<Separator class="my-3" />
<Tabs defaultValue="info" class="w-full" value="content">
<Tabs defaultValue="info" class="w-full">
<TabsList class="w-full h-8">
<TabsTrigger value="info">Info</TabsTrigger>
<TabsTrigger value="content">Content</TabsTrigger>

View File

@@ -1,10 +1,11 @@
import { fetchOrganization } from '@/modules/organizations/organizations.services';
import { Tag } from '@/modules/tags/components/tag.component';
import { fetchTags } from '@/modules/tags/tags.services';
import type { Component } from 'solid-js';
import { useParams, useSearchParams } from '@solidjs/router';
import { createQueries, keepPreviousData } from '@tanstack/solid-query';
import { castArray } from 'lodash-es';
import { type Component, createSignal, For, Show, Suspense } from 'solid-js';
import { createSignal, For, Show, Suspense } from 'solid-js';
import { fetchOrganization } from '@/modules/organizations/organizations.services';
import { Tag } from '@/modules/tags/components/tag.component';
import { fetchTags } from '@/modules/tags/tags.services';
import { DocumentUploadArea } from '../components/document-upload-area.component';
import { createdAtColumn, DocumentsPaginatedList, standardActionsColumn, tagsColumn } from '../components/documents-list.component';
import { fetchOrganizationDocuments } from '../documents.services';

View File

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

View File

@@ -75,12 +75,16 @@ export type LocaleKeys =
| 'layout.menu.documents'
| 'layout.menu.tags'
| 'layout.menu.tagging-rules'
| 'layout.menu.integrations'
| 'layout.menu.deleted-documents'
| 'layout.menu.organization-settings'
| 'layout.menu.api-keys'
| 'layout.menu.settings'
| 'layout.menu.account'
| 'layout.menu.general-settings'
| 'layout.menu.intake-emails'
| 'layout.menu.webhooks'
| 'layout.menu.members'
| 'layout.menu.invitations'
| 'tagging-rules.field.name'
| 'tagging-rules.field.content'
| 'tagging-rules.operator.equals'
@@ -118,9 +122,6 @@ export type LocaleKeys =
| 'tagging-rules.form.conditions.no-conditions.description'
| 'tagging-rules.form.conditions.no-conditions.confirm'
| 'tagging-rules.form.conditions.no-conditions.cancel'
| 'tagging-rules.form.conditions.field.label'
| 'tagging-rules.form.conditions.operator.label'
| 'tagging-rules.form.conditions.value.label'
| 'tagging-rules.form.conditions.value.placeholder'
| 'tagging-rules.form.conditions.value.min-length'
| 'tagging-rules.form.tags.label'
@@ -129,7 +130,6 @@ export type LocaleKeys =
| 'tagging-rules.form.tags.add-tag'
| 'tagging-rules.form.submit'
| 'tagging-rules.update.title'
| 'tagging-rules.update.success'
| 'tagging-rules.update.error'
| 'tagging-rules.update.submit'
| 'tagging-rules.update.cancel'
@@ -160,6 +160,10 @@ export type LocaleKeys =
| 'api-errors.intake_email.limit_reached'
| 'api-errors.user.max_organization_count_reached'
| 'api-errors.default'
| 'api-errors.organization.invitation_already_exists'
| 'api-errors.user.already_in_organization'
| 'api-errors.user.organization_invitation_limit_reached'
| 'api-errors.demo.not_available'
| 'api-keys.permissions.documents.title'
| 'api-keys.permissions.documents.documents:create'
| 'api-keys.permissions.documents.documents:read'
@@ -178,23 +182,97 @@ export type LocaleKeys =
| 'api-keys.create.form.name.placeholder'
| 'api-keys.create.form.name.required'
| 'api-keys.create.form.permissions.label'
| 'api-keys.create.form.permissions.description'
| 'api-keys.create.form.permissions.required'
| 'api-keys.create.form.submit'
| 'api-keys.create.created.title'
| 'api-keys.create.created.description'
| 'api-keys.list.title'
| 'api-keys.list.description'
| 'api-keys.list.delete'
| 'api-keys.list.create'
| 'api-keys.list.empty.title'
| 'api-keys.list.empty.description'
| 'api-keys.list.card.last-used'
| 'api-keys.list.card.never'
| 'api-keys.list.card.created'
| 'api-keys.list.card.delete'
| 'api-keys.delete.success'
| 'api-keys.delete.confirm.title'
| 'api-keys.delete.confirm.message'
| 'api-keys.delete.confirm.confirm-button'
| 'api-keys.delete.confirm.cancel-button';
| 'api-keys.delete.confirm.cancel-button'
| 'webhooks.list.title'
| 'webhooks.list.description'
| 'webhooks.list.empty.title'
| 'webhooks.list.empty.description'
| 'webhooks.list.create'
| 'webhooks.list.card.last-triggered'
| 'webhooks.list.card.never'
| 'webhooks.list.card.created'
| 'webhooks.create.title'
| 'webhooks.create.description'
| 'webhooks.create.success'
| 'webhooks.create.back'
| 'webhooks.create.form.submit'
| 'webhooks.create.form.name.label'
| 'webhooks.create.form.name.placeholder'
| 'webhooks.create.form.name.required'
| 'webhooks.create.form.url.label'
| 'webhooks.create.form.url.placeholder'
| 'webhooks.create.form.url.required'
| 'webhooks.create.form.url.invalid'
| 'webhooks.create.form.secret.label'
| 'webhooks.create.form.secret.placeholder'
| 'webhooks.create.form.events.label'
| 'webhooks.create.form.events.required'
| 'webhooks.update.title'
| 'webhooks.update.description'
| 'webhooks.update.success'
| 'webhooks.update.submit'
| 'webhooks.update.cancel'
| 'webhooks.update.form.secret.placeholder'
| 'webhooks.update.form.secret.placeholder-redacted'
| 'webhooks.update.form.rotate-secret.button'
| 'webhooks.delete.success'
| 'webhooks.delete.confirm.title'
| 'webhooks.delete.confirm.message'
| 'webhooks.delete.confirm.confirm-button'
| 'webhooks.delete.confirm.cancel-button'
| 'webhooks.events.documents.document:created.description'
| 'webhooks.events.documents.document:deleted.description'
| 'organizations.members.title'
| 'organizations.members.description'
| 'organizations.members.invite-member'
| 'organizations.members.invite-member-disabled-tooltip'
| 'organizations.members.remove-from-organization'
| 'organizations.members.role'
| 'organizations.members.roles.owner'
| 'organizations.members.roles.admin'
| 'organizations.members.roles.member'
| 'organizations.members.delete.confirm.title'
| 'organizations.members.delete.confirm.message'
| 'organizations.members.delete.confirm.confirm-button'
| 'organizations.members.delete.confirm.cancel-button'
| 'organizations.members.delete.success'
| 'organizations.members.update-role.success'
| 'organizations.invite-member.title'
| 'organizations.invite-member.description'
| 'organizations.invite-member.form.email.label'
| 'organizations.invite-member.form.email.placeholder'
| 'organizations.invite-member.form.email.required'
| 'organizations.invite-member.form.role.label'
| 'organizations.invite-member.form.submit'
| 'organizations.invite-member.success.message'
| 'organizations.invite-member.success.description'
| 'organizations.invite-member.error.message'
| 'invitations.list.title'
| 'invitations.list.description'
| 'invitations.list.empty.title'
| 'invitations.list.empty.description'
| 'invitations.list.headers.organization'
| 'invitations.list.headers.created'
| 'invitations.list.headers.actions'
| 'invitations.list.actions.accept'
| 'invitations.list.actions.reject'
| 'invitations.list.actions.accept.success.message'
| 'invitations.list.actions.accept.success.description'
| 'invitations.list.actions.reject.success.message'
| 'invitations.list.actions.reject.success.description';

View File

@@ -1,5 +1,11 @@
import type { DialogTriggerProps } from '@kobalte/core/dialog';
import type { Component, JSX } from 'solid-js';
import type { IntakeEmail } from '../intake-emails.types';
import { safely } from '@corentinth/chisels';
import { useParams } from '@solidjs/router';
import { createQuery } from '@tanstack/solid-query';
import { createSignal, For, Show, Suspense } from 'solid-js';
import * as v from 'valibot';
import { useConfig } from '@/modules/config/config.provider';
import { useConfirmModal } from '@/modules/shared/confirm';
import { createForm } from '@/modules/shared/form/form';
@@ -13,12 +19,6 @@ import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, Di
import { EmptyState } from '@/modules/ui/components/empty';
import { createToast } from '@/modules/ui/components/sonner';
import { TextField, TextFieldLabel, TextFieldRoot } from '@/modules/ui/components/textfield';
import { safely } from '@corentinth/chisels';
import { useParams } from '@solidjs/router';
import { createQuery } from '@tanstack/solid-query';
import { type Component, For, type JSX, Show, Suspense } from 'solid-js';
import { createSignal } from 'solid-js';
import * as v from 'valibot';
import { createIntakeEmail, deleteIntakeEmail, fetchIntakeEmails, updateIntakeEmail } from '../intake-emails.services';
const AllowedOriginsDialog: Component<{ children: (props: DialogTriggerProps) => JSX.Element; intakeEmails: IntakeEmail }> = (props) => {
@@ -133,12 +133,27 @@ export const IntakeEmailsPage: Component = () => {
if (!config.intakeEmails.isEnabled) {
return (
<Card class="p-6">
<h2 class="text-base font-bold">Intake Emails</h2>
<div class="p-6 max-w-screen-md mx-auto mt-10">
<h1 class="text-xl font-semibold">Intake Emails</h1>
<p class="text-muted-foreground mt-1">
Intake emails are disabled on this instance. Please contact your administrator to enable them.
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.
</p>
</Card>
<Card class="px-6 py-4 mt-4 flex items-center gap-4">
<div class="i-tabler-mail-off size-12 text-muted-foreground flex-shrink-0" />
<div>
<h2 class="text-base font-bold text-muted-foreground">Intake Emails are disabled</h2>
<p class="text-muted-foreground mt-1">
Intake emails are disabled on this instance. Please contact your administrator to enable them. See the
{' '}
<a href="https://docs.papra.app/guides/intake-emails-with-owlrelay/" target="_blank" class="text-primary">documentation</a>
{' '}
for more information.
</p>
</div>
</Card>
</div>
);
}
@@ -211,9 +226,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 +279,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 +357,6 @@ export const IntakeEmailsPage: Component = () => {
)}
</Show>
</Suspense>
</Card>
</div>
);
};

View File

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

View File

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

View File

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

View File

@@ -1,10 +1,10 @@
import type { Component } from 'solid-js';
import { safely } from '@corentinth/chisels';
import * as v from 'valibot';
import { createForm } from '@/modules/shared/form/form';
import { isHttpErrorWithCode } from '@/modules/shared/http/http-errors';
import { Button } from '@/modules/ui/components/button';
import { TextField, TextFieldLabel, TextFieldRoot } from '@/modules/ui/components/textfield';
import { safely } from '@corentinth/chisels';
import * as v from 'valibot';
import { organizationNameSchema } from '../organizations.schemas';
export const CreateOrganizationForm: Component<{

View File

@@ -1,7 +1,9 @@
import { useNavigate, useParams } from '@solidjs/router';
import { useQuery } from '@tanstack/solid-query';
import { queryClient } from '@/modules/shared/query/query-client';
import { createToast } from '@/modules/ui/components/sonner';
import { useNavigate } from '@solidjs/router';
import { createOrganization, deleteOrganization, updateOrganization } from './organizations.services';
import { ORGANIZATION_ROLES } from './organizations.constants';
import { createOrganization, deleteOrganization, getMembership, updateOrganization } from './organizations.services';
export function useCreateOrganization() {
const navigate = useNavigate();
@@ -50,3 +52,29 @@ export function useDeleteOrganization() {
},
};
}
export function useCurrentUserRole({ organizationId }: { organizationId?: string } = {}) {
const params = useParams();
const getOrganizationId = () => organizationId ?? params.organizationId;
const query = useQuery(() => ({
queryKey: ['organizations', getOrganizationId(), 'members', 'me'],
queryFn: () => getMembership({ organizationId: getOrganizationId() }),
}));
const getRole = () => query.data?.member.role;
const getIsMember = () => getRole() === ORGANIZATION_ROLES.MEMBER;
const getIsAdmin = () => getRole() === ORGANIZATION_ROLES.ADMIN;
const getIsOwner = () => getRole() === ORGANIZATION_ROLES.OWNER;
const getIsAtLeastAdmin = () => getIsAdmin() || getIsOwner();
return {
query,
getRole,
getIsMember,
getIsAdmin,
getIsOwner,
getIsAtLeastAdmin,
};
}

View File

@@ -0,0 +1,7 @@
export const ORGANIZATION_ROLES = {
OWNER: 'owner',
ADMIN: 'admin',
MEMBER: 'member',
} as const;
export const ORGANIZATION_ROLES_LIST = Object.values(ORGANIZATION_ROLES);

View File

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

View File

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

View File

@@ -1,6 +1,17 @@
import type { User } from 'better-auth/types';
export type Organization = {
id: string;
name: string;
createdAt: Date;
updatedAt: Date;
};
export type OrganizationMember = {
id: string;
organizationId: string;
user: User;
role: OrganizationMemberRole;
};
export type OrganizationMemberRole = 'owner' | 'admin' | 'member';

View File

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

View File

@@ -1,6 +1,6 @@
import type { Component } from 'solid-js';
import { Button } from '@/modules/ui/components/button';
import { A } from '@solidjs/router';
import { Button } from '@/modules/ui/components/button';
import { CreateOrganizationForm } from '../components/create-organization-form.component';
import { useCreateOrganization } from '../organizations.composables';

View File

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

View File

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

View File

@@ -1,12 +1,13 @@
import type { Component } from 'solid-js';
import { formatBytes } from '@corentinth/chisels';
import { useParams } from '@solidjs/router';
import { createQueries, keepPreviousData } from '@tanstack/solid-query';
import { createSignal, Show, Suspense } from 'solid-js';
import { DocumentUploadArea } from '@/modules/documents/components/document-upload-area.component';
import { createdAtColumn, DocumentsPaginatedList, standardActionsColumn, tagsColumn } from '@/modules/documents/components/documents-list.component';
import { useUploadDocuments } from '@/modules/documents/documents.composables';
import { fetchOrganizationDocuments, getOrganizationDocumentsStats } from '@/modules/documents/documents.services';
import { Button } from '@/modules/ui/components/button';
import { formatBytes } from '@corentinth/chisels';
import { useParams } from '@solidjs/router';
import { createQueries, keepPreviousData } from '@tanstack/solid-query';
import { type Component, createSignal, Show, Suspense } from 'solid-js';
export const OrganizationPage: Component = () => {
const params = useParams();

View File

@@ -1,4 +1,10 @@
import type { Component } from 'solid-js';
import type { Organization } from '../organizations.types';
import { safely } from '@corentinth/chisels';
import { useParams } from '@solidjs/router';
import { createQuery } from '@tanstack/solid-query';
import { createSignal, Show, Suspense } from 'solid-js';
import * as v from 'valibot';
import { buildTimeConfig } from '@/modules/config/config';
import { useConfirmModal } from '@/modules/shared/confirm';
import { createForm } from '@/modules/shared/form/form';
@@ -7,11 +13,6 @@ import { Button } from '@/modules/ui/components/button';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/modules/ui/components/card';
import { createToast } from '@/modules/ui/components/sonner';
import { TextField, TextFieldLabel, TextFieldRoot } from '@/modules/ui/components/textfield';
import { safely } from '@corentinth/chisels';
import { useParams } from '@solidjs/router';
import { createQuery } from '@tanstack/solid-query';
import { type Component, createSignal, Show, Suspense } from 'solid-js';
import * as v from 'valibot';
import { useDeleteOrganization, useUpdateOrganization } from '../organizations.composables';
import { organizationNameSchema } from '../organizations.schemas';
import { fetchOrganization } from '../organizations.services';
@@ -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 { buildTimeConfig } from '@/modules/config/config';
import type { HttpClientOptions, ResponseType } from './http-client';
import { safely } from '@corentinth/chisels';
import { httpClient, type HttpClientOptions, type ResponseType } from './http-client';
import { buildTimeConfig } from '@/modules/config/config';
import { httpClient } from './http-client';
import { isHttpErrorWithStatusCode } from './http-errors';
export async function apiClient<T, R extends ResponseType = 'json'>({

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
import type { Component } from 'solid-js';
import { Button } from '@/modules/ui/components/button';
import { A } from '@solidjs/router';
import { Button } from '@/modules/ui/components/button';
export const NotFoundPage: Component = () => {
return (

View File

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

View File

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

View File

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

View File

@@ -1,9 +1,9 @@
import type { Component } from 'solid-js';
import type { TaggingRuleForCreation } from '../tagging-rules.types';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { createToast } from '@/modules/ui/components/sonner';
import { useNavigate, useParams } from '@solidjs/router';
import { createMutation } from '@tanstack/solid-query';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { createToast } from '@/modules/ui/components/sonner';
import { TaggingRuleForm } from '../components/tagging-rule-form.component';
import { createTaggingRule } from '../tagging-rules.services';

View File

@@ -1,13 +1,14 @@
import type { Component } from 'solid-js';
import type { TaggingRule } from '../tagging-rules.types';
import { A, useParams } from '@solidjs/router';
import { createMutation, createQuery } from '@tanstack/solid-query';
import { For, Match, Show, Switch } from 'solid-js';
import { useConfig } from '@/modules/config/config.provider';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { queryClient } from '@/modules/shared/query/query-client';
import { Alert } from '@/modules/ui/components/alert';
import { Button } from '@/modules/ui/components/button';
import { EmptyState } from '@/modules/ui/components/empty';
import { A, useParams } from '@solidjs/router';
import { createMutation, createQuery } from '@tanstack/solid-query';
import { type Component, For, Match, Show, Switch } from 'solid-js';
import { deleteTaggingRule, fetchTaggingRules } from '../tagging-rules.services';
const TaggingRuleCard: Component<{ taggingRule: TaggingRule }> = (props) => {

View File

@@ -1,10 +1,11 @@
import type { Component } from 'solid-js';
import type { TaggingRuleForCreation } from '../tagging-rules.types';
import { useNavigate, useParams } from '@solidjs/router';
import { createMutation, createQuery } from '@tanstack/solid-query';
import { Show } from 'solid-js';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { queryClient } from '@/modules/shared/query/query-client';
import { createToast } from '@/modules/ui/components/sonner';
import { useNavigate, useParams } from '@solidjs/router';
import { createMutation, createQuery } from '@tanstack/solid-query';
import { type Component, Show } from 'solid-js';
import { TaggingRuleForm } from '../components/tagging-rule-form.component';
import { getTaggingRule, updateTaggingRule } from '../tagging-rules.services';

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 { Combobox, ComboboxContent, ComboboxInput, ComboboxItem, ComboboxTrigger } from '@/modules/ui/components/combobox';
import { fetchTags } from '../tags.services';
import { Tag as TagComponent } from './tag.component';

View File

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

View File

@@ -1,5 +1,11 @@
import type { DialogTriggerProps } from '@kobalte/core/dialog';
import type { Component, JSX } from 'solid-js';
import type { Tag as TagType } from '../tags.types';
import { getValues } from '@modular-forms/solid';
import { A, useParams } from '@solidjs/router';
import { createQuery } from '@tanstack/solid-query';
import { createSignal, For, Show, Suspense } from 'solid-js';
import * as v from 'valibot';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { useConfirmModal } from '@/modules/shared/confirm';
import { timeAgo } from '@/modules/shared/date/time-ago';
@@ -12,11 +18,6 @@ import { createToast } from '@/modules/ui/components/sonner';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/modules/ui/components/table';
import { TextArea } from '@/modules/ui/components/textarea';
import { TextField, TextFieldLabel, TextFieldRoot } from '@/modules/ui/components/textfield';
import { getValues } from '@modular-forms/solid';
import { A, useParams } from '@solidjs/router';
import { createQuery } from '@tanstack/solid-query';
import { type Component, createSignal, For, type JSX, 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,6 +1,7 @@
import type { Component } from 'solid-js';
import { createEffect } from 'solid-js';
import { useSession } from '@/modules/auth/auth.services';
import { buildTimeConfig } from '@/modules/config/config';
import { type Component, 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

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

View File

@@ -1,7 +1,8 @@
import type { VariantProps } from 'class-variance-authority';
import { cn } from '@/modules/shared/style/cn';
import type { ComponentProps } from 'solid-js';
import { cva } from 'class-variance-authority';
import { type ComponentProps, splitProps } from 'solid-js';
import { splitProps } from 'solid-js';
import { cn } from '@/modules/shared/style/cn';
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

@@ -2,10 +2,10 @@ import type { ButtonRootProps } from '@kobalte/core/button';
import type { PolymorphicProps } from '@kobalte/core/polymorphic';
import type { VariantProps } from 'class-variance-authority';
import type { JSX, ValidComponent } from 'solid-js';
import { cn } from '@/modules/shared/style/cn';
import { Button as ButtonPrimitive } from '@kobalte/core/button';
import { cva } from 'class-variance-authority';
import { splitProps } from 'solid-js';
import { cn } from '@/modules/shared/style/cn';
export const buttonVariants = cva(
'inline-flex items-center justify-center rounded-md text-sm font-medium transition-shadow focus-visible:(outline-none ring-1.5 ring-ring) disabled:(pointer-events-none opacity-50) bg-inherit',

View File

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

View File

@@ -1,9 +1,9 @@
import type { CheckboxControlProps } from '@kobalte/core/checkbox';
import type { PolymorphicProps } from '@kobalte/core/polymorphic';
import type { JSX, ValidComponent, VoidProps } from 'solid-js';
import { cn } from '@/modules/shared/style/cn';
import { Checkbox as CheckboxPrimitive } from '@kobalte/core/checkbox';
import { splitProps } from 'solid-js';
import { cn } from '@/modules/shared/style/cn';
export const CheckboxLabel = CheckboxPrimitive.Label;
export const Checkbox = CheckboxPrimitive;

View File

@@ -6,9 +6,9 @@ import type {
} from '@kobalte/core/combobox';
import type { PolymorphicProps } from '@kobalte/core/polymorphic';
import type { JSX, ParentProps, ValidComponent, VoidProps } from 'solid-js';
import { cn } from '@/modules/shared/style/cn';
import { Combobox as ComboboxPrimitive } from '@kobalte/core/combobox';
import { splitProps } from 'solid-js';
import { cn } from '@/modules/shared/style/cn';
export const Combobox = ComboboxPrimitive;
export const ComboboxDescription = ComboboxPrimitive.Description;

View File

@@ -9,9 +9,9 @@ import type {
CommandRootProps,
} from 'cmdk-solid';
import type { ComponentProps, VoidProps } from 'solid-js';
import { cn } from '@/modules/shared/style/cn';
import { Command as CommandPrimitive } from 'cmdk-solid';
import { splitProps } from 'solid-js';
import { cn } from '@/modules/shared/style/cn';
import { Dialog, DialogContent } from './dialog';
export function Command(props: CommandRootProps) {

View File

@@ -5,9 +5,9 @@ import type {
} from '@kobalte/core/dialog';
import type { PolymorphicProps } from '@kobalte/core/polymorphic';
import type { ComponentProps, ParentProps, ValidComponent } from 'solid-js';
import { cn } from '@/modules/shared/style/cn';
import { Dialog as DialogPrimitive } from '@kobalte/core/dialog';
import { splitProps } from 'solid-js';
import { cn } from '@/modules/shared/style/cn';
export const Dialog = DialogPrimitive;
export const DialogTrigger = DialogPrimitive.Trigger;

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