Compare commits

...

12 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
148 changed files with 8797 additions and 2321 deletions

View File

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

View File

@@ -40,4 +40,13 @@ jobs:
title: "chore(release): update versions"
env:
GITHUB_TOKEN: ${{ secrets.CHANGESET_GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Trigger Docker build
if: steps.changesets.outputs.published == 'true' && contains(fromJson(steps.changesets.outputs.publishedPackages).*.name, '@papra/app-server')
run: |
VERSION=$(echo '${{ steps.changesets.outputs.publishedPackages }}' | jq -r '.[] | select(.name=="@papra/app-server") | .version')
echo "VERSION: $VERSION"
gh workflow run release-docker.yaml -f version="$VERSION"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -61,9 +61,9 @@ Papra is currently in **beta**. The core functionality is stable and usable, but
- **Content extraction**: Automatically extract text from images or scanned documents for search.
- **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:* **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.

View File

@@ -1,5 +1,13 @@
# @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

View File

@@ -3,7 +3,9 @@ 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;
@@ -16,6 +18,7 @@ const posthogScript = posthogRawScript.replace('[POSTHOG-API-KEY]', posthogApiKe
export default defineConfig({
site: 'https://docs.papra.app',
integrations: [
UnoCSS(),
starlight({
plugins: [starlightThemeRapide(), starlightLinksValidator({ exclude: ['http://localhost:1221'] })],
title: 'Papra Docs',
@@ -38,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,7 +1,7 @@
{
"name": "@papra/docs",
"type": "module",
"version": "0.3.1",
"version": "0.4.0",
"private": true,
"packageManager": "pnpm@10.9.0",
"description": "Papra documentation website",
@@ -18,11 +18,16 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@astrojs/starlight": "^0.34.2",
"astro": "^5.7.10",
"@astrojs/solid-js": "^5.1.0",
"@astrojs/starlight": "^0.34.3",
"astro": "^5.8.0",
"sharp": "^0.32.5",
"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": {
@@ -35,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

@@ -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

@@ -53,9 +53,9 @@ In today's digital world, managing countless important documents efficiently and
- **Content extraction**: Automatically extract text from images or scanned documents for search.
- **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:* **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,6 +31,10 @@ 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',
},
],
},
{

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

@@ -1,5 +1,17 @@
# @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

View File

@@ -1,7 +1,7 @@
{
"name": "@papra/app-client",
"type": "module",
"version": "0.4.0",
"version": "0.5.0",
"private": true,
"packageManager": "pnpm@10.9.0",
"description": "Papra frontend client",

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
@@ -85,7 +85,8 @@ 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
@@ -165,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
@@ -243,3 +248,44 @@ 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

View File

@@ -1,8 +1,8 @@
import type { LocaleKeys } from '@/modules/i18n/locales.types';
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 { 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,15 +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 { 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,4 +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';
@@ -6,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 { 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,8 +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 { 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,8 @@
import type { Component } from 'solid-js';
import type { SsoProviderKey } from '../auth.types';
import type { SsoProviderConfig } from '../auth.types';
import { A, useNavigate } from '@solidjs/router';
import { createSignal, For, Show } from 'solid-js';
import * as v from 'valibot';
import { useConfig } from '@/modules/config/config.provider';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { createForm } from '@/modules/shared/form/form';
@@ -7,12 +10,9 @@ import { Button } from '@/modules/ui/components/button';
import { Checkbox, CheckboxControl, CheckboxLabel } from '@/modules/ui/components/checkbox';
import { Separator } from '@/modules/ui/components/separator';
import { TextField, TextFieldLabel, TextFieldRoot } from '@/modules/ui/components/textfield';
import { A, useNavigate } from '@solidjs/router';
import { createSignal, For, Show } from 'solid-js';
import * as v from 'valibot';
import { AuthLayout } from '../../ui/layouts/auth-layout.component';
import { 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';
@@ -105,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,17 +1,17 @@
import type { Component } from 'solid-js';
import type { ssoProviders } from '../auth.constants';
import type { SsoProviderConfig } from '../auth.types';
import { A, useNavigate } from '@solidjs/router';
import { createSignal, For, Show } from 'solid-js';
import * as v from 'valibot';
import { useConfig } from '@/modules/config/config.provider';
import { 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 { 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';
@@ -133,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;
@@ -169,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,13 +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 { 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 { createSignal, onMount } from 'solid-js';
import * as v from 'valibot';
import { AuthLayout } from '../../ui/layouts/auth-layout.component';
import { resetPassword } from '../auth.services';

View File

@@ -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

@@ -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() });
},
};
}

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,8 +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 { 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,8 +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';
@@ -10,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 { For, Match, 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,5 +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';
@@ -8,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 { 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,4 +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';
@@ -14,10 +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 { createSignal, For, Show, Suspense } from 'solid-js';
import { DocumentPreview } from '../components/document-preview.component';
import { getDaysBeforePermanentDeletion } from '../document.models';
import { useDeleteDocument, useRestoreDocument } from '../documents.composables';

View File

@@ -1,11 +1,11 @@
import type { Component } from 'solid-js';
import { fetchOrganization } from '@/modules/organizations/organizations.services';
import { Tag } from '@/modules/tags/components/tag.component';
import { fetchTags } from '@/modules/tags/tags.services';
import { useParams, useSearchParams } from '@solidjs/router';
import { createQueries, keepPreviousData } from '@tanstack/solid-query';
import { castArray } from 'lodash-es';
import { 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

@@ -40,6 +40,7 @@ describe('locales', () => {
/^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(

View File

@@ -83,6 +83,8 @@ export type LocaleKeys =
| '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'
@@ -158,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'
@@ -231,4 +237,42 @@ export type LocaleKeys =
| 'webhooks.delete.confirm.confirm-button'
| 'webhooks.delete.confirm.cancel-button'
| 'webhooks.events.documents.document:created.description'
| 'webhooks.events.documents.document:deleted.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,6 +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';
@@ -14,11 +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 { createSignal, For, Show, Suspense } 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>
);
}

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,8 +1,8 @@
import type { Component } from 'solid-js';
import { useCurrentUser } from '@/modules/users/composables/useCurrentUser';
import { useNavigate } from '@solidjs/router';
import { createQuery } from '@tanstack/solid-query';
import { 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,13 +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 { createSignal, Show, Suspense } from 'solid-js';
export const OrganizationPage: Component = () => {
const params = useParams();

View File

@@ -1,5 +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';
@@ -8,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 { 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';

View File

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

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,5 +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';
@@ -10,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 { 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,14 +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 { For, Match, Show, Switch } from 'solid-js';
import { deleteTaggingRule, fetchTaggingRules } from '../tagging-rules.services';
const TaggingRuleCard: Component<{ taggingRule: TaggingRule }> = (props) => {

View File

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

View File

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

View File

@@ -1,6 +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';
@@ -13,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 { createSignal, For, Show, Suspense } from 'solid-js';
import * as v from 'valibot';
import { Tag } from '../components/tag.component';
import { createTag, deleteTag, fetchTags, updateTag } from '../tags.services';

View File

@@ -1,7 +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 { createEffect } from 'solid-js';
import { trackingServices } from '../tracking.services';
export const IdentifyUser: Component = () => {

View File

@@ -2,10 +2,10 @@ import type { AlertRootProps } from '@kobalte/core/alert';
import type { PolymorphicProps } from '@kobalte/core/polymorphic';
import type { 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,8 +1,8 @@
import type { VariantProps } from 'class-variance-authority';
import type { ComponentProps } from 'solid-js';
import { cn } from '@/modules/shared/style/cn';
import { cva } from 'class-variance-authority';
import { 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;

View File

@@ -11,9 +11,9 @@ import type {
} from '@kobalte/core/dropdown-menu';
import type { PolymorphicProps } from '@kobalte/core/polymorphic';
import type { ComponentProps, ParentProps, ValidComponent } from 'solid-js';
import { cn } from '@/modules/shared/style/cn';
import { DropdownMenu as DropdownMenuPrimitive } from '@kobalte/core/dropdown-menu';
import { mergeProps, splitProps } from 'solid-js';
import { cn } from '@/modules/shared/style/cn';
export const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
export const DropdownMenuGroup = DropdownMenuPrimitive.Group;

View File

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

View File

@@ -9,9 +9,9 @@ import type {
} from '@kobalte/core/number-field';
import type { PolymorphicProps } from '@kobalte/core/polymorphic';
import type { ComponentProps, ValidComponent, VoidProps } from 'solid-js';
import { cn } from '@/modules/shared/style/cn';
import { NumberField as NumberFieldPrimitive } from '@kobalte/core/number-field';
import { splitProps } from 'solid-js';
import { cn } from '@/modules/shared/style/cn';
import { textfieldLabel } from './textfield';
export const NumberFieldHiddenInput = NumberFieldPrimitive.HiddenInput;

View File

@@ -4,9 +4,9 @@ import type {
PopoverRootProps,
} from '@kobalte/core/popover';
import type { ParentProps, ValidComponent } from 'solid-js';
import { cn } from '@/modules/shared/style/cn';
import { Popover as PopoverPrimitive } from '@kobalte/core/popover';
import { mergeProps, splitProps } from 'solid-js';
import { cn } from '@/modules/shared/style/cn';
export const PopoverTrigger = PopoverPrimitive.Trigger;
export const PopoverTitle = PopoverPrimitive.Title;

View File

@@ -1,9 +1,9 @@
import type { PolymorphicProps } from '@kobalte/core/polymorphic';
import type { ProgressRootProps } from '@kobalte/core/progress';
import type { ParentProps, ValidComponent } from 'solid-js';
import { cn } from '@/modules/shared/style/cn';
import { Progress as ProgressPrimitive } from '@kobalte/core/progress';
import { splitProps } from 'solid-js';
import { cn } from '@/modules/shared/style/cn';
export const ProgressLabel = ProgressPrimitive.Label;
export const ProgressValueLabel = ProgressPrimitive.ValueLabel;

View File

@@ -5,9 +5,9 @@ import type {
SelectTriggerProps,
} from '@kobalte/core/select';
import type { ParentProps, ValidComponent } from 'solid-js';
import { cn } from '@/modules/shared/style/cn';
import { Select as SelectPrimitive } from '@kobalte/core/select';
import { splitProps } from 'solid-js';
import { cn } from '@/modules/shared/style/cn';
export const Select = SelectPrimitive;
export const SelectValue = SelectPrimitive.Value;

View File

@@ -1,9 +1,9 @@
import type { PolymorphicProps } from '@kobalte/core/polymorphic';
import type { SeparatorRootProps } from '@kobalte/core/separator';
import type { ValidComponent } from 'solid-js';
import { cn } from '@/modules/shared/style/cn';
import { Separator as SeparatorPrimitive } from '@kobalte/core/separator';
import { splitProps } from 'solid-js';
import { cn } from '@/modules/shared/style/cn';
type separatorProps<T extends ValidComponent = 'hr'> = SeparatorRootProps<T> & {
class?: string;

View File

@@ -6,10 +6,10 @@ import type {
import type { PolymorphicProps } from '@kobalte/core/polymorphic';
import type { VariantProps } from 'class-variance-authority';
import type { ComponentProps, ParentProps, ValidComponent } from 'solid-js';
import { cn } from '@/modules/shared/style/cn';
import { Dialog as DialogPrimitive } from '@kobalte/core/dialog';
import { cva } from 'class-variance-authority';
import { mergeProps, splitProps } from 'solid-js';
import { cn } from '@/modules/shared/style/cn';
export const Sheet = DialogPrimitive;
export const SheetTrigger = DialogPrimitive.Trigger;

View File

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

View File

@@ -4,9 +4,9 @@ import type {
SwitchThumbProps,
} from '@kobalte/core/switch';
import type { ParentProps, ValidComponent, VoidProps } from 'solid-js';
import { cn } from '@/modules/shared/style/cn';
import { Switch as SwitchPrimitive } from '@kobalte/core/switch';
import { splitProps } from 'solid-js';
import { cn } from '@/modules/shared/style/cn';
export const SwitchLabel = SwitchPrimitive.Label;
export const Switch = SwitchPrimitive;

View File

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

View File

@@ -8,10 +8,10 @@ import type {
} from '@kobalte/core/tabs';
import type { VariantProps } from 'class-variance-authority';
import type { ValidComponent, VoidProps } from 'solid-js';
import { cn } from '@/modules/shared/style/cn';
import { Tabs as TabsPrimitive } from '@kobalte/core/tabs';
import { cva } from 'class-variance-authority';
import { splitProps } from 'solid-js';
import { cn } from '@/modules/shared/style/cn';
type tabsProps<T extends ValidComponent = 'div'> = TabsRootProps<T> & {
class?: string;

View File

@@ -1,9 +1,9 @@
import type { PolymorphicProps } from '@kobalte/core/polymorphic';
import type { TextFieldTextAreaProps } from '@kobalte/core/text-field';
import type { ValidComponent, VoidProps } from 'solid-js';
import { cn } from '@/modules/shared/style/cn';
import { TextArea as TextFieldPrimitive } from '@kobalte/core/text-field';
import { splitProps } from 'solid-js';
import { cn } from '@/modules/shared/style/cn';
type textAreaProps<T extends ValidComponent = 'textarea'> = VoidProps<
TextFieldTextAreaProps<T> & {

View File

@@ -7,10 +7,10 @@ import type {
TextFieldRootProps,
} from '@kobalte/core/text-field';
import type { ValidComponent, VoidProps } from 'solid-js';
import { cn } from '@/modules/shared/style/cn';
import { TextField as TextFieldPrimitive } from '@kobalte/core/text-field';
import { cva } from 'class-variance-authority';
import { splitProps } from 'solid-js';
import { cn } from '@/modules/shared/style/cn';
type textFieldProps<T extends ValidComponent = 'div'> =
TextFieldRootProps<T> & {

View File

@@ -5,9 +5,9 @@ import type {
} from '@kobalte/core/toggle-group';
import type { VariantProps } from 'class-variance-authority';
import type { Accessor, ParentProps, ValidComponent } from 'solid-js';
import { cn } from '@/modules/shared/style/cn';
import { ToggleGroup as ToggleGroupPrimitive } from '@kobalte/core/toggle-group';
import { createContext, createMemo, splitProps, useContext } from 'solid-js';
import { cn } from '@/modules/shared/style/cn';
import { toggleVariants } from './toggle';
const ToggleGroupContext = createContext<Accessor<VariantProps<typeof toggleVariants>>>();

View File

@@ -2,10 +2,10 @@ import type { PolymorphicProps } from '@kobalte/core/polymorphic';
import type { ToggleButtonRootProps } from '@kobalte/core/toggle-button';
import type { VariantProps } from 'class-variance-authority';
import type { ValidComponent } from 'solid-js';
import { cn } from '@/modules/shared/style/cn';
import { ToggleButton as ToggleButtonPrimitive } from '@kobalte/core/toggle-button';
import { cva } from 'class-variance-authority';
import { splitProps } from 'solid-js';
import { cn } from '@/modules/shared/style/cn';
export const toggleVariants = cva(
'inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors transition-property-[box-shadow,color,background-color] hover:(bg-muted text-muted-foreground) focus-visible:(outline-none ring-1.5 ring-ring) disabled:(pointer-events-none opacity-50) data-[pressed]:(bg-accent text-accent-foreground) transition-shadow',

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