Compare commits
160 Commits
@papra/app
...
@papra/lec
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f6eae043fa | ||
|
|
e1b0555202 | ||
|
|
93517d0f13 | ||
|
|
d967fa6cef | ||
|
|
9b43bafe33 | ||
|
|
334fcbdee4 | ||
|
|
981731bbe5 | ||
|
|
96403c0047 | ||
|
|
08f4a1cd05 | ||
|
|
ca808064fa | ||
|
|
dc6ee5b228 | ||
|
|
14071b0bc9 | ||
|
|
ae3abe9ec7 | ||
|
|
479a603001 | ||
|
|
19f96a1625 | ||
|
|
a03eae79a0 | ||
|
|
4bcfb878f1 | ||
|
|
d2676052c3 | ||
|
|
ec33ae6294 | ||
|
|
432a192b94 | ||
|
|
98d272fb60 | ||
|
|
1d20c0cfe3 | ||
|
|
07a42da57a | ||
|
|
9dee142948 | ||
|
|
5ccdf446f0 | ||
|
|
11ad13058e | ||
|
|
ee9eff4914 | ||
|
|
499b2cdba7 | ||
|
|
b0877645a8 | ||
|
|
8308e93fdf | ||
|
|
1dce0ace41 | ||
|
|
868281bcff | ||
|
|
5b5ce85061 | ||
|
|
157a5cadd1 | ||
|
|
1922f24c0a | ||
|
|
7ac06a0649 | ||
|
|
c150e231aa | ||
|
|
0c235031d2 | ||
|
|
8a7c1c8368 | ||
|
|
cb1f1b5b01 | ||
|
|
abc463f751 | ||
|
|
8edfd48ceb | ||
|
|
3903eed170 | ||
|
|
c70d7e419a | ||
|
|
2240f58f04 | ||
|
|
79e9bb1b61 | ||
|
|
6e18162435 | ||
|
|
16ae4617df | ||
|
|
1c46071e00 | ||
|
|
377c11c185 | ||
|
|
28c3c15cef | ||
|
|
0391a3bcd5 | ||
|
|
2c75eec862 | ||
|
|
ccf7602f19 | ||
|
|
b8a515a313 | ||
|
|
0aad88471b | ||
|
|
efd2ae1c73 | ||
|
|
e9a719d06a | ||
|
|
68714267ad | ||
|
|
75a13da526 | ||
|
|
59d5819018 | ||
|
|
a857370343 | ||
|
|
f4740ba59a | ||
|
|
b0abf7f78a | ||
|
|
182ccbb30b | ||
|
|
75340f0ce7 | ||
|
|
1228486f28 | ||
|
|
655a1c5475 | ||
|
|
d1797eb9be | ||
|
|
bd3e321eb7 | ||
|
|
be25de7721 | ||
|
|
e85403f9a1 | ||
|
|
7de5d0956b | ||
|
|
b1a88230cd | ||
|
|
55bb29582e | ||
|
|
d9263dc703 | ||
|
|
c3ffa8387e | ||
|
|
d40514c043 | ||
|
|
d7df2f095b | ||
|
|
afdcc1c5ba | ||
|
|
92daaa35bb | ||
|
|
e4295e14ab | ||
|
|
ae37d1db36 | ||
|
|
a7464f8b89 | ||
|
|
2dd9ca9835 | ||
|
|
54cc14052c | ||
|
|
f930e46dde | ||
|
|
df75e5accb | ||
|
|
f66a9f5d1b | ||
|
|
c5b337f3bb | ||
|
|
bb1ba3e15e | ||
|
|
ce839c4127 | ||
|
|
8aabd28168 | ||
|
|
1a7a14b3ed | ||
|
|
17cebde051 | ||
|
|
12ead3d017 | ||
|
|
f6c0221858 | ||
|
|
1aaf2c96cd | ||
|
|
9c6f14fc13 | ||
|
|
3d49962ca5 | ||
|
|
c434d873bc | ||
|
|
60982da847 | ||
|
|
73ab9e8ab5 | ||
|
|
c4a9b9b088 | ||
|
|
9a6e822e71 | ||
|
|
e52bc261db | ||
|
|
624ad62c53 | ||
|
|
630f9cc328 | ||
|
|
9f5be458fe | ||
|
|
1bfdb8aa66 | ||
|
|
2e2bb6fbbd | ||
|
|
d09b9ed70d | ||
|
|
e1571d2b87 | ||
|
|
c9a66e4aa8 | ||
|
|
9fa2df4235 | ||
|
|
c84a921988 | ||
|
|
9b5f3993c3 | ||
|
|
b28772317c | ||
|
|
a3f9f05c66 | ||
|
|
0616635cd6 | ||
|
|
9e7a3ba70b | ||
|
|
04990b986e | ||
|
|
097b6bf2b7 | ||
|
|
cb3ce6b1d8 | ||
|
|
405ba645f6 | ||
|
|
ab6fd6ad10 | ||
|
|
782f70ff66 | ||
|
|
1abbf18e94 | ||
|
|
6bcb2a71e9 | ||
|
|
936bc2bd0a | ||
|
|
2efe7321cd | ||
|
|
947bdf8385 | ||
|
|
b5bf0cca4b | ||
|
|
208a561668 | ||
|
|
40cb1d71d5 | ||
|
|
3da13f7591 | ||
|
|
2a444aad31 | ||
|
|
47d8bbd356 | ||
|
|
ed4d7e4a00 | ||
|
|
f382397c0e | ||
|
|
54514e15db | ||
|
|
bb9d5556d3 | ||
|
|
83e943c5b4 | ||
|
|
40b0557553 | ||
|
|
b5a0317d24 | ||
|
|
9730a06468 | ||
|
|
ec0a437d86 | ||
|
|
1606310745 | ||
|
|
0a03f42231 | ||
|
|
a62d376772 | ||
|
|
ea9d90d6cf | ||
|
|
27a6b0b53d | ||
|
|
94c4d76b86 | ||
|
|
b08241f20f | ||
|
|
e77a42fbf1 | ||
|
|
d488efe2cc | ||
|
|
14c3587de0 | ||
|
|
7400a3a6ec | ||
|
|
14bc2b8f8d | ||
|
|
e48745331f |
@@ -5,13 +5,11 @@
|
||||
{ "repo": "papra-hq/papra"}
|
||||
],
|
||||
"commit": false,
|
||||
"fixed": [
|
||||
["@papra/app-client", "@papra/app-server"]
|
||||
],
|
||||
"fixed": [],
|
||||
"access": "public",
|
||||
"baseBranch": "main",
|
||||
"updateInternalDependencies": "patch",
|
||||
"ignore": [],
|
||||
"ignore": ["@papra/app-client", "@papra/app-server", "@papra/docs"],
|
||||
"privatePackages": {
|
||||
"tag": true,
|
||||
"version": true
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
node_modules
|
||||
.pnp
|
||||
.pnp.*
|
||||
*.log
|
||||
dist
|
||||
*.local
|
||||
.git
|
||||
db.sqlite
|
||||
local-documents
|
||||
.env
|
||||
**/.env
|
||||
1
.dockerignore
Symbolic link
@@ -0,0 +1 @@
|
||||
packages/docker/.dockerignore
|
||||
42
.github/workflows/ci-apps-docs.yaml
vendored
@@ -1,42 +0,0 @@
|
||||
name: CI - Docs
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
ci-apps-docs:
|
||||
name: CI - Docs
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: apps/docs
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm i
|
||||
working-directory: ./
|
||||
|
||||
- name: Run linters
|
||||
run: pnpm lint
|
||||
|
||||
# - name: Type check
|
||||
# run: pnpm typecheck
|
||||
|
||||
# - name: Run unit test
|
||||
# run: pnpm test
|
||||
|
||||
- name: Build the app
|
||||
run: pnpm build
|
||||
49
.github/workflows/ci-apps-papra-client.yaml
vendored
@@ -1,49 +0,0 @@
|
||||
name: CI - App Client
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
ci-apps-papra-client:
|
||||
name: CI - Papra Client
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: apps/papra-client
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm i
|
||||
working-directory: ./
|
||||
|
||||
- name: Run linters
|
||||
run: pnpm lint
|
||||
|
||||
- name: Type check
|
||||
run: pnpm typecheck
|
||||
|
||||
- name: Run unit test
|
||||
run: pnpm test
|
||||
|
||||
# Ensure locales types are up to date, must be run before building the app
|
||||
- name: Check locales types
|
||||
run: |
|
||||
pnpm script:generate-i18n-types
|
||||
git diff --exit-code -- src/modules/i18n/locales.types.ts > /dev/null || (echo "Locales types are outdated, please run 'pnpm script:generate-i18n-types' and commit the changes." && exit 1)
|
||||
|
||||
- name: Build the app
|
||||
run: pnpm build
|
||||
|
||||
43
.github/workflows/ci-apps-papra-server.yaml
vendored
@@ -1,43 +0,0 @@
|
||||
name: CI - App Server
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
ci-apps-papra-server:
|
||||
name: CI - Papra Server
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: apps/papra-server
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pnpm i --frozen-lockfile
|
||||
pnpm --filter "@papra/app-server^..." build
|
||||
|
||||
- name: Run linters
|
||||
run: pnpm lint
|
||||
|
||||
- name: Type check
|
||||
run: pnpm typecheck
|
||||
|
||||
- name: Run unit test
|
||||
run: pnpm test
|
||||
|
||||
- name: Build the app
|
||||
run: pnpm build
|
||||
41
.github/workflows/ci-packages-api-sdk.yaml
vendored
@@ -1,41 +0,0 @@
|
||||
name: CI - Api SDK
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
ci-packages-api-sdk:
|
||||
name: CI - Api SDK
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: packages/api-sdk
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm i
|
||||
|
||||
- name: Run linters
|
||||
run: pnpm lint
|
||||
|
||||
- name: Type check
|
||||
run: pnpm typecheck
|
||||
|
||||
# - name: Run unit test
|
||||
# run: pnpm test
|
||||
|
||||
- name: Build the app
|
||||
run: pnpm build
|
||||
44
.github/workflows/ci-packages-cli.yaml
vendored
@@ -1,44 +0,0 @@
|
||||
name: CI - CLI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
ci-packages-cli:
|
||||
name: CI - CLI
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: packages/cli
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm i
|
||||
|
||||
- name: Build related packages
|
||||
run: cd ../api-sdk && pnpm build
|
||||
|
||||
- name: Run linters
|
||||
run: pnpm lint
|
||||
|
||||
- name: Type check
|
||||
run: pnpm typecheck
|
||||
|
||||
# - name: Run unit test
|
||||
# run: pnpm test
|
||||
|
||||
- name: Build the app
|
||||
run: pnpm build
|
||||
41
.github/workflows/ci-packages-lecture.yaml
vendored
@@ -1,41 +0,0 @@
|
||||
name: CI - Lecture
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
ci-packages-lecture:
|
||||
name: CI - Lecture
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: packages/lecture
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm i
|
||||
|
||||
- name: Run linters
|
||||
run: pnpm lint
|
||||
|
||||
- name: Type check
|
||||
run: pnpm typecheck
|
||||
|
||||
- name: Run unit test
|
||||
run: pnpm test
|
||||
|
||||
- name: Build the app
|
||||
run: pnpm build
|
||||
41
.github/workflows/ci-packages-webhook.yaml
vendored
@@ -1,41 +0,0 @@
|
||||
name: CI - Webhooks
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
ci-packages-webhooks:
|
||||
name: CI - Webhooks
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: packages/webhooks
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm i
|
||||
|
||||
- name: Run linters
|
||||
run: pnpm lint
|
||||
|
||||
- name: Type check
|
||||
run: pnpm typecheck
|
||||
|
||||
- name: Run unit test
|
||||
run: pnpm test
|
||||
|
||||
- name: Build the app
|
||||
run: pnpm build
|
||||
47
.github/workflows/ci.yaml
vendored
Normal file
@@ -0,0 +1,47 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
ci:
|
||||
name: CI
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm i
|
||||
|
||||
# Build only the packages first to properly run the lint and test first to get faster feedback
|
||||
- name: Build packages
|
||||
run: pnpm -r --parallel -F "./packages/*" build
|
||||
|
||||
- name: Run linters
|
||||
run: pnpm -r --parallel lint
|
||||
|
||||
- name: Type check
|
||||
# Exclude docs as their are some typing issues we are ok with for now
|
||||
run: pnpm -r --parallel -F "!@papra/docs" typecheck
|
||||
|
||||
# Tests are run using vitest projects
|
||||
- name: Run tests
|
||||
run: pnpm test
|
||||
|
||||
# Now build the apps, the longer step, so we do it last as they are more unlikely to fail if the previous steps works
|
||||
- name: Build the apps
|
||||
run: pnpm -r --parallel -F "./apps/*" build
|
||||
|
||||
- name: Ensure no non-excluded files are changed for the whole repo
|
||||
run: git diff --exit-code > /dev/null || (echo "After running the CI, some un-committed changes were detected. Please ensure cleanness before merging." && exit 1)
|
||||
14
.github/workflows/release-docker.yaml
vendored
@@ -1,10 +1,10 @@
|
||||
name: Release new versions
|
||||
name: Build and publish Docker images
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Version to release'
|
||||
description: 'Version to release (e.g. 0.8.2)'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
@@ -14,7 +14,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
docker-release:
|
||||
name: Release Docker images
|
||||
name: Build and publish Docker images
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -43,8 +43,8 @@ jobs:
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/Dockerfile
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
file: ./packages/docker/Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: |
|
||||
corentinth/papra:latest-root
|
||||
@@ -56,8 +56,8 @@ jobs:
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/Dockerfile.rootless
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
file: ./packages/docker/Dockerfile.rootless
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: |
|
||||
corentinth/papra:latest
|
||||
|
||||
16
.github/workflows/release.yml
vendored
@@ -11,6 +11,7 @@ jobs:
|
||||
release:
|
||||
name: Release
|
||||
runs-on: ubuntu-latest
|
||||
if: github.repository == 'papra-hq/papra'
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
@@ -18,14 +19,18 @@ jobs:
|
||||
actions: write
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: 'pnpm'
|
||||
node-version: 24
|
||||
cache: "pnpm"
|
||||
|
||||
# Ensure npm 11.5.1 or later is installed
|
||||
- name: Update npm
|
||||
run: npm install -g npm@latest
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm i
|
||||
@@ -41,12 +46,11 @@ jobs:
|
||||
title: "chore(release): update versions"
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.CHANGESET_GITHUB_TOKEN }}
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
- name: Trigger Docker build
|
||||
if: steps.changesets.outputs.published == 'true' && contains(fromJson(steps.changesets.outputs.publishedPackages).*.name, '@papra/app-server')
|
||||
if: steps.changesets.outputs.published == 'true' && contains(fromJson(steps.changesets.outputs.publishedPackages).*.name, '@papra/docker')
|
||||
run: |
|
||||
VERSION=$(echo '${{ steps.changesets.outputs.publishedPackages }}' | jq -r '.[] | select(.name=="@papra/app-server") | .version')
|
||||
VERSION=$(echo '${{ steps.changesets.outputs.publishedPackages }}' | jq -r '.[] | select(.name=="@papra/docker") | .version')
|
||||
echo "VERSION: $VERSION"
|
||||
gh workflow run release-docker.yaml -f version="$VERSION"
|
||||
env:
|
||||
|
||||
7
.gitignore
vendored
@@ -35,8 +35,13 @@ cache
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
*.sqlite
|
||||
*.sqlite-shm
|
||||
*.sqlite-wal
|
||||
|
||||
local-documents
|
||||
ingestion
|
||||
.cursorrules
|
||||
*.traineddata
|
||||
*.traineddata
|
||||
|
||||
.eslintcache
|
||||
.claude
|
||||
222
CLAUDE.md
Normal file
@@ -0,0 +1,222 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
Papra is a minimalistic document management and archiving platform built as a monorepo using PNPM workspaces. The project includes a SolidJS frontend, HonoJS backend, CLI tools, and supporting packages.
|
||||
It's open-source and designed for easy self-hosting using Docker, and also offers a cloud-hosted SaaS version.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Monorepo Structure
|
||||
|
||||
- **apps/papra-server**: Backend API server (HonoJS + Drizzle ORM + Better Auth)
|
||||
- **apps/papra-client**: Frontend application (SolidJS + UnoCSS + Shadcn Solid)
|
||||
- **apps/docs**: Documentation site (Astro + Starlight)
|
||||
- **packages/lecture**: Text extraction library for documents
|
||||
- **packages/api-sdk**: API client SDK
|
||||
- **packages/cli**: Command-line interface
|
||||
- **packages/webhooks**: Webhook types and utilities
|
||||
|
||||
### Backend Architecture (apps/papra-server)
|
||||
|
||||
The backend follows a modular architecture with feature-based modules:
|
||||
|
||||
- **Module pattern**: Each feature lives in `src/modules/<feature>/` with:
|
||||
- `*.repository.ts`: Database access layer (Drizzle ORM queries)
|
||||
- `*.usecases.ts`: Business logic and orchestration
|
||||
- `*.routes.ts`: HTTP route handlers (Hono)
|
||||
- `*.services.ts`: Service layer for external integrations
|
||||
- `*.table.ts`: Drizzle schema definitions
|
||||
- `*.types.ts`: TypeScript type definitions
|
||||
- `*.errors.ts`: Error definitions
|
||||
|
||||
- **Core modules**: `app`, `shared`, `config`, `tasks`
|
||||
- **Feature modules**: `documents`, `organizations`, `users`, `tags`, `tagging-rules`, `intake-emails`, `ingestion-folders`, `webhooks`, `api-keys`, `subscriptions`, etc.
|
||||
|
||||
- **Database**: Uses Drizzle ORM with SQLite/Turso (libsql). Schema is in `*.table.ts` files, migrations in `src/migrations/`
|
||||
|
||||
- **Authentication**: Better Auth library for user auth
|
||||
|
||||
- **Task system**: Background job processing using Cadence MQ, a custom made queue system (papra-hq/cadence-mq)
|
||||
|
||||
- **Document storage**: Abstracted storage supporting local filesystem, S3, and Azure Blob
|
||||
|
||||
### Frontend Architecture (apps/papra-client)
|
||||
|
||||
- **SolidJS** for reactivity with router (`@solidjs/router`)
|
||||
- **Module pattern**: Features in `src/modules/<feature>/` with:
|
||||
- `components/`: UI components
|
||||
- `pages/`: Route components
|
||||
- `*.services.ts`: API client calls
|
||||
- `*.provider.tsx`: Context providers
|
||||
- `*.types.ts`: Type definitions
|
||||
|
||||
- **Routing**: Defined in `src/routes.tsx`
|
||||
- **Styling**: UnoCSS for atomic CSS with Shadcn Solid components
|
||||
- **State**: TanStack Query for server state, local storage for client state
|
||||
- **i18n**: TypeScript-based translations in `src/locales/*.dictionary.ts`
|
||||
|
||||
### Dependency Injection Pattern
|
||||
|
||||
The server uses a dependency injection pattern with `@corentinth/chisels/injectArguments` to create testable services that accept dependencies as parameters.
|
||||
|
||||
## Development Commands
|
||||
|
||||
### Initial Setup
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
pnpm install
|
||||
|
||||
# Build all packages (required before running apps)
|
||||
pnpm build:packages
|
||||
```
|
||||
|
||||
### Backend Development
|
||||
|
||||
```bash
|
||||
cd apps/papra-server
|
||||
|
||||
# Run database migrations
|
||||
pnpm migrate:up
|
||||
|
||||
# Start development server (localhost:1221)
|
||||
pnpm dev
|
||||
|
||||
# Run tests
|
||||
pnpm test # All tests
|
||||
pnpm test:watch # Watch mode
|
||||
pnpm test:unit # Unit tests only
|
||||
pnpm test:int # Integration tests only
|
||||
|
||||
# Lint and typecheck
|
||||
pnpm lint
|
||||
pnpm typecheck
|
||||
|
||||
# Database management
|
||||
pnpm db:studio # Open Drizzle Studio
|
||||
pnpm migrate:create "migration_name" # Create new migration
|
||||
```
|
||||
|
||||
### Frontend Development
|
||||
|
||||
```bash
|
||||
cd apps/papra-client
|
||||
|
||||
# Start development server (localhost:3000)
|
||||
pnpm dev
|
||||
|
||||
# Run tests
|
||||
pnpm test
|
||||
pnpm test:watch
|
||||
pnpm test:e2e # Playwright E2E tests
|
||||
|
||||
# Lint and typecheck
|
||||
pnpm lint
|
||||
pnpm typecheck
|
||||
|
||||
# i18n key synchronization
|
||||
pnpm script:sync-i18n-key-order
|
||||
```
|
||||
|
||||
### Package Development
|
||||
|
||||
```bash
|
||||
cd packages/<package-name>
|
||||
|
||||
# Build package
|
||||
pnpm build
|
||||
pnpm build:watch # Watch mode (or pnpm dev)
|
||||
|
||||
# Run tests
|
||||
pnpm test
|
||||
pnpm test:watch
|
||||
```
|
||||
|
||||
### Root-level Commands
|
||||
|
||||
```bash
|
||||
# Run tests across all packages
|
||||
pnpm test
|
||||
pnpm test:watch
|
||||
|
||||
# Build all packages
|
||||
pnpm build:packages
|
||||
|
||||
# Version management (changesets)
|
||||
pnpm changeset # Create changeset
|
||||
pnpm version # Apply changesets and bump versions
|
||||
|
||||
# Docker builds
|
||||
pnpm docker:build:root
|
||||
pnpm docker:build:root:amd64
|
||||
pnpm docker:build:root:arm64
|
||||
```
|
||||
|
||||
### Documentation Development
|
||||
|
||||
```bash
|
||||
cd apps/docs
|
||||
pnpm dev # localhost:4321
|
||||
```
|
||||
|
||||
## Testing Guidelines
|
||||
|
||||
- Use **Vitest** for all testing
|
||||
- Test files: `*.test.ts` for unit tests, `*.int.test.ts` for integration tests
|
||||
- Integration tests may use Testcontainers (Azurite, LocalStack)
|
||||
- All new features require test coverage
|
||||
|
||||
### Writing Good Test Names
|
||||
|
||||
Test names should explain the **why** (business logic, user scenario, or expected behavior), not the **how** (implementation details or return values).
|
||||
|
||||
**Key principles:**
|
||||
- **Describe blocks** should explain the business goal or rule being tested
|
||||
- **Test names** should explain the scenario, context, and reason for the behavior
|
||||
- Avoid implementation details like "returns X", "should be Y", "calls Z method"
|
||||
- Focus on user scenarios and business rules
|
||||
- Make tests readable as documentation - someone unfamiliar with the code should understand what's being tested and why
|
||||
|
||||
## Code Style
|
||||
|
||||
- **ESLint config**: `@antfu/eslint-config` (auto-fix on save recommended)
|
||||
- **Conventions**:
|
||||
- Use functional programming where possible
|
||||
- Prefer clarity and maintainability over performance
|
||||
- Use meaningful names for variables, functions, and components
|
||||
- Follow Conventional Commits for commit messages
|
||||
- **Type safety**: Strict TypeScript throughout
|
||||
|
||||
## i18n
|
||||
|
||||
- Language files in `apps/papra-client/src/locales/*.dictionary.ts`
|
||||
- Reference `en.dictionary.ts` for all keys (English is fallback)
|
||||
- Fully type-safe with TypeScript
|
||||
- Update `i18n.constants.ts` when adding new languages
|
||||
- Use `pnpm script:sync-i18n-key-order` to sync key order
|
||||
- **Branchlet/core**: Uses `@branchlet/core` for pluralization and conditional i18n string templates (variant of ICU message format)
|
||||
- Basic interpolation: `'Hello {{ name }}!'` with `{ name: 'World' }`
|
||||
- Conditionals: `'{{ count, =0:no items, =1:one item, many items }}'`
|
||||
- Pluralization with variables: `'{{ count, =0:no items, =1:{count} item, {count} items }}'`
|
||||
- Range conditions: `'{{ score, [0-50]:bad, [51-75]:good, [76-100]:excellent }}'`
|
||||
- See [branchlet documentation](https://github.com/CorentinTh/branchlet) for more details
|
||||
|
||||
## Contributing Flow
|
||||
|
||||
1. Open an issue before submitting PRs for features/bugs
|
||||
2. Target the `main` branch (continuously deployed to production)
|
||||
3. Keep PRs small and atomic
|
||||
4. Ensure CI is green (linting, type checking, testing, building)
|
||||
5. PRs are squashed on merge
|
||||
|
||||
## Key Technologies
|
||||
|
||||
- **Frontend**: SolidJS, UnoCSS, Shadcn Solid, TanStack Query, Vite
|
||||
- **Backend**: HonoJS, Drizzle ORM, Better Auth, Zod, Cadence MQ
|
||||
- **Database**: SQLite/Turso (libsql)
|
||||
- **Testing**: Vitest, Playwright, Testcontainers
|
||||
- **Monorepo**: PNPM workspaces with catalog for shared dependencies
|
||||
- **Build**: esbuild (backend), Vite (frontend), tsdown (packages)
|
||||
@@ -43,9 +43,9 @@ We welcome contributions to improve and expand the app's internationalization (i
|
||||
|
||||
### Adding a New Language
|
||||
|
||||
1. **Create a Language File**: To add a new language, create a YAML file named with the appropriate [ISO language code](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes) (e.g., `fr.yml` for French) in the [`apps/papra-client/src/locales`](./apps/papra-client/src/locales) directory.
|
||||
1. **Create a Language File**: To add a new language, create a TypeScript file named with the appropriate [ISO language code](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes) followed by `.dictionary.ts` (e.g., `fr.dictionary.ts` for French) in the [`apps/papra-client/src/locales`](./apps/papra-client/src/locales) directory.
|
||||
|
||||
2. **Use the Reference File**: Refer to the [`en.yml`](./apps/papra-client/src/locales/en.yml) file, which contains all keys used in the app. Use it as a base to ensure consistency when creating your new language file. And act as a fallback if a key is missing in the new language file.
|
||||
2. **Use the Reference File**: Refer to the [`en.dictionary.ts`](./apps/papra-client/src/locales/en.dictionary.ts) file, which contains all keys used in the app. Use it as a base to ensure consistency when creating your new language file. The English translations act as a fallback if a key is missing in the new language file.
|
||||
|
||||
3. **Update the Locale List**: After adding the new language file, include the language code in the `locales` array found in the [`apps/papra-client/src/modules/i18n/i18n.constants.ts`](./apps/papra-client/src/modules/i18n/i18n.constants.ts) file.
|
||||
|
||||
@@ -53,17 +53,21 @@ We welcome contributions to improve and expand the app's internationalization (i
|
||||
|
||||
### Updating an Existing Language
|
||||
|
||||
If you want to update an existing language file, you can do so directly in the corresponding JSON file in the [`apps/papra-client/src/locales`](./apps/papra-client/src/locales) directory. If you're adding or removing keys in the default language file ([`en.yml`](./apps/papra-client/src/locales/en.yml)), please run the following command to update the types (used for type checking the translations keys in the app):
|
||||
|
||||
```bash
|
||||
pnpm script:generate-i18n-types
|
||||
```
|
||||
|
||||
- This command will update the file [`locales.types.ts`](./apps/papra-client/src/modules/i18n/locale.types.ts) with the new/removed keys.
|
||||
- When developing in papra-client (using `pnpm dev`), **the i18n types definition will automatically update** when you touch the [`en.yml`](./apps/papra-client/src/locales/en.yml) file, so no need to run the command above.
|
||||
If you want to update an existing language file, you can do so directly in the corresponding TypeScript file in the [`apps/papra-client/src/locales`](./apps/papra-client/src/locales) directory. The translation keys are now fully type-safe with TypeScript, so you'll get immediate feedback if you add invalid keys or have syntax errors.
|
||||
|
||||
> [!TIP]
|
||||
> You can use the command `pnpm script:sync-i18n-key-order` to sync the order of the keys in the i18n files, it'll also add the missing keys as comments.
|
||||
> You can use the command `pnpm script:sync-i18n-key-order` to sync the order of the keys in the TypeScript i18n files, it'll also add the missing keys as comments.
|
||||
|
||||
### Using Branchlet for Pluralization and Conditionals
|
||||
|
||||
Papra uses [`@branchlet/core`](https://github.com/CorentinTh/branchlet) for pluralization and conditional i18n string templates (a variant of ICU message format). Here are some common patterns:
|
||||
|
||||
- **Basic interpolation**: `'Hello {{ name }}!'` with `{ name: 'World' }`
|
||||
- **Conditionals**: `'{{ count, =0:no items, =1:one item, many items }}'`
|
||||
- **Pluralization with variables**: `'{{ count, =0:no items, =1:{count} item, {count} items }}'`
|
||||
- **Range conditions**: `'{{ score, [0-50]:bad, [51-75]:good, [76-100]:excellent }}'`
|
||||
|
||||
See the [branchlet documentation](https://github.com/CorentinTh/branchlet) for more details on syntax and advanced usage.
|
||||
|
||||
## Development Setup
|
||||
|
||||
@@ -112,6 +116,73 @@ We recommend running the app locally for development. Follow these steps:
|
||||
|
||||
6. Open your browser and navigate to `http://localhost:3000`.
|
||||
|
||||
### IDE Setup
|
||||
|
||||
#### ESLint Extension
|
||||
|
||||
We recommend installing the [ESLint extension](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint) for VS Code to get real-time linting feedback and automatic code fixing.
|
||||
The linting configuration is based on [@antfu/eslint-config](https://github.com/antfu/eslint-config), you can find specific IDE configurations in their repository.
|
||||
|
||||
<details>
|
||||
<summary>Recommended VS Code Settings</summary>
|
||||
|
||||
Create or update your `.vscode/settings.json` file with the following configuration:
|
||||
|
||||
```json
|
||||
{
|
||||
// Disable the default formatter, use eslint instead
|
||||
"prettier.enable": false,
|
||||
"editor.formatOnSave": false,
|
||||
|
||||
// Auto fix
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": "explicit",
|
||||
"source.organizeImports": "never"
|
||||
},
|
||||
|
||||
// Silent the stylistic rules in your IDE, but still auto fix them
|
||||
"eslint.rules.customizations": [
|
||||
{ "rule": "style/*", "severity": "off", "fixable": true },
|
||||
{ "rule": "format/*", "severity": "off", "fixable": true },
|
||||
{ "rule": "*-indent", "severity": "off", "fixable": true },
|
||||
{ "rule": "*-spacing", "severity": "off", "fixable": true },
|
||||
{ "rule": "*-spaces", "severity": "off", "fixable": true },
|
||||
{ "rule": "*-order", "severity": "off", "fixable": true },
|
||||
{ "rule": "*-dangle", "severity": "off", "fixable": true },
|
||||
{ "rule": "*-newline", "severity": "off", "fixable": true },
|
||||
{ "rule": "*quotes", "severity": "off", "fixable": true },
|
||||
{ "rule": "*semi", "severity": "off", "fixable": true }
|
||||
],
|
||||
|
||||
// Enable eslint for all supported languages
|
||||
"eslint.validate": [
|
||||
"javascript",
|
||||
"javascriptreact",
|
||||
"typescript",
|
||||
"typescriptreact",
|
||||
"vue",
|
||||
"html",
|
||||
"markdown",
|
||||
"json",
|
||||
"jsonc",
|
||||
"yaml",
|
||||
"toml",
|
||||
"xml",
|
||||
"gql",
|
||||
"graphql",
|
||||
"astro",
|
||||
"svelte",
|
||||
"css",
|
||||
"less",
|
||||
"scss",
|
||||
"pcss",
|
||||
"postcss"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### Testing
|
||||
|
||||
We use **Vitest** for testing. Each package comes with its own testing commands.
|
||||
|
||||
@@ -118,6 +118,7 @@ Papra would not have been possible without the following open-source projects:
|
||||
- **[HonoJS](https://hono.dev/)**: A small, fast, and lightweight web framework for building APIs.
|
||||
- **[Drizzle](https://orm.drizzle.team/)**: A simple and lightweight ORM for Node.js.
|
||||
- **[Better Auth](https://better-auth.com/)**: A simple and lightweight authentication library for Node.js.
|
||||
- **[CadenceMQ](https://github.com/papra-hq/cadence-mq)**: A self-hosted-friendly job queue for Node.js, made by Papra.
|
||||
- And other dependencies listed in the **[server package.json](./apps/papra-server/package.json)**
|
||||
- **Documentation**
|
||||
- **[Astro](https://astro.build)**: A great static site generator.
|
||||
@@ -128,7 +129,7 @@ Papra would not have been possible without the following open-source projects:
|
||||
- **[Github Actions](https://github.com/features/actions)**: For CI/CD.
|
||||
- **Infrastructure**
|
||||
- **[Cloudflare Pages](https://pages.cloudflare.com/)**: For static site hosting.
|
||||
- **[Render](https://render.com/)**: For backend hosting.
|
||||
- **[Fly.io](https://fly.io/)**: For backend hosting.
|
||||
- **[Turso](https://turso.tech/)**: For production database.
|
||||
|
||||
### Inspiration
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
# @papra/docs
|
||||
|
||||
## 0.6.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#512](https://github.com/papra-hq/papra/pull/512) [`cb3ce6b`](https://github.com/papra-hq/papra/commit/cb3ce6b1d8d5dba09cbf0d2964f14b1c93220571) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added organizations permissions for api keys
|
||||
|
||||
## 0.6.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [#480](https://github.com/papra-hq/papra/pull/480) [`0a03f42`](https://github.com/papra-hq/papra/commit/0a03f42231f691d339c7ab5a5916c52385e31bd2) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added documents encryption layer
|
||||
|
||||
## 0.5.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
1382
apps/docs/package-lock.json
generated
@@ -1,9 +1,8 @@
|
||||
{
|
||||
"name": "@papra/docs",
|
||||
"type": "module",
|
||||
"version": "0.5.3",
|
||||
"version": "0.6.1",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.12.3",
|
||||
"description": "Papra documentation website",
|
||||
"author": "Corentin Thomasset <corentinth@proton.me> (https://corentin.tech)",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
@@ -28,19 +27,21 @@
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"unocss-preset-animations": "^1.2.1",
|
||||
"yaml": "^2.8.0",
|
||||
"zod": "^3.25.67",
|
||||
"zod-to-json-schema": "^3.24.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@antfu/eslint-config": "^3.13.0",
|
||||
"@antfu/eslint-config": "catalog:",
|
||||
"@iconify-json/tabler": "^1.1.120",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@unocss/reset": "^0.64.0",
|
||||
"eslint": "^9.17.0",
|
||||
"eslint": "catalog:",
|
||||
"eslint-plugin-astro": "^1.3.1",
|
||||
"figue": "^2.2.2",
|
||||
"figue": "^3.1.1",
|
||||
"lodash-es": "^4.17.21",
|
||||
"marked": "^15.0.6",
|
||||
"typescript": "^5.7.3",
|
||||
"unocss": "0.65.0-beta.2"
|
||||
"typescript": "catalog:",
|
||||
"unocss": "0.65.0-beta.2",
|
||||
"vitest": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
BIN
apps/docs/src/assets/docs/encryption-schema-dark.png
Normal file
|
After Width: | Height: | Size: 458 KiB |
BIN
apps/docs/src/assets/docs/encryption-schema-light.png
Normal file
|
After Width: | Height: | Size: 460 KiB |
48
apps/docs/src/changelog-parser.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
const linesToRemove = [
|
||||
/^# (.*)$/gm, // Remove main title
|
||||
/^### (.*)$/gm, // Remove section titles
|
||||
];
|
||||
|
||||
export function parseChangelog(changelog: string) {
|
||||
const logs: { entries: {
|
||||
pr: { number: number; url: string };
|
||||
commit: { hash: string; url: string };
|
||||
contributor: { username: string; url: string };
|
||||
content: string;
|
||||
}[]; version: string; }[] = [];
|
||||
|
||||
for (const lineToRemove of linesToRemove) {
|
||||
changelog = changelog.replace(lineToRemove, '');
|
||||
}
|
||||
|
||||
const sections = changelog.match(/## (.*)\n([\s\S]*?)(?=\n## |$)/g) ?? [];
|
||||
|
||||
for (const section of sections) {
|
||||
const version = section.match(/## (.*)\n/)?.[1].trim() ?? 'unknown version';
|
||||
|
||||
const entries = section.split('\n- ').slice(1).map((entry) => {
|
||||
// Example entry:
|
||||
// [#280](https://github.com/papra-hq/papra/pull/280) [`85fa5c4`](https://github.com/papra-hq/papra/commit/85fa5c43424d139f5c2752a3ad644082e61d3d67) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Maybe multiline content
|
||||
|
||||
// Thanks copilot! :sweat-smile:
|
||||
const prMatch = entry.match(/\[#(\d+)\]\((https:\/\/github\.com\/papra-hq\/papra\/pull\/\d+)\)/);
|
||||
const commitMatch = entry.match(/\[`([a-f0-9]{7,40})`\]\((https:\/\/github\.com\/papra-hq\/papra\/commit\/[a-f0-9]{7,40})\)/);
|
||||
const contributorMatch = entry.match(/Thanks \[@([\w-]+)\]\((https:\/\/github\.com\/[\w-]+)\)/);
|
||||
const contentMatch = entry.match(/\)! - (.*)$/s);
|
||||
|
||||
return {
|
||||
pr: prMatch ? { number: Number.parseInt(prMatch[1], 10), url: prMatch[2] } : { number: 0, url: '' },
|
||||
commit: commitMatch ? { hash: commitMatch[1], url: commitMatch[2] } : { hash: '', url: '' },
|
||||
contributor: contributorMatch ? { username: contributorMatch[1], url: contributorMatch[2] } : { username: 'unknown', url: '' },
|
||||
content: contentMatch ? contentMatch[1].trim() : entry.trim(),
|
||||
};
|
||||
});
|
||||
|
||||
logs.push({
|
||||
version,
|
||||
entries,
|
||||
});
|
||||
}
|
||||
|
||||
return logs;
|
||||
}
|
||||
154
apps/docs/src/components/encryption-key-generator.astro
Normal file
@@ -0,0 +1,154 @@
|
||||
---
|
||||
const iconSize = '20';
|
||||
const refreshIcon = `<svg xmlns="http://www.w3.org/2000/svg" width="${iconSize}" height="${iconSize}" viewBox="0 0 24 24"><!-- Icon from Tabler Icons by Paweł Kuna - https://github.com/tabler/tabler-icons/blob/master/LICENSE --><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 11A8.1 8.1 0 0 0 4.5 9M4 5v4h4m-4 4a8.1 8.1 0 0 0 15.5 2m.5 4v-4h-4"/></svg>`;
|
||||
const copyIcon = `<svg xmlns="http://www.w3.org/2000/svg" width="${iconSize}" height="${iconSize}" viewBox="0 0 24 24"><!-- Icon from Tabler Icons by Paweł Kuna - https://github.com/tabler/tabler-icons/blob/master/LICENSE --><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M7 9.667A2.667 2.667 0 0 1 9.667 7h8.666A2.667 2.667 0 0 1 21 9.667v8.666A2.667 2.667 0 0 1 18.333 21H9.667A2.667 2.667 0 0 1 7 18.333z"/><path d="M4.012 16.737A2 2 0 0 1 3 15V5c0-1.1.9-2 2-2h10c.75 0 1.158.385 1.5 1"/></g></svg>`;
|
||||
const copiedIcon = `<svg xmlns="http://www.w3.org/2000/svg" width="${iconSize}" height="${iconSize}" viewBox="0 0 24 24"><!-- Icon from Tabler Icons by Paweł Kuna - https://github.com/tabler/tabler-icons/blob/master/LICENSE --><path fill="currentColor" d="M18.333 6A3.667 3.667 0 0 1 22 9.667v8.666A3.667 3.667 0 0 1 18.333 22H9.667A3.667 3.667 0 0 1 6 18.333V9.667A3.667 3.667 0 0 1 9.667 6zM15 2c1.094 0 1.828.533 2.374 1.514a1 1 0 1 1-1.748.972C15.405 4.088 15.284 4 15 4H5c-.548 0-1 .452-1 1v9.998c0 .32.154.618.407.805l.1.065a1 1 0 1 1-.99 1.738A3 3 0 0 1 2 15V5c0-1.652 1.348-3 3-3zm1.293 9.293L13 14.585l-1.293-1.292a1 1 0 0 0-1.414 1.414l2 2a1 1 0 0 0 1.414 0l4-4a1 1 0 0 0-1.414-1.414"/></svg>`;
|
||||
---
|
||||
|
||||
<div class="key-generator">
|
||||
<div class="key-row">
|
||||
<input type="text" class="key-input" readonly />
|
||||
<button class="cbtn btn-refresh" title="Generate new key">
|
||||
<span set:html={refreshIcon} aria-label="Refresh" />
|
||||
</button>
|
||||
<button class="cbtn btn-copy" title="Copy to clipboard">
|
||||
<span set:html={copyIcon} aria-label="Copy" class="icon-copy" />
|
||||
<span set:html={copiedIcon} aria-label="Copied" class="icon-copied hidden" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="info-text">
|
||||
Generated locally in your browser - no network or server involved
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function generateKey({ keyInputElement }: { keyInputElement: HTMLInputElement }) {
|
||||
// Generate a 32-byte (256-bit) encryption key
|
||||
const array = new Uint8Array(32);
|
||||
crypto.getRandomValues(array);
|
||||
|
||||
// Convert to hex format
|
||||
const key = Array.from(array, byte => byte.toString(16).padStart(2, '0')).join('');
|
||||
keyInputElement.value = key;
|
||||
}
|
||||
|
||||
function copyToClipboard({ keyInputElement, copyButtonElement, iconCopyElement, iconCopiedElement }: { keyInputElement: HTMLInputElement; copyButtonElement: HTMLButtonElement; iconCopyElement: HTMLSpanElement; iconCopiedElement: HTMLSpanElement }) {
|
||||
keyInputElement.select();
|
||||
keyInputElement.setSelectionRange(0, 64); // For mobile devices
|
||||
|
||||
navigator.clipboard.writeText(keyInputElement.value).then(() => {
|
||||
iconCopyElement.classList.add('hidden');
|
||||
iconCopiedElement.classList.remove('hidden');
|
||||
copyButtonElement.disabled = true;
|
||||
|
||||
setTimeout(() => {
|
||||
iconCopyElement.classList.remove('hidden');
|
||||
iconCopiedElement.classList.add('hidden');
|
||||
copyButtonElement.disabled = false;
|
||||
}, 1_000);
|
||||
}).catch(() => {
|
||||
// Fallback for older browsers
|
||||
document.execCommand('copy');
|
||||
});
|
||||
}
|
||||
|
||||
const keyGenerators = document.querySelectorAll('.key-generator');
|
||||
|
||||
keyGenerators.forEach((keyGenerator) => {
|
||||
const refreshButtonElement = keyGenerator.querySelector('.btn-refresh')!;
|
||||
const copyButtonElement = keyGenerator.querySelector<HTMLButtonElement>('.btn-copy')!;
|
||||
const keyInputElement = keyGenerator.querySelector<HTMLInputElement>('.key-input')!;
|
||||
const iconCopyElement = keyGenerator.querySelector<HTMLSpanElement>('.icon-copy')!;
|
||||
const iconCopiedElement = keyGenerator.querySelector<HTMLSpanElement>('.icon-copied')!;
|
||||
|
||||
generateKey({ keyInputElement });
|
||||
|
||||
refreshButtonElement.addEventListener('click', () => generateKey({ keyInputElement }));
|
||||
copyButtonElement.addEventListener('click', () => copyToClipboard({ copyButtonElement, keyInputElement, iconCopyElement, iconCopiedElement }));
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.key-generator {
|
||||
/* background-color: var(--ec-frm-trmBg);
|
||||
border-radius: var(--ec-brdRad);
|
||||
border: 1px solid var(--ec-brdCol);
|
||||
font-family: monospace;
|
||||
max-width: 100%; */
|
||||
}
|
||||
|
||||
.key-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.key-input {
|
||||
flex: 1;
|
||||
background-color: var(--sl-color-black);
|
||||
border: 1px solid var(--sl-color-gray-5);
|
||||
border-radius: 4px 0 0 4px;
|
||||
padding: 8px 12px;
|
||||
font-family: var(--__sl-font-mono, monospace);
|
||||
font-size: 14px;
|
||||
color: var(--sl-color-gray-2);
|
||||
min-width: 0; /* Allow input to shrink */
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.key-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--ec-frm-inpBrd, #4a9eff);
|
||||
box-shadow: 0 0 0 2px var(--ec-frm-inpBrd, #4a9eff)33;
|
||||
}
|
||||
|
||||
.cbtn {
|
||||
background-color: var(--ec-frm-btnBg);
|
||||
border: 1px solid var(--ec-brdCol);
|
||||
padding: 10px 12px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
transition: all 0.2s ease;
|
||||
min-width: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.cbtn.btn-copy {
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
border-top-right-radius: 4px;
|
||||
border-bottom-right-radius: 4px;
|
||||
border-left: none;
|
||||
}
|
||||
|
||||
.cbtn.btn-refresh {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.cbtn:hover {
|
||||
background-color: var(--sl-color-gray-6)!important;
|
||||
}
|
||||
|
||||
.cbtn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.btn-refresh:hover:not(:disabled) {
|
||||
background-color: var(--ec-frm-btnBgHover, #3a3a3a);
|
||||
}
|
||||
|
||||
.btn-copy:hover:not(:disabled) {
|
||||
background-color: var(--ec-frm-btnBgHover, #3a3a3a);
|
||||
}
|
||||
|
||||
.info-text {
|
||||
color: var(--ec-frm-txtSecondary, #888888);
|
||||
font-style: italic;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { ConfigDefinition, ConfigDefinitionElement } from 'figue';
|
||||
import { isArray, isEmpty, isNil } from 'lodash-es';
|
||||
import { marked } from 'marked';
|
||||
import { castArray, isArray, isEmpty, isNil } from 'lodash-es';
|
||||
|
||||
import { configDefinition } from '../../papra-server/src/modules/config/config';
|
||||
import { renderMarkdown } from './markdown';
|
||||
|
||||
function walk(configDefinition: ConfigDefinition, path: string[] = []): (ConfigDefinitionElement & { path: string[] })[] {
|
||||
return Object
|
||||
@@ -46,16 +46,21 @@ const rows = configDetails
|
||||
};
|
||||
});
|
||||
|
||||
const mdSections = rows.map(({ documentation, env, path, defaultValue }) => `
|
||||
### ${env}
|
||||
const mdSections = rows.map(({ documentation, env, path, defaultValue }) => {
|
||||
const envs = castArray(env);
|
||||
const [firstEnv, ...restEnvs] = envs;
|
||||
|
||||
return `
|
||||
### ${firstEnv}
|
||||
${documentation}
|
||||
|
||||
- Path: \`${path.join('.')}\`
|
||||
- Environment variable: \`${env}\`
|
||||
- Environment variable: \`${firstEnv}\` ${restEnvs.length ? `, with fallback to: ${restEnvs.map(e => `\`${e}\``).join(', ')}` : ''}
|
||||
- Default value: \`${defaultValue}\`
|
||||
|
||||
|
||||
`.trim()).join('\n\n---\n\n');
|
||||
`.trim();
|
||||
}).join('\n\n---\n\n');
|
||||
|
||||
function wrapText(text: string, maxLength = 75) {
|
||||
const words = text.split(' ');
|
||||
@@ -80,25 +85,15 @@ function wrapText(text: string, maxLength = 75) {
|
||||
|
||||
const fullDotEnv = rows.map(({ env, defaultValue, documentation }) => {
|
||||
const isEmptyDefaultValue = isNil(defaultValue) || (isArray(defaultValue) && isEmpty(defaultValue)) || defaultValue === '';
|
||||
const envs = castArray(env);
|
||||
const [firstEnv] = envs;
|
||||
|
||||
return [
|
||||
...wrapText(documentation),
|
||||
`# ${env}=${isEmptyDefaultValue ? '' : defaultValue}`,
|
||||
`# ${firstEnv}=${isEmptyDefaultValue ? '' : defaultValue}`,
|
||||
].join('\n');
|
||||
}).join('\n\n');
|
||||
|
||||
// Dirty hack to add the same anchors to the headings as the ones generated by Starlight
|
||||
const renderer = new marked.Renderer();
|
||||
renderer.heading = function ({ text, depth }) {
|
||||
const slug = text.toLowerCase().replace(/\W+/g, '-');
|
||||
return `
|
||||
<div class="sl-heading-wrapper level-h${depth}">
|
||||
<h${depth} id="${slug}">${text}</h${depth}>
|
||||
<a class="sl-anchor-link" href="#${slug}"><span aria-hidden="true" class="sl-anchor-icon"><svg width="16" height="16" viewBox="0 0 24 24"><path fill="currentcolor" d="m12.11 15.39-3.88 3.88a2.52 2.52 0 0 1-3.5 0 2.47 2.47 0 0 1 0-3.5l3.88-3.88a1 1 0 0 0-1.42-1.42l-3.88 3.89a4.48 4.48 0 0 0 6.33 6.33l3.89-3.88a1 1 0 1 0-1.42-1.42Zm8.58-12.08a4.49 4.49 0 0 0-6.33 0l-3.89 3.88a1 1 0 0 0 1.42 1.42l3.88-3.88a2.52 2.52 0 0 1 3.5 0 2.47 2.47 0 0 1 0 3.5l-3.88 3.88a1 1 0 1 0 1.42 1.42l3.88-3.89a4.49 4.49 0 0 0 0-6.33ZM8.83 15.17a1 1 0 0 0 1.1.22 1 1 0 0 0 .32-.22l4.92-4.92a1 1 0 0 0-1.42-1.42l-4.92 4.92a1 1 0 0 0 0 1.42Z"></path></svg></span><span class="sr-only">Section titled “Configuration files”</span></a>
|
||||
</div>
|
||||
`.trim().replace(/\n/g, '');
|
||||
};
|
||||
|
||||
const sectionsHtml = marked.parse(mdSections, { renderer });
|
||||
const sectionsHtml = renderMarkdown(mdSections);
|
||||
|
||||
export { fullDotEnv, mdSections, sectionsHtml };
|
||||
|
||||
@@ -39,7 +39,7 @@ By integrating Papra with OwlRelay, your instance will generate email addresses
|
||||
|
||||
3. **Configure your Papra instance**
|
||||
|
||||
Once you have created your API key, you can configure your Papra instance to receive emails by setting the `OWLRELAY_API_KEY` and `OWLRELAY_WEBHOOK_SECRET` environment variables.
|
||||
Once you have created your API key, you can configure your Papra instance to receive emails by setting the `OWLRELAY_API_KEY` and `INTAKE_EMAILS_WEBHOOK_SECRET` environment variables.
|
||||
|
||||
```bash
|
||||
# Enable intake emails
|
||||
|
||||
@@ -52,7 +52,7 @@ The code for the Email Worker proxy is available in the [papra-hq/email-proxy](h
|
||||
|
||||
- **Option 2**: Build and deploy the Email Worker
|
||||
|
||||
Clone the [papra-hq/email-proxy](https://github.com/papra-hq/email-proxy) repository and deploy the worker using Wrangler cli. You will need to have Node.js v22 and pnpm installed.
|
||||
Clone the [papra-hq/email-proxy](https://github.com/papra-hq/email-proxy) repository and deploy the worker using Wrangler cli. You will need to have Node.js v24 and pnpm installed.
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
|
||||
@@ -0,0 +1,448 @@
|
||||
---
|
||||
title: Setup Document Encryption
|
||||
description: Step-by-step guide to enable and configure document encryption in Papra for enhanced data security.
|
||||
slug: guides/document-encryption
|
||||
---
|
||||
import { Steps } from '@astrojs/starlight/components';
|
||||
import { Aside } from '@astrojs/starlight/components';
|
||||
import { Code } from '@astrojs/starlight/components';
|
||||
import { Tabs, TabItem } from '@astrojs/starlight/components';
|
||||
import EncryptionKeyGenerator from '../../../components/encryption-key-generator.astro';
|
||||
|
||||
<Aside type="note">
|
||||
Document encryption is available in Papra v0.9.0 and above.
|
||||
</Aside>
|
||||
|
||||
Document encryption in Papra provides end-to-end protection for your stored documents using industry-standard AES-256-GCM encryption. This guide will walk you through enabling encryption, understanding how it works, and managing encryption keys.
|
||||
|
||||
## How Encryption Works
|
||||
|
||||
Papra uses a two-layer encryption approach that provides both security and flexibility:
|
||||
|
||||
### Key Encryption Architecture
|
||||
|
||||
1. **Key Encryption Key (KEK)**: A master key that you provide, used to encrypt document-specific keys
|
||||
2. **Document Encryption Key (DEK)**: Unique per-document keys that actually encrypt your files
|
||||
3. **File Encryption**: Each document gets its own random 256-bit encryption key for maximum security
|
||||
|
||||
<div class="dark:block hidden">
|
||||

|
||||
</div>
|
||||
<div class="dark:hidden block">
|
||||

|
||||
</div>
|
||||
|
||||
### Encryption Flow
|
||||
|
||||
<Steps>
|
||||
|
||||
1. **Document Upload**: When you upload a document, Papra generates a unique 256-bit encryption key (DEK)
|
||||
|
||||
2. **File Encryption**: The document is encrypted using AES-256-GCM with the DEK
|
||||
|
||||
3. **Key Wrapping**: The DEK is encrypted (wrapped) using your Key Encryption Key (KEK)
|
||||
|
||||
4. **Storage**: The encrypted document and wrapped DEK are stored separately - the file in your storage backend, the wrapped key in the database along with the document metadata
|
||||
|
||||
5. **Retrieval**: When accessing a document, Papra unwraps the DEK using your KEK, then decrypts the file stream
|
||||
|
||||
</Steps>
|
||||
|
||||
<Aside type="note">
|
||||
This architecture means that even if someone gains access to your file storage, they cannot decrypt documents without access to both your document records and your KEK, in other words, without your database and your environment variables.
|
||||
</Aside>
|
||||
|
||||
## Quick Setup
|
||||
|
||||
<Steps>
|
||||
|
||||
1. **Generate an encryption key**
|
||||
|
||||
Generate a secure random 256-bit key in hex format, using this generator or OpenSSL command.
|
||||
|
||||
<Tabs>
|
||||
<TabItem label="Key generator">
|
||||
<EncryptionKeyGenerator />
|
||||
</TabItem>
|
||||
|
||||
<TabItem label="OpenSSL command">
|
||||
```bash
|
||||
openssl rand -hex 32
|
||||
```
|
||||
|
||||
This will output something like: `0deba5534bd70548de92d1fd4ae37cf901cca3dc20589b7e022ddb680c98e50c`
|
||||
</TabItem>
|
||||
|
||||
</Tabs>
|
||||
|
||||
|
||||
|
||||
2. **Enable encryption in your configuration**
|
||||
|
||||
Add the following environment variables to your `.env` file or Docker configuration:
|
||||
|
||||
```bash
|
||||
DOCUMENT_STORAGE_ENCRYPTION_IS_ENABLED=true
|
||||
DOCUMENT_STORAGE_DOCUMENT_KEY_ENCRYPTION_KEYS=<your-encryption-key>
|
||||
```
|
||||
|
||||
3. **Restart Papra**
|
||||
|
||||
Restart your Papra instance to apply the encryption settings.
|
||||
|
||||
</Steps>
|
||||
|
||||
## Configuration Options
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Description | Required |
|
||||
|----------|-------------|----------|
|
||||
| `DOCUMENT_STORAGE_ENCRYPTION_IS_ENABLED` | Enable/disable document encryption | No |
|
||||
| `DOCUMENT_STORAGE_DOCUMENT_KEY_ENCRYPTION_KEYS` | Key encryption keys for document encryption | Yes (if encryption enabled) |
|
||||
|
||||
### Key Formats
|
||||
|
||||
<Tabs>
|
||||
<TabItem label="Single Key">
|
||||
For simple setups, provide a single 32-byte hex string:
|
||||
|
||||
```bash
|
||||
DOCUMENT_STORAGE_DOCUMENT_KEY_ENCRYPTION_KEYS=<your-encryption-key>
|
||||
```
|
||||
|
||||
This key will automatically be assigned version `1`.
|
||||
</TabItem>
|
||||
|
||||
<TabItem label="Multiple Keys">
|
||||
For key rotation and advanced setups, provide versioned keys:
|
||||
|
||||
```bash
|
||||
DOCUMENT_STORAGE_DOCUMENT_KEY_ENCRYPTION_KEYS=1:<your-encryption-key-1>,2:<your-encryption-key-2>
|
||||
```
|
||||
|
||||
- The highest version key encrypts new documents
|
||||
- All keys can decrypt existing documents
|
||||
- Versions can be any alphabetically sortable string
|
||||
- Order in the list doesn't matter
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
## Docker Compose Setup
|
||||
|
||||
Add encryption configuration to your Docker Compose file:
|
||||
|
||||
<Tabs>
|
||||
<TabItem label="Environment Variables">
|
||||
```yaml title="docker-compose.yml" ins={8-9}
|
||||
services:
|
||||
papra:
|
||||
container_name: papra
|
||||
image: ghcr.io/papra-hq/papra:latest
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
# ... other environment variables ...
|
||||
- DOCUMENT_STORAGE_ENCRYPTION_IS_ENABLED=true
|
||||
- DOCUMENT_STORAGE_DOCUMENT_KEY_ENCRYPTION_KEYS=<your-encryption-key>
|
||||
volumes:
|
||||
- ./app-data:/app/app-data
|
||||
ports:
|
||||
- "1221:1221"
|
||||
```
|
||||
</TabItem>
|
||||
|
||||
<TabItem label="Config File">
|
||||
```yaml title="docker-compose.yml"
|
||||
services:
|
||||
papra:
|
||||
container_name: papra
|
||||
image: ghcr.io/papra-hq/papra:latest
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./app-data:/app/app-data
|
||||
- ./papra.config.yaml:/app/app-data/papra.config.yaml
|
||||
ports:
|
||||
- "1221:1221"
|
||||
```
|
||||
|
||||
```yaml title="./papra.config.yaml"
|
||||
documentsStorage:
|
||||
encryption:
|
||||
isEncryptionEnabled: true
|
||||
documentKeyEncryptionKeys: "<your-encryption-key>"
|
||||
```
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
## Key Management
|
||||
|
||||
### Key Rotation
|
||||
|
||||
Key rotation allows you to replace encryption keys without losing access to existing documents:
|
||||
|
||||
<Steps>
|
||||
|
||||
1. **Generate a new key**
|
||||
|
||||
```bash
|
||||
openssl rand -hex 32
|
||||
```
|
||||
|
||||
2. **Add the new key with a higher version**
|
||||
|
||||
```bash
|
||||
DOCUMENT_STORAGE_DOCUMENT_KEY_ENCRYPTION_KEYS=1:old_key_here,2:new_key_here
|
||||
```
|
||||
|
||||
3. **Restart Papra**
|
||||
|
||||
New documents will use the highest version key (version 2), while existing documents remain accessible with the old key.
|
||||
|
||||
4. **Optional: Remove old keys**
|
||||
|
||||
Once you're confident all documents are using the new key, you can remove old keys. However, this will make any documents encrypted with old keys inaccessible.
|
||||
|
||||
</Steps>
|
||||
|
||||
<Aside type="caution">
|
||||
Never remove a key version if there are still documents encrypted with that key, unless you're certain you no longer need access to those documents.
|
||||
</Aside>
|
||||
|
||||
### Key Security Best Practices
|
||||
|
||||
1. **Store keys securely**: Use a secrets management system in production
|
||||
2. **Use different keys per environment**: Development, staging, and production should have separate keys
|
||||
3. **Backup your keys**: Loss of encryption keys means permanent loss of document access
|
||||
4. **Rotate keys periodically**: Consider rotating keys annually or after security incidents
|
||||
5. **Limit key access**: Only authorized personnel should have access to encryption keys
|
||||
|
||||
### Docker Secrets Example
|
||||
|
||||
For production environments, store your encryption keys securely using external secret management systems or secure file systems, and reference them via environment variables.
|
||||
|
||||
## Compatibility and Migration
|
||||
|
||||
### Enabling Encryption on Existing Instances
|
||||
|
||||
When you enable encryption on a Papra instance that already has documents:
|
||||
|
||||
- **Existing documents**: Remain unencrypted but accessible
|
||||
- **New documents**: Are encrypted using the current KEK
|
||||
- **Mixed storage**: Papra automatically handles both encrypted and unencrypted documents
|
||||
|
||||
### Migrating Existing Documents to Encrypted Format
|
||||
|
||||
If you want to encrypt all existing unencrypted documents after enabling encryption, Papra provides a maintenance command to handle this migration automatically.
|
||||
|
||||
<Aside type="caution">
|
||||
It's advised to make a backup of your documents and database before running the migration.
|
||||
</Aside>
|
||||
|
||||
<Steps>
|
||||
|
||||
1. **Verify encryption is properly configured**
|
||||
|
||||
Ensure encryption is enabled and working for new documents before migrating existing ones:
|
||||
|
||||
```bash
|
||||
# Check that your configuration includes:
|
||||
DOCUMENT_STORAGE_ENCRYPTION_IS_ENABLED=true
|
||||
DOCUMENT_STORAGE_DOCUMENT_KEY_ENCRYPTION_KEYS=<your-key>
|
||||
```
|
||||
|
||||
2. **Run dry-run to preview changes**
|
||||
|
||||
<Tabs>
|
||||
<TabItem label="Docker Compose">
|
||||
```bash
|
||||
# Run dry-run inside the Docker container
|
||||
docker compose exec papra pnpm maintenance:encrypt-all-documents --dry-run
|
||||
```
|
||||
</TabItem>
|
||||
|
||||
<TabItem label="Docker">
|
||||
```bash
|
||||
# Run dry-run inside the Docker container
|
||||
docker exec -it papra pnpm maintenance:encrypt-all-documents --dry-run
|
||||
```
|
||||
</TabItem>
|
||||
|
||||
<TabItem label="Source Installation">
|
||||
```bash
|
||||
# From your Papra server directory
|
||||
pnpm maintenance:encrypt-all-documents --dry-run
|
||||
```
|
||||
</TabItem>
|
||||
|
||||
|
||||
</Tabs>
|
||||
|
||||
This will show you:
|
||||
- How many documents will be encrypted
|
||||
- Which documents will be affected
|
||||
- No actual encryption will be performed
|
||||
|
||||
4. **Run the migration**
|
||||
|
||||
<Tabs>
|
||||
<TabItem label="Docker Compose">
|
||||
```bash
|
||||
# Run migration inside the Docker container
|
||||
docker compose exec papra pnpm maintenance:encrypt-all-documents
|
||||
```
|
||||
</TabItem>
|
||||
|
||||
<TabItem label="Docker">
|
||||
```bash
|
||||
# Run migration inside the Docker container
|
||||
docker exec -it papra pnpm maintenance:encrypt-all-documents
|
||||
```
|
||||
</TabItem>
|
||||
|
||||
<TabItem label="Source Installation">
|
||||
```bash
|
||||
# From your Papra server directory
|
||||
pnpm maintenance:encrypt-all-documents
|
||||
```
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
The command will:
|
||||
- Find all unencrypted documents
|
||||
- Encrypt each document using your configured KEK
|
||||
- Update database records with encryption metadata
|
||||
- Remove original unencrypted files from storage
|
||||
- Provide progress logging throughout the process
|
||||
|
||||
5. **Verify migration success**
|
||||
|
||||
After migration:
|
||||
- Test document access through the Papra interface
|
||||
- Check that storage files are now encrypted (should start with `PP01`)
|
||||
- Verify all documents are accessible and downloadable
|
||||
|
||||
</Steps>
|
||||
|
||||
<Aside type="tip">
|
||||
**Migration Performance**
|
||||
|
||||
- The migration processes documents sequentially to ensure reliability
|
||||
- Large document collections may take considerable time
|
||||
- Monitor disk space during migration (temporary storage overhead)
|
||||
- Consider running during maintenance windows for production systems
|
||||
</Aside>
|
||||
|
||||
#### Troubleshooting Migration Issues
|
||||
|
||||
**Migration fails with "Document encryption is not enabled"**
|
||||
- Verify `DOCUMENT_STORAGE_ENCRYPTION_IS_ENABLED=true` is set
|
||||
- Restart Papra after configuration changes
|
||||
|
||||
**Migration fails with "Document encryption keys are not set"**
|
||||
- Ensure `DOCUMENT_STORAGE_DOCUMENT_KEY_ENCRYPTION_KEYS` contains valid keys
|
||||
- Verify key format is correct (64-character hex string)
|
||||
|
||||
**Migration stops or fails partway**
|
||||
- Check available disk space
|
||||
- Review Papra logs for specific error messages
|
||||
- Restore from backup and retry after fixing the issue
|
||||
|
||||
**Documents inaccessible after migration**
|
||||
- Verify encryption keys are still properly configured
|
||||
- Check that Papra can access your storage backend
|
||||
- Restore from backup if necessary
|
||||
|
||||
### Disabling Encryption
|
||||
|
||||
If you disable encryption:
|
||||
|
||||
- **Encrypted documents**: Remain encrypted but are automatically decrypted when accessed (if KEK is still available)
|
||||
- **New documents**: Are stored unencrypted
|
||||
- **Data loss risk**: If you remove the KEK while encrypted documents exist, those documents become inaccessible
|
||||
|
||||
<Aside type="caution">
|
||||
Disabling encryption doesn't automatically decrypt existing documents in storage. They remain encrypted and require the KEK for access.
|
||||
</Aside>
|
||||
|
||||
### Storage Driver Compatibility
|
||||
|
||||
The encryption layer sits between Papra and your chosen storage driver, providing consistent encryption regardless of where files are stored (S3, Azure Blob Storage, File System, etc.).
|
||||
|
||||
## Technical Details
|
||||
|
||||
### Encryption Algorithm
|
||||
|
||||
- **Algorithm**: AES-256-GCM (Authenticated Encryption)
|
||||
- **Key size**: 256 bits (32 bytes)
|
||||
- **IV size**: 96 bits (12 bytes)
|
||||
- **Authentication tag**: 128 bits (16 bytes)
|
||||
|
||||
### File Format
|
||||
|
||||
Encrypted files use a custom format with a magic number for identification:
|
||||
|
||||
```
|
||||
| Magic (4 bytes) | IV (12 bytes) | Encrypted Data | Auth Tag (16 bytes) |
|
||||
```
|
||||
|
||||
- **Magic number**: `PP01` - identifies Papra encrypted files
|
||||
- **IV**: Initialization vector for GCM mode
|
||||
- **Encrypted Data**: The actual encrypted document content
|
||||
- **Auth Tag**: Authentication tag for integrity verification
|
||||
|
||||
### Performance Considerations
|
||||
|
||||
- **Streaming encryption**: Files are encrypted/decrypted in streams, minimizing memory usage
|
||||
- **No size overhead**: Minimal storage overhead (32 bytes per file for headers)
|
||||
- **CPU impact**: Modern processors handle AES encryption efficiently
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
**"Document KEK required" error**
|
||||
- Ensure `DOCUMENT_STORAGE_DOCUMENT_KEY_ENCRYPTION_KEYS` is set
|
||||
- Verify the key format is correct (64 character hex string)
|
||||
|
||||
**"Document KEK not found" error**
|
||||
- The document was encrypted with a key version that's no longer available
|
||||
- Add the missing key version back to your configuration
|
||||
|
||||
**"Unsupported encryption algorithm" error**
|
||||
- The document uses an encryption algorithm not supported by this Papra version
|
||||
- This shouldn't occur in normal operation
|
||||
|
||||
**Performance issues**
|
||||
- Consider your storage driver's performance characteristics
|
||||
- Encryption adds minimal overhead, but network/disk I/O remains the bottleneck
|
||||
|
||||
### Verification
|
||||
|
||||
To verify encryption is working:
|
||||
|
||||
1. Upload a document after enabling encryption
|
||||
2. Check your storage backend - the file should not be readable as plain text
|
||||
3. The file should start with the magic number `PP01` if you examine it directly
|
||||
|
||||
<Aside>
|
||||
You can find complete configuration options in the [configuration reference](/self-hosting/configuration). Look for variables prefixed with `DOCUMENT_STORAGE_ENCRYPTION_`.
|
||||
</Aside>
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Threat Model
|
||||
|
||||
Document encryption in Papra protects against:
|
||||
|
||||
- **Storage compromise**: If your file storage is breached, documents remain encrypted
|
||||
- **Database-only breach**: Without the KEK, wrapped DEKs cannot be unwrapped
|
||||
- **Configuration exposure**: If the KEK is exposed, the files remain encrypted as long as the DEK are not exposed
|
||||
|
||||
### Limitations
|
||||
|
||||
Encryption does not protect against:
|
||||
|
||||
- **Application-level access**: Users with document access can view decrypted content
|
||||
- **Memory dumps**: Decrypted content exists temporarily in application memory
|
||||
- **Key and database compromise**: If KEKs are stolen, all DEKs can be decrypted if the database is compromised
|
||||
- **Full system compromise**: If the entire Papra instance is compromised, documents can be accessed
|
||||
102
apps/docs/src/content/docs/03-guides/06-tagging-rules.mdx
Normal file
@@ -0,0 +1,102 @@
|
||||
---
|
||||
title: Using Tagging Rules
|
||||
description: Learn how to automate document organization with tagging rules.
|
||||
slug: guides/tagging-rules
|
||||
---
|
||||
|
||||
## What are Tagging Rules?
|
||||
|
||||
Tagging rules allow you to automatically apply tags to documents based on specific conditions. This helps maintain consistent organization without manual effort, especially when dealing with large numbers of documents.
|
||||
|
||||
## How Tagging Rules Work
|
||||
|
||||
When a tagging rule is enabled, it automatically checks new documents as they're uploaded. If a document matches the rule's conditions, the specified tags are automatically applied.
|
||||
|
||||
### Rule Components
|
||||
|
||||
Each tagging rule consists of:
|
||||
|
||||
1. **Conditions**: Rules that determine which documents should be tagged
|
||||
- Field: The document property to check (e.g., name, content)
|
||||
- Operator: How to compare the field (e.g., contains, equals)
|
||||
- Value: The text to match against
|
||||
|
||||
2. **Actions**: The tags to apply when conditions are met
|
||||
|
||||
## Applying Rules to Existing Documents
|
||||
|
||||
### The "Run Now" Feature
|
||||
|
||||
When you create a new tagging rule, it only applies to documents uploaded *after* the rule is created. To apply the rule to documents that already exist in your organization, use the **"Apply to existing documents"** button.
|
||||
|
||||
This feature is particularly useful when:
|
||||
- You create a new rule and want to organize your existing documents
|
||||
- You modify a rule and want to reprocess documents
|
||||
- You're setting up your organization and want to retroactively organize imported documents
|
||||
|
||||
### How to Apply a Rule to Existing Documents
|
||||
|
||||
1. Navigate to your organization's Tagging Rules page
|
||||
2. Find the rule you want to apply
|
||||
3. Click the **"Apply to existing documents"** button
|
||||
4. Confirm the action in the dialog
|
||||
5. The task is queued and will be processed in the background
|
||||
|
||||
The system will:
|
||||
- Queue a background task to process all documents
|
||||
- Process documents in batches to avoid overloading the system
|
||||
- Check all existing documents in your organization
|
||||
- Apply tags where the rule's conditions match
|
||||
- Show you a success message once the task is queued
|
||||
|
||||
:::tip
|
||||
Applying a rule to existing documents runs as a background task, so you don't need to wait for it to complete. The processing happens asynchronously and efficiently handles large document collections by processing them in batches.
|
||||
:::
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Creating Effective Rules
|
||||
|
||||
1. **Be specific**: Use precise conditions to avoid over-tagging
|
||||
2. **Test first**: Create a rule and test it on a few documents before applying to all existing documents
|
||||
3. **Use multiple conditions**: Combine conditions for more accurate matching
|
||||
4. **Review regularly**: Periodically review your rules to ensure they're still relevant
|
||||
|
||||
### Example Rules
|
||||
|
||||
**Invoice Classification**
|
||||
- Condition: Document name contains "invoice"
|
||||
- Action: Apply "Invoice" tag
|
||||
|
||||
**Quarterly Reports**
|
||||
- Condition: Document name contains "Q1" or "Q2" or "Q3" or "Q4"
|
||||
- Action: Apply "Report" tag
|
||||
|
||||
## Using the API
|
||||
|
||||
You can also apply tagging rules programmatically using the API. The endpoint enqueues a background task and returns immediately:
|
||||
|
||||
```bash
|
||||
curl -X POST \
|
||||
-H "Authorization: Bearer YOUR_API_TOKEN" \
|
||||
https://api.papra.app/api/organizations/YOUR_ORG_ID/tagging-rules/RULE_ID/apply
|
||||
```
|
||||
|
||||
Response (HTTP 202 Accepted):
|
||||
```json
|
||||
{
|
||||
"taskId": "task_abc123"
|
||||
}
|
||||
```
|
||||
|
||||
Where:
|
||||
- `taskId`: The ID of the background task processing your request
|
||||
|
||||
:::note
|
||||
The API returns a task ID immediately. The actual processing happens in the background and may take some time depending on the number of documents. Task status retrieval will be available in a future release.
|
||||
:::
|
||||
|
||||
## Related Resources
|
||||
|
||||
- [API Endpoints Documentation](/resources/api-endpoints)
|
||||
- [CLI Documentation](/resources/cli)
|
||||
@@ -18,8 +18,107 @@ The public API uses a bearer token for authentication. You can get a token by lo
|
||||
</details>
|
||||
|
||||
|
||||
To authenticate your requests, include the token in the `Authorization` header with the `Bearer` prefix:
|
||||
|
||||
```
|
||||
Authorization: Bearer YOUR_API_TOKEN
|
||||
```
|
||||
|
||||
### Examples
|
||||
|
||||
**Using cURL:**
|
||||
```bash
|
||||
curl -H "Authorization: Bearer YOUR_API_TOKEN" \
|
||||
https://api.papra.app/api/organizations
|
||||
```
|
||||
|
||||
**Using JavaScript (fetch):**
|
||||
```javascript
|
||||
const response = await fetch('https://api.papra.app/api/organizations', {
|
||||
headers: {
|
||||
'Authorization': 'Bearer YOUR_API_TOKEN',
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### API Key Permissions
|
||||
|
||||
When creating an API key, you can select from the following permissions:
|
||||
|
||||
**Organizations:**
|
||||
- `organizations:create` - Create new organizations
|
||||
- `organizations:read` - Read organization information and list organizations of the user
|
||||
- `organizations:update` - Update organization details
|
||||
- `organizations:delete` - Delete organizations
|
||||
|
||||
**Documents:**
|
||||
- `documents:create` - Upload and create new documents
|
||||
- `documents:read` - Read and download documents
|
||||
- `documents:update` - Update document metadata and content
|
||||
- `documents:delete` - Delete documents
|
||||
|
||||
**Tags:**
|
||||
- `tags:create` - Create new tags
|
||||
- `tags:read` - Read tag information and list tags
|
||||
- `tags:update` - Update tag details
|
||||
- `tags:delete` - Delete tags
|
||||
|
||||
## Endpoints
|
||||
|
||||
### List organizations
|
||||
|
||||
**GET** `/api/organizations`
|
||||
|
||||
List all organizations accessible to the authenticated user.
|
||||
|
||||
- Required API key permissions: `organizations:read`
|
||||
- Response (JSON)
|
||||
- `organizations`: The list of organizations.
|
||||
|
||||
### Create an organization
|
||||
|
||||
**POST** `/api/organizations`
|
||||
|
||||
Create a new organization.
|
||||
|
||||
- Required API key permissions: `organizations:create`
|
||||
- Body (JSON)
|
||||
- `name`: The organization name (3-50 characters).
|
||||
- Response (JSON)
|
||||
- `organization`: The created organization.
|
||||
|
||||
### Get an organization
|
||||
|
||||
**GET** `/api/organizations/:organizationId`
|
||||
|
||||
Get an organization by its ID.
|
||||
|
||||
- Required API key permissions: `organizations:read`
|
||||
- Response (JSON)
|
||||
- `organization`: The organization.
|
||||
|
||||
### Update an organization
|
||||
|
||||
**PUT** `/api/organizations/:organizationId`
|
||||
|
||||
Update an organization's name.
|
||||
|
||||
- Required API key permissions: `organizations:update`
|
||||
- Body (JSON)
|
||||
- `name`: The new organization name (3-50 characters).
|
||||
- Response (JSON)
|
||||
- `organization`: The updated organization.
|
||||
|
||||
### Delete an organization
|
||||
|
||||
**DELETE** `/api/organizations/:organizationId`
|
||||
|
||||
Delete an organization by its ID.
|
||||
|
||||
- Required API key permissions: `organizations:delete`
|
||||
- Response: empty (204 status code)
|
||||
|
||||
### Create a document
|
||||
|
||||
**POST** `/api/organizations/:organizationId/documents`
|
||||
@@ -102,7 +201,10 @@ Search documents in the organization by name or content.
|
||||
- `pageIndex`: (optional, default: 0) The page index to start from.
|
||||
- `pageSize`: (optional, default: 100) The number of documents to return.
|
||||
- Response (JSON)
|
||||
- `documents`: The list of documents.
|
||||
- `searchResults`: The search results.
|
||||
- `documents`: The list of matching documents.
|
||||
- `id`: The document ID.
|
||||
- `name`: The document name.
|
||||
|
||||
### Get organization documents statistics
|
||||
|
||||
@@ -208,3 +310,13 @@ Remove a tag from a document.
|
||||
|
||||
- Required API key permissions: `tags:read` and `documents:update`
|
||||
- Response: empty (204 status code)
|
||||
|
||||
### Apply tagging rule to existing documents
|
||||
|
||||
**POST** `/api/organizations/:organizationId/tagging-rules/:taggingRuleId/apply`
|
||||
|
||||
Enqueue a background task to apply a tagging rule to all existing documents in the organization. This endpoint returns immediately with a task ID, and the processing happens asynchronously in the background. The task will check all documents and apply tags where the rule's conditions match.
|
||||
|
||||
- Required API key permissions: `tags:read` and `documents:update`
|
||||
- Response (JSON, HTTP 202)
|
||||
- `taskId`: The ID of the background task. You can use this to track the task's progress (task status retrieval coming in a future release).
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
title: Document Deduplication
|
||||
description: How Papra prevents duplicate documents and saves storage space.
|
||||
slug: architecture/document-deduplication
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Papra automatically detects and prevents duplicate documents per organization using content hashing. This ensures that if the same file is uploaded multiple times, only one copy is stored, saving storage space and reducing clutter.
|
||||
|
||||
## How It Works
|
||||
|
||||
When a document is added to an organization (upload, email ingestion, folder sync, ...), the server computes a **SHA-256 hash** of the file content and checks if a document with the same hash already exists in that organization.
|
||||
|
||||
- If there is **no document with the same hash** in the organization, the new document is added as usual
|
||||
- If a document **with same content exists**, the upload is rejected
|
||||
- If a document **with same content was previously deleted** (in trash), it is restored instead of creating a new copy, the metadata is updated to match the newly added document
|
||||
|
||||
|
||||
## Technical Details
|
||||
|
||||
### Hash Algorithm
|
||||
|
||||
- Papra uses **SHA-256** for content hashing.
|
||||
- Computed during streaming upload (no extra I/O)
|
||||
- 64-character hexadecimal string stored in the database
|
||||
|
||||
### Database Constraint
|
||||
|
||||
The database enforces uniqueness with a composite index:
|
||||
|
||||
```sql
|
||||
UNIQUE (organization_id, original_sha256_hash)
|
||||
```
|
||||
|
||||
This guarantees no two active documents in the same organization can have identical content.
|
||||
|
||||
### File Content Only
|
||||
|
||||
Only the **file content** is hashed and used for deduplication, filenames, upload dates, and metadata don't affect deduplication. Two files are considered duplicates if and only if their content is strictly identical.
|
||||
@@ -0,0 +1,38 @@
|
||||
---
|
||||
title: No-Mutation Principle
|
||||
description: Why Papra never modifies your original documents and the architectural decisions behind this choice.
|
||||
slug: architecture/no-mutation-principle
|
||||
---
|
||||
|
||||
## Core Philosophy
|
||||
|
||||
Papra follows a fundamental principle: **documents are never mutated after upload**. When you input a document, you can always retrieve it exactly as it was uploaded.
|
||||
|
||||
## The Design Choice
|
||||
|
||||
An archiving platform should guarantee users they can retrieve their documents in their original form. This means:
|
||||
|
||||
- No conversion to different formats
|
||||
- No metadata injection into the file itself
|
||||
- No overlay of OCR-ed content on scanned PDFs
|
||||
- No processing that modifies the original file
|
||||
|
||||
The simple mental model is: **"If I input X, I'll retrieve X"**
|
||||
|
||||
## Why This Matters
|
||||
|
||||
### Trust and Reliability
|
||||
|
||||
When archiving important documents, users need absolute confidence that their files remain untouched. Whether it's a legal document, a medical record, or a personal photo, the original should be sacrosanct.
|
||||
|
||||
### Simplicity
|
||||
|
||||
This approach eliminates the mental overhead of wondering "what happened to my file?" Users don't need to understand concepts like:
|
||||
- Original vs. processed versions
|
||||
- Format conversions
|
||||
- OCR overlays
|
||||
- Metadata injection
|
||||
|
||||
### Flexibility for the Future
|
||||
|
||||
While Papra currently doesn't mutate documents, the architecture leaves room for future enhancements. If needed, a "processed" version concept could be added alongside originals, giving users the choice without forcing a particular model.
|
||||
@@ -0,0 +1,62 @@
|
||||
---
|
||||
title: Organization Deletion & Purge
|
||||
description: How Papra handles organization deletion with a grace period and eventual purge.
|
||||
slug: architecture/organization-deletion-purge
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Papra implements a two-phase deletion process for organizations: soft deletion followed by hard deletion (purge). This provides a grace period for recovery while ensuring eventual cleanup of resources.
|
||||
|
||||
## Deletion Process
|
||||
|
||||
### Who Can Delete
|
||||
|
||||
Only the **organization owner** can delete an organization. Admins and members do not have this permission.
|
||||
|
||||
### What Happens During Deletion
|
||||
|
||||
When an organization is deleted:
|
||||
|
||||
1. **Members are removed** - All organization members are stripped from the organization, leaving them dangling
|
||||
2. **Invitations are removed** - All pending invitations are deleted
|
||||
3. **Metadata is recorded**:
|
||||
- `deletedAt`: Timestamp when the deletion occurred
|
||||
- `deletedBy`: ID of the user (owner) who deleted the organization
|
||||
- `scheduledPurgeAt`: Future date when hard deletion will occur (default: 30 days)
|
||||
|
||||
The organization itself remains in the database in a soft-deleted state, allowing for potential restoration.
|
||||
|
||||
## Purge Process
|
||||
|
||||
### When Purge Occurs
|
||||
|
||||
Hard deletion (purge) happens when `scheduledPurgeAt` is reached. By default, this is **30 days** after the deletion date.
|
||||
|
||||
### What Gets Purged
|
||||
|
||||
When an organization is purged:
|
||||
|
||||
- **All documents** are deleted from storage
|
||||
- **All database records** related to the organization are removed (cascade handles related records, like Tags, Intake Emails, etc.)
|
||||
- The organization itself is permanently deleted
|
||||
|
||||
The process handles documents in batches using an iterator to avoid memory issues with large organizations.
|
||||
|
||||
### Background Task
|
||||
|
||||
Purging is handled by a periodic background task that:
|
||||
|
||||
1. Queries for organizations with `scheduledPurgeAt` in the past
|
||||
2. For each expired organization:
|
||||
- Deletes all document files from storage
|
||||
- Hard deletes the organization (cascade handles related records)
|
||||
3. Logs the process for monitoring and debugging
|
||||
|
||||
The task continues even if individual file deletions fail, logging errors without blocking the entire purge operation.
|
||||
|
||||
## Recovery
|
||||
|
||||
Organizations can be restored before the `scheduledPurgeAt` date is reached, but only by the user who deleted them (the previous owner). After this date, recovery is no longer possible, even if the purge has not yet occurred.
|
||||
|
||||
> Note: After recovery, the organization owner must re-invite members as they were removed during deletion.
|
||||
@@ -5,6 +5,7 @@ export const sidebar = [
|
||||
label: 'Getting Started',
|
||||
items: [
|
||||
{ label: 'Introduction', slug: '' },
|
||||
{ label: 'Changelog', link: '/changelog' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -35,6 +36,31 @@ export const sidebar = [
|
||||
label: 'Setup Custom OAuth2 Providers',
|
||||
slug: 'guides/setup-custom-oauth2-providers',
|
||||
},
|
||||
{
|
||||
label: 'Document Encryption',
|
||||
slug: 'guides/document-encryption',
|
||||
},
|
||||
{
|
||||
label: 'Tagging Rules',
|
||||
slug: 'guides/tagging-rules',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Architecture',
|
||||
items: [
|
||||
{
|
||||
label: 'No-Mutation Principle',
|
||||
slug: 'architecture/no-mutation-principle',
|
||||
},
|
||||
{
|
||||
label: 'Document Deduplication',
|
||||
slug: 'architecture/document-deduplication',
|
||||
},
|
||||
{
|
||||
label: 'Organization Deletion',
|
||||
slug: 'architecture/organization-deletion-purge',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
16
apps/docs/src/markdown.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { marked } from 'marked';
|
||||
|
||||
const renderer = new marked.Renderer();
|
||||
renderer.heading = function ({ text, depth }) {
|
||||
const slug = text.toLowerCase().replace(/\W+/g, '-');
|
||||
return `
|
||||
<div class="sl-heading-wrapper level-h${depth}">
|
||||
<h${depth} id="${slug}">${text}</h${depth}>
|
||||
<a class="sl-anchor-link" href="#${slug}"><span aria-hidden="true" class="sl-anchor-icon"><svg width="16" height="16" viewBox="0 0 24 24"><path fill="currentcolor" d="m12.11 15.39-3.88 3.88a2.52 2.52 0 0 1-3.5 0 2.47 2.47 0 0 1 0-3.5l3.88-3.88a1 1 0 0 0-1.42-1.42l-3.88 3.89a4.48 4.48 0 0 0 6.33 6.33l3.89-3.88a1 1 0 1 0-1.42-1.42Zm8.58-12.08a4.49 4.49 0 0 0-6.33 0l-3.89 3.88a1 1 0 0 0 1.42 1.42l3.88-3.88a2.52 2.52 0 0 1 3.5 0 2.47 2.47 0 0 1 0 3.5l-3.88 3.88a1 1 0 1 0 1.42 1.42l3.88-3.89a4.49 4.49 0 0 0 0-6.33ZM8.83 15.17a1 1 0 0 0 1.1.22 1 1 0 0 0 .32-.22l4.92-4.92a1 1 0 0 0-1.42-1.42l-4.92 4.92a1 1 0 0 0 0 1.42Z"></path></svg></span><span class="sr-only">Section titled “Configuration files”</span></a>
|
||||
</div>
|
||||
`.trim().replace(/\n/g, '');
|
||||
};
|
||||
|
||||
export function renderMarkdown(markdown: string) {
|
||||
return marked.parse(markdown, { renderer });
|
||||
}
|
||||
55
apps/docs/src/pages/changelog.astro
Normal file
@@ -0,0 +1,55 @@
|
||||
---
|
||||
import StarlightPage from '@astrojs/starlight/components/StarlightPage.astro';
|
||||
import rawChangelog from '../../../../packages/docker/CHANGELOG.md?raw';
|
||||
import { parseChangelog } from '../changelog-parser';
|
||||
import { renderMarkdown } from '../markdown';
|
||||
|
||||
const changelog = parseChangelog(rawChangelog);
|
||||
|
||||
---
|
||||
|
||||
<StarlightPage
|
||||
frontmatter={{
|
||||
title: 'Papra changelog',
|
||||
description: 'View the changelogs of the docker images released by Papra.',
|
||||
tableOfContents: false,
|
||||
}}
|
||||
>
|
||||
<p>
|
||||
Here are the changelogs of the docker images released by Papra.<br />
|
||||
For version after v0.9.6, Papra uses Calver as a versioning system with the format YY.MM.N where N is the number of releases in the month starting at 0 (e.g. 25.06.0 is the first release of June 2025).
|
||||
</p>
|
||||
|
||||
|
||||
{
|
||||
changelog.map(({ entries, version }) => (
|
||||
<section>
|
||||
|
||||
<h2 id={version} class="pb-1 mt-14">v{version}</h2>
|
||||
<ul>
|
||||
{entries.map(entry => (
|
||||
<li>
|
||||
<div class="flex flex-col">
|
||||
<div class="text-foreground lh-normal changelog-entry" set:html={renderMarkdown(entry.content)} />
|
||||
<div class="text-xs mt-1 flex gap-1 flex-wrap">
|
||||
<a href={entry.pr.url} class="text-muted-foreground hover:bg-muted transition border border-muted border-solid rounded-md no-underline px-1 py-0.5">PR #{entry.pr.number}</a>
|
||||
<a href={entry.commit.url} class="text-muted-foreground hover:bg-muted transition border border-muted border-solid rounded-md no-underline px-1 py-0.5">{entry.commit.hash.slice(0, 7)}</a>
|
||||
<a href={entry.contributor.url} class="text-muted-foreground hover:bg-muted transition border border-muted border-solid rounded-md no-underline px-1 py-0.5">
|
||||
By @{entry.contributor.username}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
))
|
||||
}
|
||||
</StarlightPage>
|
||||
|
||||
<style is:global>
|
||||
.changelog-entry pre {
|
||||
border-radius: 6px;
|
||||
color: hsl(var(--muted-foreground) / var(--un-text-opacity));
|
||||
}
|
||||
</style>
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import type { ConfigDefinition } from 'figue';
|
||||
import { z } from 'astro:content';
|
||||
import { mapValues } from 'lodash-es';
|
||||
import { z } from 'zod';
|
||||
import { zodToJsonSchema } from 'zod-to-json-schema';
|
||||
import { configDefinition } from '../../../papra-server/src/modules/config/config';
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
defineConfig,
|
||||
presetTypography,
|
||||
presetUno,
|
||||
transformerDirectives,
|
||||
transformerVariantGroup,
|
||||
@@ -10,12 +11,13 @@ export default defineConfig({
|
||||
presets: [
|
||||
presetUno({
|
||||
dark: {
|
||||
dark: '[data-kb-theme="dark"]',
|
||||
light: '[data-kb-theme="light"]',
|
||||
dark: '[data-theme="dark"]',
|
||||
light: '[data-theme="light"]',
|
||||
},
|
||||
prefix: '',
|
||||
}),
|
||||
presetAnimations(),
|
||||
presetTypography(),
|
||||
],
|
||||
transformers: [transformerVariantGroup(), transformerDirectives()],
|
||||
theme: {
|
||||
|
||||
43
apps/mobile/.gitignore
vendored
Normal file
@@ -0,0 +1,43 @@
|
||||
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
|
||||
|
||||
# dependencies
|
||||
node_modules/
|
||||
|
||||
# Expo
|
||||
.expo/
|
||||
dist/
|
||||
web-build/
|
||||
expo-env.d.ts
|
||||
|
||||
# Native
|
||||
.kotlin/
|
||||
*.orig.*
|
||||
*.jks
|
||||
*.p8
|
||||
*.p12
|
||||
*.key
|
||||
*.mobileprovision
|
||||
|
||||
# Metro
|
||||
.metro-health-check*
|
||||
|
||||
# debug
|
||||
npm-debug.*
|
||||
yarn-debug.*
|
||||
yarn-error.*
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
|
||||
app-example
|
||||
|
||||
# generated native folders
|
||||
/ios
|
||||
/android
|
||||
5
apps/mobile/README.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# Papra Mobile App
|
||||
|
||||
React Native mobile application for Papra document management platform, built with Expo.
|
||||
|
||||
// Todo: Add more details about setup, development, and usage instructions.
|
||||
55
apps/mobile/app.json
Normal file
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"expo": {
|
||||
"name": "mobile",
|
||||
"slug": "mobile",
|
||||
"version": "1.0.0",
|
||||
"orientation": "portrait",
|
||||
"icon": "./src/assets/images/icon.png",
|
||||
"scheme": "papra",
|
||||
"userInterfaceStyle": "automatic",
|
||||
"newArchEnabled": true,
|
||||
"ios": {
|
||||
"supportsTablet": true
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
"backgroundColor": "#E6F4FE",
|
||||
"foregroundImage": "./src/assets/images/android-icon-foreground.png",
|
||||
"backgroundImage": "./src/assets/images/android-icon-background.png",
|
||||
"monochromeImage": "./src/assets/images/android-icon-monochrome.png"
|
||||
},
|
||||
"edgeToEdgeEnabled": true,
|
||||
"predictiveBackGestureEnabled": false
|
||||
},
|
||||
"web": {
|
||||
"output": "static",
|
||||
"favicon": "./src/assets/images/favicon.png"
|
||||
},
|
||||
"plugins": [
|
||||
"expo-router",
|
||||
[
|
||||
"expo-splash-screen",
|
||||
{
|
||||
"image": "./src/assets/images/splash-icon.png",
|
||||
"imageWidth": 200,
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#ffffff",
|
||||
"dark": {
|
||||
"backgroundColor": "#000000"
|
||||
}
|
||||
}
|
||||
],
|
||||
"expo-secure-store"
|
||||
],
|
||||
"experiments": {
|
||||
"typedRoutes": true,
|
||||
"reactCompiler": true
|
||||
},
|
||||
"extra": {
|
||||
"router": {},
|
||||
"eas": {
|
||||
"projectId": "f40c21f5-38e6-40d8-8627-528c1d3a533a"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Tabs } from 'expo-router';
|
||||
import React from 'react';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
import { HapticTab } from '@/modules/ui/components/haptic-tab';
|
||||
import { Icon } from '@/modules/ui/components/icon';
|
||||
import { ImportTabButton } from '@/modules/ui/components/import-tab-button';
|
||||
import { useThemeColor } from '@/modules/ui/providers/use-theme-color';
|
||||
|
||||
export default function TabLayout() {
|
||||
const colors = useThemeColor();
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
screenOptions={{
|
||||
tabBarActiveTintColor: colors.primary,
|
||||
headerShown: false,
|
||||
tabBarButton: HapticTab,
|
||||
tabBarStyle: {
|
||||
backgroundColor: colors.secondaryBackground,
|
||||
borderTopColor: colors.border,
|
||||
paddingTop: 15,
|
||||
paddingBottom: insets.bottom,
|
||||
height: 65 + insets.bottom,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Tabs.Screen
|
||||
name="list"
|
||||
options={{
|
||||
title: 'Documents',
|
||||
tabBarIcon: ({ color }) => <Icon name="home" size={30} color={color} style={{ height: 30 }} />,
|
||||
tabBarLabel: () => null,
|
||||
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="import"
|
||||
options={{
|
||||
title: 'Import',
|
||||
tabBarButton: () => <ImportTabButton />,
|
||||
tabBarLabel: () => null,
|
||||
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="settings"
|
||||
options={{
|
||||
title: 'Settings',
|
||||
tabBarIcon: ({ color }) => <Icon name="settings" size={30} color={color} style={{ height: 30 }} />,
|
||||
tabBarLabel: () => null,
|
||||
|
||||
}}
|
||||
/>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
// This is a dummy screen that will never be rendered
|
||||
// The import tab button intercepts the press and opens a drawer instead
|
||||
export default function ImportScreen() {
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { DocumentsListScreen } from '@/modules/documents/screens/documents-list.screen';
|
||||
|
||||
export default DocumentsListScreen;
|
||||
@@ -0,0 +1,3 @@
|
||||
import SettingsScreen from '@/modules/users/screens/settings.screen';
|
||||
|
||||
export default SettingsScreen;
|
||||
13
apps/mobile/app/(app)/(with-organizations)/_layout.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Stack } from 'expo-router';
|
||||
import { OrganizationsProvider } from '@/modules/organizations/organizations.provider';
|
||||
|
||||
export default function WithOrganizationsLayout() {
|
||||
return (
|
||||
<OrganizationsProvider>
|
||||
<Stack>
|
||||
<Stack.Screen name="organizations/create" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
||||
</Stack>
|
||||
</OrganizationsProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { OrganizationCreateScreen } from '@/modules/organizations/screens/organization-create.screen';
|
||||
|
||||
export default OrganizationCreateScreen;
|
||||
18
apps/mobile/app/(app)/_layout.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Stack } from 'expo-router';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
|
||||
import { ApiProvider } from '@/modules/api/providers/api.provider';
|
||||
import 'react-native-reanimated';
|
||||
|
||||
export default function RootLayout() {
|
||||
return (
|
||||
<ApiProvider>
|
||||
<Stack>
|
||||
<Stack.Screen name="auth/login" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="auth/signup" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="(with-organizations)" options={{ headerShown: false }} />
|
||||
</Stack>
|
||||
<StatusBar style="auto" />
|
||||
</ApiProvider>
|
||||
);
|
||||
}
|
||||
3
apps/mobile/app/(app)/auth/login.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
import { LoginScreen } from '@/modules/auth/screens/login.screen';
|
||||
|
||||
export default LoginScreen;
|
||||
3
apps/mobile/app/(app)/auth/signup.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
import { SignupScreen } from '@/modules/auth/screens/signup.screen';
|
||||
|
||||
export default SignupScreen;
|
||||
45
apps/mobile/app/+not-found.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { Link, Stack } from 'expo-router';
|
||||
import { StyleSheet, Text, useColorScheme, View } from 'react-native';
|
||||
|
||||
export default function NotFoundScreen() {
|
||||
const colorScheme = useColorScheme();
|
||||
const isDark = colorScheme === 'dark';
|
||||
|
||||
const styles = createStylesNotFound(isDark);
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen options={{ title: 'Oops!' }} />
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.title}>This screen doesn't exist.</Text>
|
||||
<Link href="/" style={styles.link}>
|
||||
<Text style={styles.linkText}>Go to home screen</Text>
|
||||
</Link>
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function createStylesNotFound(isDark: boolean) {
|
||||
return StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 20,
|
||||
},
|
||||
title: {
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 20,
|
||||
color: isDark ? '#fff' : '#000',
|
||||
},
|
||||
link: {
|
||||
marginTop: 15,
|
||||
paddingVertical: 15,
|
||||
},
|
||||
linkText: {
|
||||
fontSize: 14,
|
||||
color: '#007AFF',
|
||||
},
|
||||
});
|
||||
}
|
||||
25
apps/mobile/app/_layout.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native';
|
||||
import { Stack } from 'expo-router';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { AppProviders } from '@/modules/app/providers/app-providers';
|
||||
|
||||
import { useColorScheme } from '@/modules/ui/providers/use-color-scheme';
|
||||
import 'react-native-reanimated';
|
||||
|
||||
export default function RootLayout() {
|
||||
const colorScheme = useColorScheme();
|
||||
|
||||
return (
|
||||
<AppProviders>
|
||||
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
|
||||
<Stack>
|
||||
<Stack.Screen name="index" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="config/server-selection" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="(app)" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="modal" options={{ presentation: 'modal', title: 'Modal' }} />
|
||||
</Stack>
|
||||
<StatusBar style="auto" />
|
||||
</ThemeProvider>
|
||||
</AppProviders>
|
||||
);
|
||||
}
|
||||
3
apps/mobile/app/config/server-selection.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
import { ServerSelectionScreen } from '@/modules/config/screens/server-selection.screen';
|
||||
|
||||
export default ServerSelectionScreen;
|
||||
28
apps/mobile/app/index.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Redirect } from 'expo-router';
|
||||
import { configLocalStorage } from '@/modules/config/config.local-storage';
|
||||
|
||||
export default function Index() {
|
||||
const query = useQuery({
|
||||
queryKey: ['api-server-url'],
|
||||
queryFn: configLocalStorage.getApiServerBaseUrl,
|
||||
});
|
||||
|
||||
const getRedirection = () => {
|
||||
if (query.isLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (query.isError || query.data == null) {
|
||||
return <Redirect href="/config/server-selection" />;
|
||||
}
|
||||
|
||||
return <Redirect href="/auth/login" />;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{getRedirection()}
|
||||
</>
|
||||
);
|
||||
}
|
||||
29
apps/mobile/app/modal.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Link } from 'expo-router';
|
||||
import { StyleSheet } from 'react-native';
|
||||
|
||||
import { ThemedText } from '@/modules/ui/components/themed-text';
|
||||
import { ThemedView } from '@/modules/ui/components/themed-view';
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 20,
|
||||
},
|
||||
link: {
|
||||
marginTop: 15,
|
||||
paddingVertical: 15,
|
||||
},
|
||||
});
|
||||
|
||||
export default function ModalScreen() {
|
||||
return (
|
||||
<ThemedView style={styles.container}>
|
||||
<ThemedText type="title">This is a modal</ThemedText>
|
||||
<Link href="/" dismissTo style={styles.link}>
|
||||
<ThemedText type="link">Go to home screen</ThemedText>
|
||||
</Link>
|
||||
</ThemedView>
|
||||
);
|
||||
}
|
||||
21
apps/mobile/eas.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"cli": {
|
||||
"version": ">= 16.27.0",
|
||||
"appVersionSource": "remote"
|
||||
},
|
||||
"build": {
|
||||
"development": {
|
||||
"developmentClient": true,
|
||||
"distribution": "internal"
|
||||
},
|
||||
"preview": {
|
||||
"distribution": "internal"
|
||||
},
|
||||
"production": {
|
||||
"autoIncrement": true
|
||||
}
|
||||
},
|
||||
"submit": {
|
||||
"production": {}
|
||||
}
|
||||
}
|
||||
29
apps/mobile/eslint.config.js
Normal file
@@ -0,0 +1,29 @@
|
||||
import antfu from '@antfu/eslint-config';
|
||||
|
||||
export default antfu({
|
||||
typescript: {
|
||||
tsconfigPath: './tsconfig.json',
|
||||
overridesTypeAware: {
|
||||
'ts/no-misused-promises': ['error', { checksVoidReturn: false }],
|
||||
'ts/strict-boolean-expressions': ['error', { allowNullableObject: true }],
|
||||
},
|
||||
|
||||
},
|
||||
stylistic: {
|
||||
semi: true,
|
||||
},
|
||||
|
||||
rules: {
|
||||
// To allow export on top of files
|
||||
'ts/no-use-before-define': ['error', { allowNamedExports: true, functions: false }],
|
||||
'curly': ['error', 'all'],
|
||||
'vitest/consistent-test-it': ['error', { fn: 'test' }],
|
||||
'ts/consistent-type-definitions': ['error', 'type'],
|
||||
'style/brace-style': ['error', '1tbs', { allowSingleLine: false }],
|
||||
'unused-imports/no-unused-vars': ['error', {
|
||||
argsIgnorePattern: '^_',
|
||||
varsIgnorePattern: '^_',
|
||||
caughtErrorsIgnorePattern: '^_',
|
||||
}],
|
||||
},
|
||||
});
|
||||
8
apps/mobile/metro.config.js
Normal file
@@ -0,0 +1,8 @@
|
||||
const { getDefaultConfig } = require('expo/metro-config');
|
||||
|
||||
const config = getDefaultConfig(__dirname);
|
||||
|
||||
// Enable package exports for Better Auth
|
||||
config.resolver.unstable_enablePackageExports = true;
|
||||
|
||||
module.exports = config;
|
||||
65
apps/mobile/package.json
Normal file
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"name": "mobile",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"main": "expo-router/entry",
|
||||
"scripts": {
|
||||
"dev": "pnpm start",
|
||||
"start": "expo start",
|
||||
"android": "expo start --android",
|
||||
"ios": "expo start --ios",
|
||||
"web": "expo start --web",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"@better-auth/expo": "catalog:",
|
||||
"@corentinth/chisels": "catalog:",
|
||||
"@expo/vector-icons": "^15.0.3",
|
||||
"@react-native-async-storage/async-storage": "^2.2.0",
|
||||
"@react-navigation/bottom-tabs": "^7.4.0",
|
||||
"@react-navigation/elements": "^2.6.3",
|
||||
"@react-navigation/native": "^7.1.8",
|
||||
"@tanstack/react-form": "^1.23.8",
|
||||
"@tanstack/react-query": "^5.90.7",
|
||||
"better-auth": "catalog:",
|
||||
"expo": "~54.0.22",
|
||||
"expo-constants": "~18.0.10",
|
||||
"expo-document-picker": "^14.0.7",
|
||||
"expo-file-system": "^19.0.19",
|
||||
"expo-font": "~14.0.9",
|
||||
"expo-haptics": "~15.0.7",
|
||||
"expo-image": "~3.0.10",
|
||||
"expo-linking": "~8.0.8",
|
||||
"expo-router": "~6.0.14",
|
||||
"expo-secure-store": "^15.0.7",
|
||||
"expo-splash-screen": "~31.0.10",
|
||||
"expo-status-bar": "~3.0.8",
|
||||
"expo-symbols": "~1.0.7",
|
||||
"expo-system-ui": "~6.0.8",
|
||||
"expo-web-browser": "~15.0.9",
|
||||
"ofetch": "^1.4.1",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-native": "0.81.5",
|
||||
"react-native-gesture-handler": "~2.28.0",
|
||||
"react-native-reanimated": "~4.1.1",
|
||||
"react-native-safe-area-context": "~5.6.0",
|
||||
"react-native-screens": "~4.16.0",
|
||||
"react-native-web": "~0.21.0",
|
||||
"react-native-worklets": "0.5.1",
|
||||
"valibot": "1.0.0-beta.10"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@antfu/eslint-config": "catalog:",
|
||||
"@types/react": "~19.1.0",
|
||||
"eas-cli": "^16.27.0",
|
||||
"eslint": "catalog:",
|
||||
"eslint-config-expo": "~10.0.0",
|
||||
"typescript": "catalog:",
|
||||
"vitest": "catalog:"
|
||||
}
|
||||
}
|
||||
BIN
apps/mobile/src/assets/images/android-icon-background.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
apps/mobile/src/assets/images/android-icon-foreground.png
Normal file
|
After Width: | Height: | Size: 77 KiB |
BIN
apps/mobile/src/assets/images/android-icon-monochrome.png
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
apps/mobile/src/assets/images/favicon.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
apps/mobile/src/assets/images/icon.png
Normal file
|
After Width: | Height: | Size: 384 KiB |
BIN
apps/mobile/src/assets/images/partial-react-logo.png
Normal file
|
After Width: | Height: | Size: 5.0 KiB |
BIN
apps/mobile/src/assets/images/react-logo.png
Normal file
|
After Width: | Height: | Size: 6.2 KiB |
BIN
apps/mobile/src/assets/images/react-logo@2x.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
apps/mobile/src/assets/images/react-logo@3x.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
apps/mobile/src/assets/images/splash-icon.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
30
apps/mobile/src/modules/api/api.client.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { HttpClientOptions, ResponseType } from './http.client';
|
||||
import { Platform } from 'react-native';
|
||||
import { httpClient } from './http.client';
|
||||
|
||||
export type ApiClient = ReturnType<typeof createApiClient>;
|
||||
|
||||
export function createApiClient({
|
||||
baseUrl,
|
||||
getAuthCookie,
|
||||
}: {
|
||||
baseUrl: string;
|
||||
getAuthCookie: () => string;
|
||||
}) {
|
||||
return async <T, R extends ResponseType = 'json'>({ path, ...rest}: { path: string } & Omit<HttpClientOptions<R>, 'url'>) => {
|
||||
return httpClient<T, R>({
|
||||
baseUrl,
|
||||
url: path,
|
||||
credentials: Platform.OS === 'web' ? 'include' : 'omit',
|
||||
headers: {
|
||||
...(Platform.OS === 'web'
|
||||
? {}
|
||||
: {
|
||||
Cookie: getAuthCookie(),
|
||||
}),
|
||||
...rest.headers,
|
||||
},
|
||||
...rest,
|
||||
});
|
||||
};
|
||||
}
|
||||
19
apps/mobile/src/modules/api/api.models.test.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { coerceDate } from './api.models';
|
||||
|
||||
describe('api models', () => {
|
||||
describe('coerceDate', () => {
|
||||
test('transforms date-ish values into Date instances', () => {
|
||||
expect(coerceDate(new Date('2024-01-01T00:00:00Z'))).toEqual(new Date('2024-01-01T00:00:00Z'));
|
||||
expect(coerceDate('2024-01-01T00:00:00Z')).toEqual(new Date('2024-01-01T00:00:00Z'));
|
||||
expect(coerceDate('2024-01-01')).toEqual(new Date('2024-01-01T00:00:00Z'));
|
||||
expect(coerceDate(1704067200000)).toEqual(new Date('2024-01-01T00:00:00Z'));
|
||||
|
||||
expect(() => coerceDate(null)).toThrow('Invalid date: expected Date, string, or number, but received value "null" of type "object"');
|
||||
expect(() => coerceDate(undefined)).toThrow('Invalid date: expected Date, string, or number, but received value "undefined" of type "undefined"');
|
||||
expect(() => coerceDate({})).toThrow('Invalid date: expected Date, string, or number, but received value "[object Object]" of type "object"');
|
||||
expect(() => coerceDate(['foo'])).toThrow('Invalid date: expected Date, string, or number, but received value "foo" of type "object"');
|
||||
expect(() => coerceDate(true)).toThrow('Invalid date: expected Date, string, or number, but received value "true" of type "boolean"');
|
||||
});
|
||||
});
|
||||
});
|
||||
43
apps/mobile/src/modules/api/api.models.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
type DateKeys = 'createdAt' | 'updatedAt' | 'deletedAt' | 'expiresAt' | 'lastTriggeredAt' | 'lastUsedAt' | 'scheduledPurgeAt';
|
||||
|
||||
type CoerceDate<T> = T extends string | Date
|
||||
? Date
|
||||
: T extends string | Date | null | undefined
|
||||
? Date | undefined
|
||||
: T;
|
||||
|
||||
type CoerceDates<T> = {
|
||||
[K in keyof T]: K extends DateKeys ? CoerceDate<T[K]> : T[K];
|
||||
};
|
||||
|
||||
export function coerceDate(date: unknown): Date {
|
||||
if (date instanceof Date) {
|
||||
return date;
|
||||
}
|
||||
|
||||
if (typeof date === 'string' || typeof date === 'number') {
|
||||
return new Date(date);
|
||||
}
|
||||
|
||||
throw new Error(`Invalid date: expected Date, string, or number, but received value "${String(date)}" of type "${typeof date}"`);
|
||||
}
|
||||
|
||||
export function coerceDateOrUndefined(date: unknown): Date | undefined {
|
||||
if (date == null) {
|
||||
return undefined;
|
||||
}
|
||||
return coerceDate(date);
|
||||
}
|
||||
|
||||
export function coerceDates<T extends Record<string, unknown>>(obj: T): CoerceDates<T> {
|
||||
return {
|
||||
...obj,
|
||||
...('createdAt' in obj ? { createdAt: coerceDateOrUndefined(obj.createdAt) } : {}),
|
||||
...('updatedAt' in obj ? { updatedAt: coerceDateOrUndefined(obj.updatedAt) } : {}),
|
||||
...('deletedAt' in obj ? { deletedAt: coerceDateOrUndefined(obj.deletedAt) } : {}),
|
||||
...('expiresAt' in obj ? { expiresAt: coerceDateOrUndefined(obj.expiresAt) } : {}),
|
||||
...('lastTriggeredAt' in obj ? { lastTriggeredAt: coerceDateOrUndefined(obj.lastTriggeredAt) } : {}),
|
||||
...('lastUsedAt' in obj ? { lastUsedAt: coerceDateOrUndefined(obj.lastUsedAt) } : {}),
|
||||
...('scheduledPurgeAt' in obj ? { scheduledPurgeAt: coerceDateOrUndefined(obj.scheduledPurgeAt) } : {}),
|
||||
} as CoerceDates<T>;
|
||||
}
|
||||
12
apps/mobile/src/modules/api/http.client.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { FetchOptions, ResponseType } from 'ofetch';
|
||||
import { ofetch } from 'ofetch';
|
||||
|
||||
export { ResponseType };
|
||||
export type HttpClientOptions<R extends ResponseType = 'json'> = Omit<FetchOptions<R>, 'baseURL'> & { url: string; baseUrl?: string };
|
||||
|
||||
export async function httpClient<A, R extends ResponseType = 'json'>({ url, baseUrl, ...rest }: HttpClientOptions<R>) {
|
||||
return ofetch<A, R>(url, {
|
||||
baseURL: baseUrl,
|
||||
...rest,
|
||||
});
|
||||
}
|
||||
69
apps/mobile/src/modules/api/providers/api.provider.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import type { ApiClient } from '@/modules/api/api.client';
|
||||
import type { AuthClient } from '@/modules/auth/auth.client';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { createContext, useContext, useEffect, useState } from 'react';
|
||||
import { createApiClient } from '@/modules/api/api.client';
|
||||
import { createAuthClient } from '@/modules/auth/auth.client';
|
||||
import { configLocalStorage } from '@/modules/config/config.local-storage';
|
||||
|
||||
type ApiProviderProps = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
const AuthClientContext = createContext<AuthClient | undefined>(undefined);
|
||||
const ApiClientContext = createContext<ApiClient | undefined>(undefined);
|
||||
|
||||
export function ApiProvider({ children }: ApiProviderProps) {
|
||||
const [authClient, setAuthClient] = useState<AuthClient | undefined>(undefined);
|
||||
const [apiClient, setApiClient] = useState<ApiClient | undefined>(undefined);
|
||||
|
||||
const { data: baseUrl } = useQuery({
|
||||
queryKey: ['api-server-url'],
|
||||
queryFn: configLocalStorage.getApiServerBaseUrl,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (baseUrl == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const authClient = createAuthClient({ baseUrl });
|
||||
setAuthClient(() => authClient);
|
||||
|
||||
const apiClient = createApiClient({ baseUrl, getAuthCookie: () => authClient.getCookie() });
|
||||
setApiClient(() => apiClient);
|
||||
}, [baseUrl]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{ authClient && apiClient && (
|
||||
<AuthClientContext.Provider value={authClient}>
|
||||
<ApiClientContext.Provider value={apiClient}>
|
||||
{children}
|
||||
</ApiClientContext.Provider>
|
||||
</AuthClientContext.Provider>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuthClient(): AuthClient {
|
||||
const context = useContext(AuthClientContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error('useAuthClient must be used within ApiProvider');
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
export function useApiClient(): ApiClient {
|
||||
const context = useContext(ApiClientContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error('useApiClient must be used within ApiProvider');
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
24
apps/mobile/src/modules/api/providers/query.provider.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: 2,
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
gcTime: 1000 * 60 * 10, // 10 minutes
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
type QueryProviderProps = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export function QueryProvider({ children }: QueryProviderProps) {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
17
apps/mobile/src/modules/app/providers/app-providers.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { QueryProvider } from '../../api/providers/query.provider';
|
||||
import { AlertProvider } from '../../ui/providers/alert-provider';
|
||||
|
||||
type AppProvidersProps = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export function AppProviders({ children }: AppProvidersProps) {
|
||||
return (
|
||||
<QueryProvider>
|
||||
<AlertProvider>
|
||||
{children}
|
||||
</AlertProvider>
|
||||
</QueryProvider>
|
||||
);
|
||||
}
|
||||
22
apps/mobile/src/modules/auth/auth.client.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { expoClient } from '@better-auth/expo/client';
|
||||
import { createAuthClient as createBetterAuthClient } from 'better-auth/react';
|
||||
import Constants from 'expo-constants';
|
||||
import * as SecureStore from 'expo-secure-store';
|
||||
import { Platform } from 'react-native';
|
||||
|
||||
export type AuthClient = ReturnType<typeof createAuthClient>;
|
||||
|
||||
export function createAuthClient({ baseUrl}: { baseUrl: string }) {
|
||||
return createBetterAuthClient({
|
||||
baseURL: baseUrl,
|
||||
plugins: [
|
||||
expoClient({
|
||||
scheme: String(Constants.expoConfig?.scheme ?? 'papra'),
|
||||
storagePrefix: String(Constants.expoConfig?.scheme ?? 'papra'),
|
||||
storage: Platform.OS === 'web'
|
||||
? localStorage
|
||||
: SecureStore,
|
||||
}),
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { ThemeColors } from '@/modules/ui/theme.constants';
|
||||
import { router } from 'expo-router';
|
||||
import { StyleSheet, Text, TouchableOpacity } from 'react-native';
|
||||
import { Icon } from '@/modules/ui/components/icon';
|
||||
import { useThemeColor } from '@/modules/ui/providers/use-theme-color';
|
||||
|
||||
export function BackToServerSelectionButton() {
|
||||
const themeColors = useThemeColor();
|
||||
const styles = createStyles({ themeColors });
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={styles.backToServerButton}
|
||||
onPress={() => router.push('/config/server-selection')}
|
||||
>
|
||||
<Icon name="arrow-left" size={20} color={themeColors.mutedForeground} />
|
||||
<Text style={styles.backToServerText}>
|
||||
Select server
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
function createStyles({ themeColors }: { themeColors: ThemeColors }) {
|
||||
return StyleSheet.create({
|
||||
backToServerButton: {
|
||||
marginBottom: 16,
|
||||
alignSelf: 'flex-start',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
backgroundColor: themeColors.secondaryBackground,
|
||||
borderRadius: 8,
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: 14,
|
||||
borderWidth: 1,
|
||||
borderColor: themeColors.border,
|
||||
},
|
||||
backToServerText: {
|
||||
color: themeColors.mutedForeground,
|
||||
fontSize: 16,
|
||||
},
|
||||
});
|
||||
}
|
||||
346
apps/mobile/src/modules/auth/screens/login.screen.tsx
Normal file
@@ -0,0 +1,346 @@
|
||||
import type { ThemeColors } from '@/modules/ui/theme.constants';
|
||||
import { useForm } from '@tanstack/react-form';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import * as v from 'valibot';
|
||||
import { useAuthClient } from '@/modules/api/providers/api.provider';
|
||||
import { useAlert } from '@/modules/ui/providers/alert-provider';
|
||||
import { useThemeColor } from '@/modules/ui/providers/use-theme-color';
|
||||
import { useServerConfig } from '../../config/hooks/use-server-config';
|
||||
import { BackToServerSelectionButton } from '../components/back-to-server-selection';
|
||||
|
||||
const loginSchema = v.object({
|
||||
email: v.pipe(v.string(), v.email('Please enter a valid email')),
|
||||
password: v.pipe(v.string(), v.minLength(8, 'Password must be at least 8 characters')),
|
||||
});
|
||||
|
||||
export function LoginScreen() {
|
||||
const router = useRouter();
|
||||
const themeColors = useThemeColor();
|
||||
const authClient = useAuthClient();
|
||||
const { showAlert } = useAlert();
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const { data: serverConfig, isLoading: isLoadingConfig } = useServerConfig();
|
||||
|
||||
const form = useForm({
|
||||
defaultValues: {
|
||||
email: '',
|
||||
password: '',
|
||||
},
|
||||
validators: {
|
||||
onChange: loginSchema,
|
||||
},
|
||||
|
||||
onSubmit: async ({ value }) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const response = await authClient.signIn.email({ email: value.email, password: value.password, rememberMe: true });
|
||||
|
||||
if (response.error) {
|
||||
throw new Error(response.error.message);
|
||||
}
|
||||
|
||||
router.replace('/(app)/(with-organizations)/(tabs)/list');
|
||||
} catch (error) {
|
||||
showAlert({
|
||||
title: 'Login Failed',
|
||||
message: error instanceof Error ? error.message : 'An error occurred',
|
||||
});
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const handleSocialSignIn = async (provider: string) => {
|
||||
try {
|
||||
const response = await authClient.signIn.social({ provider, callbackURL: '/' });
|
||||
if (response.error) {
|
||||
throw Object.assign(new Error(response.error.message), response.error);
|
||||
}
|
||||
} catch (error) {
|
||||
showAlert({
|
||||
title: 'Sign In Failed',
|
||||
message: error instanceof Error ? error.message : 'An error occurred',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const authConfig = serverConfig?.config?.auth;
|
||||
const isEmailEnabled = authConfig?.providers?.email?.isEnabled ?? false;
|
||||
const isGoogleEnabled = authConfig?.providers?.google?.isEnabled ?? false;
|
||||
const isGithubEnabled = authConfig?.providers?.github?.isEnabled ?? false;
|
||||
const customProviders = authConfig?.providers?.customs ?? [];
|
||||
|
||||
const styles = createStyles({ themeColors });
|
||||
|
||||
if (isLoadingConfig) {
|
||||
return (
|
||||
<View style={[styles.container, styles.centerContent]}>
|
||||
<ActivityIndicator size="large" color={themeColors.primary} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
style={{ ...styles.container, paddingTop: insets.top }}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
>
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
<BackToServerSelectionButton />
|
||||
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>Welcome Back</Text>
|
||||
<Text style={styles.subtitle}>Sign in to your account</Text>
|
||||
</View>
|
||||
|
||||
{isEmailEnabled && (
|
||||
<View style={styles.formContainer}>
|
||||
<form.Field name="email">
|
||||
{field => (
|
||||
<View style={styles.fieldContainer}>
|
||||
<Text style={styles.label}>Email</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="you@example.com"
|
||||
placeholderTextColor={themeColors.mutedForeground}
|
||||
value={field.state.value}
|
||||
onChangeText={field.handleChange}
|
||||
onBlur={field.handleBlur}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
keyboardType="email-address"
|
||||
editable={!isSubmitting}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Field name="password">
|
||||
{field => (
|
||||
<View style={styles.fieldContainer}>
|
||||
<Text style={styles.label}>Password</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="Enter your password"
|
||||
placeholderTextColor={themeColors.mutedForeground}
|
||||
value={field.state.value}
|
||||
onChangeText={field.handleChange}
|
||||
onBlur={field.handleBlur}
|
||||
secureTextEntry
|
||||
editable={!isSubmitting}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.button, isSubmitting && styles.buttonDisabled]}
|
||||
onPress={async () => form.handleSubmit()}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting
|
||||
? (
|
||||
<ActivityIndicator color="#fff" />
|
||||
)
|
||||
: (
|
||||
<Text style={styles.buttonText}>Sign In</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{(isGoogleEnabled || isGithubEnabled || customProviders.length > 0) && (
|
||||
<>
|
||||
{isEmailEnabled && (
|
||||
<View style={styles.divider}>
|
||||
<View style={styles.dividerLine} />
|
||||
<Text style={styles.dividerText}>OR</Text>
|
||||
<View style={styles.dividerLine} />
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={styles.socialButtons}>
|
||||
{isGoogleEnabled && (
|
||||
<TouchableOpacity
|
||||
style={styles.socialButton}
|
||||
onPress={async () => handleSocialSignIn('google')}
|
||||
>
|
||||
<Text style={styles.socialButtonText}>Continue with Google</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
{isGithubEnabled && (
|
||||
<TouchableOpacity
|
||||
style={styles.socialButton}
|
||||
onPress={async () => handleSocialSignIn('github')}
|
||||
>
|
||||
<Text style={styles.socialButtonText}>Continue with GitHub</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
{customProviders.map(provider => (
|
||||
<TouchableOpacity
|
||||
key={provider.providerId}
|
||||
style={styles.socialButton}
|
||||
onPress={async () => handleSocialSignIn(provider.providerId)}
|
||||
>
|
||||
<Text style={styles.socialButtonText}>
|
||||
Continue with
|
||||
{' '}
|
||||
{provider.providerName}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
|
||||
{authConfig?.isRegistrationEnabled === true && (
|
||||
<TouchableOpacity
|
||||
style={styles.linkButton}
|
||||
onPress={() => router.push('/auth/signup')}
|
||||
>
|
||||
<Text style={styles.linkText}>
|
||||
Don't have an account? Sign up
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
|
||||
function createStyles({ themeColors }: { themeColors: ThemeColors }) {
|
||||
return StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: themeColors.background,
|
||||
},
|
||||
centerContent: {
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
scrollContent: {
|
||||
flexGrow: 1,
|
||||
padding: 24,
|
||||
},
|
||||
header: {
|
||||
marginBottom: 48,
|
||||
marginTop: 16,
|
||||
},
|
||||
title: {
|
||||
fontSize: 32,
|
||||
fontWeight: 'bold',
|
||||
color: themeColors.foreground,
|
||||
marginBottom: 8,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 16,
|
||||
color: themeColors.mutedForeground,
|
||||
},
|
||||
formContainer: {
|
||||
gap: 16,
|
||||
},
|
||||
fieldContainer: {
|
||||
gap: 8,
|
||||
},
|
||||
label: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: themeColors.foreground,
|
||||
},
|
||||
input: {
|
||||
height: 50,
|
||||
borderWidth: 1,
|
||||
borderColor: themeColors.border,
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 16,
|
||||
fontSize: 16,
|
||||
color: themeColors.foreground,
|
||||
backgroundColor: themeColors.secondaryBackground,
|
||||
},
|
||||
button: {
|
||||
height: 50,
|
||||
backgroundColor: themeColors.primary,
|
||||
borderRadius: 8,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginTop: 8,
|
||||
},
|
||||
buttonDisabled: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
buttonText: {
|
||||
color: themeColors.primaryForeground,
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
divider: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginVertical: 24,
|
||||
},
|
||||
dividerLine: {
|
||||
flex: 1,
|
||||
height: 1,
|
||||
backgroundColor: themeColors.border,
|
||||
},
|
||||
dividerText: {
|
||||
marginHorizontal: 16,
|
||||
color: themeColors.mutedForeground,
|
||||
fontSize: 14,
|
||||
},
|
||||
socialButtons: {
|
||||
gap: 12,
|
||||
},
|
||||
socialButton: {
|
||||
height: 50,
|
||||
borderWidth: 1,
|
||||
borderColor: themeColors.border,
|
||||
borderRadius: 8,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: themeColors.secondaryBackground,
|
||||
},
|
||||
socialButtonText: {
|
||||
color: themeColors.foreground,
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
},
|
||||
linkButton: {
|
||||
marginTop: 24,
|
||||
alignItems: 'center',
|
||||
},
|
||||
linkText: {
|
||||
color: themeColors.primary,
|
||||
fontSize: 14,
|
||||
},
|
||||
backToServerButton: {
|
||||
marginBottom: 16,
|
||||
alignSelf: 'flex-start',
|
||||
},
|
||||
backToServerText: {
|
||||
color: themeColors.primary,
|
||||
fontSize: 14,
|
||||
},
|
||||
});
|
||||
}
|
||||
293
apps/mobile/src/modules/auth/screens/signup.screen.tsx
Normal file
@@ -0,0 +1,293 @@
|
||||
import type { ThemeColors } from '@/modules/ui/theme.constants';
|
||||
import { useForm } from '@tanstack/react-form';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import * as v from 'valibot';
|
||||
import { useAuthClient } from '@/modules/api/providers/api.provider';
|
||||
import { useAlert } from '@/modules/ui/providers/alert-provider';
|
||||
import { useThemeColor } from '@/modules/ui/providers/use-theme-color';
|
||||
import { useServerConfig } from '../../config/hooks/use-server-config';
|
||||
import { BackToServerSelectionButton } from '../components/back-to-server-selection';
|
||||
|
||||
const signupSchema = v.object({
|
||||
name: v.pipe(v.string(), v.minLength(1, 'Name is required')),
|
||||
email: v.pipe(v.string(), v.email('Please enter a valid email')),
|
||||
password: v.pipe(v.string(), v.minLength(8, 'Password must be at least 8 characters')),
|
||||
});
|
||||
|
||||
export function SignupScreen() {
|
||||
const router = useRouter();
|
||||
const themeColors = useThemeColor();
|
||||
const authClient = useAuthClient();
|
||||
const { showAlert } = useAlert();
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const { data: serverConfig, isLoading: isLoadingConfig } = useServerConfig();
|
||||
|
||||
const form = useForm({
|
||||
defaultValues: {
|
||||
name: '',
|
||||
email: '',
|
||||
password: '',
|
||||
},
|
||||
validators: {
|
||||
onChange: signupSchema,
|
||||
},
|
||||
onSubmit: async ({ value }) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const { name, email, password } = value;
|
||||
|
||||
await authClient.signUp.email({ name, email, password });
|
||||
|
||||
const isEmailVerificationRequired = serverConfig?.config?.auth?.isEmailVerificationRequired ?? false;
|
||||
|
||||
if (isEmailVerificationRequired) {
|
||||
showAlert({
|
||||
title: 'Check your email',
|
||||
message: 'We sent you a verification link. Please check your email to verify your account.',
|
||||
buttons: [{ text: 'OK', onPress: () => router.replace('/auth/login') }],
|
||||
});
|
||||
} else {
|
||||
router.replace('/(app)/(with-organizations)/(tabs)/list');
|
||||
}
|
||||
} catch (error) {
|
||||
showAlert({
|
||||
title: 'Signup Failed',
|
||||
message: error instanceof Error ? error.message : 'An error occurred',
|
||||
});
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const authConfig = serverConfig?.config?.auth;
|
||||
const isRegistrationEnabled = authConfig?.isRegistrationEnabled ?? false;
|
||||
|
||||
const styles = createStyles({ themeColors });
|
||||
|
||||
if (isLoadingConfig) {
|
||||
return (
|
||||
<View style={[styles.container, styles.centerContent]}>
|
||||
<ActivityIndicator size="large" color={themeColors.primary} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isRegistrationEnabled) {
|
||||
return (
|
||||
<View style={[styles.container, styles.centerContent]}>
|
||||
<Text style={styles.errorText}>Registration is currently disabled</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.linkButton}
|
||||
onPress={() => router.back()}
|
||||
>
|
||||
<Text style={styles.linkText}>Go back to login</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
style={{ ...styles.container, paddingTop: insets.top }}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
>
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
<BackToServerSelectionButton />
|
||||
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>Create Account</Text>
|
||||
<Text style={styles.subtitle}>Sign up to get started</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.formContainer}>
|
||||
<form.Field name="name">
|
||||
{field => (
|
||||
<View style={styles.fieldContainer}>
|
||||
<Text style={styles.label}>Name</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="Your name"
|
||||
placeholderTextColor={themeColors.mutedForeground}
|
||||
value={field.state.value}
|
||||
onChangeText={field.handleChange}
|
||||
onBlur={field.handleBlur}
|
||||
autoCapitalize="words"
|
||||
editable={!isSubmitting}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Field name="email">
|
||||
{field => (
|
||||
<View style={styles.fieldContainer}>
|
||||
<Text style={styles.label}>Email</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="you@example.com"
|
||||
placeholderTextColor={themeColors.mutedForeground}
|
||||
value={field.state.value}
|
||||
onChangeText={field.handleChange}
|
||||
onBlur={field.handleBlur}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
keyboardType="email-address"
|
||||
editable={!isSubmitting}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Field name="password">
|
||||
{field => (
|
||||
<View style={styles.fieldContainer}>
|
||||
<Text style={styles.label}>Password</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="At least 8 characters"
|
||||
placeholderTextColor={themeColors.mutedForeground}
|
||||
value={field.state.value}
|
||||
onChangeText={field.handleChange}
|
||||
onBlur={field.handleBlur}
|
||||
secureTextEntry
|
||||
editable={!isSubmitting}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.button, isSubmitting && styles.buttonDisabled]}
|
||||
onPress={async () => form.handleSubmit()}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting
|
||||
? (
|
||||
<ActivityIndicator color="#fff" />
|
||||
)
|
||||
: (
|
||||
<Text style={styles.buttonText}>Sign Up</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.linkButton}
|
||||
onPress={() => router.back()}
|
||||
>
|
||||
<Text style={styles.linkText}>Already have an account? Sign in</Text>
|
||||
</TouchableOpacity>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
|
||||
function createStyles({ themeColors }: { themeColors: ThemeColors }) {
|
||||
return StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: themeColors.background,
|
||||
},
|
||||
centerContent: {
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: 24,
|
||||
},
|
||||
scrollContent: {
|
||||
flexGrow: 1,
|
||||
padding: 24,
|
||||
},
|
||||
header: {
|
||||
marginBottom: 48,
|
||||
marginTop: 16,
|
||||
},
|
||||
title: {
|
||||
fontSize: 32,
|
||||
fontWeight: 'bold',
|
||||
color: themeColors.foreground,
|
||||
marginBottom: 8,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 16,
|
||||
color: themeColors.mutedForeground,
|
||||
},
|
||||
formContainer: {
|
||||
gap: 16,
|
||||
},
|
||||
fieldContainer: {
|
||||
gap: 8,
|
||||
},
|
||||
label: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: themeColors.foreground,
|
||||
},
|
||||
input: {
|
||||
height: 50,
|
||||
borderWidth: 1,
|
||||
borderColor: themeColors.border,
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 16,
|
||||
fontSize: 16,
|
||||
color: themeColors.foreground,
|
||||
backgroundColor: themeColors.secondaryBackground,
|
||||
},
|
||||
button: {
|
||||
height: 50,
|
||||
backgroundColor: themeColors.primary,
|
||||
borderRadius: 8,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginTop: 8,
|
||||
},
|
||||
buttonDisabled: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
buttonText: {
|
||||
color: themeColors.primaryForeground,
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
linkButton: {
|
||||
marginTop: 24,
|
||||
alignItems: 'center',
|
||||
},
|
||||
linkText: {
|
||||
color: themeColors.primary,
|
||||
fontSize: 14,
|
||||
},
|
||||
errorText: {
|
||||
fontSize: 16,
|
||||
color: themeColors.primary,
|
||||
marginBottom: 16,
|
||||
textAlign: 'center',
|
||||
},
|
||||
backToServerButton: {
|
||||
marginBottom: 16,
|
||||
alignSelf: 'flex-start',
|
||||
},
|
||||
backToServerText: {
|
||||
color: themeColors.primary,
|
||||
fontSize: 14,
|
||||
},
|
||||
});
|
||||
}
|
||||
1
apps/mobile/src/modules/config/config.constants.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const MANAGED_SERVER_URL = 'https://api.papra.app';
|
||||
9
apps/mobile/src/modules/config/config.local-storage.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { buildStorageKey } from '../lib/local-storage/local-storage.models';
|
||||
import { storage } from '../lib/local-storage/local-storage.services';
|
||||
|
||||
const CONFIG_API_SERVER_URL_KEY = buildStorageKey(['config', 'api-server-url']);
|
||||
|
||||
export const configLocalStorage = {
|
||||
getApiServerBaseUrl: async () => storage.getItem(CONFIG_API_SERVER_URL_KEY),
|
||||
setApiServerBaseUrl: async ({ apiServerBaseUrl}: { apiServerBaseUrl: string }) => storage.setItem(CONFIG_API_SERVER_URL_KEY, apiServerBaseUrl),
|
||||
};
|
||||
44
apps/mobile/src/modules/config/config.services.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { ApiClient } from '../api/api.client';
|
||||
import { httpClient } from '../api/http.client';
|
||||
|
||||
export async function fetchServerConfig({ apiClient}: { apiClient: ApiClient }) {
|
||||
return apiClient<{
|
||||
config: {
|
||||
auth: {
|
||||
isEmailVerificationRequired: boolean;
|
||||
isPasswordResetEnabled: boolean;
|
||||
isRegistrationEnabled: boolean;
|
||||
showLegalLinksOnAuthPage: boolean;
|
||||
providers: {
|
||||
email: {
|
||||
isEnabled: boolean;
|
||||
};
|
||||
github: {
|
||||
isEnabled: boolean;
|
||||
};
|
||||
google: {
|
||||
isEnabled: boolean;
|
||||
};
|
||||
customs: {
|
||||
providerId: string;
|
||||
providerName: string;
|
||||
}[];
|
||||
};
|
||||
};
|
||||
};
|
||||
}>({
|
||||
path: '/api/config',
|
||||
});
|
||||
}
|
||||
|
||||
export async function pingServer({ url}: { url: string }): Promise<true | never> {
|
||||
const response = await httpClient<{ status: 'ok' | 'error' }>({ url: `/api/ping`, baseUrl: url })
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
|
||||
if (!response) {
|
||||
throw new Error('Could not reach the server');
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
12
apps/mobile/src/modules/config/hooks/use-server-config.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useApiClient } from '@/modules/api/providers/api.provider';
|
||||
import { fetchServerConfig } from '../config.services';
|
||||
|
||||
export function useServerConfig() {
|
||||
const apiClient = useApiClient();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['server', 'config'],
|
||||
queryFn: async () => fetchServerConfig({ apiClient }),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
import type { ThemeColors } from '@/modules/ui/theme.constants';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { queryClient } from '@/modules/api/providers/query.provider';
|
||||
import { useAlert } from '@/modules/ui/providers/alert-provider';
|
||||
import { useThemeColor } from '@/modules/ui/providers/use-theme-color';
|
||||
import { MANAGED_SERVER_URL } from '../config.constants';
|
||||
import { configLocalStorage } from '../config.local-storage';
|
||||
import { pingServer } from '../config.services';
|
||||
|
||||
function getDefaultCustomServerUrl() {
|
||||
if (!__DEV__) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// eslint-disable-next-line node/prefer-global/process
|
||||
return process.env.EXPO_PUBLIC_API_URL ?? '';
|
||||
}
|
||||
|
||||
export function ServerSelectionScreen() {
|
||||
const router = useRouter();
|
||||
const themeColors = useThemeColor();
|
||||
const { showAlert } = useAlert();
|
||||
const styles = createStyles({ themeColors });
|
||||
|
||||
const [selectedOption, setSelectedOption] = useState<'managed' | 'self-hosted'>('managed');
|
||||
const [customUrl, setCustomUrl] = useState(getDefaultCustomServerUrl());
|
||||
const [isValidating, setIsValidating] = useState(false);
|
||||
|
||||
const handleValidateCustomUrl = async ({ url}: { url: string }) => {
|
||||
setIsValidating(true);
|
||||
try {
|
||||
await pingServer({ url });
|
||||
await configLocalStorage.setApiServerBaseUrl({ apiServerBaseUrl: url });
|
||||
await queryClient.invalidateQueries({ queryKey: ['api-server-url'] });
|
||||
|
||||
router.replace('/auth/login');
|
||||
} catch {
|
||||
showAlert({
|
||||
title: 'Connection Failed',
|
||||
message: 'Could not reach the server.',
|
||||
});
|
||||
} finally {
|
||||
setIsValidating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
style={styles.container}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
>
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>Welcome to Papra</Text>
|
||||
<Text style={styles.subtitle}>Choose your server</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.options}>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.optionCard,
|
||||
selectedOption === 'managed' && styles.optionCardSelected,
|
||||
]}
|
||||
onPress={() => setSelectedOption('managed')}
|
||||
disabled={isValidating}
|
||||
>
|
||||
<Text style={styles.optionTitle}>Managed Cloud</Text>
|
||||
<Text style={styles.optionDescription}>
|
||||
Use the official Papra cloud service
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.optionCard,
|
||||
selectedOption === 'self-hosted' && styles.optionCardSelected,
|
||||
]}
|
||||
onPress={() => setSelectedOption('self-hosted')}
|
||||
disabled={isValidating}
|
||||
>
|
||||
<Text style={styles.optionTitle}>Self-Hosted</Text>
|
||||
<Text style={styles.optionDescription}>
|
||||
Connect to your own Papra server
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{selectedOption === 'managed' && (
|
||||
<TouchableOpacity
|
||||
style={[styles.button, isValidating && styles.buttonDisabled]}
|
||||
onPress={async () => handleValidateCustomUrl({ url: MANAGED_SERVER_URL })}
|
||||
disabled={isValidating}
|
||||
>
|
||||
{isValidating
|
||||
? (
|
||||
<ActivityIndicator color="#fff" />
|
||||
)
|
||||
: (
|
||||
<Text style={styles.buttonText}>Continue with Managed</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
{selectedOption === 'self-hosted' && (
|
||||
<View style={styles.customUrlContainer}>
|
||||
<Text style={styles.inputLabel}>Server URL</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="https://your-server.com"
|
||||
placeholderTextColor={themeColors.mutedForeground}
|
||||
value={customUrl}
|
||||
onChangeText={setCustomUrl}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
keyboardType="url"
|
||||
editable={!isValidating}
|
||||
/>
|
||||
<TouchableOpacity
|
||||
style={[styles.button, isValidating && styles.buttonDisabled]}
|
||||
onPress={async () => handleValidateCustomUrl({ url: customUrl })}
|
||||
disabled={isValidating}
|
||||
>
|
||||
{isValidating
|
||||
? (
|
||||
<ActivityIndicator color="#fff" />
|
||||
)
|
||||
: (
|
||||
<Text style={styles.buttonText}>Connect</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
|
||||
function createStyles({ themeColors }: { themeColors: ThemeColors }) {
|
||||
return StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: themeColors.background,
|
||||
},
|
||||
scrollContent: {
|
||||
flexGrow: 1,
|
||||
padding: 24,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
header: {
|
||||
marginBottom: 40,
|
||||
alignItems: 'center',
|
||||
},
|
||||
title: {
|
||||
fontSize: 32,
|
||||
fontWeight: 'bold',
|
||||
color: themeColors.foreground,
|
||||
marginBottom: 8,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 16,
|
||||
color: themeColors.mutedForeground,
|
||||
},
|
||||
options: {
|
||||
gap: 16,
|
||||
marginBottom: 24,
|
||||
},
|
||||
optionCard: {
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 14,
|
||||
borderRadius: 12,
|
||||
borderWidth: 2,
|
||||
borderColor: themeColors.border,
|
||||
backgroundColor: themeColors.secondaryBackground,
|
||||
},
|
||||
optionCardSelected: {
|
||||
borderColor: themeColors.primary,
|
||||
},
|
||||
optionTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
color: themeColors.foreground,
|
||||
margin: 0,
|
||||
},
|
||||
optionDescription: {
|
||||
fontSize: 14,
|
||||
color: themeColors.mutedForeground,
|
||||
},
|
||||
customUrlContainer: {
|
||||
gap: 12,
|
||||
},
|
||||
inputLabel: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: themeColors.foreground,
|
||||
marginBottom: 4,
|
||||
},
|
||||
input: {
|
||||
height: 50,
|
||||
borderWidth: 1,
|
||||
borderColor: themeColors.border,
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 16,
|
||||
fontSize: 16,
|
||||
color: themeColors.foreground,
|
||||
backgroundColor: themeColors.secondaryBackground,
|
||||
},
|
||||
button: {
|
||||
height: 50,
|
||||
backgroundColor: themeColors.primary,
|
||||
borderRadius: 8,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
buttonDisabled: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
buttonText: {
|
||||
color: themeColors.primaryForeground,
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
}
|
||||
233
apps/mobile/src/modules/documents/components/import-drawer.tsx
Normal file
@@ -0,0 +1,233 @@
|
||||
import type { ThemeColors } from '@/modules/ui/theme.constants';
|
||||
import * as DocumentPicker from 'expo-document-picker';
|
||||
import { File } from 'expo-file-system';
|
||||
import {
|
||||
Modal,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { useApiClient } from '@/modules/api/providers/api.provider';
|
||||
import { queryClient } from '@/modules/api/providers/query.provider';
|
||||
import { useOrganizations } from '@/modules/organizations/organizations.provider';
|
||||
import { Icon } from '@/modules/ui/components/icon';
|
||||
import { useAlert } from '@/modules/ui/providers/alert-provider';
|
||||
import { useThemeColor } from '@/modules/ui/providers/use-theme-color';
|
||||
import { uploadDocument } from '../documents.services';
|
||||
|
||||
type ImportDrawerProps = {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export function ImportDrawer({ visible, onClose }: ImportDrawerProps) {
|
||||
const themeColors = useThemeColor();
|
||||
const { showAlert } = useAlert();
|
||||
const styles = createStyles({ themeColors });
|
||||
const apiClient = useApiClient();
|
||||
const { currentOrganizationId } = useOrganizations();
|
||||
|
||||
const handleImportFromFiles = async () => {
|
||||
onClose();
|
||||
|
||||
try {
|
||||
if (currentOrganizationId == null) {
|
||||
showAlert({
|
||||
title: 'No Organization Selected',
|
||||
message: 'Please select an organization before importing documents.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await DocumentPicker.getDocumentAsync({
|
||||
type: [
|
||||
'application/pdf',
|
||||
'image/*',
|
||||
'application/msword',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'application/vnd.ms-excel',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'text/plain',
|
||||
],
|
||||
copyToCacheDirectory: true,
|
||||
multiple: false,
|
||||
});
|
||||
|
||||
if (result.canceled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [pickerFile] = result.assets;
|
||||
if (!pickerFile) {
|
||||
return;
|
||||
}
|
||||
|
||||
const file = new File(pickerFile.uri);
|
||||
|
||||
await uploadDocument({ file, apiClient, organizationId: currentOrganizationId });
|
||||
await queryClient.invalidateQueries({ queryKey: ['organizations', currentOrganizationId, 'documents'] });
|
||||
|
||||
showAlert({
|
||||
title: 'Upload Successful',
|
||||
message: `Successfully uploaded: ${file.name}`,
|
||||
});
|
||||
} catch (error) {
|
||||
showAlert({
|
||||
title: 'Error',
|
||||
message: error instanceof Error ? error.message : 'Failed to pick document',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// const handleScanDocument = () => {
|
||||
// onClose();
|
||||
// showAlert({
|
||||
// title: 'Coming Soon',
|
||||
// message: 'Camera document scanning will be available soon!',
|
||||
// });
|
||||
// };
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
transparent
|
||||
animationType="slide"
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
<TouchableOpacity
|
||||
style={styles.backdrop}
|
||||
activeOpacity={1}
|
||||
onPress={onClose}
|
||||
>
|
||||
<View style={styles.drawer}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>Import Document</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.optionsContainer}>
|
||||
<TouchableOpacity
|
||||
style={styles.optionItem}
|
||||
onPress={handleImportFromFiles}
|
||||
>
|
||||
<View style={styles.optionIconContainer}>
|
||||
<Icon name="file-plus" size={24} style={styles.optionIcon} />
|
||||
</View>
|
||||
<View style={styles.optionTextContainer}>
|
||||
<Text style={styles.optionTitle}>Import from Files</Text>
|
||||
<Text style={styles.optionDescription}>
|
||||
Choose a document from your device
|
||||
</Text>
|
||||
</View>
|
||||
<Icon name="chevron-right" size={18} style={styles.chevronIcon} />
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* <TouchableOpacity
|
||||
style={styles.optionItem}
|
||||
onPress={handleScanDocument}
|
||||
>
|
||||
<View style={styles.optionIconContainer}>
|
||||
<Icon name="camera" size={24} style={styles.optionIcon} />
|
||||
</View>
|
||||
<View style={styles.optionTextContainer}>
|
||||
<Text style={styles.optionTitle}>Scan Document</Text>
|
||||
<Text style={styles.optionDescription}>
|
||||
Use camera to scan (Coming soon)
|
||||
</Text>
|
||||
</View>
|
||||
<Icon name="chevron-right" size={18} style={styles.chevronIcon} />
|
||||
</TouchableOpacity> */}
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.cancelButton}
|
||||
onPress={onClose}
|
||||
>
|
||||
<Text style={styles.cancelButtonText}>Cancel</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function createStyles({ themeColors }: { themeColors: ThemeColors }) {
|
||||
return StyleSheet.create({
|
||||
backdrop: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
justifyContent: 'flex-end',
|
||||
},
|
||||
drawer: {
|
||||
backgroundColor: themeColors.background,
|
||||
borderTopLeftRadius: 20,
|
||||
borderTopRightRadius: 20,
|
||||
paddingBottom: 20,
|
||||
},
|
||||
header: {
|
||||
padding: 20,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: themeColors.border,
|
||||
},
|
||||
title: {
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
color: themeColors.foreground,
|
||||
},
|
||||
optionsContainer: {
|
||||
paddingVertical: 8,
|
||||
},
|
||||
optionItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 16,
|
||||
paddingHorizontal: 20,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: themeColors.border,
|
||||
},
|
||||
optionIconContainer: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 24,
|
||||
backgroundColor: themeColors.secondaryBackground,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginRight: 16,
|
||||
},
|
||||
optionIcon: {
|
||||
fontSize: 24,
|
||||
color: themeColors.primary,
|
||||
},
|
||||
optionTextContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
optionTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: themeColors.foreground,
|
||||
marginBottom: 4,
|
||||
},
|
||||
optionDescription: {
|
||||
fontSize: 14,
|
||||
color: themeColors.mutedForeground,
|
||||
},
|
||||
chevronIcon: {
|
||||
fontSize: 18,
|
||||
color: themeColors.mutedForeground,
|
||||
},
|
||||
cancelButton: {
|
||||
margin: 20,
|
||||
marginTop: 12,
|
||||
paddingVertical: 14,
|
||||
borderWidth: 1,
|
||||
borderColor: themeColors.border,
|
||||
borderRadius: 8,
|
||||
alignItems: 'center',
|
||||
},
|
||||
cancelButtonText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: themeColors.foreground,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { BottomTabBarButtonProps } from '@react-navigation/bottom-tabs';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import { useState } from 'react';
|
||||
import { Pressable, StyleSheet, View } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { ImportDrawer } from '@/modules/documents/components/import-drawer';
|
||||
import { Icon } from '@/modules/ui/components/icon';
|
||||
import { useThemeColor } from '@/modules/ui/providers/use-theme-color';
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
button: {
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: 28,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: 2,
|
||||
},
|
||||
shadowOpacity: 0.25,
|
||||
shadowRadius: 3.84,
|
||||
elevation: 5,
|
||||
},
|
||||
});
|
||||
|
||||
export function ImportTabButton(props: BottomTabBarButtonProps) {
|
||||
const [isDrawerVisible, setIsDrawerVisible] = useState(false);
|
||||
const themeColors = useThemeColor();
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
const handlePress = () => {
|
||||
void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
||||
setIsDrawerVisible(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Pressable
|
||||
onPress={handlePress}
|
||||
style={[styles.container, props.style]}
|
||||
>
|
||||
<View style={[styles.button, { backgroundColor: themeColors.primary, marginBottom: 20 + insets.bottom }]}>
|
||||
<Icon name="plus" size={32} color={themeColors.primaryForeground} style={{ height: 32 }} />
|
||||
</View>
|
||||
</Pressable>
|
||||
|
||||
<ImportDrawer
|
||||
visible={isDrawerVisible}
|
||||
onClose={() => setIsDrawerVisible(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
74
apps/mobile/src/modules/documents/documents.services.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import type { ApiClient } from '../api/api.client';
|
||||
import type { Document } from './documents.types';
|
||||
import { coerceDates } from '../api/api.models';
|
||||
|
||||
export function getFormData(pojo: Record<string, string | Blob>): FormData {
|
||||
const formData = new FormData();
|
||||
Object.entries(pojo).forEach(([key, value]) => formData.append(key, value));
|
||||
return formData;
|
||||
}
|
||||
|
||||
export async function uploadDocument({
|
||||
file,
|
||||
organizationId,
|
||||
|
||||
apiClient,
|
||||
}: {
|
||||
file: Blob;
|
||||
organizationId: string;
|
||||
|
||||
apiClient: ApiClient;
|
||||
}) {
|
||||
const { document } = await apiClient<{ document: Document }>({
|
||||
method: 'POST',
|
||||
path: `/api/organizations/${organizationId}/documents`,
|
||||
body: getFormData({ file }),
|
||||
});
|
||||
|
||||
return {
|
||||
document: coerceDates(document),
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchOrganizationDocuments({
|
||||
organizationId,
|
||||
pageIndex,
|
||||
pageSize,
|
||||
filters,
|
||||
|
||||
apiClient,
|
||||
}: {
|
||||
organizationId: string;
|
||||
pageIndex: number;
|
||||
pageSize: number;
|
||||
filters?: {
|
||||
tags?: string[];
|
||||
};
|
||||
|
||||
apiClient: ApiClient;
|
||||
}) {
|
||||
const {
|
||||
documents: apiDocuments,
|
||||
documentsCount,
|
||||
} = await apiClient<{ documents: Document[]; documentsCount: number }>({
|
||||
method: 'GET',
|
||||
path: `/api/organizations/${organizationId}/documents`,
|
||||
query: {
|
||||
pageIndex,
|
||||
pageSize,
|
||||
...filters,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const documents = apiDocuments.map(coerceDates);
|
||||
|
||||
return {
|
||||
documentsCount,
|
||||
documents,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error fetching documents:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
14
apps/mobile/src/modules/documents/documents.types.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export type Document = {
|
||||
id: string;
|
||||
name: string;
|
||||
mimeType: string;
|
||||
originalSize: number;
|
||||
organizationId: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
tags: {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
}[];
|
||||
};
|
||||
@@ -0,0 +1,240 @@
|
||||
import type { ThemeColors } from '@/modules/ui/theme.constants';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
FlatList,
|
||||
RefreshControl,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useApiClient } from '@/modules/api/providers/api.provider';
|
||||
import { OrganizationPickerButton } from '@/modules/organizations/components/organization-picker-button';
|
||||
import { OrganizationPickerDrawer } from '@/modules/organizations/components/organization-picker-drawer';
|
||||
import { useOrganizations } from '@/modules/organizations/organizations.provider';
|
||||
import { Icon } from '@/modules/ui/components/icon';
|
||||
import { useThemeColor } from '@/modules/ui/providers/use-theme-color';
|
||||
import { fetchOrganizationDocuments } from '../documents.services';
|
||||
|
||||
export function DocumentsListScreen() {
|
||||
const themeColors = useThemeColor();
|
||||
const apiClient = useApiClient();
|
||||
const { currentOrganizationId, isLoading: isLoadingOrganizations } = useOrganizations();
|
||||
const [isDrawerVisible, setIsDrawerVisible] = useState(false);
|
||||
const pagination = { pageIndex: 0, pageSize: 20 };
|
||||
|
||||
const documentsQuery = useQuery({
|
||||
queryKey: ['organizations', currentOrganizationId, 'documents', pagination],
|
||||
queryFn: async () => {
|
||||
if (currentOrganizationId == null) {
|
||||
return { documents: [], documentsCount: 0 };
|
||||
}
|
||||
|
||||
return fetchOrganizationDocuments({
|
||||
organizationId: currentOrganizationId,
|
||||
...pagination,
|
||||
apiClient,
|
||||
});
|
||||
},
|
||||
enabled: currentOrganizationId !== null && currentOrganizationId !== '',
|
||||
});
|
||||
|
||||
const styles = createStyles({ themeColors });
|
||||
|
||||
const formatDate = (date: Date) => {
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
}).format(date);
|
||||
};
|
||||
|
||||
const formatFileSize = (bytes: number) => {
|
||||
if (bytes < 1024) {
|
||||
return `${bytes} B`;
|
||||
}
|
||||
if (bytes < 1024 * 1024) {
|
||||
return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
}
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
};
|
||||
|
||||
const onRefresh = async () => {
|
||||
await documentsQuery.refetch();
|
||||
};
|
||||
|
||||
if (isLoadingOrganizations) {
|
||||
return (
|
||||
<View style={[styles.container, styles.centerContent]}>
|
||||
<ActivityIndicator size="large" color={themeColors.primary} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>Documents</Text>
|
||||
<OrganizationPickerButton onPress={() => setIsDrawerVisible(true)} />
|
||||
</View>
|
||||
|
||||
{documentsQuery.isLoading
|
||||
? (
|
||||
<View style={styles.centerContent}>
|
||||
<ActivityIndicator size="large" color={themeColors.primary} />
|
||||
</View>
|
||||
)
|
||||
: (
|
||||
<FlatList
|
||||
data={documentsQuery.data?.documents ?? []}
|
||||
keyExtractor={item => item.id}
|
||||
renderItem={({ item }) => (
|
||||
<View style={styles.documentCard}>
|
||||
<View style={{ backgroundColor: themeColors.muted, padding: 10, borderRadius: 6, marginRight: 12 }}>
|
||||
<Icon name="file-text" size={24} color={themeColors.primary} />
|
||||
</View>
|
||||
<View>
|
||||
<Text style={styles.documentTitle} numberOfLines={2}>
|
||||
{item.name}
|
||||
</Text>
|
||||
<View style={styles.documentMeta}>
|
||||
<Text style={styles.metaText}>{formatFileSize(item.originalSize)}</Text>
|
||||
<Text style={styles.metaSplitter}>-</Text>
|
||||
<Text style={styles.metaText}>{formatDate(item.createdAt)}</Text>
|
||||
{item.tags.length > 0 && (
|
||||
<View style={styles.tagsContainer}>
|
||||
{item.tags.map(tag => (
|
||||
<View
|
||||
key={tag.id}
|
||||
style={[
|
||||
styles.tag,
|
||||
{ backgroundColor: `${tag.color}10` },
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.tagText, { color: tag.color }]}>
|
||||
{tag.name}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
ListEmptyComponent={(
|
||||
<View style={styles.emptyContainer}>
|
||||
<Text style={styles.emptyText}>No documents yet</Text>
|
||||
<Text style={styles.emptySubtext}>
|
||||
Upload your first document to get started
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
contentContainerStyle={documentsQuery.data?.documents.length === 0 ? styles.emptyList : undefined}
|
||||
refreshControl={(
|
||||
<RefreshControl
|
||||
refreshing={documentsQuery.isRefetching}
|
||||
onRefresh={onRefresh}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<OrganizationPickerDrawer
|
||||
visible={isDrawerVisible}
|
||||
onClose={() => setIsDrawerVisible(false)}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
function createStyles({ themeColors }: { themeColors: ThemeColors }) {
|
||||
return StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: themeColors.background,
|
||||
},
|
||||
centerContent: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
header: {
|
||||
padding: 16,
|
||||
paddingTop: 20,
|
||||
gap: 12,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: themeColors.border,
|
||||
},
|
||||
title: {
|
||||
fontSize: 28,
|
||||
fontWeight: 'bold',
|
||||
color: themeColors.foreground,
|
||||
},
|
||||
emptyList: {
|
||||
flex: 1,
|
||||
},
|
||||
emptyContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 60,
|
||||
},
|
||||
emptyText: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
color: themeColors.foreground,
|
||||
marginBottom: 8,
|
||||
},
|
||||
emptySubtext: {
|
||||
fontSize: 14,
|
||||
color: themeColors.mutedForeground,
|
||||
},
|
||||
documentCard: {
|
||||
padding: 16,
|
||||
borderBottomWidth: 1,
|
||||
borderColor: themeColors.border,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
documentTitle: {
|
||||
flex: 1,
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: themeColors.foreground,
|
||||
marginRight: 12,
|
||||
},
|
||||
documentMeta: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
marginTop: 4,
|
||||
},
|
||||
metaText: {
|
||||
fontSize: 13,
|
||||
color: themeColors.mutedForeground,
|
||||
},
|
||||
metaSplitter: {
|
||||
fontSize: 13,
|
||||
color: themeColors.mutedForeground,
|
||||
},
|
||||
tagsContainer: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: 8,
|
||||
},
|
||||
tag: {
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 6,
|
||||
},
|
||||
tagText: {
|
||||
fontSize: 12,
|
||||
fontWeight: '500',
|
||||
lineHeight: 12,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export const STORAGE_KEY_BASE_PREFIX = '@papra';
|
||||
@@ -0,0 +1,5 @@
|
||||
import { STORAGE_KEY_BASE_PREFIX } from './local-storage.constants';
|
||||
|
||||
export function buildStorageKey(sections: string[]): string {
|
||||
return [STORAGE_KEY_BASE_PREFIX, ...sections].join(':');
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
|
||||
export const storage = {
|
||||
getItem: async (key: string) => AsyncStorage.getItem(key),
|
||||
setItem: async (key: string, value: string) => AsyncStorage.setItem(key, value),
|
||||
removeItem: async (key: string) => AsyncStorage.removeItem(key),
|
||||
clear: async () => AsyncStorage.clear(),
|
||||
};
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { ThemeColors } from '@/modules/ui/theme.constants';
|
||||
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import { Icon } from '@/modules/ui/components/icon';
|
||||
import { useThemeColor } from '@/modules/ui/providers/use-theme-color';
|
||||
import { useOrganizations } from '../organizations.provider';
|
||||
|
||||
type OrganizationPickerButtonProps = {
|
||||
onPress: () => void;
|
||||
};
|
||||
|
||||
export function OrganizationPickerButton({ onPress }: OrganizationPickerButtonProps) {
|
||||
const themeColors = useThemeColor();
|
||||
const { organizations, currentOrganizationId } = useOrganizations();
|
||||
|
||||
const styles = createStyles({ themeColors });
|
||||
|
||||
const currentOrganization = organizations.find(org => org.id === currentOrganizationId);
|
||||
|
||||
return (
|
||||
<TouchableOpacity style={styles.button} onPress={onPress}>
|
||||
<View style={styles.content}>
|
||||
<Text style={styles.orgName} numberOfLines={1}>
|
||||
{currentOrganization?.name ?? 'Select Organization'}
|
||||
</Text>
|
||||
</View>
|
||||
<Icon name="chevron-down" style={styles.caret} size={20} />
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
function createStyles({ themeColors }: { themeColors: ThemeColors }) {
|
||||
return StyleSheet.create({
|
||||
button: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 16,
|
||||
backgroundColor: themeColors.secondaryBackground,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
borderColor: themeColors.border,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
marginRight: 8,
|
||||
},
|
||||
label: {
|
||||
fontSize: 12,
|
||||
color: themeColors.mutedForeground,
|
||||
marginBottom: 2,
|
||||
},
|
||||
orgName: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: themeColors.foreground,
|
||||
},
|
||||
caret: {
|
||||
color: themeColors.mutedForeground,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import type { ThemeColors } from '@/modules/ui/theme.constants';
|
||||
import { useRouter } from 'expo-router';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
FlatList,
|
||||
Modal,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { Icon } from '@/modules/ui/components/icon';
|
||||
import { useThemeColor } from '@/modules/ui/providers/use-theme-color';
|
||||
import { useOrganizations } from '../organizations.provider';
|
||||
|
||||
type OrganizationPickerDrawerProps = {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export function OrganizationPickerDrawer({ visible, onClose }: OrganizationPickerDrawerProps) {
|
||||
const themeColors = useThemeColor();
|
||||
const router = useRouter();
|
||||
const { organizations, currentOrganizationId, setCurrentOrganizationId, isLoading } = useOrganizations();
|
||||
|
||||
const styles = createStyles({ themeColors });
|
||||
|
||||
const handleSelectOrganization = async (organizationId: string) => {
|
||||
await setCurrentOrganizationId(organizationId);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleCreateOrganization = () => {
|
||||
onClose();
|
||||
router.push('/(app)/(with-organizations)/organizations/create');
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
transparent
|
||||
animationType="slide"
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
<TouchableOpacity
|
||||
style={styles.backdrop}
|
||||
activeOpacity={1}
|
||||
onPress={onClose}
|
||||
>
|
||||
<View style={styles.drawer}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>Select Organization</Text>
|
||||
</View>
|
||||
|
||||
{isLoading
|
||||
? (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color={themeColors.primary} />
|
||||
</View>
|
||||
)
|
||||
: (
|
||||
<FlatList
|
||||
data={organizations}
|
||||
keyExtractor={item => item.id}
|
||||
renderItem={({ item }) => (
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.orgItem,
|
||||
item.id === currentOrganizationId && styles.orgItemSelected,
|
||||
]}
|
||||
onPress={() => {
|
||||
void handleSelectOrganization(item.id);
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.orgName,
|
||||
item.id === currentOrganizationId && styles.orgNameSelected,
|
||||
]}
|
||||
>
|
||||
{item.name}
|
||||
</Text>
|
||||
{item.id === currentOrganizationId && (
|
||||
<Icon name="check" style={styles.checkmark} />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
contentContainerStyle={styles.listContent}
|
||||
/>
|
||||
)}
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.createButton}
|
||||
onPress={handleCreateOrganization}
|
||||
>
|
||||
<Text style={styles.createButtonText}>+ Create New Organization</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function createStyles({ themeColors }: { themeColors: ThemeColors }) {
|
||||
return StyleSheet.create({
|
||||
backdrop: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
justifyContent: 'flex-end',
|
||||
},
|
||||
drawer: {
|
||||
backgroundColor: themeColors.background,
|
||||
borderTopLeftRadius: 20,
|
||||
borderTopRightRadius: 20,
|
||||
maxHeight: '70%',
|
||||
paddingBottom: 20,
|
||||
},
|
||||
header: {
|
||||
padding: 20,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: themeColors.border,
|
||||
},
|
||||
title: {
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
color: themeColors.foreground,
|
||||
},
|
||||
loadingContainer: {
|
||||
padding: 40,
|
||||
alignItems: 'center',
|
||||
},
|
||||
listContent: {
|
||||
paddingVertical: 8,
|
||||
},
|
||||
orgItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingVertical: 16,
|
||||
paddingHorizontal: 20,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: themeColors.border,
|
||||
},
|
||||
orgItemSelected: {
|
||||
backgroundColor: themeColors.secondaryBackground,
|
||||
},
|
||||
orgName: {
|
||||
fontSize: 16,
|
||||
color: themeColors.foreground,
|
||||
},
|
||||
orgNameSelected: {
|
||||
fontWeight: '600',
|
||||
color: themeColors.primary,
|
||||
},
|
||||
checkmark: {
|
||||
fontSize: 18,
|
||||
color: themeColors.primary,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
createButton: {
|
||||
margin: 20,
|
||||
marginTop: 0,
|
||||
paddingVertical: 14,
|
||||
borderWidth: 1,
|
||||
borderColor: themeColors.border,
|
||||
borderRadius: 8,
|
||||
alignItems: 'center',
|
||||
},
|
||||
createButtonText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: themeColors.primary,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { STORAGE_KEY_BASE_PREFIX } from '../lib/local-storage/local-storage.constants';
|
||||
import { storage } from '../lib/local-storage/local-storage.services';
|
||||
|
||||
const CURRENT_ORGANIZATION_ID_KEY = `${STORAGE_KEY_BASE_PREFIX}:current-organization-id`;
|
||||
|
||||
export const organizationsLocalStorage = {
|
||||
getCurrentOrganizationId: async (): Promise<string | null> => {
|
||||
return storage.getItem(CURRENT_ORGANIZATION_ID_KEY);
|
||||
},
|
||||
|
||||
setCurrentOrganizationId: async (organizationId: string): Promise<void> => {
|
||||
await storage.setItem(CURRENT_ORGANIZATION_ID_KEY, organizationId);
|
||||
},
|
||||
|
||||
clearCurrentOrganizationId: async (): Promise<void> => {
|
||||
await storage.removeItem(CURRENT_ORGANIZATION_ID_KEY);
|
||||
},
|
||||
};
|
||||
102
apps/mobile/src/modules/organizations/organizations.provider.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import type { Organization } from '@/modules/organizations/organizations.types';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { createContext, useContext, useEffect, useState } from 'react';
|
||||
import { useApiClient } from '../api/providers/api.provider';
|
||||
import { organizationsLocalStorage } from './organizations.local-storage';
|
||||
import { fetchOrganizations } from './organizations.services';
|
||||
|
||||
type OrganizationsContextValue = {
|
||||
currentOrganizationId: string | null;
|
||||
setCurrentOrganizationId: (organizationId: string) => Promise<void>;
|
||||
organizations: Organization[];
|
||||
isLoading: boolean;
|
||||
refetch: () => Promise<void>;
|
||||
};
|
||||
|
||||
const OrganizationsContext = createContext<OrganizationsContextValue | null>(null);
|
||||
|
||||
type OrganizationsProviderProps = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export function OrganizationsProvider({ children }: OrganizationsProviderProps) {
|
||||
const router = useRouter();
|
||||
const apiClient = useApiClient();
|
||||
const queryClient = useQueryClient();
|
||||
const [currentOrganizationId, setCurrentOrganizationIdState] = useState<string | null>(null);
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
|
||||
const organizationsQuery = useQuery({
|
||||
queryKey: ['organizations'],
|
||||
queryFn: async () => fetchOrganizations({ apiClient }),
|
||||
});
|
||||
|
||||
// Load current organization ID from storage on mount
|
||||
useEffect(() => {
|
||||
const loadCurrentOrganizationId = async () => {
|
||||
const storedOrgId = await organizationsLocalStorage.getCurrentOrganizationId();
|
||||
setCurrentOrganizationIdState(storedOrgId);
|
||||
setIsInitialized(true);
|
||||
};
|
||||
|
||||
void loadCurrentOrganizationId();
|
||||
}, []);
|
||||
|
||||
const setCurrentOrganizationId = async (organizationId: string) => {
|
||||
await organizationsLocalStorage.setCurrentOrganizationId(organizationId);
|
||||
setCurrentOrganizationIdState(organizationId);
|
||||
};
|
||||
|
||||
// Redirect to organization selection if no organizations or no current org set
|
||||
useEffect(() => {
|
||||
if (!isInitialized || organizationsQuery.isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
const organizations = organizationsQuery.data?.organizations ?? [];
|
||||
|
||||
if (organizations.length === 0) {
|
||||
// No organizations, redirect to organization create to create one
|
||||
router.replace('/(app)/(with-organizations)/organizations/create');
|
||||
return;
|
||||
}
|
||||
|
||||
// If there's no current org set, or the current org doesn't exist anymore, set to first org
|
||||
if (currentOrganizationId == null || !organizations.some(org => org.id === currentOrganizationId)) {
|
||||
const firstOrg = organizations[0];
|
||||
if (firstOrg) {
|
||||
void setCurrentOrganizationId(firstOrg.id);
|
||||
}
|
||||
}
|
||||
}, [isInitialized, organizationsQuery.isLoading, organizationsQuery.data, currentOrganizationId, router]);
|
||||
|
||||
const refetch = async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ['organizations'] });
|
||||
};
|
||||
|
||||
const value: OrganizationsContextValue = {
|
||||
currentOrganizationId,
|
||||
setCurrentOrganizationId,
|
||||
organizations: organizationsQuery.data?.organizations ?? [],
|
||||
isLoading: organizationsQuery.isLoading || !isInitialized,
|
||||
refetch,
|
||||
};
|
||||
|
||||
return (
|
||||
<OrganizationsContext.Provider value={value}>
|
||||
{children}
|
||||
</OrganizationsContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useOrganizations(): OrganizationsContextValue {
|
||||
const context = useContext(OrganizationsContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error('useOrganizations must be used within OrganizationsProvider');
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { ApiClient } from '../api/api.client';
|
||||
import type { Organization } from '@/modules/organizations/organizations.types';
|
||||
|
||||
export async function fetchOrganizations({ apiClient }: { apiClient: ApiClient }) {
|
||||
return apiClient<{
|
||||
organizations: Organization[];
|
||||
}>({
|
||||
method: 'GET',
|
||||
path: '/api/organizations',
|
||||
});
|
||||
}
|
||||
|
||||
export async function createOrganization({ name, apiClient }: { name: string; apiClient: ApiClient }) {
|
||||
return apiClient<{
|
||||
organization: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
}>({
|
||||
method: 'POST',
|
||||
path: '/api/organizations',
|
||||
body: { name },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export type Organization = {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||