Compare commits
28 Commits
ui-redesig
...
master
| Author | SHA1 | Date |
|---|---|---|
|
|
c22d26bcf6 | |
|
|
447a89923a | |
|
|
2b3413670f | |
|
|
c5b7b9f5b8 | |
|
|
210f273c05 | |
|
|
beede0e497 | |
|
|
57e8c761f1 | |
|
|
18d037905c | |
|
|
b6b734dbd1 | |
|
|
e995ac2764 | |
|
|
e1ae1c91af | |
|
|
e19bd68ebd | |
|
|
8f0b14bc62 | |
|
|
9f27b95f07 | |
|
|
fb54426bea | |
|
|
aaa72be54e | |
|
|
0aba091d56 | |
|
|
7912eaf252 | |
|
|
2681d7d5ba | |
|
|
90113a6514 | |
|
|
af92aa6c47 | |
|
|
eb9ce84001 | |
|
|
b8dbad73ac | |
|
|
dd7a041820 | |
|
|
7258984386 | |
|
|
683a64158e | |
|
|
3b27a0fd5b | |
|
|
3be3903986 |
31
.env-sample
31
.env-sample
|
|
@ -13,27 +13,20 @@ ADOBE_CLIENT_SECRET=your-adobe-client-secret
|
||||||
ADOBE_ACCESS_TOKEN=
|
ADOBE_ACCESS_TOKEN=
|
||||||
ADOBE_REFRESH_TOKEN=
|
ADOBE_REFRESH_TOKEN=
|
||||||
ADOBE_SIGN_BASE_URL=https://api.eu2.adobesign.com/api/rest/v6
|
ADOBE_SIGN_BASE_URL=https://api.eu2.adobesign.com/api/rest/v6
|
||||||
|
ADOBE_REDIRECT_URI=http://localhost:8000/api/auth/adobe/callback
|
||||||
|
|
||||||
# ─── DocuSign ────────────────────────────────────────────────────────────────
|
# ─── DocuSign ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
# Integration key (client ID) from the DocuSign developer console
|
# Integration key (client ID) from the DocuSign developer console
|
||||||
DOCUSIGN_CLIENT_ID=your-integration-key
|
DOCUSIGN_CLIENT_ID=your-integration-key
|
||||||
|
|
||||||
# Client secret — only needed for the one-time Auth Code Grant consent flow
|
# Client secret used for the Auth Code Grant and refresh-token exchange
|
||||||
DOCUSIGN_CLIENT_SECRET=your-client-secret
|
DOCUSIGN_CLIENT_SECRET=your-client-secret
|
||||||
|
|
||||||
# GUID of the DocuSign user to impersonate via JWT grant
|
|
||||||
# Found in the DocuSign admin UI under Users → user details
|
|
||||||
DOCUSIGN_USER_ID=your-docusign-user-guid
|
|
||||||
|
|
||||||
# Account ID of the target DocuSign account
|
# Account ID of the target DocuSign account
|
||||||
# Found in the DocuSign admin UI under Settings → Account Profile
|
# Found in the DocuSign admin UI under Settings → Account Profile
|
||||||
DOCUSIGN_ACCOUNT_ID=your-docusign-account-id
|
DOCUSIGN_ACCOUNT_ID=your-docusign-account-id
|
||||||
|
|
||||||
# Path to the RSA private key file used for JWT signing
|
|
||||||
# Generate a keypair in the DocuSign developer console and save the private key here
|
|
||||||
DOCUSIGN_PRIVATE_KEY_PATH=/path/to/private.key
|
|
||||||
|
|
||||||
# OAuth auth server — use account-d.docusign.com for sandbox, account.docusign.com for production
|
# OAuth auth server — use account-d.docusign.com for sandbox, account.docusign.com for production
|
||||||
DOCUSIGN_AUTH_SERVER=account-d.docusign.com
|
DOCUSIGN_AUTH_SERVER=account-d.docusign.com
|
||||||
|
|
||||||
|
|
@ -42,10 +35,24 @@ DOCUSIGN_AUTH_SERVER=account-d.docusign.com
|
||||||
# Production: https://na3.docusign.net/restapi (replace na3 with your shard)
|
# Production: https://na3.docusign.net/restapi (replace na3 with your shard)
|
||||||
DOCUSIGN_BASE_URL=https://demo.docusign.net/restapi
|
DOCUSIGN_BASE_URL=https://demo.docusign.net/restapi
|
||||||
|
|
||||||
# Redirect URI registered in your DocuSign app (used only during one-time consent flow)
|
# Redirect URI registered in your DocuSign app
|
||||||
DOCUSIGN_REDIRECT_URI=http://localhost:8080/callback
|
DOCUSIGN_REDIRECT_URI=http://localhost:8000/api/auth/docusign/callback
|
||||||
|
|
||||||
# Auto-written by src/docusign_auth.py to cache the JWT access token.
|
# Auto-written by src/docusign_auth.py after the initial authorization flow.
|
||||||
# Leave blank; they will be populated automatically.
|
# Leave blank; they will be populated automatically.
|
||||||
DOCUSIGN_ACCESS_TOKEN=
|
DOCUSIGN_ACCESS_TOKEN=
|
||||||
|
DOCUSIGN_REFRESH_TOKEN=
|
||||||
DOCUSIGN_TOKEN_EXPIRY=
|
DOCUSIGN_TOKEN_EXPIRY=
|
||||||
|
|
||||||
|
# ─── Web UI sessions ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# Required for browser session signing.
|
||||||
|
SESSION_SECRET_KEY=change-me
|
||||||
|
|
||||||
|
# Optional override for the server-side browser session store.
|
||||||
|
# Each tester gets an isolated session file here after they connect in the web UI.
|
||||||
|
SESSION_STORE_DIR=
|
||||||
|
|
||||||
|
# Optional comma-separated admin emails.
|
||||||
|
# Matching Adobe or DocuSign user emails can view all audit activity; everyone else only sees their own session activity.
|
||||||
|
ADMIN_EMAILS=
|
||||||
|
|
|
||||||
|
|
@ -9,5 +9,7 @@ __pycache__/
|
||||||
*.b64
|
*.b64
|
||||||
downloads/
|
downloads/
|
||||||
migration-output/
|
migration-output/
|
||||||
|
.session-store/
|
||||||
|
.audit-log.jsonl
|
||||||
*.pdf
|
*.pdf
|
||||||
private.key
|
private.key
|
||||||
|
|
|
||||||
|
|
@ -20,13 +20,13 @@ Develop an agent/toolkit that can programmatically extract template data and fie
|
||||||
|
|
||||||
#### Components
|
#### Components
|
||||||
- **Adobe Sign Client** (`src/adobe_api.py`) — authenticated API calls, template listing/download
|
- **Adobe Sign Client** (`src/adobe_api.py`) — authenticated API calls, template listing/download
|
||||||
- **DocuSign Client** (`src/upload_docusign_template.py`, `src/docusign_auth.py`) — JWT auth, template upsert
|
- **DocuSign Client** (`src/upload_docusign_template.py`, `src/docusign_auth.py`) — OAuth auth, template upsert
|
||||||
- **Normalized Schema Model** (`src/models/normalized_template.py`) — platform-agnostic intermediate representation
|
- **Normalized Schema Model** (`src/models/normalized_template.py`) — platform-agnostic intermediate representation
|
||||||
- **Mapping Service** (`src/services/mapping_service.py`) — field type, recipient role, coordinate translation
|
- **Mapping Service** (`src/services/mapping_service.py`) — field type, recipient role, coordinate translation; produces `NormalizedTemplate`
|
||||||
- **Validation Service** (`src/services/validation_service.py`) — field count comparison, recipient checks, missing role detection
|
- **Validation Service** (`src/services/validation_service.py`) — blocker and warning checks on the normalized schema
|
||||||
- **Migration Service** (`src/services/migration_service.py`) — orchestrates download → normalize → validate → compose → upload
|
- **Compose** (`src/compose_docusign_template.py`) — converts `NormalizedTemplate` → DocuSign `envelopeTemplate` JSON; emits `FieldIssue` objects for partial/dropped features
|
||||||
- **Report Builder** (`src/reports/report_builder.py`) — structured success/warning/error output
|
- **Report Builder** (`src/reports/report_builder.py`) — structured success/warning/error output per template
|
||||||
- **Web API** (`web/`) — FastAPI endpoints for browser-based orchestration
|
- **Web API** (`web/`) — FastAPI endpoints for browser-based orchestration; full pipeline orchestration lives in `web/routers/migrate.py`
|
||||||
- **Frontend** (`web/static/`) — side-by-side template browser, migration UI
|
- **Frontend** (`web/static/`) — side-by-side template browser, migration UI
|
||||||
|
|
||||||
#### Service Separation
|
#### Service Separation
|
||||||
|
|
@ -34,16 +34,22 @@ Develop an agent/toolkit that can programmatically extract template data and fie
|
||||||
src/
|
src/
|
||||||
models/
|
models/
|
||||||
normalized_template.py # intermediate schema
|
normalized_template.py # intermediate schema
|
||||||
|
field_issue.py # structured field-issue model + issue codes
|
||||||
services/
|
services/
|
||||||
migration_service.py # pipeline orchestration
|
|
||||||
mapping_service.py # field/role/coord transformations
|
mapping_service.py # field/role/coord transformations
|
||||||
validation_service.py # pre/post migration checks
|
validation_service.py # pre/post migration checks
|
||||||
reports/
|
reports/
|
||||||
report_builder.py # structured report output
|
report_builder.py # structured report output
|
||||||
utils/
|
utils/
|
||||||
pdf_coords.py # coordinate normalization helpers
|
retry.py # exponential backoff retry helpers
|
||||||
|
log_sanitizer.py # secret redaction from logs
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> Note: pipeline orchestration (download → normalize → validate → compose → upload → report) is
|
||||||
|
> implemented inline in `web/routers/migrate.py` (`_migrate_one()`) for the web layer and in
|
||||||
|
> `src/migrate_template.py` for the CLI. There is no shared `migration_service.py` orchestration
|
||||||
|
> layer — this is a known divergence from the original spec that is acceptable for the current scope.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### High-Level Migration Flow
|
### High-Level Migration Flow
|
||||||
|
|
|
||||||
115
README.md
115
README.md
|
|
@ -10,7 +10,7 @@ It downloads templates via the Adobe Sign API, converts them to DocuSign format,
|
||||||
1. **Authenticates** with Adobe Sign via OAuth (one-time browser flow, tokens saved to `.env`)
|
1. **Authenticates** with Adobe Sign via OAuth (one-time browser flow, tokens saved to `.env`)
|
||||||
2. **Downloads** templates — PDF, metadata, and form field definitions
|
2. **Downloads** templates — PDF, metadata, and form field definitions
|
||||||
3. **Converts** each template to a DocuSign `envelopeTemplate` JSON, mapping all field types, coordinates, recipient roles, and conditional field logic
|
3. **Converts** each template to a DocuSign `envelopeTemplate` JSON, mapping all field types, coordinates, recipient roles, and conditional field logic
|
||||||
4. **Authenticates** with DocuSign via JWT grant (one-time browser consent, then fully automated)
|
4. **Authenticates** with DocuSign via OAuth Authorization Code Grant (one-time browser login, then refresh-token based)
|
||||||
5. **Uploads** the converted template to DocuSign via the REST API
|
5. **Uploads** the converted template to DocuSign via the REST API
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -19,7 +19,7 @@ It downloads templates via the Adobe Sign API, converts them to DocuSign format,
|
||||||
|
|
||||||
- Python 3.10+
|
- Python 3.10+
|
||||||
- An Adobe Sign OAuth app (EU2 shard) with scopes: `library_read:self library_write:self user_read:self`
|
- An Adobe Sign OAuth app (EU2 shard) with scopes: `library_read:self library_write:self user_read:self`
|
||||||
- A DocuSign developer account with an integration key and RSA keypair
|
- A DocuSign developer account with an OAuth app client ID and client secret
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -39,21 +39,20 @@ variables with descriptions. Use `account-d.docusign.com` and
|
||||||
`https://demo.docusign.net/restapi` for sandbox; for production replace with
|
`https://demo.docusign.net/restapi` for sandbox; for production replace with
|
||||||
`account.docusign.com` and your account's base URL (e.g. `https://na3.docusign.net/restapi`).
|
`account.docusign.com` and your account's base URL (e.g. `https://na3.docusign.net/restapi`).
|
||||||
|
|
||||||
**3. Authenticate with Adobe Sign** (one-time):
|
**3. Authenticate with Adobe Sign** (one-time for CLI use):
|
||||||
```bash
|
```bash
|
||||||
python3 src/adobe_auth.py
|
python3 src/adobe_auth.py
|
||||||
```
|
```
|
||||||
Opens a browser. After authorizing, paste the redirect URL back into the terminal.
|
Opens a browser. After authorizing, paste the redirect URL back into the terminal.
|
||||||
Tokens are saved to `.env` and auto-refreshed on subsequent runs.
|
Tokens are saved to `.env` and auto-refreshed on subsequent runs.
|
||||||
|
|
||||||
**4. Grant consent for DocuSign** (one-time per user):
|
**4. Authorize DocuSign** (CLI, one-time per machine/user):
|
||||||
```bash
|
```bash
|
||||||
python3 src/docusign_auth.py --consent
|
python3 src/docusign_auth.py --authorize
|
||||||
```
|
```
|
||||||
Opens a browser for the DocuSign OAuth consent screen. After approving, paste the
|
Opens a browser for the DocuSign OAuth screen. After approving, paste the
|
||||||
redirect URL back into the terminal. This grants the `impersonation` scope required
|
redirect URL back into the terminal. The app stores both access and refresh
|
||||||
for JWT grant. After this runs once, all subsequent API calls use JWT automatically —
|
tokens in `.env`, and later API calls refresh access tokens automatically.
|
||||||
no further browser interaction needed.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -108,6 +107,7 @@ shell, multi-customer project context, and a full migration workflow.
|
||||||
**Additional `.env` keys required for the web UI:**
|
**Additional `.env` keys required for the web UI:**
|
||||||
```
|
```
|
||||||
SESSION_SECRET_KEY=<any random string>
|
SESSION_SECRET_KEY=<any random string>
|
||||||
|
SESSION_STORE_DIR=/absolute/path/for/browser-session-files
|
||||||
DOCUSIGN_CLIENT_SECRET=<your DocuSign app client secret>
|
DOCUSIGN_CLIENT_SECRET=<your DocuSign app client secret>
|
||||||
DOCUSIGN_REDIRECT_URI=http://localhost:8000/api/auth/docusign/callback
|
DOCUSIGN_REDIRECT_URI=http://localhost:8000/api/auth/docusign/callback
|
||||||
ADOBE_REDIRECT_URI=http://localhost:8000/api/auth/adobe/callback
|
ADOBE_REDIRECT_URI=http://localhost:8000/api/auth/adobe/callback
|
||||||
|
|
@ -119,6 +119,24 @@ uvicorn web.app:app --reload --port 8000
|
||||||
```
|
```
|
||||||
Then open [http://localhost:8000](http://localhost:8000) in your browser.
|
Then open [http://localhost:8000](http://localhost:8000) in your browser.
|
||||||
|
|
||||||
|
### Multi-user testing
|
||||||
|
|
||||||
|
The web UI now supports concurrent testers on one shared deployment:
|
||||||
|
|
||||||
|
- each browser gets its own server-side session file
|
||||||
|
- DocuSign web OAuth is isolated per tester session
|
||||||
|
- migration history and batch-job polling are scoped to that tester session
|
||||||
|
- Adobe Sign web auth now supports the same redirect-based browser callback pattern as DocuSign
|
||||||
|
- Adobe Sign can still be connected from shared `.env` credentials if you use the top-bar Adobe connect flow
|
||||||
|
|
||||||
|
Important behavior:
|
||||||
|
|
||||||
|
- the CLI still stores DocuSign tokens in `.env`
|
||||||
|
- the web UI does **not** reuse `.env` DocuSign refresh tokens for all users anymore
|
||||||
|
- each tester who needs DocuSign upload/verification should connect DocuSign in their own browser session
|
||||||
|
- if a DocuSign user belongs to multiple accounts, the web UI fetches the full account list from `/oauth/userinfo`, sorts it alphabetically, and requires the user to choose an account for the session
|
||||||
|
- browser-session files live under `.session-store/` by default and can be deleted to force reconnects
|
||||||
|
|
||||||
### Navigation
|
### Navigation
|
||||||
|
|
||||||
| Screen | Path | Purpose |
|
| Screen | Path | Purpose |
|
||||||
|
|
@ -134,6 +152,10 @@ Then open [http://localhost:8000](http://localhost:8000) in your browser.
|
||||||
|
|
||||||
1. **Create a project** — the switcher modal opens on first run; name it after the customer.
|
1. **Create a project** — the switcher modal opens on first run; name it after the customer.
|
||||||
2. **Connect platforms** — click the Adobe Sign and Docusign chips in the top bar.
|
2. **Connect platforms** — click the Adobe Sign and Docusign chips in the top bar.
|
||||||
|
- For group testing, each tester should connect Docusign in their own browser.
|
||||||
|
- Adobe Sign also supports a normal browser redirect callback when shared `.env` credentials are not being used.
|
||||||
|
- If your DocuSign login belongs to multiple accounts, the app will prompt you to choose one account for this session.
|
||||||
|
- Settings now shows the browser session ID, auth mode, and selected DocuSign account for easier troubleshooting.
|
||||||
3. **Review templates** — the Templates view shows readiness badges:
|
3. **Review templates** — the Templates view shows readiness badges:
|
||||||
- **Ready** (green) — no issues, safe to migrate
|
- **Ready** (green) — no issues, safe to migrate
|
||||||
- **Caveats** (amber) — warnings exist; migration will proceed but check Issues view
|
- **Caveats** (amber) — warnings exist; migration will proceed but check Issues view
|
||||||
|
|
@ -155,6 +177,79 @@ Create one project per customer to keep history and settings separate.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Production deployment
|
||||||
|
|
||||||
|
The web UI is designed for local or private-network use during a migration engagement. If you do expose it more broadly, follow these steps:
|
||||||
|
|
||||||
|
### Run behind a reverse proxy (HTTPS required for OAuth)
|
||||||
|
|
||||||
|
OAuth callbacks from both Adobe Sign and DocuSign require HTTPS. Use nginx, Caddy, or a cloud load balancer to terminate TLS and proxy to uvicorn:
|
||||||
|
|
||||||
|
```
|
||||||
|
# nginx example
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:8000;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Forwarded-Proto https;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Start uvicorn without `--reload` in production:
|
||||||
|
```bash
|
||||||
|
uvicorn web.app:app --host 127.0.0.1 --port 8000 --workers 1
|
||||||
|
```
|
||||||
|
|
||||||
|
> Use `--workers 1` — batch job state is in-memory and not safe to share across workers.
|
||||||
|
|
||||||
|
### Required environment variables
|
||||||
|
|
||||||
|
| Variable | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| `SESSION_SECRET_KEY` | Random secret for signing session cookies. Generate one with `python3 -c "import secrets; print(secrets.token_hex(32))"` |
|
||||||
|
| `SESSION_STORE_DIR` | Absolute path for server-side session files (default: `.session-store/` in project root) |
|
||||||
|
| `AUDIT_LOG_FILE` | Absolute path for the JSONL audit log (default: `.audit-log.jsonl` in project root) |
|
||||||
|
| `ADOBE_REDIRECT_URI` | Must match the callback URL registered in your Adobe Sign app (e.g. `https://migrator.example.com/api/auth/adobe/callback`) |
|
||||||
|
| `DOCUSIGN_REDIRECT_URI` | Must match the callback URL registered in your DocuSign app (e.g. `https://migrator.example.com/api/auth/docusign/callback`) |
|
||||||
|
|
||||||
|
### Rotating SESSION_SECRET_KEY
|
||||||
|
|
||||||
|
Changing `SESSION_SECRET_KEY` invalidates all existing browser sessions — every user will be logged out and must reconnect their Adobe Sign and DocuSign accounts. There is no migration path for existing session files. To rotate:
|
||||||
|
|
||||||
|
1. Update `SESSION_SECRET_KEY` in `.env`
|
||||||
|
2. Delete all files in `SESSION_STORE_DIR`
|
||||||
|
3. Restart the server
|
||||||
|
|
||||||
|
### Shard configuration
|
||||||
|
|
||||||
|
By default the app targets the Adobe Sign **EU2** shard. To target a different shard, set `ADOBE_SIGN_BASE_URL` in `.env`:
|
||||||
|
|
||||||
|
```
|
||||||
|
# NA1 shard
|
||||||
|
ADOBE_SIGN_BASE_URL=https://api.na1.adobesign.com/api/rest/v6
|
||||||
|
|
||||||
|
# EU2 shard (default)
|
||||||
|
ADOBE_SIGN_BASE_URL=https://api.eu2.adobesign.com/api/rest/v6
|
||||||
|
```
|
||||||
|
|
||||||
|
Also update `ADOBE_REDIRECT_URI` and the OAuth app registration to match your shard's auth server if it differs.
|
||||||
|
|
||||||
|
For DocuSign, switch from sandbox to production by updating:
|
||||||
|
```
|
||||||
|
DOCUSIGN_AUTH_SERVER=account.docusign.com
|
||||||
|
DOCUSIGN_BASE_URL=https://na3.docusign.net/restapi # your account's base URL
|
||||||
|
```
|
||||||
|
|
||||||
|
### Session store maintenance
|
||||||
|
|
||||||
|
Session files accumulate in `SESSION_STORE_DIR` — one file per browser session. Delete stale files periodically:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Delete session files older than 7 days
|
||||||
|
find .session-store/ -name "*.json" -mtime +7 -delete
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Running tests
|
## Running tests
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
@ -274,7 +369,7 @@ src/
|
||||||
adobe_api.py # Adobe Sign API client (auto token refresh)
|
adobe_api.py # Adobe Sign API client (auto token refresh)
|
||||||
download_templates.py # List and download templates from Adobe Sign
|
download_templates.py # List and download templates from Adobe Sign
|
||||||
compose_docusign_template.py # Core conversion: Adobe Sign → DocuSign JSON
|
compose_docusign_template.py # Core conversion: Adobe Sign → DocuSign JSON
|
||||||
docusign_auth.py # DocuSign JWT auth + one-time consent flow
|
docusign_auth.py # DocuSign auth-code + refresh-token helper
|
||||||
upload_docusign_template.py # Upsert upload: PUT if exists, POST if not
|
upload_docusign_template.py # Upsert upload: PUT if exists, POST if not
|
||||||
migrate_template.py # End-to-end CLI runner (download → convert → upload)
|
migrate_template.py # End-to-end CLI runner (download → convert → upload)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,12 +13,15 @@ DocuSign API reference:
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import sys
|
||||||
import csv
|
import csv
|
||||||
import json
|
import json
|
||||||
import argparse
|
import argparse
|
||||||
import requests
|
import requests
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
from auth_helper import get_access_token # reuses existing JWT auth
|
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
|
||||||
|
from docusign_auth import get_access_token
|
||||||
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,68 +1,277 @@
|
||||||
# Architecture & Design Overview
|
# Architecture & Design — Adobe Sign → DocuSign Migrator
|
||||||
|
|
||||||
## System Components
|
*Last updated: 2026-04-23*
|
||||||
- **Extraction Layer**: Handles authentication, API calls, and raw data retrieval from Adobe Sign. Input: .env credentials. Output: JSON metadata + field data.
|
|
||||||
- **Mapping/Transform Layer**: Pure logic between raw Adobe template objects and canonical DocuSign template model. Handles all 1:1, many:1, and lossy mappings. Logging of ambiguities.
|
|
||||||
- **DocuSign Ingest Layer**: Authenticates, creates/updates templates in DocuSign using mapped objects. Handles feedback, errors, and reporting.
|
|
||||||
- **Validation/QA Layer**: Compares final artifacts, runs coverage and correctness checks, supports dry-run/test modes.
|
|
||||||
- **Testing/Scenario Folder**: Sample templates and responses (see `/sample-templates/`) and mapping/transform test cases.
|
|
||||||
|
|
||||||
## Data Flow
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
graph TD
|
|
||||||
A[Adobe Sign API] -->|Extract| B[Raw JSON]
|
|
||||||
B -->|Transform/Map| C[Canonical Model]
|
|
||||||
C -->|Ingest| D[DocuSign API]
|
|
||||||
D -->|Validate| E[QA/Reporting]
|
|
||||||
E -->|Feedback| B
|
|
||||||
```
|
|
||||||
|
|
||||||
1. Extract Adobe template (metadata, fields, roles, workflows)
|
|
||||||
2. Pass to transform/mapping functions (per field/role/conditional)
|
|
||||||
3. Generate canonical model; attempt creation in DocuSign
|
|
||||||
4. Log result; pull DocuSign result and validate against input
|
|
||||||
5. Drop all validated or problematic test scenarios in `/sample-templates/` or a new `tests/` folder for regression & future QA
|
|
||||||
|
|
||||||
## Key Design Decisions & Logger
|
|
||||||
- Focus on batch/parallelization via pipelined scripts/modules
|
|
||||||
- Use local cache of all raw API payloads for traceability
|
|
||||||
- Mapping module must be testable with static samples (no account needed at first)
|
|
||||||
- Agent harness structure for project traceability, autonomous improvement
|
|
||||||
- **Decision Log** (expand as project runs):
|
|
||||||
- [2026-04-14] Start with static JSON tests and pure transforms before integrating live API. Document all lossy mappings inline in mapping functions & doc.
|
|
||||||
- [2026-04-14] Capture all feature-mapping challenges (fields, roles) as they appear in real-world test cases and update this doc.
|
|
||||||
|
|
||||||
## Extensibility
|
|
||||||
- Designed for: new field types, more templates, transform plugins
|
|
||||||
- Support “mapping hints” or forced overrides for ambiguous/complex field cases
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## v2 Architecture — Web UI (2026-04-17)
|
## System Overview
|
||||||
|
|
||||||
The pipeline is extended with a FastAPI web layer that wraps all existing src/ modules.
|
The migrator is a Python toolkit with two interfaces that share the same core pipeline:
|
||||||
|
|
||||||
```mermaid
|
- **CLI** (`src/`) — shell scripts for one-off or scripted migrations
|
||||||
graph TD
|
- **Web UI** (`web/`) — FastAPI + vanilla JS SPA for browser-based, multi-user migrations
|
||||||
Browser -->|HTTP| FastAPI
|
|
||||||
FastAPI -->|OAuth| AdobeSign[Adobe Sign API]
|
|
||||||
FastAPI -->|OAuth/JWT| DocuSign[DocuSign API]
|
|
||||||
FastAPI -->|calls| Compose[compose_docusign_template.py]
|
|
||||||
FastAPI -->|calls| Upload[upload_docusign_template.py]
|
|
||||||
Upload -->|upsert| DocuSign
|
|
||||||
FastAPI -->|reads/writes| History[migration-output/.history.json]
|
|
||||||
```
|
|
||||||
|
|
||||||
**New layers:**
|
Both interfaces execute the same sequence: authenticate → download → normalize → validate → compose → upload → report.
|
||||||
- `web/routers/auth.py` — browser-initiated OAuth for Adobe Sign and DocuSign
|
|
||||||
- `web/routers/templates.py` — template listing + migration status computation
|
|
||||||
- `web/routers/migrate.py` — triggers pipeline; records history
|
|
||||||
- `web/static/` — vanilla HTML/JS SPA (no build step)
|
|
||||||
|
|
||||||
**Idempotent Upload (v2):**
|
|
||||||
`upload_docusign_template.py` now searches for an existing DocuSign template by exact name match and updates the most recently modified one (PUT). Falls back to create (POST) if no match. `--force-create` flag bypasses upsert.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*Update as architecture/requirements change. Generated by Cleo (2026-04-14). Updated 2026-04-17.*
|
## Component Map
|
||||||
|
|
||||||
|
```
|
||||||
|
Browser / CLI
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────┐
|
||||||
|
│ web/app.py (FastAPI) OR src/migrate_*.py │
|
||||||
|
│ – session management (web only) │
|
||||||
|
│ – OAuth orchestration (web only) │
|
||||||
|
│ – batch job queue (in-memory dict, web only) │
|
||||||
|
└──────────────┬──────────────────────────────────┘
|
||||||
|
│ calls
|
||||||
|
┌──────────┴──────────┐
|
||||||
|
▼ ▼
|
||||||
|
src/adobe_api.py src/upload_docusign_template.py
|
||||||
|
(Adobe Sign REST) (DocuSign REST — upsert)
|
||||||
|
│ ▲
|
||||||
|
│ raw JSON │ DocuSign JSON
|
||||||
|
▼ │
|
||||||
|
src/services/mapping_service.py
|
||||||
|
└─► src/models/normalized_template.py
|
||||||
|
│ NormalizedTemplate
|
||||||
|
▼
|
||||||
|
src/services/validation_service.py
|
||||||
|
│ blockers / warnings
|
||||||
|
▼
|
||||||
|
src/compose_docusign_template.py
|
||||||
|
└─► src/models/field_issue.py
|
||||||
|
│ (template_dict, warnings, field_issues)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
src/reports/report_builder.py
|
||||||
|
└─► MigrationReport written to migration-output/.history.json
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pipeline Stages
|
||||||
|
|
||||||
|
### 1. Authentication
|
||||||
|
|
||||||
|
| Surface | Adobe Sign | DocuSign |
|
||||||
|
|---------|-----------|---------|
|
||||||
|
| CLI | OAuth Auth Code via `adobe_auth.py`; tokens stored in `.env` | OAuth Auth Code via `docusign_auth.py`; tokens stored in `.env` |
|
||||||
|
| Web | OAuth Auth Code via `/api/auth/adobe/callback`; tokens in server-side session file | OAuth Auth Code via `/api/auth/docusign/callback`; tokens in server-side session file |
|
||||||
|
|
||||||
|
The web UI never stores OAuth tokens in `.env` — each browser session carries its own tokens in a signed server-side session file under `.session-store/`. Sessions are identified by a cookie (`session_id`) signed with `SESSION_SECRET_KEY`.
|
||||||
|
|
||||||
|
### 2. Download (Adobe Sign)
|
||||||
|
|
||||||
|
`src/adobe_api.py` fetches from the Adobe Sign REST v6 API. Shard is configured via `ADOBE_SIGN_BASE_URL` (default: `https://api.eu2.adobesign.com/api/rest/v6`).
|
||||||
|
|
||||||
|
For each template, three artifacts are written to `downloads/<template-name>__<id>/`:
|
||||||
|
|
||||||
|
| File | Content |
|
||||||
|
|------|---------|
|
||||||
|
| `metadata.json` | Template metadata (name, status, creator, dates) |
|
||||||
|
| `form_fields.json` | Full form field list with locations, conditions, validations |
|
||||||
|
| `documents.json` | Document list metadata |
|
||||||
|
| `<name>.pdf` | Binary PDF (base64 decoded) |
|
||||||
|
|
||||||
|
### 3. Normalize (`mapping_service.py`)
|
||||||
|
|
||||||
|
`MappingService.from_folder(path)` reads the three JSON files and produces a `NormalizedTemplate` (Pydantic model). This platform-agnostic intermediate schema decouples Adobe-specific field names from the DocuSign composition step.
|
||||||
|
|
||||||
|
Key transformations at this stage:
|
||||||
|
- Participant sets → typed role list (`SIGN`, `APPROVE`, `CC`)
|
||||||
|
- Field locations expanded into flat list (multi-location fields produce N entries)
|
||||||
|
- Conditional action references converted to normalized `ConditionalRule` objects
|
||||||
|
|
||||||
|
### 4. Validate (`validation_service.py`)
|
||||||
|
|
||||||
|
Runs pre-migration checks and returns `(blockers: list[str], warnings: list[str])`.
|
||||||
|
|
||||||
|
| Check | Result on failure |
|
||||||
|
|-------|-----------------|
|
||||||
|
| No recipients | Blocker |
|
||||||
|
| No documents | Blocker |
|
||||||
|
| No signature fields | Warning |
|
||||||
|
| Unassigned fields | Warning |
|
||||||
|
| Unsupported feature detected | Warning |
|
||||||
|
|
||||||
|
Blockers halt migration. Warnings are stored in the history and surfaced in the UI but do not stop the pipeline.
|
||||||
|
|
||||||
|
### 5. Compose (`compose_docusign_template.py`)
|
||||||
|
|
||||||
|
Converts `NormalizedTemplate` → DocuSign `envelopeTemplate` JSON. Returns a 3-tuple:
|
||||||
|
|
||||||
|
```python
|
||||||
|
(template_dict: dict, warnings: list[str], field_issues: list[dict])
|
||||||
|
```
|
||||||
|
|
||||||
|
`field_issues` are structured `FieldIssue` objects (see `src/models/field_issue.py`) emitted when a field migrates successfully but something was silently dropped or approximated. Each issue has a machine-readable `code` (e.g. `CROSS_RECIPIENT_CONDITIONAL`, `HIDE_ACTION`, `FIELD_TYPE_SKIPPED`). See [field-mapping.md](../field-mapping.md) for the full list.
|
||||||
|
|
||||||
|
### 6. Upload (`upload_docusign_template.py`)
|
||||||
|
|
||||||
|
Upsert pattern:
|
||||||
|
1. Search DocuSign for an existing template with the same name
|
||||||
|
2. If found: `PUT /templates/{id}` (update the most recently modified match)
|
||||||
|
3. If not found: `POST /templates` (create new)
|
||||||
|
4. `--force-create` flag bypasses the search and always creates
|
||||||
|
|
||||||
|
### 7. Report (`report_builder.py`)
|
||||||
|
|
||||||
|
A `MigrationReport` is built per template and appended to `migration-output/.history.json`. Each record contains:
|
||||||
|
- template name, Adobe ID, DocuSign ID
|
||||||
|
- status (`success`, `dry_run`, `skipped`, `error`)
|
||||||
|
- blockers, warnings, field_issues
|
||||||
|
- PDF checksum (SHA-256)
|
||||||
|
- timestamp
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Web Layer
|
||||||
|
|
||||||
|
### FastAPI App (`web/app.py`)
|
||||||
|
|
||||||
|
- Mounts all routers under `/api/`
|
||||||
|
- Serves the SPA shell from `web/static/index.html`
|
||||||
|
- Installs `SanitizingFilter` on the root logger at startup (redacts tokens and secrets from all log output)
|
||||||
|
- Logs a warning at startup if `SESSION_SECRET_KEY` is the default development value
|
||||||
|
|
||||||
|
### Routers
|
||||||
|
|
||||||
|
| Router | Prefix | Responsibility |
|
||||||
|
|--------|--------|---------------|
|
||||||
|
| `auth.py` | `/api/auth` | Adobe Sign + DocuSign OAuth flows, session status |
|
||||||
|
| `templates.py` | `/api/templates` | Adobe template listing; migration status per template |
|
||||||
|
| `migrate.py` | `/api/migrate` | Single and batch migration; history; job polling |
|
||||||
|
| `verify.py` | `/api/verify` | Send test envelopes; poll status; void |
|
||||||
|
| `audit.py` | `/api/audit` | Audit log access + CSV export |
|
||||||
|
| `admin.py` | `/api/admin` | Admin-only operations (admin_emails gating) |
|
||||||
|
|
||||||
|
### Session Lifecycle
|
||||||
|
|
||||||
|
```
|
||||||
|
Browser makes first request
|
||||||
|
→ middleware generates UUID session_id
|
||||||
|
→ signed cookie set (itsdangerous, SESSION_SECRET_KEY)
|
||||||
|
→ session file created at .session-store/<session_id>.json
|
||||||
|
|
||||||
|
User connects Adobe Sign / DocuSign
|
||||||
|
→ OAuth tokens written to session file (never to .env)
|
||||||
|
→ session file updated on every token refresh
|
||||||
|
|
||||||
|
User disconnects or session file deleted
|
||||||
|
→ next request gets a fresh session_id and new file
|
||||||
|
→ old file can be deleted manually to force re-auth
|
||||||
|
```
|
||||||
|
|
||||||
|
Session files are plain JSON. Delete all files in `.session-store/` to reset all user sessions. Set `SESSION_STORE_DIR` in `.env` to change the location.
|
||||||
|
|
||||||
|
### Multi-Account DocuSign Support
|
||||||
|
|
||||||
|
When a DocuSign user belongs to multiple accounts, the web UI:
|
||||||
|
1. Fetches `/oauth/userinfo` after the OAuth callback
|
||||||
|
2. Sorts available accounts alphabetically
|
||||||
|
3. Prompts the user to pick one account for the session
|
||||||
|
4. Stores `docusign_account_id` in the session alongside the tokens
|
||||||
|
|
||||||
|
### Batch Job State
|
||||||
|
|
||||||
|
Batch migrations are tracked in an in-memory dict (`_batch_jobs`) in `web/routers/migrate.py`. Job state is lost on server restart — any in-flight batch becomes unrecoverable. This is a known limitation appropriate for single-operator deployments. Production deployments requiring durability should persist job state to a database or file store.
|
||||||
|
|
||||||
|
### Audit Log
|
||||||
|
|
||||||
|
`web/audit.py` writes one JSONL record per migration event to `AUDIT_LOG_FILE` (default: `.audit-log.jsonl`). Each record:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"timestamp": "2026-04-23T12:00:00Z",
|
||||||
|
"session_id": "abc123",
|
||||||
|
"user_email": "user@example.com",
|
||||||
|
"action": "migrate",
|
||||||
|
"template_name": "Sales Agreement",
|
||||||
|
"adobe_template_id": "3AAA...",
|
||||||
|
"docusign_template_id": "uuid",
|
||||||
|
"status": "success",
|
||||||
|
"field_issues_count": 2,
|
||||||
|
"pdf_checksum": "sha256:abcdef..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `/api/audit` endpoints expose this log with filtering and CSV export. Sensitive fields (tokens, secrets) are never written — the `SanitizingFilter` on the root logger ensures they are redacted before hitting any output.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Frontend SPA
|
||||||
|
|
||||||
|
Single-page app in `web/static/`. No build step — plain HTML + ES modules.
|
||||||
|
|
||||||
|
| File | Responsibility |
|
||||||
|
|------|---------------|
|
||||||
|
| `index.html` | Shell, left nav, top bar, router outlet |
|
||||||
|
| `js/router.js` | Hash-based routing (`#/templates`, `#/results`, etc.) |
|
||||||
|
| `js/state.js` | Global pub/sub state store |
|
||||||
|
| `js/api.js` | Typed fetch wrappers for all backend endpoints |
|
||||||
|
| `js/auth.js` | Auth chip UI, OAuth flow, toast notifications |
|
||||||
|
| `js/templates.js` | Templates view + detail tabs (overview / issues / history) |
|
||||||
|
| `js/migration.js` | Migration modal, progress polling, results view |
|
||||||
|
| `js/issues.js` | Issues & Warnings view |
|
||||||
|
| `js/verification.js` | Verification view (send / poll / void envelopes) |
|
||||||
|
| `js/history.js` | History & Audit view |
|
||||||
|
| `js/settings.js` | Settings view |
|
||||||
|
| `js/project.js` | Per-customer project context (localStorage) |
|
||||||
|
| `js/utils.js` | `escHtml`, `formatDate`, `renderFieldIssues`, etc. |
|
||||||
|
|
||||||
|
CSS uses DocuSign 2024 brand design tokens defined in `css/tokens.css`.
|
||||||
|
|
||||||
|
### Template Issue Summary
|
||||||
|
|
||||||
|
The Templates and Issues & Warnings pages use `/api/templates/status`. A
|
||||||
|
template is shown as `Clean` only when all of these are empty:
|
||||||
|
|
||||||
|
- validation `blockers`
|
||||||
|
- validation `warnings`
|
||||||
|
- composition `field_issues`
|
||||||
|
|
||||||
|
On the web server, migration downloads are temporary. If no persistent
|
||||||
|
`downloads/` folder exists for re-analysis, `/api/templates/status` falls back
|
||||||
|
to the current browser session's `migration-output/.history.json` records so
|
||||||
|
field issues discovered during migration still appear in the Templates summary.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security Design
|
||||||
|
|
||||||
|
| Concern | Mechanism |
|
||||||
|
|---------|----------|
|
||||||
|
| Token leakage in logs | `SanitizingFilter` installed on root logger at startup; redacts Bearer tokens, JWTs, long base64 strings, and key=value assignments for known secret fields |
|
||||||
|
| Session integrity | Sessions signed with `SESSION_SECRET_KEY` via `itsdangerous`; secret must be set in `.env` |
|
||||||
|
| Secret exposure at startup | Warning logged if `SESSION_SECRET_KEY` is the default value |
|
||||||
|
| PDF integrity | SHA-256 checksum computed before upload and stored in history |
|
||||||
|
| Credential storage | OAuth tokens stored in server-side session files, never in browser localStorage or logs |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Utilities
|
||||||
|
|
||||||
|
### `src/utils/retry.py`
|
||||||
|
|
||||||
|
`retry_with_backoff` and `async_retry_with_backoff` decorators implement exponential backoff (configurable max retries, base delay, max delay). They target HTTP 429 / 5xx transient errors. These decorators are defined and tested but are not yet applied to API call sites — adding `@retry_with_backoff()` to functions in `adobe_api.py` and `upload_docusign_template.py` is the recommended next step for production hardening.
|
||||||
|
|
||||||
|
### `src/utils/log_sanitizer.py`
|
||||||
|
|
||||||
|
`install_sanitizing_filter()` attaches a `logging.Filter` to the root logger. The filter runs `redact()` on every log record's message and args, replacing Bearer tokens, JWTs, long base64 strings, and key=value secret assignments with `[REDACTED]`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Known Limitations
|
||||||
|
|
||||||
|
| Limitation | Impact | Mitigation |
|
||||||
|
|-----------|--------|-----------|
|
||||||
|
| Batch job state is in-memory | Lost on restart | Acceptable for CLI/single-operator; add DB persistence for multi-operator prod |
|
||||||
|
| Adobe shard configured via full base URL only | Changing shard requires `.env` update | Set `ADOBE_SIGN_BASE_URL` in `.env` |
|
||||||
|
| Retry decorators not applied to API calls | 429/5xx errors propagate immediately | Apply `@retry_with_backoff()` to `adobe_api.py` + `upload_docusign_template.py` |
|
||||||
|
| Regression tests require real fixture data | CI cannot run regression tests without downloaded templates | Check in anonymised fixtures or generate synthetic ones |
|
||||||
|
|
||||||
|
*Updated 2026-04-23 — reflects v2 web UI, session lifecycle, audit log schema, multi-account support, batch job state, security design.*
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,215 @@
|
||||||
|
# Oracle VM Deploy Cheat Sheet — Adobe Sign → DocuSign Migrator
|
||||||
|
|
||||||
|
_Last updated: 2026-04-21 (post-deploy note added)_
|
||||||
|
|
||||||
|
This is the short version of the deployment process.
|
||||||
|
Use this when you already know what you are doing and just need the commands.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Local project path
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/paulh/.openclaw/workspace/projects/adobe-to-docusign-migrator
|
||||||
|
```
|
||||||
|
|
||||||
|
## 1. Check what changed
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git status
|
||||||
|
git branch --show-current
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. Run tests
|
||||||
|
|
||||||
|
Full suite:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pytest tests/ -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Quick smoke test:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pytest tests/test_api_health.py -v
|
||||||
|
pytest tests/test_api_auth.py -v
|
||||||
|
pytest tests/test_api_templates.py -v
|
||||||
|
pytest tests/test_api_migrate.py -v
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Commit and push
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add .
|
||||||
|
git commit -m "Describe the change"
|
||||||
|
git push origin <branch-name>
|
||||||
|
```
|
||||||
|
|
||||||
|
If deploying `master`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git push origin master
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. SSH to Oracle VM
|
||||||
|
|
||||||
|
Using IP:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh ubuntu@<VM_PUBLIC_IP>
|
||||||
|
```
|
||||||
|
|
||||||
|
Using hostname:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh ubuntu@dstemplate.mooo.com
|
||||||
|
```
|
||||||
|
|
||||||
|
If SSH fails on a new machine with host key verification issues:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh-keyscan -H dstemplate.mooo.com >> ~/.ssh/known_hosts
|
||||||
|
ssh ubuntu@dstemplate.mooo.com
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Pull latest code on VM
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/ubuntu/projects/adobe-to-docusign-migrator
|
||||||
|
git branch --show-current
|
||||||
|
git status
|
||||||
|
```
|
||||||
|
|
||||||
|
Deploy `master`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git checkout master
|
||||||
|
git pull origin master
|
||||||
|
```
|
||||||
|
|
||||||
|
Deploy a feature branch intentionally:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git checkout <branch-name>
|
||||||
|
git pull origin <branch-name>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Update dependencies
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/ubuntu/projects/adobe-to-docusign-migrator
|
||||||
|
source venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Restart app
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl restart adobe-migrator
|
||||||
|
sudo systemctl status adobe-migrator --no-pager
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Smoke test on VM
|
||||||
|
|
||||||
|
App health:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://127.0.0.1:8000/health
|
||||||
|
```
|
||||||
|
|
||||||
|
HTML through nginx:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://127.0.0.1/
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Check logs if broken
|
||||||
|
|
||||||
|
```bash
|
||||||
|
journalctl -u adobe-migrator -n 100 --no-pager
|
||||||
|
journalctl -u adobe-migrator -f
|
||||||
|
```
|
||||||
|
|
||||||
|
Check nginx:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo nginx -t
|
||||||
|
sudo systemctl reload nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Important paths
|
||||||
|
|
||||||
|
Local project:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/home/paulh/.openclaw/workspace/projects/adobe-to-docusign-migrator
|
||||||
|
```
|
||||||
|
|
||||||
|
VM project:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/home/ubuntu/projects/adobe-to-docusign-migrator
|
||||||
|
```
|
||||||
|
|
||||||
|
Service file:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/etc/systemd/system/adobe-migrator.service
|
||||||
|
```
|
||||||
|
|
||||||
|
Nginx site:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/etc/nginx/sites-available/dstemplate
|
||||||
|
/etc/nginx/sites-enabled/dstemplate
|
||||||
|
```
|
||||||
|
|
||||||
|
Public URL:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://dstemplate.mooo.com
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## One-block deploy command set
|
||||||
|
|
||||||
|
If code is already pushed and you are deploying `master`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh ubuntu@dstemplate.mooo.com '
|
||||||
|
cd /home/ubuntu/projects/adobe-to-docusign-migrator &&
|
||||||
|
git checkout master &&
|
||||||
|
git pull origin master &&
|
||||||
|
source venv/bin/activate &&
|
||||||
|
pip install -r requirements.txt &&
|
||||||
|
sudo systemctl restart adobe-migrator &&
|
||||||
|
sudo systemctl status adobe-migrator --no-pager &&
|
||||||
|
curl -s http://127.0.0.1:8000/health
|
||||||
|
'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Safety reminders
|
||||||
|
|
||||||
|
- Know which branch you are deploying
|
||||||
|
- Run tests first
|
||||||
|
- Commit before deploy
|
||||||
|
- Don’t overwrite `.env`, `.env-adobe`, or `private.key` casually
|
||||||
|
- Don’t casually delete `.session-store/` while testers are active
|
||||||
|
- If the site breaks, check `journalctl -u adobe-migrator`
|
||||||
|
|
@ -0,0 +1,535 @@
|
||||||
|
# Deploying the Adobe Sign → DocuSign Migrator to the Oracle Cloud VM
|
||||||
|
|
||||||
|
_Last updated: 2026-04-21 (post-deploy note added)_
|
||||||
|
|
||||||
|
This document explains:
|
||||||
|
1. the **current live deployment setup** on the Oracle VM
|
||||||
|
2. how to **deploy updated app code**
|
||||||
|
3. how to **restart and verify** the site
|
||||||
|
4. where the important config files live
|
||||||
|
|
||||||
|
This is written so future-you does not have to rediscover the setup.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Current Live Deployment Setup
|
||||||
|
|
||||||
|
### Server
|
||||||
|
The app is currently deployed to an Oracle Cloud Ubuntu VM.
|
||||||
|
|
||||||
|
### Live app directory
|
||||||
|
```bash
|
||||||
|
/home/ubuntu/projects/adobe-to-docusign-migrator
|
||||||
|
```
|
||||||
|
|
||||||
|
### Process manager
|
||||||
|
The app is run by **systemd** as a service named:
|
||||||
|
```bash
|
||||||
|
adobe-migrator.service
|
||||||
|
```
|
||||||
|
|
||||||
|
### App server
|
||||||
|
The service launches the FastAPI app with `uvicorn`:
|
||||||
|
```bash
|
||||||
|
/home/ubuntu/projects/adobe-to-docusign-migrator/venv/bin/uvicorn web.app:app --host 0.0.0.0 --port 8000
|
||||||
|
```
|
||||||
|
|
||||||
|
### Reverse proxy
|
||||||
|
**nginx** listens on port 80 and proxies traffic to the app on port 8000.
|
||||||
|
|
||||||
|
### Live hostname
|
||||||
|
```text
|
||||||
|
dstemplate.mooo.com
|
||||||
|
```
|
||||||
|
|
||||||
|
### Nginx site config
|
||||||
|
```bash
|
||||||
|
/etc/nginx/sites-available/dstemplate
|
||||||
|
/etc/nginx/sites-enabled/dstemplate
|
||||||
|
```
|
||||||
|
|
||||||
|
### Systemd service file
|
||||||
|
```bash
|
||||||
|
/etc/systemd/system/adobe-migrator.service
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Current Service Configuration
|
||||||
|
|
||||||
|
The current systemd service file is:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[Unit]
|
||||||
|
Description=Adobe Sign to DocuSign Migrator
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=ubuntu
|
||||||
|
WorkingDirectory=/home/ubuntu/projects/adobe-to-docusign-migrator
|
||||||
|
ExecStart=/home/ubuntu/projects/adobe-to-docusign-migrator/venv/bin/uvicorn web.app:app --host 0.0.0.0 --port 8000
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
Environment=PATH=/home/ubuntu/projects/adobe-to-docusign-migrator/venv/bin
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
```
|
||||||
|
|
||||||
|
What this means:
|
||||||
|
- the app runs as user `ubuntu`
|
||||||
|
- the working directory is the project folder
|
||||||
|
- it uses the project’s Python virtual environment
|
||||||
|
- if the app crashes, systemd restarts it automatically
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Current Nginx Configuration
|
||||||
|
|
||||||
|
The live nginx site config is:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name dstemplate.mooo.com;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:8000;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection 'upgrade';
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
What this means:
|
||||||
|
- visitors go to `http://dstemplate.mooo.com`
|
||||||
|
- nginx receives the request on port 80
|
||||||
|
- nginx forwards it to the FastAPI app on `127.0.0.1:8000`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Environment and Secrets on the VM
|
||||||
|
|
||||||
|
The deployment relies on environment files stored in the app directory.
|
||||||
|
|
||||||
|
Important files on the VM:
|
||||||
|
```bash
|
||||||
|
/home/ubuntu/projects/adobe-to-docusign-migrator/.env
|
||||||
|
/home/ubuntu/projects/adobe-to-docusign-migrator/.env-adobe
|
||||||
|
/home/ubuntu/projects/adobe-to-docusign-migrator/private.key
|
||||||
|
/home/ubuntu/projects/adobe-to-docusign-migrator/.session-store/
|
||||||
|
```
|
||||||
|
|
||||||
|
These should **not** be overwritten casually.
|
||||||
|
They likely contain:
|
||||||
|
- Adobe Sign credentials
|
||||||
|
- DocuSign credentials
|
||||||
|
- refresh tokens / secrets
|
||||||
|
- app configuration
|
||||||
|
- active browser-session files for concurrent testers
|
||||||
|
|
||||||
|
Before a risky deploy, back them up.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```bash
|
||||||
|
cd /home/ubuntu/projects/adobe-to-docusign-migrator
|
||||||
|
cp .env .env.backup
|
||||||
|
cp .env-adobe .env-adobe.backup
|
||||||
|
cp private.key private.key.backup
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Current Git Situation
|
||||||
|
|
||||||
|
### On local workstation
|
||||||
|
The project currently exists at:
|
||||||
|
```bash
|
||||||
|
/home/paulh/.openclaw/workspace/projects/adobe-to-docusign-migrator
|
||||||
|
```
|
||||||
|
|
||||||
|
### On Oracle VM
|
||||||
|
The deployed copy currently exists at:
|
||||||
|
```bash
|
||||||
|
/home/ubuntu/projects/adobe-to-docusign-migrator
|
||||||
|
```
|
||||||
|
|
||||||
|
### Important note
|
||||||
|
Current expected deployment flow:
|
||||||
|
- local workspace deployment branch: `master`
|
||||||
|
- Oracle VM deployment branch: `master`
|
||||||
|
|
||||||
|
So deployment should be done intentionally.
|
||||||
|
Do not assume the VM is following your current local branch automatically.
|
||||||
|
|
||||||
|
### SSH note for new machines
|
||||||
|
If you SSH to `dstemplate.mooo.com` from a machine that has never connected before, you may need to accept or record the server host key first.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```bash
|
||||||
|
ssh-keyscan -H dstemplate.mooo.com >> ~/.ssh/known_hosts
|
||||||
|
```
|
||||||
|
|
||||||
|
Then connect normally:
|
||||||
|
```bash
|
||||||
|
ssh ubuntu@dstemplate.mooo.com
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Standard Deployment Procedure
|
||||||
|
|
||||||
|
This is the recommended clean deployment flow.
|
||||||
|
|
||||||
|
### Step 1: Review local changes
|
||||||
|
On your local machine:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/paulh/.openclaw/workspace/projects/adobe-to-docusign-migrator
|
||||||
|
git status
|
||||||
|
```
|
||||||
|
|
||||||
|
Make sure you understand:
|
||||||
|
- what changed
|
||||||
|
- which branch you are on
|
||||||
|
- whether the changes are ready to ship
|
||||||
|
|
||||||
|
### Step 2: Run tests locally
|
||||||
|
Before deploying, run the tests that matter.
|
||||||
|
|
||||||
|
For the full test suite:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/paulh/.openclaw/workspace/projects/adobe-to-docusign-migrator
|
||||||
|
pytest tests/ -v
|
||||||
|
```
|
||||||
|
|
||||||
|
For a quicker smoke test:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pytest tests/test_api_health.py -v
|
||||||
|
pytest tests/test_api_auth.py -v
|
||||||
|
pytest tests/test_api_templates.py -v
|
||||||
|
pytest tests/test_api_migrate.py -v
|
||||||
|
```
|
||||||
|
|
||||||
|
If you changed frontend files only, still run at least a small backend smoke test.
|
||||||
|
|
||||||
|
### Step 3: Commit your changes locally
|
||||||
|
```bash
|
||||||
|
cd /home/paulh/.openclaw/workspace/projects/adobe-to-docusign-migrator
|
||||||
|
git add .
|
||||||
|
git commit -m "Describe the deployment-worthy change"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 4: Push to the remote repo
|
||||||
|
```bash
|
||||||
|
git push origin <branch-name>
|
||||||
|
```
|
||||||
|
|
||||||
|
If deploying from `master`:
|
||||||
|
```bash
|
||||||
|
git push origin master
|
||||||
|
```
|
||||||
|
|
||||||
|
If deploying from a feature branch:
|
||||||
|
- either merge it first
|
||||||
|
- or intentionally deploy that branch on the VM
|
||||||
|
|
||||||
|
### Step 5: SSH into the Oracle VM
|
||||||
|
```bash
|
||||||
|
ssh ubuntu@<VM_PUBLIC_IP>
|
||||||
|
```
|
||||||
|
|
||||||
|
Or if DNS is set up and working:
|
||||||
|
```bash
|
||||||
|
ssh ubuntu@dstemplate.mooo.com
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 6: Go to the app directory on the VM
|
||||||
|
```bash
|
||||||
|
cd /home/ubuntu/projects/adobe-to-docusign-migrator
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 7: Check current branch and status
|
||||||
|
```bash
|
||||||
|
git branch --show-current
|
||||||
|
git status
|
||||||
|
```
|
||||||
|
|
||||||
|
If the VM has local changes, stop and inspect before pulling.
|
||||||
|
|
||||||
|
### Step 8: Pull the code you want to deploy
|
||||||
|
If deploying `master`:
|
||||||
|
```bash
|
||||||
|
git checkout master
|
||||||
|
git pull origin master
|
||||||
|
```
|
||||||
|
|
||||||
|
If deploying a feature branch intentionally:
|
||||||
|
```bash
|
||||||
|
git checkout <branch-name>
|
||||||
|
git pull origin <branch-name>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 9: Update Python dependencies
|
||||||
|
Use the project virtual environment.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/ubuntu/projects/adobe-to-docusign-migrator
|
||||||
|
source venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
If requirements did not change, this is still safe to run.
|
||||||
|
|
||||||
|
### Step 10: Restart the service
|
||||||
|
```bash
|
||||||
|
sudo systemctl restart adobe-migrator
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 11: Confirm the service is healthy
|
||||||
|
```bash
|
||||||
|
sudo systemctl status adobe-migrator --no-pager
|
||||||
|
```
|
||||||
|
|
||||||
|
You want to see:
|
||||||
|
- `active (running)`
|
||||||
|
|
||||||
|
### Step 12: Test locally on the VM
|
||||||
|
```bash
|
||||||
|
curl http://127.0.0.1:8000/health
|
||||||
|
```
|
||||||
|
|
||||||
|
and/or:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://127.0.0.1/
|
||||||
|
```
|
||||||
|
|
||||||
|
If nginx is working, the second command should return HTML for the app.
|
||||||
|
|
||||||
|
### Step 13: Test from a browser
|
||||||
|
Open:
|
||||||
|
```text
|
||||||
|
http://dstemplate.mooo.com
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify:
|
||||||
|
- the page loads
|
||||||
|
- CSS/JS load correctly
|
||||||
|
- authentication buttons still work
|
||||||
|
- the main workflow still renders
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Fast Deploy Shortcut
|
||||||
|
|
||||||
|
Once you are confident in the process, this is the shortest safe deploy sequence on the VM after code has been pushed:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/ubuntu/projects/adobe-to-docusign-migrator
|
||||||
|
git checkout master
|
||||||
|
git pull origin master
|
||||||
|
source venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
sudo systemctl restart adobe-migrator
|
||||||
|
sudo systemctl status adobe-migrator --no-pager
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. How to Inspect the Current Live Deployment
|
||||||
|
|
||||||
|
### Check service status
|
||||||
|
```bash
|
||||||
|
sudo systemctl status adobe-migrator --no-pager
|
||||||
|
```
|
||||||
|
|
||||||
|
### Restart service
|
||||||
|
```bash
|
||||||
|
sudo systemctl restart adobe-migrator
|
||||||
|
```
|
||||||
|
|
||||||
|
### Stop service
|
||||||
|
```bash
|
||||||
|
sudo systemctl stop adobe-migrator
|
||||||
|
```
|
||||||
|
|
||||||
|
### Start service
|
||||||
|
```bash
|
||||||
|
sudo systemctl start adobe-migrator
|
||||||
|
```
|
||||||
|
|
||||||
|
### View recent service logs
|
||||||
|
```bash
|
||||||
|
journalctl -u adobe-migrator -n 100 --no-pager
|
||||||
|
```
|
||||||
|
|
||||||
|
### Follow live logs
|
||||||
|
```bash
|
||||||
|
journalctl -u adobe-migrator -f
|
||||||
|
```
|
||||||
|
|
||||||
|
### Check nginx config
|
||||||
|
```bash
|
||||||
|
sudo nginx -t
|
||||||
|
```
|
||||||
|
|
||||||
|
### Reload nginx
|
||||||
|
```bash
|
||||||
|
sudo systemctl reload nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
### Check which process is listening on port 8000
|
||||||
|
```bash
|
||||||
|
ss -ltnp | grep 8000
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Troubleshooting
|
||||||
|
|
||||||
|
### Problem: service restarted but site still broken
|
||||||
|
Check:
|
||||||
|
```bash
|
||||||
|
journalctl -u adobe-migrator -n 100 --no-pager
|
||||||
|
```
|
||||||
|
|
||||||
|
Common causes:
|
||||||
|
- missing Python dependency
|
||||||
|
- bad `.env` value
|
||||||
|
- import error in code
|
||||||
|
- startup crash in FastAPI app
|
||||||
|
|
||||||
|
### Problem: nginx returns 502 Bad Gateway
|
||||||
|
Usually means nginx cannot reach the app on port 8000.
|
||||||
|
|
||||||
|
Check:
|
||||||
|
```bash
|
||||||
|
sudo systemctl status adobe-migrator --no-pager
|
||||||
|
curl http://127.0.0.1:8000/health
|
||||||
|
```
|
||||||
|
|
||||||
|
If the health check fails, the app is not running correctly.
|
||||||
|
|
||||||
|
### Problem: static files not loading
|
||||||
|
Check:
|
||||||
|
- app HTML returns successfully
|
||||||
|
- browser dev tools for 404s on `/static/...`
|
||||||
|
- FastAPI static mounting still exists
|
||||||
|
|
||||||
|
### Problem: pulled wrong branch
|
||||||
|
Check:
|
||||||
|
```bash
|
||||||
|
git branch --show-current
|
||||||
|
git rev-parse HEAD
|
||||||
|
```
|
||||||
|
|
||||||
|
### Problem: secrets got overwritten
|
||||||
|
Restore from backup if you made one:
|
||||||
|
```bash
|
||||||
|
cp .env.backup .env
|
||||||
|
cp .env-adobe.backup .env-adobe
|
||||||
|
cp private.key.backup private.key
|
||||||
|
sudo systemctl restart adobe-migrator
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Manual One-Off Deploy Without Git Pull
|
||||||
|
|
||||||
|
If for some reason you need to copy a local working tree directly instead of using Git:
|
||||||
|
|
||||||
|
### From local machine
|
||||||
|
```bash
|
||||||
|
rsync -av --exclude '.git' --exclude 'venv' \
|
||||||
|
/home/paulh/.openclaw/workspace/projects/adobe-to-docusign-migrator/ \
|
||||||
|
ubuntu@<VM_PUBLIC_IP>:/home/ubuntu/projects/adobe-to-docusign-migrator/
|
||||||
|
```
|
||||||
|
|
||||||
|
Then on the VM:
|
||||||
|
```bash
|
||||||
|
cd /home/ubuntu/projects/adobe-to-docusign-migrator
|
||||||
|
source venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
sudo systemctl restart adobe-migrator
|
||||||
|
```
|
||||||
|
|
||||||
|
Use this carefully. Git-based deployment is cleaner.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Recommended Deployment Rules
|
||||||
|
|
||||||
|
1. **Always know what branch you are deploying.**
|
||||||
|
2. **Run tests before deploy.**
|
||||||
|
3. **Commit before deploy.**
|
||||||
|
4. **Prefer Git pull on the VM over manual file copying.**
|
||||||
|
5. **Do not overwrite `.env`, `.env-adobe`, or `private.key` unless intended.**
|
||||||
|
6. **Do not casually delete `.session-store/` during active testing.**
|
||||||
|
7. **Restart the systemd service after code changes.**
|
||||||
|
8. **Smoke test both localhost and the public URL.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Quick Reference
|
||||||
|
|
||||||
|
### Local project path
|
||||||
|
```bash
|
||||||
|
/home/paulh/.openclaw/workspace/projects/adobe-to-docusign-migrator
|
||||||
|
```
|
||||||
|
|
||||||
|
### VM project path
|
||||||
|
```bash
|
||||||
|
/home/ubuntu/projects/adobe-to-docusign-migrator
|
||||||
|
```
|
||||||
|
|
||||||
|
### Service file
|
||||||
|
```bash
|
||||||
|
/etc/systemd/system/adobe-migrator.service
|
||||||
|
```
|
||||||
|
|
||||||
|
### Nginx site config
|
||||||
|
```bash
|
||||||
|
/etc/nginx/sites-available/dstemplate
|
||||||
|
/etc/nginx/sites-enabled/dstemplate
|
||||||
|
```
|
||||||
|
|
||||||
|
### Restart app
|
||||||
|
```bash
|
||||||
|
sudo systemctl restart adobe-migrator
|
||||||
|
```
|
||||||
|
|
||||||
|
### Check app status
|
||||||
|
```bash
|
||||||
|
sudo systemctl status adobe-migrator --no-pager
|
||||||
|
```
|
||||||
|
|
||||||
|
### Check logs
|
||||||
|
```bash
|
||||||
|
journalctl -u adobe-migrator -n 100 --no-pager
|
||||||
|
```
|
||||||
|
|
||||||
|
### Public URL
|
||||||
|
```text
|
||||||
|
http://dstemplate.mooo.com
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Future Improvements
|
||||||
|
|
||||||
|
Possible future upgrades for deployment:
|
||||||
|
- HTTPS with Let’s Encrypt
|
||||||
|
- deployment script (`deploy.sh`)
|
||||||
|
- backup/rollback script
|
||||||
|
- CI/CD from Gitea
|
||||||
|
- separate staging environment
|
||||||
|
- branch-based preview deploys
|
||||||
|
|
@ -80,6 +80,32 @@ Tab types that do not merge (only first location used or handled specially):
|
||||||
`radioGroupTabs` — each location is one radio button within the group
|
`radioGroupTabs` — each location is one radio button within the group
|
||||||
`signerAttachmentTabs` — each location is an independent attachment request
|
`signerAttachmentTabs` — each location is an independent attachment request
|
||||||
|
|
||||||
|
## Multi-Document Templates
|
||||||
|
|
||||||
|
Adobe Sign library documents can contain multiple documents (PDFs) stacked into one template. DocuSign templates also support multiple documents — each document gets a unique `documentId` starting from 1.
|
||||||
|
|
||||||
|
### How it works
|
||||||
|
|
||||||
|
The compose pipeline assigns a `documentId` to each document in the order returned by the Adobe Sign `documents.json` list. All form fields reference their page position within the document they belong to (`pageNumber` is 1-based within the document's own page sequence, not the overall template page count).
|
||||||
|
|
||||||
|
```
|
||||||
|
Adobe Sign template with 2 docs:
|
||||||
|
doc[0]: "Contract.pdf" (3 pages) → documentId: 1
|
||||||
|
doc[1]: "Exhibit-A.pdf" (2 pages) → documentId: 2
|
||||||
|
|
||||||
|
A field on page 2 of Exhibit-A.pdf:
|
||||||
|
adobe_location.pageNumber = 2 (within the exhibit)
|
||||||
|
compose emits: documentId=2, pageNumber=2
|
||||||
|
```
|
||||||
|
|
||||||
|
DocuSign uses `(documentId, pageNumber)` together to locate every tab. If only one document exists, `documentId` is always `1`.
|
||||||
|
|
||||||
|
### Known limitation
|
||||||
|
|
||||||
|
Adobe Sign form fields store `pageNumber` as a sequential page number across the **entire** template (all documents concatenated). If a template has two 3-page documents, fields on document 2 have `pageNumber` 4–6. The compose pipeline does not currently rebase page numbers per document — it passes Adobe's page numbers through as-is and sets `documentId` based on field assignment.
|
||||||
|
|
||||||
|
**Impact**: For single-document templates this is correct. For multi-document templates, verify field placement visually in DocuSign after migration if the template spans more than one PDF.
|
||||||
|
|
||||||
## Conditional Logic Mapping
|
## Conditional Logic Mapping
|
||||||
|
|
||||||
Adobe Sign `conditionalAction` → DocuSign `conditionalParentLabel` + `conditionalParentValue` on the dependent tab.
|
Adobe Sign `conditionalAction` → DocuSign `conditionalParentLabel` + `conditionalParentValue` on the dependent tab.
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,9 @@ uvicorn[standard]
|
||||||
itsdangerous
|
itsdangerous
|
||||||
httpx
|
httpx
|
||||||
|
|
||||||
|
# PDF generation (sample template tooling)
|
||||||
|
reportlab
|
||||||
|
|
||||||
# Testing
|
# Testing
|
||||||
responses
|
responses
|
||||||
respx
|
respx
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,15 @@
|
||||||
import os
|
import os
|
||||||
|
import sys
|
||||||
import requests
|
import requests
|
||||||
from dotenv import load_dotenv, set_key
|
from dotenv import load_dotenv, set_key
|
||||||
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(__file__))
|
||||||
|
from utils.retry import RetryableHTTPError, raise_for_retryable_status, retry_with_backoff
|
||||||
|
|
||||||
|
_RETRY = dict(max_retries=3, base_delay=1.0, max_delay=16.0, retryable_exceptions=(RetryableHTTPError,))
|
||||||
|
|
||||||
SHARD = "eu2"
|
SHARD = "eu2"
|
||||||
TOKEN_URL = f"https://api.{SHARD}.adobesign.com/oauth/v2/token" # initial auth code exchange
|
TOKEN_URL = f"https://api.{SHARD}.adobesign.com/oauth/v2/token" # initial auth code exchange
|
||||||
REFRESH_URL = f"https://api.{SHARD}.adobesign.com/oauth/v2/refresh" # token refresh (non-standard separate endpoint)
|
REFRESH_URL = f"https://api.{SHARD}.adobesign.com/oauth/v2/refresh" # token refresh (non-standard separate endpoint)
|
||||||
|
|
@ -36,6 +42,7 @@ def _refresh_access_token():
|
||||||
return new_token
|
return new_token
|
||||||
|
|
||||||
|
|
||||||
|
@retry_with_backoff(**_RETRY)
|
||||||
def adobe_api_post_multipart(endpoint, files, data=None):
|
def adobe_api_post_multipart(endpoint, files, data=None):
|
||||||
"""Upload a file via multipart/form-data (e.g. transient documents)."""
|
"""Upload a file via multipart/form-data (e.g. transient documents)."""
|
||||||
token = os.getenv("ADOBE_ACCESS_TOKEN")
|
token = os.getenv("ADOBE_ACCESS_TOKEN")
|
||||||
|
|
@ -47,10 +54,11 @@ def adobe_api_post_multipart(endpoint, files, data=None):
|
||||||
token = _refresh_access_token()
|
token = _refresh_access_token()
|
||||||
headers["Authorization"] = f"Bearer {token}"
|
headers["Authorization"] = f"Bearer {token}"
|
||||||
resp = requests.post(url, headers=headers, files=files, data=data or {})
|
resp = requests.post(url, headers=headers, files=files, data=data or {})
|
||||||
resp.raise_for_status()
|
raise_for_retryable_status(resp)
|
||||||
return resp.json()
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
@retry_with_backoff(**_RETRY)
|
||||||
def adobe_api_post_json(endpoint, body):
|
def adobe_api_post_json(endpoint, body):
|
||||||
"""POST JSON body to an Adobe Sign endpoint."""
|
"""POST JSON body to an Adobe Sign endpoint."""
|
||||||
token = os.getenv("ADOBE_ACCESS_TOKEN")
|
token = os.getenv("ADOBE_ACCESS_TOKEN")
|
||||||
|
|
@ -66,10 +74,11 @@ def adobe_api_post_json(endpoint, body):
|
||||||
token = _refresh_access_token()
|
token = _refresh_access_token()
|
||||||
headers["Authorization"] = f"Bearer {token}"
|
headers["Authorization"] = f"Bearer {token}"
|
||||||
resp = requests.post(url, headers=headers, json=body)
|
resp = requests.post(url, headers=headers, json=body)
|
||||||
resp.raise_for_status()
|
raise_for_retryable_status(resp)
|
||||||
return resp.json()
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
@retry_with_backoff(**_RETRY)
|
||||||
def adobe_api_put_json(endpoint, body):
|
def adobe_api_put_json(endpoint, body):
|
||||||
"""PUT JSON body to an Adobe Sign endpoint."""
|
"""PUT JSON body to an Adobe Sign endpoint."""
|
||||||
token = os.getenv("ADOBE_ACCESS_TOKEN")
|
token = os.getenv("ADOBE_ACCESS_TOKEN")
|
||||||
|
|
@ -85,10 +94,11 @@ def adobe_api_put_json(endpoint, body):
|
||||||
token = _refresh_access_token()
|
token = _refresh_access_token()
|
||||||
headers["Authorization"] = f"Bearer {token}"
|
headers["Authorization"] = f"Bearer {token}"
|
||||||
resp = requests.put(url, headers=headers, json=body)
|
resp = requests.put(url, headers=headers, json=body)
|
||||||
resp.raise_for_status()
|
raise_for_retryable_status(resp)
|
||||||
return resp.json()
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
@retry_with_backoff(**_RETRY)
|
||||||
def adobe_api_get_bytes(endpoint):
|
def adobe_api_get_bytes(endpoint):
|
||||||
"""Download binary content (e.g. PDF files) from the Adobe Sign API."""
|
"""Download binary content (e.g. PDF files) from the Adobe Sign API."""
|
||||||
token = os.getenv("ADOBE_ACCESS_TOKEN")
|
token = os.getenv("ADOBE_ACCESS_TOKEN")
|
||||||
|
|
@ -103,10 +113,11 @@ def adobe_api_get_bytes(endpoint):
|
||||||
headers["Authorization"] = f"Bearer {token}"
|
headers["Authorization"] = f"Bearer {token}"
|
||||||
resp = requests.get(url, headers=headers)
|
resp = requests.get(url, headers=headers)
|
||||||
|
|
||||||
resp.raise_for_status()
|
raise_for_retryable_status(resp)
|
||||||
return resp.content
|
return resp.content
|
||||||
|
|
||||||
|
|
||||||
|
@retry_with_backoff(**_RETRY)
|
||||||
def adobe_api_get(endpoint, params=None):
|
def adobe_api_get(endpoint, params=None):
|
||||||
token = os.getenv("ADOBE_ACCESS_TOKEN")
|
token = os.getenv("ADOBE_ACCESS_TOKEN")
|
||||||
base_url = os.getenv("ADOBE_SIGN_BASE_URL", f"https://api.{SHARD}.adobesign.com/api/rest/v6")
|
base_url = os.getenv("ADOBE_SIGN_BASE_URL", f"https://api.{SHARD}.adobesign.com/api/rest/v6")
|
||||||
|
|
@ -125,7 +136,7 @@ def adobe_api_get(endpoint, params=None):
|
||||||
headers["Authorization"] = f"Bearer {token}"
|
headers["Authorization"] = f"Bearer {token}"
|
||||||
resp = requests.get(url, headers=headers, params=params)
|
resp = requests.get(url, headers=headers, params=params)
|
||||||
|
|
||||||
resp.raise_for_status()
|
raise_for_retryable_status(resp)
|
||||||
return resp.json()
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,31 +1,23 @@
|
||||||
"""
|
"""
|
||||||
docusign_auth.py
|
docusign_auth.py
|
||||||
----------------
|
----------------
|
||||||
Handles DocuSign authentication for the migration toolkit.
|
Handles DocuSign OAuth using the Authorization Code Grant.
|
||||||
|
|
||||||
Two flows:
|
|
||||||
JWT Grant — service-to-service, no user interaction. Used for all
|
|
||||||
normal API calls. Requires consent to have been granted.
|
|
||||||
Auth Code Grant — browser-based OAuth flow. Run once with --consent to
|
|
||||||
grant the app the 'impersonation' scope it needs for JWT.
|
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
python3 src/docusign_auth.py --consent # one-time browser consent
|
python3 src/docusign_auth.py --authorize # one-time browser login
|
||||||
python3 src/docusign_auth.py # print a fresh access token (smoke test)
|
python3 src/docusign_auth.py # print a fresh access token
|
||||||
|
|
||||||
Required .env keys:
|
Required .env keys:
|
||||||
DOCUSIGN_CLIENT_ID Integration key from your DocuSign app
|
DOCUSIGN_CLIENT_ID
|
||||||
DOCUSIGN_USER_ID GUID of the DocuSign user the app will act as
|
DOCUSIGN_CLIENT_SECRET
|
||||||
DOCUSIGN_ACCOUNT_ID Your DocuSign account ID
|
DOCUSIGN_AUTH_SERVER
|
||||||
DOCUSIGN_PRIVATE_KEY_PATH Path to your RSA private key (.pem or .key)
|
DOCUSIGN_REDIRECT_URI
|
||||||
DOCUSIGN_AUTH_SERVER account-d.docusign.com (sandbox)
|
DOCUSIGN_BASE_URL
|
||||||
or account.docusign.com (production)
|
|
||||||
DOCUSIGN_BASE_URL https://demo.docusign.net/restapi (sandbox)
|
|
||||||
or https://na3.docusign.net/restapi (prod, check your account)
|
|
||||||
|
|
||||||
For --consent only:
|
Auto-written to .env after authorization:
|
||||||
DOCUSIGN_CLIENT_SECRET OAuth client secret
|
DOCUSIGN_ACCESS_TOKEN
|
||||||
DOCUSIGN_REDIRECT_URI Must match your app config (default: http://localhost:8080/callback)
|
DOCUSIGN_REFRESH_TOKEN
|
||||||
|
DOCUSIGN_TOKEN_EXPIRY
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
|
@ -33,89 +25,36 @@ import os
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
import webbrowser
|
import webbrowser
|
||||||
from urllib.parse import urlencode, urlparse, parse_qs
|
from urllib.parse import parse_qs, urlencode, urlparse
|
||||||
|
|
||||||
import jwt
|
|
||||||
import requests
|
import requests
|
||||||
from dotenv import load_dotenv, set_key
|
from dotenv import load_dotenv, set_key
|
||||||
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
ENV_FILE = os.path.join(os.path.dirname(__file__), "..", ".env")
|
ENV_FILE = os.path.join(os.path.dirname(__file__), "..", ".env")
|
||||||
TOKEN_EXPIRY_BUFFER = 120 # refresh token 2 minutes before it expires
|
TOKEN_EXPIRY_BUFFER = 120
|
||||||
|
DOCUSIGN_SCOPE = "signature"
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
def _required_env(name: str) -> str:
|
||||||
# JWT Grant
|
value = os.getenv(name)
|
||||||
# ---------------------------------------------------------------------------
|
if not value:
|
||||||
|
raise RuntimeError(f"{name} must be set in .env")
|
||||||
def _load_private_key():
|
return value
|
||||||
key_path = os.getenv("DOCUSIGN_PRIVATE_KEY_PATH")
|
|
||||||
if not key_path:
|
|
||||||
raise RuntimeError("DOCUSIGN_PRIVATE_KEY_PATH is not set in .env")
|
|
||||||
key_path = os.path.expanduser(key_path)
|
|
||||||
if not os.path.exists(key_path):
|
|
||||||
raise RuntimeError(f"Private key not found: {key_path}")
|
|
||||||
with open(key_path, "r") as f:
|
|
||||||
return f.read()
|
|
||||||
|
|
||||||
|
|
||||||
def _request_jwt_token():
|
def _auth_server() -> str:
|
||||||
"""Exchange a JWT assertion for a DocuSign access token."""
|
return os.getenv("DOCUSIGN_AUTH_SERVER", "account-d.docusign.com")
|
||||||
client_id = os.getenv("DOCUSIGN_CLIENT_ID")
|
|
||||||
user_id = os.getenv("DOCUSIGN_USER_ID")
|
|
||||||
auth_server = os.getenv("DOCUSIGN_AUTH_SERVER", "account-d.docusign.com")
|
|
||||||
private_key = _load_private_key()
|
|
||||||
|
|
||||||
if not all([client_id, user_id]):
|
|
||||||
raise RuntimeError("DOCUSIGN_CLIENT_ID and DOCUSIGN_USER_ID must be set in .env")
|
|
||||||
|
|
||||||
now = int(time.time())
|
|
||||||
payload = {
|
|
||||||
"iss": client_id,
|
|
||||||
"sub": user_id,
|
|
||||||
"aud": auth_server,
|
|
||||||
"iat": now,
|
|
||||||
"exp": now + 3600,
|
|
||||||
"scope": "signature impersonation",
|
|
||||||
}
|
|
||||||
|
|
||||||
assertion = jwt.encode(payload, private_key, algorithm="RS256")
|
|
||||||
# PyJWT >= 2.0 returns str; older versions return bytes
|
|
||||||
if isinstance(assertion, bytes):
|
|
||||||
assertion = assertion.decode("utf-8")
|
|
||||||
|
|
||||||
resp = requests.post(
|
|
||||||
f"https://{auth_server}/oauth/token",
|
|
||||||
data={
|
|
||||||
"grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer",
|
|
||||||
"assertion": assertion,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if resp.status_code == 400 and "consent_required" in resp.text:
|
|
||||||
raise RuntimeError(
|
|
||||||
"Consent not yet granted for this app/user combination.\n"
|
|
||||||
"Run: python3 src/docusign_auth.py --consent\n"
|
|
||||||
"Then retry."
|
|
||||||
)
|
|
||||||
resp.raise_for_status()
|
|
||||||
return resp.json()
|
|
||||||
|
|
||||||
|
|
||||||
def get_access_token() -> str:
|
def _redirect_uri() -> str:
|
||||||
"""
|
return os.getenv("DOCUSIGN_REDIRECT_URI", "http://localhost:8000/api/auth/docusign/callback")
|
||||||
Return a valid DocuSign access token, refreshing via JWT grant if needed.
|
|
||||||
Caches the token in .env to avoid unnecessary round-trips.
|
|
||||||
"""
|
|
||||||
cached_token = os.getenv("DOCUSIGN_ACCESS_TOKEN")
|
|
||||||
cached_expiry = os.getenv("DOCUSIGN_TOKEN_EXPIRY")
|
|
||||||
|
|
||||||
if cached_token and cached_expiry:
|
|
||||||
if int(time.time()) < int(cached_expiry) - TOKEN_EXPIRY_BUFFER:
|
|
||||||
return cached_token
|
|
||||||
|
|
||||||
token_data = _request_jwt_token()
|
def _persist_token_data(token_data: dict) -> str:
|
||||||
access_token = token_data["access_token"]
|
access_token = token_data["access_token"]
|
||||||
|
refresh_token = token_data.get("refresh_token")
|
||||||
expiry = int(time.time()) + int(token_data.get("expires_in", 3600))
|
expiry = int(time.time()) + int(token_data.get("expires_in", 3600))
|
||||||
|
|
||||||
abs_env = os.path.abspath(ENV_FILE)
|
abs_env = os.path.abspath(ENV_FILE)
|
||||||
|
|
@ -124,42 +63,36 @@ def get_access_token() -> str:
|
||||||
os.environ["DOCUSIGN_ACCESS_TOKEN"] = access_token
|
os.environ["DOCUSIGN_ACCESS_TOKEN"] = access_token
|
||||||
os.environ["DOCUSIGN_TOKEN_EXPIRY"] = str(expiry)
|
os.environ["DOCUSIGN_TOKEN_EXPIRY"] = str(expiry)
|
||||||
|
|
||||||
|
if refresh_token:
|
||||||
|
set_key(abs_env, "DOCUSIGN_REFRESH_TOKEN", refresh_token)
|
||||||
|
os.environ["DOCUSIGN_REFRESH_TOKEN"] = refresh_token
|
||||||
|
|
||||||
return access_token
|
return access_token
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
def build_authorization_url(state: str | None = None) -> str:
|
||||||
# Auth Code Grant — consent flow
|
client_id = _required_env("DOCUSIGN_CLIENT_ID")
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _build_consent_url():
|
|
||||||
client_id = os.getenv("DOCUSIGN_CLIENT_ID")
|
|
||||||
auth_server = os.getenv("DOCUSIGN_AUTH_SERVER", "account-d.docusign.com")
|
|
||||||
redirect_uri = os.getenv("DOCUSIGN_REDIRECT_URI", "http://localhost:8080/callback")
|
|
||||||
|
|
||||||
params = {
|
params = {
|
||||||
"response_type": "code",
|
"response_type": "code",
|
||||||
"scope": "signature impersonation",
|
"scope": DOCUSIGN_SCOPE,
|
||||||
"client_id": client_id,
|
"client_id": client_id,
|
||||||
"redirect_uri": redirect_uri,
|
"redirect_uri": _redirect_uri(),
|
||||||
}
|
}
|
||||||
return f"https://{auth_server}/oauth/auth?{urlencode(params)}"
|
if state:
|
||||||
|
params["state"] = state
|
||||||
|
return f"https://{_auth_server()}/oauth/auth?{urlencode(params)}"
|
||||||
|
|
||||||
|
|
||||||
def _exchange_code(code: str):
|
def exchange_code_for_token(code: str) -> dict:
|
||||||
client_id = os.getenv("DOCUSIGN_CLIENT_ID")
|
client_id = _required_env("DOCUSIGN_CLIENT_ID")
|
||||||
client_secret = os.getenv("DOCUSIGN_CLIENT_SECRET")
|
client_secret = _required_env("DOCUSIGN_CLIENT_SECRET")
|
||||||
auth_server = os.getenv("DOCUSIGN_AUTH_SERVER", "account-d.docusign.com")
|
|
||||||
redirect_uri = os.getenv("DOCUSIGN_REDIRECT_URI", "http://localhost:8080/callback")
|
|
||||||
|
|
||||||
if not client_secret:
|
|
||||||
raise RuntimeError("DOCUSIGN_CLIENT_SECRET must be set in .env for the consent flow")
|
|
||||||
|
|
||||||
resp = requests.post(
|
resp = requests.post(
|
||||||
f"https://{auth_server}/oauth/token",
|
f"https://{_auth_server()}/oauth/token",
|
||||||
data={
|
data={
|
||||||
"grant_type": "authorization_code",
|
"grant_type": "authorization_code",
|
||||||
"code": code,
|
"code": code,
|
||||||
"redirect_uri": redirect_uri,
|
"redirect_uri": _redirect_uri(),
|
||||||
},
|
},
|
||||||
auth=(client_id, client_secret),
|
auth=(client_id, client_secret),
|
||||||
)
|
)
|
||||||
|
|
@ -167,18 +100,77 @@ def _exchange_code(code: str):
|
||||||
return resp.json()
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
def run_consent_flow():
|
def refresh_access_token(refresh_token: str | None = None) -> dict:
|
||||||
|
client_id = _required_env("DOCUSIGN_CLIENT_ID")
|
||||||
|
client_secret = _required_env("DOCUSIGN_CLIENT_SECRET")
|
||||||
|
refresh_token = refresh_token or os.getenv("DOCUSIGN_REFRESH_TOKEN")
|
||||||
|
|
||||||
|
if not refresh_token:
|
||||||
|
raise RuntimeError(
|
||||||
|
"No DocuSign refresh token found. Run: python3 src/docusign_auth.py --authorize"
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = requests.post(
|
||||||
|
f"https://{_auth_server()}/oauth/token",
|
||||||
|
data={
|
||||||
|
"grant_type": "refresh_token",
|
||||||
|
"refresh_token": refresh_token,
|
||||||
|
},
|
||||||
|
auth=(client_id, client_secret),
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
def save_code_token_exchange(code: str) -> str:
|
||||||
|
token_data = exchange_code_for_token(code)
|
||||||
|
return _persist_token_data(token_data)
|
||||||
|
|
||||||
|
|
||||||
|
def session_from_token_data(token_data: dict, current_session: dict | None = None) -> dict:
|
||||||
"""
|
"""
|
||||||
Open a browser for the user to grant consent, then exchange the code for
|
Merge DocuSign OAuth token data into a web-session dict without writing .env.
|
||||||
an initial access token. After this succeeds, JWT grant will work.
|
|
||||||
"""
|
"""
|
||||||
url = _build_consent_url()
|
session = dict(current_session or {})
|
||||||
print("\nOpening browser for DocuSign consent...")
|
session["docusign_access_token"] = token_data["access_token"]
|
||||||
|
session["docusign_token_expiry"] = int(time.time()) + int(token_data.get("expires_in", 3600))
|
||||||
|
session["docusign_auth_mode"] = "session_oauth"
|
||||||
|
if token_data.get("refresh_token"):
|
||||||
|
session["docusign_refresh_token"] = token_data["refresh_token"]
|
||||||
|
return session
|
||||||
|
|
||||||
|
|
||||||
|
def session_has_valid_access_token(session: dict) -> bool:
|
||||||
|
token = session.get("docusign_access_token")
|
||||||
|
expiry = session.get("docusign_token_expiry")
|
||||||
|
if not token or not expiry:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
return int(time.time()) < int(expiry) - TOKEN_EXPIRY_BUFFER
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def get_access_token() -> str:
|
||||||
|
"""Return a valid DocuSign access token using cached or refreshed OAuth tokens."""
|
||||||
|
cached_token = os.getenv("DOCUSIGN_ACCESS_TOKEN")
|
||||||
|
cached_expiry = os.getenv("DOCUSIGN_TOKEN_EXPIRY")
|
||||||
|
|
||||||
|
if cached_token and cached_expiry:
|
||||||
|
if int(time.time()) < int(cached_expiry) - TOKEN_EXPIRY_BUFFER:
|
||||||
|
return cached_token
|
||||||
|
|
||||||
|
token_data = refresh_access_token()
|
||||||
|
return _persist_token_data(token_data)
|
||||||
|
|
||||||
|
|
||||||
|
def run_authorize_flow():
|
||||||
|
url = build_authorization_url()
|
||||||
|
print("\nOpening browser for DocuSign authorization...")
|
||||||
print(f"\nIf the browser doesn't open, go to:\n{url}\n")
|
print(f"\nIf the browser doesn't open, go to:\n{url}\n")
|
||||||
webbrowser.open(url)
|
webbrowser.open(url)
|
||||||
|
|
||||||
print("Log in and click Allow. The browser will redirect to your redirect URI")
|
print("Log in, approve access, then paste the full redirect URL here.\n")
|
||||||
print("(the page may show an error — that's fine). Copy the full URL and paste it here.\n")
|
|
||||||
redirected_url = input("Paste the redirect URL: ").strip()
|
redirected_url = input("Paste the redirect URL: ").strip()
|
||||||
|
|
||||||
parsed = urlparse(redirected_url)
|
parsed = urlparse(redirected_url)
|
||||||
|
|
@ -189,37 +181,32 @@ def run_consent_flow():
|
||||||
print(f"ERROR: {error}")
|
print(f"ERROR: {error}")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
if "code" not in params:
|
code_list = params.get("code")
|
||||||
|
if not code_list:
|
||||||
print("ERROR: No authorization code found in the URL.")
|
print("ERROR: No authorization code found in the URL.")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
code = params["code"][0]
|
|
||||||
print("Exchanging code for token...")
|
print("Exchanging code for token...")
|
||||||
token_data = _exchange_code(code)
|
save_code_token_exchange(code_list[0])
|
||||||
|
print("Authorization complete. Access and refresh tokens were saved to .env.")
|
||||||
|
|
||||||
access_token = token_data["access_token"]
|
|
||||||
expiry = int(time.time()) + int(token_data.get("expires_in", 3600))
|
|
||||||
|
|
||||||
abs_env = os.path.abspath(ENV_FILE)
|
|
||||||
set_key(abs_env, "DOCUSIGN_ACCESS_TOKEN", access_token)
|
|
||||||
set_key(abs_env, "DOCUSIGN_TOKEN_EXPIRY", str(expiry))
|
|
||||||
|
|
||||||
print("Consent granted and token saved.")
|
|
||||||
print("JWT grant will now work for future calls — you won't need to run --consent again.")
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# CLI
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
parser = argparse.ArgumentParser(description="DocuSign authentication helper")
|
parser = argparse.ArgumentParser(description="DocuSign authentication helper")
|
||||||
parser.add_argument("--consent", action="store_true",
|
parser.add_argument(
|
||||||
help="Run the Auth Code Grant consent flow (required once per user/app)")
|
"--authorize",
|
||||||
|
action="store_true",
|
||||||
|
help="Run the Auth Code Grant flow and store refresh credentials",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--consent",
|
||||||
|
action="store_true",
|
||||||
|
help="Backward-compatible alias for --authorize",
|
||||||
|
)
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
if args.consent:
|
if args.authorize or args.consent:
|
||||||
run_consent_flow()
|
run_authorize_flow()
|
||||||
else:
|
else:
|
||||||
token = get_access_token()
|
token = get_access_token()
|
||||||
print(f"Access token: {token[:20]}... (valid for ~1 hour)")
|
print(f"Access token: {token[:20]}... (valid for ~1 hour)")
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,9 @@ Generates realistic sample PDFs for adobe-to-docusign migration testing.
|
||||||
Each PDF mirrors the form fields described in the matching *-formfields.json
|
Each PDF mirrors the form fields described in the matching *-formfields.json
|
||||||
so that tab positions map to visible labels on the document.
|
so that tab positions map to visible labels on the document.
|
||||||
|
|
||||||
Adobe rect coordinates are top-left origin; DocuSign yPosition is bottom-left.
|
Both Adobe Sign and DocuSign use top-left origin with y increasing downward — no
|
||||||
Formula: docusign_y = PAGE_HEIGHT - adobe_top - adobe_height
|
coordinate inversion is needed. DocuSign xPosition = adobe left, yPosition = adobe top.
|
||||||
To place a *label* just above a field: label_y = PAGE_HEIGHT - adobe_top + 2
|
To place a *label* just above a field in this PDF: label_y = page_height - adobe_top + 2
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
|
|
|
||||||
|
|
@ -115,7 +115,7 @@ def run_migration(template_dir: Path) -> Path:
|
||||||
|
|
||||||
output_path = MIGRATION_OUTPUT_DIR / template_dir.name / "docusign-template.json"
|
output_path = MIGRATION_OUTPUT_DIR / template_dir.name / "docusign-template.json"
|
||||||
print(f"\nRunning migration: {template_dir.name}")
|
print(f"\nRunning migration: {template_dir.name}")
|
||||||
template_dict, warnings = compose_template(str(template_dir), str(output_path))
|
template_dict, warnings, field_issues = compose_template(str(template_dir), str(output_path))
|
||||||
|
|
||||||
print(f" Written: {output_path}")
|
print(f" Written: {output_path}")
|
||||||
if warnings:
|
if warnings:
|
||||||
|
|
|
||||||
|
|
@ -121,7 +121,7 @@ def download_template(template) -> Path:
|
||||||
def convert_template(template_dir: Path) -> Path:
|
def convert_template(template_dir: Path) -> Path:
|
||||||
output_path = OUTPUT_DIR / template_dir.name / "docusign-template.json"
|
output_path = OUTPUT_DIR / template_dir.name / "docusign-template.json"
|
||||||
print(f"\nConverting to DocuSign format...")
|
print(f"\nConverting to DocuSign format...")
|
||||||
_, warnings = compose_template(str(template_dir), str(output_path))
|
_, warnings, _ = compose_template(str(template_dir), str(output_path))
|
||||||
print(f" Written: {output_path}")
|
print(f" Written: {output_path}")
|
||||||
for w in warnings:
|
for w in warnings:
|
||||||
print(f" WARNING: {w}")
|
print(f" WARNING: {w}")
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,8 @@ Validates that compose_docusign_template.py produces correctly structured output
|
||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
import sys
|
import sys
|
||||||
|
import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from pprint import pprint
|
|
||||||
|
|
||||||
# Support running from src/ or project root
|
# Support running from src/ or project root
|
||||||
BASE = Path(__file__).parent.parent
|
BASE = Path(__file__).parent.parent
|
||||||
|
|
@ -21,14 +21,10 @@ sys.path.insert(0, str(Path(__file__).parent))
|
||||||
from compose_docusign_template import compose_template
|
from compose_docusign_template import compose_template
|
||||||
|
|
||||||
|
|
||||||
def test_onboarding_mapping():
|
def test_onboarding_mapping(tmp_path):
|
||||||
output_path = BASE / "validation" / "compose-doc-template-complete.json"
|
template_dir = BASE / "downloads" / "David Tag Demo Form__CBJCHBCA"
|
||||||
compose_template(
|
output_path = tmp_path / "compose-doc-template-complete.json"
|
||||||
fields_path=str(BASE / "sample-templates" / "onboarding-template-formfields.json"),
|
compose_template(str(template_dir), str(output_path))
|
||||||
template_meta_path=str(BASE / "sample-templates" / "onboarding-template.json"),
|
|
||||||
pdf_b64_path=str(BASE / "sample-templates" / "onboarding-sample.pdf.b64"),
|
|
||||||
output_path=str(output_path),
|
|
||||||
)
|
|
||||||
|
|
||||||
template = json.loads(output_path.read_text())
|
template = json.loads(output_path.read_text())
|
||||||
|
|
||||||
|
|
@ -36,10 +32,9 @@ def test_onboarding_mapping():
|
||||||
assert "status" not in template, "Template must not have a top-level 'status' field"
|
assert "status" not in template, "Template must not have a top-level 'status' field"
|
||||||
|
|
||||||
signers = template["recipients"]["signers"]
|
signers = template["recipients"]["signers"]
|
||||||
assert len(signers) == 2, f"Expected 2 signers, got {len(signers)}"
|
assert len(signers) == 1, f"Expected 1 signer, got {len(signers)}"
|
||||||
|
|
||||||
signer0_tabs = signers[0]["tabs"]
|
signer0_tabs = signers[0]["tabs"]
|
||||||
signer1_tabs = signers[1]["tabs"]
|
|
||||||
|
|
||||||
# -- No email/name on role placeholders --
|
# -- No email/name on role placeholders --
|
||||||
for s in signers:
|
for s in signers:
|
||||||
|
|
@ -53,8 +48,6 @@ def test_onboarding_mapping():
|
||||||
assert "checkboxTabs" in signer0_tabs, "Signer 0 missing checkboxTabs"
|
assert "checkboxTabs" in signer0_tabs, "Signer 0 missing checkboxTabs"
|
||||||
assert "radioGroupTabs" in signer0_tabs, "Signer 0 missing radioGroupTabs"
|
assert "radioGroupTabs" in signer0_tabs, "Signer 0 missing radioGroupTabs"
|
||||||
assert "signHereTabs" in signer0_tabs, "Signer 0 missing signHereTabs"
|
assert "signHereTabs" in signer0_tabs, "Signer 0 missing signHereTabs"
|
||||||
assert "textTabs" in signer1_tabs, "Signer 1 missing textTabs"
|
|
||||||
assert "signHereTabs" in signer1_tabs, "Signer 1 missing signHereTabs"
|
|
||||||
|
|
||||||
# -- required / locked are strings --
|
# -- required / locked are strings --
|
||||||
for tab in signer0_tabs.get("textTabs", []):
|
for tab in signer0_tabs.get("textTabs", []):
|
||||||
|
|
@ -73,7 +66,7 @@ def test_onboarding_mapping():
|
||||||
radio_tab = signer0_tabs["radioGroupTabs"][0]
|
radio_tab = signer0_tabs["radioGroupTabs"][0]
|
||||||
assert "groupName" in radio_tab, "radioGroupTab missing groupName"
|
assert "groupName" in radio_tab, "radioGroupTab missing groupName"
|
||||||
assert "radios" in radio_tab, "radioGroupTab missing radios"
|
assert "radios" in radio_tab, "radioGroupTab missing radios"
|
||||||
assert len(radio_tab["radios"]) == 3, f"Expected 3 radios, got {len(radio_tab['radios'])}"
|
assert len(radio_tab["radios"]) >= 1, "Expected at least one radio option"
|
||||||
for r in radio_tab["radios"]:
|
for r in radio_tab["radios"]:
|
||||||
assert "pageNumber" in r, "radio missing pageNumber"
|
assert "pageNumber" in r, "radio missing pageNumber"
|
||||||
assert "xPosition" in r, "radio missing xPosition"
|
assert "xPosition" in r, "radio missing xPosition"
|
||||||
|
|
@ -87,18 +80,11 @@ def test_onboarding_mapping():
|
||||||
+ signer0_tabs.get("signHereTabs", [])
|
+ signer0_tabs.get("signHereTabs", [])
|
||||||
+ signer0_tabs.get("listTabs", [])
|
+ signer0_tabs.get("listTabs", [])
|
||||||
+ signer0_tabs.get("checkboxTabs", [])
|
+ signer0_tabs.get("checkboxTabs", [])
|
||||||
+ signer1_tabs.get("textTabs", [])
|
|
||||||
+ signer1_tabs.get("signHereTabs", [])
|
|
||||||
)
|
)
|
||||||
for tab in all_single_tabs:
|
for tab in all_single_tabs:
|
||||||
for field in ("documentId", "pageNumber", "xPosition", "yPosition"):
|
for field in ("documentId", "pageNumber", "xPosition", "yPosition"):
|
||||||
assert field in tab, f"Tab '{tab.get('tabLabel')}' missing '{field}'"
|
assert field in tab, f"Tab '{tab.get('tabLabel')}' missing '{field}'"
|
||||||
|
|
||||||
print("✅ All mapping assertions passed!")
|
|
||||||
print("\n--- Generated template (recipients section) ---")
|
|
||||||
pprint(template["recipients"])
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
test_onboarding_mapping()
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
test_onboarding_mapping(Path(tmpdir))
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
upload_docusign_template.py
|
upload_docusign_template.py
|
||||||
---------------------------
|
---------------------------
|
||||||
Uploads a DocuSign template JSON file to DocuSign via the REST API.
|
Uploads a DocuSign template JSON file to DocuSign via the REST API.
|
||||||
Authenticates using JWT grant (no Node.js dependency required).
|
Authenticates using DocuSign OAuth tokens stored in .env.
|
||||||
|
|
||||||
By default uses upsert: if a template with the same name already exists,
|
By default uses upsert: if a template with the same name already exists,
|
||||||
the most recently modified one is updated (PUT). Use --force-create to
|
the most recently modified one is updated (PUT). Use --force-create to
|
||||||
|
|
@ -13,12 +13,12 @@ Usage:
|
||||||
python3 src/upload_docusign_template.py --file <path> --force-create
|
python3 src/upload_docusign_template.py --file <path> --force-create
|
||||||
|
|
||||||
First-time setup:
|
First-time setup:
|
||||||
python3 src/docusign_auth.py --consent # grant consent once
|
python3 src/docusign_auth.py --authorize # authorize once
|
||||||
python3 src/upload_docusign_template.py --file <path>
|
python3 src/upload_docusign_template.py --file <path>
|
||||||
|
|
||||||
Required .env keys (see docusign_auth.py for full list):
|
Required .env keys (see docusign_auth.py for full list):
|
||||||
DOCUSIGN_CLIENT_ID, DOCUSIGN_USER_ID, DOCUSIGN_ACCOUNT_ID,
|
DOCUSIGN_CLIENT_ID, DOCUSIGN_CLIENT_SECRET, DOCUSIGN_ACCOUNT_ID,
|
||||||
DOCUSIGN_PRIVATE_KEY_PATH, DOCUSIGN_AUTH_SERVER, DOCUSIGN_BASE_URL
|
DOCUSIGN_AUTH_SERVER, DOCUSIGN_BASE_URL, DOCUSIGN_REDIRECT_URI
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
|
@ -34,6 +34,9 @@ load_dotenv()
|
||||||
sys.path.insert(0, os.path.dirname(__file__))
|
sys.path.insert(0, os.path.dirname(__file__))
|
||||||
|
|
||||||
from docusign_auth import get_access_token
|
from docusign_auth import get_access_token
|
||||||
|
from utils.retry import RetryableHTTPError, raise_for_retryable_status, retry_with_backoff
|
||||||
|
|
||||||
|
_RETRY = dict(max_retries=3, base_delay=1.0, max_delay=16.0, retryable_exceptions=(RetryableHTTPError,))
|
||||||
|
|
||||||
|
|
||||||
def _make_headers(token: str) -> dict:
|
def _make_headers(token: str) -> dict:
|
||||||
|
|
@ -68,6 +71,10 @@ def find_existing_template(
|
||||||
headers.update(_refresh_token_once(headers))
|
headers.update(_refresh_token_once(headers))
|
||||||
resp = requests.get(url, headers=headers, params={"search_text": name, "count": 100})
|
resp = requests.get(url, headers=headers, params={"search_text": name, "count": 100})
|
||||||
|
|
||||||
|
# Raise on 429/5xx so the enclosing upload_template retry decorator can handle it.
|
||||||
|
# For other non-2xx errors, treat as "no match found" rather than a fatal error.
|
||||||
|
if resp.status_code in {429, 500, 502, 503, 504}:
|
||||||
|
raise_for_retryable_status(resp)
|
||||||
if not resp.ok:
|
if not resp.ok:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
@ -84,6 +91,7 @@ def find_existing_template(
|
||||||
return exact[0]["templateId"]
|
return exact[0]["templateId"]
|
||||||
|
|
||||||
|
|
||||||
|
@retry_with_backoff(**_RETRY)
|
||||||
def upload_template(file_path: str, force_create: bool = False) -> str:
|
def upload_template(file_path: str, force_create: bool = False) -> str:
|
||||||
"""
|
"""
|
||||||
Upsert a template JSON file to DocuSign.
|
Upsert a template JSON file to DocuSign.
|
||||||
|
|
@ -123,10 +131,7 @@ def upload_template(file_path: str, force_create: bool = False) -> str:
|
||||||
headers = _refresh_token_once(headers)
|
headers = _refresh_token_once(headers)
|
||||||
resp = requests.put(url, headers=headers, json=template)
|
resp = requests.put(url, headers=headers, json=template)
|
||||||
|
|
||||||
if not resp.ok:
|
raise_for_retryable_status(resp)
|
||||||
print(f"ERROR: Update failed ({resp.status_code})")
|
|
||||||
print(resp.text)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
print(f"Template updated: {existing_id}")
|
print(f"Template updated: {existing_id}")
|
||||||
return existing_id
|
return existing_id
|
||||||
|
|
@ -139,10 +144,7 @@ def upload_template(file_path: str, force_create: bool = False) -> str:
|
||||||
headers = _refresh_token_once(headers)
|
headers = _refresh_token_once(headers)
|
||||||
resp = requests.post(url, headers=headers, json=template)
|
resp = requests.post(url, headers=headers, json=template)
|
||||||
|
|
||||||
if not resp.ok:
|
raise_for_retryable_status(resp)
|
||||||
print(f"ERROR: Upload failed ({resp.status_code})")
|
|
||||||
print(resp.text)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
result = resp.json()
|
result = resp.json()
|
||||||
template_id = result.get("templateId")
|
template_id = result.get("templateId")
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,21 @@ T = TypeVar("T")
|
||||||
_RETRYABLE_STATUS = {429, 500, 502, 503, 504}
|
_RETRYABLE_STATUS = {429, 500, 502, 503, 504}
|
||||||
|
|
||||||
|
|
||||||
|
class RetryableHTTPError(Exception):
|
||||||
|
"""Raised for HTTP status codes that warrant a retry (429, 500, 502, 503, 504)."""
|
||||||
|
|
||||||
|
|
||||||
|
def raise_for_retryable_status(resp) -> None:
|
||||||
|
"""
|
||||||
|
Raise RetryableHTTPError for retryable status codes; call raise_for_status() for
|
||||||
|
all others. Use this instead of resp.raise_for_status() in functions decorated with
|
||||||
|
@retry_with_backoff(retryable_exceptions=(RetryableHTTPError,)).
|
||||||
|
"""
|
||||||
|
if resp.status_code in _RETRYABLE_STATUS:
|
||||||
|
raise RetryableHTTPError(f"HTTP {resp.status_code} from {resp.url} — will retry")
|
||||||
|
resp.raise_for_status()
|
||||||
|
|
||||||
|
|
||||||
def retry_with_backoff(
|
def retry_with_backoff(
|
||||||
max_retries: int = 3,
|
max_retries: int = 3,
|
||||||
base_delay: float = 1.0,
|
base_delay: float = 1.0,
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ Then open [http://localhost:8000](http://localhost:8000).
|
||||||
|
|
||||||
- [ ] Top bar shows two disconnected chips (red dot): "Adobe Sign" and "DocuSign"
|
- [ ] Top bar shows two disconnected chips (red dot): "Adobe Sign" and "DocuSign"
|
||||||
- [ ] Click "Adobe Sign" chip → connects via `.env` refresh token → chip turns green
|
- [ ] Click "Adobe Sign" chip → connects via `.env` refresh token → chip turns green
|
||||||
- [ ] Click "DocuSign" chip → connects via JWT grant → chip turns green
|
- [ ] Click "DocuSign" chip → redirects through OAuth if needed, then chip turns green
|
||||||
- [ ] Disconnecting either chip → chip turns red → templates clear
|
- [ ] Disconnecting either chip → chip turns red → templates clear
|
||||||
|
|
||||||
## 4. Templates View
|
## 4. Templates View
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from web.app import app
|
||||||
|
from web.session import _COOKIE_NAME, create_test_session
|
||||||
|
|
||||||
|
client = TestClient(app, raise_server_exceptions=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_status_requires_admin():
|
||||||
|
cookie = create_test_session({"docusign_user_email": "user@example.com"})
|
||||||
|
resp = client.get("/api/admin/status", cookies={_COOKIE_NAME: cookie})
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_status_returns_build_details(monkeypatch):
|
||||||
|
import web.config as cfg
|
||||||
|
|
||||||
|
monkeypatch.setattr(cfg.settings, "admin_emails_raw", "admin@example.com")
|
||||||
|
monkeypatch.setattr(cfg.settings, "build_id", "testbuild")
|
||||||
|
monkeypatch.setattr(cfg.settings, "asset_version", "testbuild")
|
||||||
|
|
||||||
|
cookie = create_test_session({"docusign_user_email": "admin@example.com"})
|
||||||
|
resp = client.get("/api/admin/status", cookies={_COOKIE_NAME: cookie})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["build_id"] == "testbuild"
|
||||||
|
assert data["asset_version"] == "testbuild"
|
||||||
|
|
||||||
|
|
||||||
|
def test_index_includes_asset_version(monkeypatch):
|
||||||
|
import web.config as cfg
|
||||||
|
|
||||||
|
monkeypatch.setattr(cfg.settings, "asset_version", "asset123")
|
||||||
|
resp = client.get("/")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "/static/js/app.js?v=asset123" in resp.text
|
||||||
|
assert "/static/css/base.css?v=asset123" in resp.text
|
||||||
|
|
@ -0,0 +1,173 @@
|
||||||
|
"""
|
||||||
|
tests/test_api_audit.py
|
||||||
|
-----------------------
|
||||||
|
Tests for audit logging and the recent activity endpoint.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
import respx
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from web.app import app
|
||||||
|
from web.session import _COOKIE_NAME, create_test_session
|
||||||
|
import web.routers.migrate as migrate_module
|
||||||
|
|
||||||
|
client = TestClient(app, raise_server_exceptions=True)
|
||||||
|
|
||||||
|
ADOBE_BASE = "https://api.eu2.adobesign.com/api/rest/v6"
|
||||||
|
DS_BASE = "https://demo.docusign.net/restapi"
|
||||||
|
DS_ACCOUNT = "test-account-id"
|
||||||
|
ADOBE_ID = "adobe-123"
|
||||||
|
DS_NEW_ID = "ds-new-456"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def temp_audit_env(tmp_path, monkeypatch):
|
||||||
|
import web.config as cfg
|
||||||
|
|
||||||
|
monkeypatch.setattr(cfg.settings, "session_store_dir", str(tmp_path / ".session-store"))
|
||||||
|
monkeypatch.setattr(cfg.settings, "audit_log_file", str(tmp_path / ".audit-log.jsonl"))
|
||||||
|
monkeypatch.setattr(cfg.settings, "docusign_account_id", DS_ACCOUNT)
|
||||||
|
monkeypatch.setattr(cfg.settings, "docusign_base_url", DS_BASE)
|
||||||
|
monkeypatch.setattr(cfg.settings, "adobe_sign_base_url", ADOBE_BASE)
|
||||||
|
monkeypatch.setattr(migrate_module, "_HISTORY_FILE", str(tmp_path / ".history.json"))
|
||||||
|
client.cookies.clear()
|
||||||
|
yield
|
||||||
|
client.cookies.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def test_adobe_connect_writes_audit_event(monkeypatch):
|
||||||
|
monkeypatch.setenv("ADOBE_ACCESS_TOKEN", "existing-token")
|
||||||
|
monkeypatch.setenv("ADOBE_REFRESH_TOKEN", "existing-refresh")
|
||||||
|
|
||||||
|
with patch("adobe_api._refresh_access_token", return_value="refreshed-token"), \
|
||||||
|
patch("web.routers.auth._fetch_adobe_profile", return_value={
|
||||||
|
"adobe_user_name": "Paul Adobe",
|
||||||
|
"adobe_user_email": "paul@example.com",
|
||||||
|
"adobe_account_name": "Paul Sandbox",
|
||||||
|
"adobe_account_id": "adobe-account-123",
|
||||||
|
}):
|
||||||
|
resp = client.get("/api/auth/adobe/connect")
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
session_cookie = resp.cookies.get("migrator_session")
|
||||||
|
activity = client.get("/api/audit/recent", cookies={_COOKIE_NAME: session_cookie})
|
||||||
|
assert activity.status_code == 200
|
||||||
|
events = activity.json()["events"]
|
||||||
|
assert events
|
||||||
|
assert events[0]["action"] == "adobe_connected"
|
||||||
|
assert events[0]["adobe_account_name"] == "Paul Sandbox"
|
||||||
|
assert events[0]["details"]["auth_mode"] == "shared_env"
|
||||||
|
|
||||||
|
|
||||||
|
def _mock_compose(template_dir: str, output_path: str):
|
||||||
|
with open(output_path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump({"name": "Test NDA", "description": "mocked"}, f)
|
||||||
|
|
||||||
|
|
||||||
|
def _mock_download(template_id, access_token, output_dir):
|
||||||
|
os.makedirs(output_dir, exist_ok=True)
|
||||||
|
with open(os.path.join(output_dir, "metadata.json"), "w", encoding="utf-8") as f:
|
||||||
|
json.dump({"name": "Test NDA", "id": template_id}, f)
|
||||||
|
with open(os.path.join(output_dir, "form_fields.json"), "w", encoding="utf-8") as f:
|
||||||
|
json.dump({"fields": []}, f)
|
||||||
|
with open(os.path.join(output_dir, "documents.json"), "w", encoding="utf-8") as f:
|
||||||
|
json.dump({"documents": []}, f)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def _async_wrap(fn, *args, **kwargs):
|
||||||
|
return fn(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_migration_writes_audit_event():
|
||||||
|
respx.get(f"{DS_BASE}/v2.1/accounts/{DS_ACCOUNT}/templates").mock(
|
||||||
|
return_value=httpx.Response(200, json={"envelopeTemplates": []})
|
||||||
|
)
|
||||||
|
respx.post(f"{DS_BASE}/v2.1/accounts/{DS_ACCOUNT}/templates").mock(
|
||||||
|
return_value=httpx.Response(201, json={"templateId": DS_NEW_ID})
|
||||||
|
)
|
||||||
|
|
||||||
|
session_cookie = create_test_session({
|
||||||
|
"adobe_access_token": "adobe-tok",
|
||||||
|
"docusign_access_token": "ds-tok",
|
||||||
|
"adobe_account_name": "Adobe QA",
|
||||||
|
"docusign_user_name": "Paul Example",
|
||||||
|
})
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(migrate_module, "_download_adobe_template", new=lambda *args, **kwargs: _async_wrap(_mock_download, *args, **kwargs)),
|
||||||
|
patch.object(migrate_module, "_load_compose", return_value=_mock_compose),
|
||||||
|
):
|
||||||
|
resp = client.post(
|
||||||
|
"/api/migrate",
|
||||||
|
json={"adobe_template_ids": [ADOBE_ID]},
|
||||||
|
cookies={_COOKIE_NAME: session_cookie},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
activity = client.get("/api/audit/recent", cookies={_COOKIE_NAME: session_cookie})
|
||||||
|
assert activity.status_code == 200
|
||||||
|
events = activity.json()["events"]
|
||||||
|
migrate_event = next(event for event in events if event["action"] == "migration_run")
|
||||||
|
assert migrate_event["adobe_account_name"] == "Adobe QA"
|
||||||
|
assert migrate_event["details"]["template_count"] == 1
|
||||||
|
assert migrate_event["details"]["success_count"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_audit_recent_is_session_scoped_by_default():
|
||||||
|
session_a = create_test_session({
|
||||||
|
"docusign_user_email": "a@example.com",
|
||||||
|
"docusign_user_name": "Tester A",
|
||||||
|
}, session_id="session-a")
|
||||||
|
session_b = create_test_session({
|
||||||
|
"docusign_user_email": "b@example.com",
|
||||||
|
"docusign_user_name": "Tester B",
|
||||||
|
}, session_id="session-b")
|
||||||
|
|
||||||
|
import web.config as cfg
|
||||||
|
with open(cfg.settings.audit_log_file, "w", encoding="utf-8") as f:
|
||||||
|
f.write(json.dumps({"timestamp": "2026-04-22T15:00:00Z", "action": "docusign_connected", "session_id": "session-a"}) + "\n")
|
||||||
|
f.write(json.dumps({"timestamp": "2026-04-22T15:01:00Z", "action": "migration_run", "session_id": "session-b"}) + "\n")
|
||||||
|
|
||||||
|
resp_a = client.get("/api/audit/recent", cookies={_COOKIE_NAME: session_a})
|
||||||
|
resp_b = client.get("/api/audit/recent", cookies={_COOKIE_NAME: session_b})
|
||||||
|
|
||||||
|
assert resp_a.status_code == 200
|
||||||
|
assert resp_b.status_code == 200
|
||||||
|
assert [event["session_id"] for event in resp_a.json()["events"]] == ["session-a"]
|
||||||
|
assert [event["session_id"] for event in resp_b.json()["events"]] == ["session-b"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_can_request_all_audit_events(monkeypatch):
|
||||||
|
import web.config as cfg
|
||||||
|
|
||||||
|
monkeypatch.setattr(cfg.settings, "admin_emails_raw", "admin@example.com")
|
||||||
|
admin_cookie = create_test_session({
|
||||||
|
"docusign_user_email": "admin@example.com",
|
||||||
|
}, session_id="admin-session")
|
||||||
|
user_cookie = create_test_session({
|
||||||
|
"docusign_user_email": "user@example.com",
|
||||||
|
}, session_id="user-session")
|
||||||
|
|
||||||
|
with open(cfg.settings.audit_log_file, "w", encoding="utf-8") as f:
|
||||||
|
f.write(json.dumps({"timestamp": "2026-04-22T15:00:00Z", "action": "docusign_connected", "session_id": "admin-session"}) + "\n")
|
||||||
|
f.write(json.dumps({"timestamp": "2026-04-22T15:01:00Z", "action": "migration_run", "session_id": "user-session"}) + "\n")
|
||||||
|
|
||||||
|
admin_resp = client.get("/api/audit/recent?all=true", cookies={_COOKIE_NAME: admin_cookie})
|
||||||
|
user_resp = client.get("/api/audit/recent?all=true", cookies={_COOKIE_NAME: user_cookie})
|
||||||
|
|
||||||
|
assert admin_resp.status_code == 200
|
||||||
|
assert user_resp.status_code == 200
|
||||||
|
assert admin_resp.json()["scope"] == "all"
|
||||||
|
assert len(admin_resp.json()["events"]) == 2
|
||||||
|
assert user_resp.json()["scope"] == "session"
|
||||||
|
assert [event["session_id"] for event in user_resp.json()["events"]] == ["user-session"]
|
||||||
|
|
@ -12,10 +12,42 @@ from fastapi.testclient import TestClient
|
||||||
|
|
||||||
from web.app import app
|
from web.app import app
|
||||||
from web.routers.auth import _ADOBE_TOKEN_URL
|
from web.routers.auth import _ADOBE_TOKEN_URL
|
||||||
|
from web.session import create_test_session
|
||||||
|
|
||||||
client = TestClient(app, raise_server_exceptions=True)
|
client = TestClient(app, raise_server_exceptions=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _userinfo_payload():
|
||||||
|
return {
|
||||||
|
"name": "Paul Example",
|
||||||
|
"email": "paul@example.com",
|
||||||
|
"accounts": [
|
||||||
|
{
|
||||||
|
"account_id": "bbb-account",
|
||||||
|
"account_name": "Zulu Team",
|
||||||
|
"base_uri": "https://na3.docusign.net",
|
||||||
|
"is_default": False,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"account_id": "aaa-account",
|
||||||
|
"account_name": "Alpha Team",
|
||||||
|
"base_uri": "https://na2.docusign.net",
|
||||||
|
"is_default": True,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def temp_session_store(tmp_path, monkeypatch):
|
||||||
|
import web.config as cfg
|
||||||
|
monkeypatch.setattr(cfg.settings, "session_store_dir", str(tmp_path / ".session-store"))
|
||||||
|
monkeypatch.setattr(cfg.settings, "adobe_redirect_uri", "http://localhost:8000/api/auth/adobe/callback")
|
||||||
|
client.cookies.clear()
|
||||||
|
yield
|
||||||
|
client.cookies.clear()
|
||||||
|
|
||||||
|
|
||||||
def test_status_unauthenticated():
|
def test_status_unauthenticated():
|
||||||
"""Fresh session → both platforms disconnected."""
|
"""Fresh session → both platforms disconnected."""
|
||||||
resp = client.get("/api/auth/status", cookies={})
|
resp = client.get("/api/auth/status", cookies={})
|
||||||
|
|
@ -33,8 +65,8 @@ def test_adobe_url_returns_auth_url():
|
||||||
assert "url" in data
|
assert "url" in data
|
||||||
assert "adobesign.com" in data["url"]
|
assert "adobesign.com" in data["url"]
|
||||||
assert "response_type=code" in data["url"]
|
assert "response_type=code" in data["url"]
|
||||||
# Must use the registered redirect URI
|
assert "redirect_uri=http://localhost:8000/api/auth/adobe/callback" in data["url"]
|
||||||
assert "localhost%3A8080" in data["url"] or "localhost:8080" in data["url"]
|
assert resp.cookies.get("migrator_session") is not None
|
||||||
|
|
||||||
|
|
||||||
def test_adobe_connect_env_stores_token(monkeypatch):
|
def test_adobe_connect_env_stores_token(monkeypatch):
|
||||||
|
|
@ -43,7 +75,13 @@ def test_adobe_connect_env_stores_token(monkeypatch):
|
||||||
monkeypatch.setenv("ADOBE_REFRESH_TOKEN", "existing-refresh")
|
monkeypatch.setenv("ADOBE_REFRESH_TOKEN", "existing-refresh")
|
||||||
|
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
with patch("adobe_api._refresh_access_token", return_value="refreshed-token"):
|
with patch("adobe_api._refresh_access_token", return_value="refreshed-token"), \
|
||||||
|
patch("web.routers.auth._fetch_adobe_profile", return_value={
|
||||||
|
"adobe_user_name": "Paul Adobe",
|
||||||
|
"adobe_user_email": "paul@example.com",
|
||||||
|
"adobe_account_name": "Paul Sandbox",
|
||||||
|
"adobe_account_id": "adobe-account-123",
|
||||||
|
}):
|
||||||
resp = client.get("/api/auth/adobe/connect")
|
resp = client.get("/api/auth/adobe/connect")
|
||||||
|
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
|
|
@ -51,15 +89,19 @@ def test_adobe_connect_env_stores_token(monkeypatch):
|
||||||
session_cookie = resp.cookies.get("migrator_session")
|
session_cookie = resp.cookies.get("migrator_session")
|
||||||
status_resp = client.get("/api/auth/status", cookies={"migrator_session": session_cookie})
|
status_resp = client.get("/api/auth/status", cookies={"migrator_session": session_cookie})
|
||||||
assert status_resp.json()["adobe"] is True
|
assert status_resp.json()["adobe"] is True
|
||||||
|
assert status_resp.json()["adobe_account_name"] == "Paul Sandbox"
|
||||||
|
assert status_resp.json()["adobe_account_id"] == "adobe-account-123"
|
||||||
|
assert status_resp.json()["adobe_label"] == "Paul Sandbox"
|
||||||
|
|
||||||
|
|
||||||
def test_adobe_connect_env_fails_without_credentials(monkeypatch):
|
def test_adobe_connect_requests_authorization_without_credentials(monkeypatch):
|
||||||
"""GET /api/auth/adobe/connect with no .env tokens → 400."""
|
"""GET /api/auth/adobe/connect with no .env tokens returns an auth URL."""
|
||||||
monkeypatch.delenv("ADOBE_ACCESS_TOKEN", raising=False)
|
monkeypatch.delenv("ADOBE_ACCESS_TOKEN", raising=False)
|
||||||
monkeypatch.delenv("ADOBE_REFRESH_TOKEN", raising=False)
|
monkeypatch.delenv("ADOBE_REFRESH_TOKEN", raising=False)
|
||||||
resp = client.get("/api/auth/adobe/connect")
|
resp = client.get("/api/auth/adobe/connect")
|
||||||
assert resp.status_code == 400
|
assert resp.status_code == 200
|
||||||
assert "No Adobe Sign credentials" in resp.json()["error"]
|
assert resp.json()["authorization_required"] is True
|
||||||
|
assert "/api/auth/adobe/callback" in resp.json()["authorization_url"]
|
||||||
|
|
||||||
|
|
||||||
@respx.mock
|
@respx.mock
|
||||||
|
|
@ -86,6 +128,68 @@ def test_adobe_exchange_stores_token():
|
||||||
assert status_resp.json()["adobe"] is True
|
assert status_resp.json()["adobe"] is True
|
||||||
|
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_adobe_callback_stores_session_tokens():
|
||||||
|
"""GET /api/auth/adobe/callback stores tokens in this browser session."""
|
||||||
|
respx.post(_ADOBE_TOKEN_URL).mock(
|
||||||
|
return_value=httpx.Response(200, json={
|
||||||
|
"access_token": "adobe-test-token",
|
||||||
|
"refresh_token": "adobe-refresh",
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
cookie = create_test_session({"adobe_oauth_state": "expected-state"})
|
||||||
|
with patch("web.routers.auth._fetch_adobe_profile", return_value={
|
||||||
|
"adobe_user_name": "Paul Adobe",
|
||||||
|
"adobe_user_email": "paul@example.com",
|
||||||
|
"adobe_account_name": "Paul Sandbox",
|
||||||
|
"adobe_account_id": "adobe-account-123",
|
||||||
|
}):
|
||||||
|
resp = client.get(
|
||||||
|
"/api/auth/adobe/callback?code=authcode123&state=expected-state",
|
||||||
|
cookies={"migrator_session": cookie},
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code in (302, 307)
|
||||||
|
session_cookie = resp.cookies.get("migrator_session")
|
||||||
|
status_resp = client.get("/api/auth/status", cookies={"migrator_session": session_cookie})
|
||||||
|
assert status_resp.json()["adobe"] is True
|
||||||
|
assert status_resp.json()["adobe_auth_mode"] == "session_oauth"
|
||||||
|
|
||||||
|
|
||||||
|
def test_adobe_callback_requires_matching_state():
|
||||||
|
cookie = create_test_session({"adobe_oauth_state": "expected-state"})
|
||||||
|
resp = client.get(
|
||||||
|
"/api/auth/adobe/callback?code=authcode123&state=wrong-state",
|
||||||
|
cookies={"migrator_session": cookie},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert "invalid oauth state" in resp.json()["error"]
|
||||||
|
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_adobe_callback_redirects_back_to_requested_page():
|
||||||
|
respx.post(_ADOBE_TOKEN_URL).mock(
|
||||||
|
return_value=httpx.Response(200, json={
|
||||||
|
"access_token": "adobe-test-token",
|
||||||
|
"refresh_token": "adobe-refresh",
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
cookie = create_test_session({"adobe_oauth_state": "expected-state", "adobe_return_to": "#/help"})
|
||||||
|
resp = client.get(
|
||||||
|
"/api/auth/adobe/callback?code=authcode123&state=expected-state",
|
||||||
|
cookies={"migrator_session": cookie},
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code in (302, 307)
|
||||||
|
assert resp.headers["location"] == "#/help"
|
||||||
|
|
||||||
|
|
||||||
def test_adobe_exchange_rejects_missing_code():
|
def test_adobe_exchange_rejects_missing_code():
|
||||||
"""POST /api/auth/adobe/exchange with no code in URL → 400."""
|
"""POST /api/auth/adobe/exchange with no code in URL → 400."""
|
||||||
resp = client.post(
|
resp = client.post(
|
||||||
|
|
@ -96,21 +200,151 @@ def test_adobe_exchange_rejects_missing_code():
|
||||||
|
|
||||||
|
|
||||||
def test_docusign_connect_stores_token():
|
def test_docusign_connect_stores_token():
|
||||||
"""GET /api/auth/docusign/connect uses JWT grant from .env → session connected."""
|
"""GET /api/auth/docusign/connect refreshes the current session's token."""
|
||||||
from unittest.mock import patch
|
from unittest.mock import AsyncMock, patch
|
||||||
import web.routers.auth as auth_module
|
|
||||||
|
|
||||||
with patch("docusign_auth.get_access_token", return_value="ds-jwt-token"):
|
cookie = create_test_session({"docusign_refresh_token": "refresh-123"})
|
||||||
resp = client.get("/api/auth/docusign/connect")
|
with patch("docusign_auth.refresh_access_token", return_value={"access_token": "ds-oauth-token", "refresh_token": "refresh-456", "expires_in": 3600}), \
|
||||||
|
patch("web.routers.auth.fetch_userinfo", new=AsyncMock(return_value=_userinfo_payload())):
|
||||||
|
resp = client.get("/api/auth/docusign/connect", cookies={"migrator_session": cookie})
|
||||||
|
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert resp.json()["connected"] is True
|
assert resp.json()["connected"] is True
|
||||||
|
assert resp.json()["account_selection_required"] is True
|
||||||
|
|
||||||
session_cookie = resp.cookies.get("migrator_session")
|
session_cookie = resp.cookies.get("migrator_session")
|
||||||
assert session_cookie is not None
|
assert session_cookie is not None
|
||||||
|
|
||||||
status_resp = client.get("/api/auth/status", cookies={"migrator_session": session_cookie})
|
status_resp = client.get("/api/auth/status", cookies={"migrator_session": session_cookie})
|
||||||
assert status_resp.json()["docusign"] is True
|
assert status_resp.json()["docusign"] is True
|
||||||
|
assert status_resp.json()["docusign_account_selection_required"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_docusign_connect_requests_authorization_when_refresh_token_missing():
|
||||||
|
"""GET /api/auth/docusign/connect returns a session-scoped auth URL when auth is needed."""
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
with patch("docusign_auth.build_authorization_url", return_value="https://example.com/oauth"):
|
||||||
|
resp = client.get("/api/auth/docusign/connect")
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["authorization_required"] is True
|
||||||
|
assert resp.json()["authorization_url"] == "https://example.com/oauth"
|
||||||
|
assert resp.cookies.get("migrator_session") is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_docusign_callback_requires_matching_state():
|
||||||
|
"""DocuSign callback is rejected when the session state token does not match."""
|
||||||
|
cookie = create_test_session({"docusign_oauth_state": "expected-state"})
|
||||||
|
resp = client.get(
|
||||||
|
"/api/auth/docusign/callback?code=authcode123&state=wrong-state",
|
||||||
|
cookies={"migrator_session": cookie},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert "invalid oauth state" in resp.json()["error"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_docusign_callback_stores_per_session_tokens():
|
||||||
|
"""DocuSign callback stores refresh/access tokens in this browser session only."""
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
cookie = create_test_session({"docusign_oauth_state": "expected-state"})
|
||||||
|
with patch(
|
||||||
|
"docusign_auth.exchange_code_for_token",
|
||||||
|
return_value={"access_token": "access-123", "refresh_token": "refresh-123", "expires_in": 3600},
|
||||||
|
), patch("web.routers.auth.fetch_userinfo", new=AsyncMock(return_value=_userinfo_payload())):
|
||||||
|
resp = client.get(
|
||||||
|
"/api/auth/docusign/callback?code=authcode123&state=expected-state",
|
||||||
|
cookies={"migrator_session": cookie},
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code in (302, 307)
|
||||||
|
session_cookie = resp.cookies.get("migrator_session")
|
||||||
|
status_resp = client.get("/api/auth/status", cookies={"migrator_session": session_cookie})
|
||||||
|
assert status_resp.json()["docusign"] is True
|
||||||
|
assert status_resp.json()["docusign_auth_mode"] == "session_oauth"
|
||||||
|
assert status_resp.json()["docusign_account_selection_required"] is True
|
||||||
|
assert resp.headers["location"] == "#/templates"
|
||||||
|
|
||||||
|
|
||||||
|
def test_docusign_callback_redirects_back_to_requested_page():
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
cookie = create_test_session({"docusign_oauth_state": "expected-state", "docusign_return_to": "#/help"})
|
||||||
|
with patch(
|
||||||
|
"docusign_auth.exchange_code_for_token",
|
||||||
|
return_value={"access_token": "access-123", "refresh_token": "refresh-123", "expires_in": 3600},
|
||||||
|
), patch("web.routers.auth.fetch_userinfo", new=AsyncMock(return_value=_userinfo_payload())):
|
||||||
|
resp = client.get(
|
||||||
|
"/api/auth/docusign/callback?code=authcode123&state=expected-state",
|
||||||
|
cookies={"migrator_session": cookie},
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code in (302, 307)
|
||||||
|
assert resp.headers["location"] == "#/help"
|
||||||
|
|
||||||
|
|
||||||
|
def test_docusign_sessions_are_isolated():
|
||||||
|
"""One tester's DocuSign connection does not authenticate another tester."""
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
session_a = create_test_session({"docusign_oauth_state": "state-a"})
|
||||||
|
session_b = create_test_session({})
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"docusign_auth.exchange_code_for_token",
|
||||||
|
return_value={"access_token": "access-a", "refresh_token": "refresh-a", "expires_in": 3600},
|
||||||
|
), patch("web.routers.auth.fetch_userinfo", new=AsyncMock(return_value=_userinfo_payload())):
|
||||||
|
with TestClient(app, raise_server_exceptions=True) as client_a:
|
||||||
|
callback_resp = client_a.get(
|
||||||
|
"/api/auth/docusign/callback?code=authcode123&state=state-a",
|
||||||
|
cookies={"migrator_session": session_a},
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
cookie_a = callback_resp.cookies.get("migrator_session")
|
||||||
|
status_a = client_a.get("/api/auth/status", cookies={"migrator_session": cookie_a})
|
||||||
|
|
||||||
|
with TestClient(app, raise_server_exceptions=True) as client_b:
|
||||||
|
status_b = client_b.get("/api/auth/status", cookies={"migrator_session": session_b})
|
||||||
|
|
||||||
|
assert status_a.json()["docusign"] is True
|
||||||
|
assert status_b.json()["docusign"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_docusign_accounts_are_sorted_and_selectable():
|
||||||
|
"""Account picker returns alphabetically sorted accounts and stores the user's selection."""
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
cookie = create_test_session({"docusign_oauth_state": "expected-state"})
|
||||||
|
with patch(
|
||||||
|
"docusign_auth.exchange_code_for_token",
|
||||||
|
return_value={"access_token": "access-123", "refresh_token": "refresh-123", "expires_in": 3600},
|
||||||
|
), patch("web.routers.auth.fetch_userinfo", new=AsyncMock(return_value=_userinfo_payload())):
|
||||||
|
resp = client.get(
|
||||||
|
"/api/auth/docusign/callback?code=authcode123&state=expected-state",
|
||||||
|
cookies={"migrator_session": cookie},
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
session_cookie = resp.cookies.get("migrator_session")
|
||||||
|
accounts_resp = client.get("/api/auth/docusign/accounts", cookies={"migrator_session": session_cookie})
|
||||||
|
assert accounts_resp.status_code == 200
|
||||||
|
accounts = accounts_resp.json()["accounts"]
|
||||||
|
assert [a["account_name"] for a in accounts] == ["Alpha Team", "Zulu Team"]
|
||||||
|
assert accounts_resp.json()["selection_required"] is True
|
||||||
|
|
||||||
|
select_resp = client.post(
|
||||||
|
"/api/auth/docusign/account-select",
|
||||||
|
json={"account_id": "aaa-account"},
|
||||||
|
cookies={"migrator_session": session_cookie},
|
||||||
|
)
|
||||||
|
assert select_resp.status_code == 200
|
||||||
|
status_resp = client.get("/api/auth/status", cookies={"migrator_session": session_cookie})
|
||||||
|
assert status_resp.json()["docusign_account_id"] == "aaa-account"
|
||||||
|
assert status_resp.json()["docusign_account_name"] == "Alpha Team"
|
||||||
|
assert status_resp.json()["docusign_account_selection_required"] is False
|
||||||
|
|
||||||
|
|
||||||
@respx.mock
|
@respx.mock
|
||||||
|
|
@ -123,7 +357,7 @@ def test_disconnect_clears_token():
|
||||||
# Connect Adobe via exchange
|
# Connect Adobe via exchange
|
||||||
connect_resp = client.post(
|
connect_resp = client.post(
|
||||||
"/api/auth/adobe/exchange",
|
"/api/auth/adobe/exchange",
|
||||||
json={"redirect_url": "https://localhost:8080/callback?code=abc"},
|
json={"redirect_url": "http://localhost:8000/api/auth/adobe/callback?code=abc"},
|
||||||
)
|
)
|
||||||
session_cookie = connect_resp.cookies["migrator_session"]
|
session_cookie = connect_resp.cookies["migrator_session"]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -159,7 +159,7 @@ def test_status_needs_update():
|
||||||
|
|
||||||
@respx.mock
|
@respx.mock
|
||||||
def test_status_includes_blockers_and_warnings_fields():
|
def test_status_includes_blockers_and_warnings_fields():
|
||||||
"""Each template in the status response has blockers and warnings keys."""
|
"""Each template in the status response has issue-analysis keys."""
|
||||||
respx.get(f"{ADOBE_BASE}/libraryDocuments").mock(
|
respx.get(f"{ADOBE_BASE}/libraryDocuments").mock(
|
||||||
return_value=httpx.Response(200, json={
|
return_value=httpx.Response(200, json={
|
||||||
"libraryDocumentList": [
|
"libraryDocumentList": [
|
||||||
|
|
@ -175,13 +175,16 @@ def test_status_includes_blockers_and_warnings_fields():
|
||||||
t = resp.json()["templates"][0]
|
t = resp.json()["templates"][0]
|
||||||
assert "blockers" in t
|
assert "blockers" in t
|
||||||
assert "warnings" in t
|
assert "warnings" in t
|
||||||
|
assert "field_issues" in t
|
||||||
|
assert "analysis_status" in t
|
||||||
assert isinstance(t["blockers"], list)
|
assert isinstance(t["blockers"], list)
|
||||||
assert isinstance(t["warnings"], list)
|
assert isinstance(t["warnings"], list)
|
||||||
|
assert isinstance(t["field_issues"], list)
|
||||||
|
|
||||||
|
|
||||||
@respx.mock
|
@respx.mock
|
||||||
def test_status_empty_blockers_when_not_downloaded():
|
def test_status_empty_blockers_when_not_downloaded():
|
||||||
"""Template not in downloads dir → blockers and warnings are empty lists."""
|
"""Template not in downloads dir → no local template analysis issues."""
|
||||||
respx.get(f"{ADOBE_BASE}/libraryDocuments").mock(
|
respx.get(f"{ADOBE_BASE}/libraryDocuments").mock(
|
||||||
return_value=httpx.Response(200, json={
|
return_value=httpx.Response(200, json={
|
||||||
"libraryDocumentList": [
|
"libraryDocumentList": [
|
||||||
|
|
@ -196,6 +199,8 @@ def test_status_empty_blockers_when_not_downloaded():
|
||||||
t = resp.json()["templates"][0]
|
t = resp.json()["templates"][0]
|
||||||
assert t["blockers"] == []
|
assert t["blockers"] == []
|
||||||
assert t["warnings"] == []
|
assert t["warnings"] == []
|
||||||
|
assert t["field_issues"] == []
|
||||||
|
assert t["analysis_status"] == "not_downloaded"
|
||||||
|
|
||||||
|
|
||||||
@respx.mock
|
@respx.mock
|
||||||
|
|
@ -229,3 +234,101 @@ def test_status_blockers_populated_when_template_downloaded(tmp_path, monkeypatc
|
||||||
# blockers and warnings are lists (may be empty if downloads path not resolved in test)
|
# blockers and warnings are lists (may be empty if downloads path not resolved in test)
|
||||||
assert isinstance(t["blockers"], list)
|
assert isinstance(t["blockers"], list)
|
||||||
assert isinstance(t["warnings"], list)
|
assert isinstance(t["warnings"], list)
|
||||||
|
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_status_includes_field_issues_when_template_has_mapping_caveats(tmp_path, monkeypatch):
|
||||||
|
"""Composition caveats are surfaced in the template summary, not only migration results."""
|
||||||
|
import json
|
||||||
|
import web.config as cfg
|
||||||
|
|
||||||
|
template_dir = tmp_path / "Contract__adobe-field-warning"
|
||||||
|
template_dir.mkdir()
|
||||||
|
(template_dir / "metadata.json").write_text(json.dumps({"name": "Contract", "id": "adobe-field-warning"}))
|
||||||
|
(template_dir / "documents.json").write_text(json.dumps({"documents": [{"name": "contract.pdf"}]}))
|
||||||
|
(template_dir / "contract.pdf").write_bytes(b"%PDF-1.4\n% test\n")
|
||||||
|
(template_dir / "form_fields.json").write_text(json.dumps({
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"name": "approval_toggle",
|
||||||
|
"inputType": "CHECKBOX",
|
||||||
|
"assignee": "recipient0",
|
||||||
|
"locations": [{"pageNumber": 1, "left": 20, "top": 20, "width": 20, "height": 20}],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "conditional_notes",
|
||||||
|
"inputType": "TEXT_FIELD",
|
||||||
|
"contentType": "DATA",
|
||||||
|
"assignee": "recipient0",
|
||||||
|
"locations": [{"pageNumber": 1, "left": 50, "top": 50, "width": 80, "height": 20}],
|
||||||
|
"conditionalAction": {
|
||||||
|
"action": "HIDE",
|
||||||
|
"predicates": [{"fieldName": "approval_toggle", "operator": "EQUALS", "value": "on"}],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}))
|
||||||
|
monkeypatch.setattr(cfg.settings, "downloads_dir", str(tmp_path))
|
||||||
|
|
||||||
|
respx.get(f"{ADOBE_BASE}/libraryDocuments").mock(
|
||||||
|
return_value=httpx.Response(200, json={
|
||||||
|
"libraryDocumentList": [
|
||||||
|
{"id": "adobe-field-warning", "name": "Contract", "modifiedDate": "2026-04-10"},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
)
|
||||||
|
respx.get(f"{DS_BASE}/v2.1/accounts/{DS_ACCOUNT}/templates").mock(
|
||||||
|
return_value=httpx.Response(200, json={"envelopeTemplates": []})
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = client.get("/api/templates/status", cookies={_COOKIE_NAME: _adobe_session()})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
t = resp.json()["templates"][0]
|
||||||
|
assert t["analysis_status"] == "analyzed"
|
||||||
|
assert any(issue["code"] == "HIDE_ACTION" for issue in t["field_issues"])
|
||||||
|
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_status_uses_history_field_issues_when_download_is_not_persistent(tmp_path, monkeypatch):
|
||||||
|
"""Server-side temp downloads are gone after migration, so status falls back to history."""
|
||||||
|
import json
|
||||||
|
import web.routers.templates as templates_module
|
||||||
|
|
||||||
|
history_path = tmp_path / ".history.json"
|
||||||
|
history_path.write_text(json.dumps([
|
||||||
|
{
|
||||||
|
"timestamp": "2026-04-23T12:00:00Z",
|
||||||
|
"owner_session_id": "legacy",
|
||||||
|
"adobe_template_id": "adobe-history",
|
||||||
|
"adobe_template_name": "History Template",
|
||||||
|
"warnings": ["Skipped: template already exists (overwrite_if_exists=false)"],
|
||||||
|
"blockers": [],
|
||||||
|
"field_issues": [
|
||||||
|
{
|
||||||
|
"code": "FIELD_TYPE_SKIPPED",
|
||||||
|
"field_name": "Image 1",
|
||||||
|
"message": "Image 1 was skipped",
|
||||||
|
"severity": "warning",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
]))
|
||||||
|
monkeypatch.setattr(templates_module, "_HISTORY_FILE", str(history_path))
|
||||||
|
|
||||||
|
respx.get(f"{ADOBE_BASE}/libraryDocuments").mock(
|
||||||
|
return_value=httpx.Response(200, json={
|
||||||
|
"libraryDocumentList": [
|
||||||
|
{"id": "adobe-history", "name": "History Template", "modifiedDate": "2026-04-10"},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
)
|
||||||
|
respx.get(f"{DS_BASE}/v2.1/accounts/{DS_ACCOUNT}/templates").mock(
|
||||||
|
return_value=httpx.Response(200, json={"envelopeTemplates": []})
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = client.get("/api/templates/status", cookies={_COOKIE_NAME: _adobe_session()})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
t = resp.json()["templates"][0]
|
||||||
|
assert t["analysis_status"] == "history"
|
||||||
|
assert t["warnings"] == []
|
||||||
|
assert t["field_issues"][0]["code"] == "FIELD_TYPE_SKIPPED"
|
||||||
|
|
|
||||||
26
web/app.py
26
web/app.py
|
|
@ -11,11 +11,26 @@ From the project root.
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse, HTMLResponse
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from web.config import settings
|
from web.config import settings
|
||||||
from web.routers import auth, templates, migrate, verify
|
from web.routers import auth, templates, migrate, verify, audit, admin
|
||||||
|
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||||
|
from src.utils.log_sanitizer import install_sanitizing_filter
|
||||||
|
|
||||||
|
install_sanitizing_filter()
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_DEFAULT_SECRET = "dev-secret-change-in-production"
|
||||||
|
if settings.session_secret_key == _DEFAULT_SECRET:
|
||||||
|
logger.warning(
|
||||||
|
"SESSION_SECRET_KEY is using the default dev value — set a random secret in .env before exposing this app"
|
||||||
|
)
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="Adobe Sign → DocuSign Migrator",
|
title="Adobe Sign → DocuSign Migrator",
|
||||||
|
|
@ -28,6 +43,8 @@ app.include_router(auth.router, prefix="/api/auth", tags=["auth"])
|
||||||
app.include_router(templates.router, prefix="/api/templates", tags=["templates"])
|
app.include_router(templates.router, prefix="/api/templates", tags=["templates"])
|
||||||
app.include_router(migrate.router, prefix="/api/migrate", tags=["migrate"])
|
app.include_router(migrate.router, prefix="/api/migrate", tags=["migrate"])
|
||||||
app.include_router(verify.router, prefix="/api/verify", tags=["verify"])
|
app.include_router(verify.router, prefix="/api/verify", tags=["verify"])
|
||||||
|
app.include_router(audit.router, prefix="/api/audit", tags=["audit"])
|
||||||
|
app.include_router(admin.router, prefix="/api/admin", tags=["admin"])
|
||||||
|
|
||||||
# Static files (frontend)
|
# Static files (frontend)
|
||||||
_static_dir = os.path.join(os.path.dirname(__file__), "static")
|
_static_dir = os.path.join(os.path.dirname(__file__), "static")
|
||||||
|
|
@ -44,5 +61,8 @@ def health():
|
||||||
def index():
|
def index():
|
||||||
index_path = os.path.join(_static_dir, "index.html")
|
index_path = os.path.join(_static_dir, "index.html")
|
||||||
if os.path.exists(index_path):
|
if os.path.exists(index_path):
|
||||||
return FileResponse(index_path)
|
with open(index_path, encoding="utf-8") as f:
|
||||||
|
html = f.read()
|
||||||
|
html = html.replace("{{ASSET_VERSION}}", settings.asset_version)
|
||||||
|
return HTMLResponse(html)
|
||||||
return {"message": "Adobe Sign → DocuSign Migrator API", "docs": "/api/docs"}
|
return {"message": "Adobe Sign → DocuSign Migrator API", "docs": "/api/docs"}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,134 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from collections import deque
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import Request
|
||||||
|
|
||||||
|
from web.config import settings
|
||||||
|
from web.session import ensure_session_id
|
||||||
|
|
||||||
|
|
||||||
|
def _audit_path() -> str:
|
||||||
|
return settings.audit_log_file
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_parent_dir() -> None:
|
||||||
|
parent = os.path.dirname(_audit_path())
|
||||||
|
if parent:
|
||||||
|
os.makedirs(parent, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _client_ip(request: Request) -> str | None:
|
||||||
|
forwarded = request.headers.get("x-forwarded-for")
|
||||||
|
if forwarded:
|
||||||
|
return forwarded.split(",")[0].strip()
|
||||||
|
if request.client:
|
||||||
|
return request.client.host
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _session_identity(session: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"session_id": session.get("_session_id"),
|
||||||
|
"adobe_user_name": session.get("adobe_user_name"),
|
||||||
|
"adobe_user_email": session.get("adobe_user_email"),
|
||||||
|
"adobe_account_name": session.get("adobe_account_name"),
|
||||||
|
"adobe_account_id": session.get("adobe_account_id"),
|
||||||
|
"docusign_user_name": session.get("docusign_user_name"),
|
||||||
|
"docusign_user_email": session.get("docusign_user_email"),
|
||||||
|
"docusign_account_name": session.get("docusign_selected_account_name"),
|
||||||
|
"docusign_account_id": session.get("docusign_selected_account_id"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def is_admin_session(session: dict[str, Any]) -> bool:
|
||||||
|
emails = {
|
||||||
|
(session.get("docusign_user_email") or "").strip().lower(),
|
||||||
|
(session.get("adobe_user_email") or "").strip().lower(),
|
||||||
|
}
|
||||||
|
emails.discard("")
|
||||||
|
return bool(emails & settings.admin_emails)
|
||||||
|
|
||||||
|
|
||||||
|
def request_context(request: Request) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"path": str(request.url.path),
|
||||||
|
"method": request.method,
|
||||||
|
"ip": _client_ip(request),
|
||||||
|
"user_agent": request.headers.get("user-agent"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_event_with_context(
|
||||||
|
context: dict[str, Any],
|
||||||
|
session: dict[str, Any],
|
||||||
|
action: str,
|
||||||
|
details: dict[str, Any] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
event = {
|
||||||
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"action": action,
|
||||||
|
"path": context.get("path"),
|
||||||
|
"method": context.get("method"),
|
||||||
|
"ip": context.get("ip"),
|
||||||
|
"user_agent": context.get("user_agent"),
|
||||||
|
**_session_identity(session),
|
||||||
|
}
|
||||||
|
if details:
|
||||||
|
event["details"] = details
|
||||||
|
return event
|
||||||
|
|
||||||
|
|
||||||
|
def build_event(request: Request, session: dict[str, Any], action: str, details: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||||
|
return build_event_with_context(request_context(request), session, action, details)
|
||||||
|
|
||||||
|
|
||||||
|
def log_event(request: Request, session: dict[str, Any], action: str, details: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||||
|
ensure_session_id(session)
|
||||||
|
event = build_event(request, session, action, details)
|
||||||
|
_ensure_parent_dir()
|
||||||
|
with open(_audit_path(), "a", encoding="utf-8") as f:
|
||||||
|
f.write(json.dumps(event, ensure_ascii=True) + "\n")
|
||||||
|
return event
|
||||||
|
|
||||||
|
|
||||||
|
def log_context_event(
|
||||||
|
context: dict[str, Any],
|
||||||
|
session: dict[str, Any],
|
||||||
|
action: str,
|
||||||
|
details: dict[str, Any] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
event = build_event_with_context(context, session, action, details)
|
||||||
|
_ensure_parent_dir()
|
||||||
|
with open(_audit_path(), "a", encoding="utf-8") as f:
|
||||||
|
f.write(json.dumps(event, ensure_ascii=True) + "\n")
|
||||||
|
return event
|
||||||
|
|
||||||
|
|
||||||
|
def recent_events(limit: int = 100, *, session_id: str | None = None, include_all: bool = False) -> list[dict[str, Any]]:
|
||||||
|
path = _audit_path()
|
||||||
|
if not os.path.exists(path):
|
||||||
|
return []
|
||||||
|
|
||||||
|
lines: deque[str] = deque(maxlen=max(1, min(limit, 500)))
|
||||||
|
with open(path, encoding="utf-8") as f:
|
||||||
|
for line in f:
|
||||||
|
line = line.strip()
|
||||||
|
if line:
|
||||||
|
lines.append(line)
|
||||||
|
|
||||||
|
events: list[dict[str, Any]] = []
|
||||||
|
for line in reversed(lines):
|
||||||
|
try:
|
||||||
|
item = json.loads(line)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
if isinstance(item, dict):
|
||||||
|
if not include_all and session_id and item.get("session_id") != session_id:
|
||||||
|
continue
|
||||||
|
events.append(item)
|
||||||
|
return events
|
||||||
|
|
@ -6,11 +6,34 @@ All values come from .env or environment variables.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import subprocess
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
|
|
||||||
|
def _project_root() -> str:
|
||||||
|
return os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||||
|
|
||||||
|
|
||||||
|
def _detect_build_id(default: str) -> str:
|
||||||
|
env_build = os.getenv("APP_BUILD_ID", "").strip()
|
||||||
|
if env_build:
|
||||||
|
return env_build
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", "rev-parse", "--short", "HEAD"],
|
||||||
|
cwd=_project_root(),
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
build_id = result.stdout.strip()
|
||||||
|
return build_id or default
|
||||||
|
except Exception:
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
class Settings:
|
class Settings:
|
||||||
# Adobe Sign OAuth
|
# Adobe Sign OAuth
|
||||||
adobe_client_id: str = os.getenv("ADOBE_CLIENT_ID", "")
|
adobe_client_id: str = os.getenv("ADOBE_CLIENT_ID", "")
|
||||||
|
|
@ -28,9 +51,29 @@ class Settings:
|
||||||
|
|
||||||
# Session
|
# Session
|
||||||
session_secret_key: str = os.getenv("SESSION_SECRET_KEY", "dev-secret-change-in-production")
|
session_secret_key: str = os.getenv("SESSION_SECRET_KEY", "dev-secret-change-in-production")
|
||||||
|
session_store_dir: str = os.getenv(
|
||||||
|
"SESSION_STORE_DIR",
|
||||||
|
os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".session-store")),
|
||||||
|
)
|
||||||
|
audit_log_file: str = os.getenv(
|
||||||
|
"AUDIT_LOG_FILE",
|
||||||
|
os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".audit-log.jsonl")),
|
||||||
|
)
|
||||||
|
admin_emails_raw: str = os.getenv("ADMIN_EMAILS", "")
|
||||||
|
|
||||||
# App
|
# App
|
||||||
version: str = "2.0"
|
version: str = "2.0"
|
||||||
|
build_id: str = _detect_build_id("dev")
|
||||||
|
asset_version: str = os.getenv("ASSET_VERSION", build_id)
|
||||||
|
downloads_dir: str = os.getenv("DOWNLOADS_DIR", os.path.abspath(os.path.join(_project_root(), "downloads")))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def admin_emails(self) -> set[str]:
|
||||||
|
return {
|
||||||
|
email.strip().lower()
|
||||||
|
for email in self.admin_emails_raw.split(",")
|
||||||
|
if email.strip()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,160 @@
|
||||||
|
"""
|
||||||
|
Helpers for DocuSign session/account context.
|
||||||
|
|
||||||
|
DocuSign users can belong to multiple accounts, each with its own account_id and
|
||||||
|
base_uri. We fetch that account list from /oauth/userinfo and store it in the
|
||||||
|
browser session so the UI can present an account picker.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from web.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
class DocusignContextError(RuntimeError):
|
||||||
|
"""Raised when the current session is missing required DocuSign context."""
|
||||||
|
|
||||||
|
def __init__(self, message: str, *, status_code: int = 400, code: str = "docusign_context_error"):
|
||||||
|
super().__init__(message)
|
||||||
|
self.status_code = status_code
|
||||||
|
self.code = code
|
||||||
|
|
||||||
|
|
||||||
|
def userinfo_url() -> str:
|
||||||
|
return f"https://{settings.docusign_auth_server}/oauth/userinfo"
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_userinfo(access_token: str) -> dict[str, Any]:
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
resp = await client.get(
|
||||||
|
userinfo_url(),
|
||||||
|
headers={"Authorization": f"Bearer {access_token}"},
|
||||||
|
)
|
||||||
|
if not resp.is_success:
|
||||||
|
raise DocusignContextError(
|
||||||
|
f"DocuSign userinfo failed ({resp.status_code})",
|
||||||
|
status_code=502,
|
||||||
|
code="userinfo_failed",
|
||||||
|
)
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_accounts(userinfo: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
accounts = []
|
||||||
|
for raw in userinfo.get("accounts", []):
|
||||||
|
account_id = raw.get("account_id") or raw.get("accountId")
|
||||||
|
base_uri = (raw.get("base_uri") or raw.get("baseUri") or "").rstrip("/")
|
||||||
|
if not account_id or not base_uri:
|
||||||
|
continue
|
||||||
|
accounts.append({
|
||||||
|
"account_id": account_id,
|
||||||
|
"account_name": raw.get("account_name") or raw.get("accountName") or account_id,
|
||||||
|
"base_uri": base_uri,
|
||||||
|
"base_url": f"{base_uri}/restapi",
|
||||||
|
"is_default": bool(raw.get("is_default") or raw.get("isDefault")),
|
||||||
|
"organization_name": raw.get("organization_name") or raw.get("organizationName"),
|
||||||
|
})
|
||||||
|
accounts.sort(key=lambda item: ((item.get("account_name") or "").lower(), item["account_id"].lower()))
|
||||||
|
return accounts
|
||||||
|
|
||||||
|
|
||||||
|
def merge_userinfo(session: dict[str, Any], userinfo: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
updated = dict(session)
|
||||||
|
updated["docusign_user_name"] = userinfo.get("name")
|
||||||
|
updated["docusign_user_email"] = userinfo.get("email")
|
||||||
|
updated["docusign_accounts"] = normalize_accounts(userinfo)
|
||||||
|
updated["docusign_accounts_count"] = len(updated["docusign_accounts"])
|
||||||
|
|
||||||
|
selected_id = updated.get("docusign_selected_account_id")
|
||||||
|
if selected_id:
|
||||||
|
selected = find_account(updated, selected_id)
|
||||||
|
if selected:
|
||||||
|
_apply_selected_account(updated, selected)
|
||||||
|
else:
|
||||||
|
clear_selected_account(updated)
|
||||||
|
|
||||||
|
if updated["docusign_accounts_count"] == 1:
|
||||||
|
_apply_selected_account(updated, updated["docusign_accounts"][0])
|
||||||
|
|
||||||
|
return updated
|
||||||
|
|
||||||
|
|
||||||
|
def find_account(session: dict[str, Any], account_id: str) -> dict[str, Any] | None:
|
||||||
|
for account in session.get("docusign_accounts", []):
|
||||||
|
if account.get("account_id") == account_id:
|
||||||
|
return account
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_selected_account(session: dict[str, Any], account: dict[str, Any]) -> None:
|
||||||
|
session["docusign_selected_account_id"] = account["account_id"]
|
||||||
|
session["docusign_selected_account_name"] = account.get("account_name")
|
||||||
|
session["docusign_selected_base_uri"] = account.get("base_uri")
|
||||||
|
session["docusign_selected_base_url"] = account.get("base_url")
|
||||||
|
|
||||||
|
|
||||||
|
def clear_selected_account(session: dict[str, Any]) -> None:
|
||||||
|
session.pop("docusign_selected_account_id", None)
|
||||||
|
session.pop("docusign_selected_account_name", None)
|
||||||
|
session.pop("docusign_selected_base_uri", None)
|
||||||
|
session.pop("docusign_selected_base_url", None)
|
||||||
|
|
||||||
|
|
||||||
|
def select_account(session: dict[str, Any], account_id: str) -> dict[str, Any]:
|
||||||
|
account = find_account(session, account_id)
|
||||||
|
if not account:
|
||||||
|
raise DocusignContextError(
|
||||||
|
"DocuSign account not found in this session.",
|
||||||
|
status_code=404,
|
||||||
|
code="account_not_found",
|
||||||
|
)
|
||||||
|
updated = dict(session)
|
||||||
|
_apply_selected_account(updated, account)
|
||||||
|
return updated
|
||||||
|
|
||||||
|
|
||||||
|
def account_picker_required(session: dict[str, Any]) -> bool:
|
||||||
|
if not session.get("docusign_access_token"):
|
||||||
|
return False
|
||||||
|
accounts = session.get("docusign_accounts") or []
|
||||||
|
return len(accounts) > 1 and not session.get("docusign_selected_account_id")
|
||||||
|
|
||||||
|
|
||||||
|
def current_account(session: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
accounts = session.get("docusign_accounts") or []
|
||||||
|
if accounts:
|
||||||
|
selected_id = session.get("docusign_selected_account_id")
|
||||||
|
if not selected_id:
|
||||||
|
raise DocusignContextError(
|
||||||
|
"Select a DocuSign account before continuing.",
|
||||||
|
status_code=409,
|
||||||
|
code="account_selection_required",
|
||||||
|
)
|
||||||
|
selected = find_account(session, selected_id)
|
||||||
|
if not selected:
|
||||||
|
raise DocusignContextError(
|
||||||
|
"Selected DocuSign account is no longer available.",
|
||||||
|
status_code=409,
|
||||||
|
code="account_selection_required",
|
||||||
|
)
|
||||||
|
return selected
|
||||||
|
|
||||||
|
# Fallback for legacy/single-account env-based behavior.
|
||||||
|
if settings.docusign_account_id and settings.docusign_base_url:
|
||||||
|
return {
|
||||||
|
"account_id": settings.docusign_account_id,
|
||||||
|
"account_name": settings.docusign_account_id,
|
||||||
|
"base_url": settings.docusign_base_url.rstrip("/"),
|
||||||
|
"base_uri": settings.docusign_base_url.rstrip("/").removesuffix("/restapi"),
|
||||||
|
"is_default": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
raise DocusignContextError(
|
||||||
|
"No DocuSign account is configured for this session.",
|
||||||
|
status_code=409,
|
||||||
|
code="account_selection_required",
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Request
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
from web.audit import is_admin_session
|
||||||
|
from web.config import settings
|
||||||
|
from web.session import get_session, session_public_view
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/status")
|
||||||
|
def admin_status(request: Request):
|
||||||
|
session = get_session(request)
|
||||||
|
if not is_admin_session(session):
|
||||||
|
return JSONResponse({"error": "admin access required"}, status_code=403)
|
||||||
|
|
||||||
|
public_session = session_public_view(session)
|
||||||
|
return {
|
||||||
|
"version": settings.version,
|
||||||
|
"build_id": settings.build_id,
|
||||||
|
"asset_version": settings.asset_version,
|
||||||
|
"timestamp_utc": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"session": public_session,
|
||||||
|
"environment": {
|
||||||
|
"docusign_base_url": settings.docusign_base_url,
|
||||||
|
"docusign_auth_server": settings.docusign_auth_server,
|
||||||
|
"docusign_redirect_uri": settings.docusign_redirect_uri,
|
||||||
|
"adobe_sign_base_url": settings.adobe_sign_base_url,
|
||||||
|
"adobe_redirect_uri": settings.adobe_redirect_uri,
|
||||||
|
"session_store_dir": settings.session_store_dir,
|
||||||
|
"audit_log_file": settings.audit_log_file,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
from fastapi import APIRouter, Request
|
||||||
|
|
||||||
|
from web.audit import is_admin_session, recent_events
|
||||||
|
from web.session import get_session
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/recent")
|
||||||
|
def get_recent_events(request: Request, limit: int = 100, all: bool = False):
|
||||||
|
limit = max(1, min(limit, 500))
|
||||||
|
session = get_session(request)
|
||||||
|
include_all = all and is_admin_session(session)
|
||||||
|
return {
|
||||||
|
"events": recent_events(
|
||||||
|
limit,
|
||||||
|
session_id=session.get("_session_id"),
|
||||||
|
include_all=include_all,
|
||||||
|
),
|
||||||
|
"scope": "all" if include_all else "session",
|
||||||
|
"is_admin": is_admin_session(session),
|
||||||
|
}
|
||||||
|
|
@ -3,15 +3,12 @@ web/routers/auth.py
|
||||||
-------------------
|
-------------------
|
||||||
OAuth endpoints for Adobe Sign and DocuSign.
|
OAuth endpoints for Adobe Sign and DocuSign.
|
||||||
|
|
||||||
Adobe Sign uses the same redirect URI as the CLI (https://localhost:8080/callback).
|
Both providers now support standard browser redirect callbacks handled directly by
|
||||||
Since nothing runs on that port, the browser lands on a failed page. The user copies
|
this server. Tokens are stored in a server-side session keyed by a signed browser
|
||||||
the URL and submits it via POST /api/auth/adobe/exchange — identical to the CLI flow.
|
cookie.
|
||||||
|
|
||||||
DocuSign uses a standard redirect callback handled directly by this server.
|
|
||||||
|
|
||||||
Tokens are stored in a signed session cookie.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import secrets
|
||||||
from urllib.parse import urlparse, parse_qs
|
from urllib.parse import urlparse, parse_qs
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
@ -19,17 +16,122 @@ from fastapi import APIRouter, Request
|
||||||
from fastapi.responses import JSONResponse, RedirectResponse
|
from fastapi.responses import JSONResponse, RedirectResponse
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from web.audit import is_admin_session, log_event
|
||||||
from web.config import settings
|
from web.config import settings
|
||||||
from web.session import get_session, save_session
|
from web.docusign_context import (
|
||||||
|
DocusignContextError,
|
||||||
|
account_picker_required,
|
||||||
|
clear_selected_account,
|
||||||
|
current_account,
|
||||||
|
fetch_userinfo,
|
||||||
|
merge_userinfo,
|
||||||
|
select_account,
|
||||||
|
)
|
||||||
|
from web.session import get_session, save_session, session_public_view
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
# Adobe Sign registers https://localhost:8080/callback — same as the CLI script.
|
|
||||||
_ADOBE_REDIRECT_URI = "https://localhost:8080/callback"
|
|
||||||
_ADOBE_AUTH_URL = "https://secure.eu2.adobesign.com/public/oauth/v2"
|
_ADOBE_AUTH_URL = "https://secure.eu2.adobesign.com/public/oauth/v2"
|
||||||
_ADOBE_TOKEN_URL = "https://api.eu2.adobesign.com/oauth/v2/token"
|
_ADOBE_TOKEN_URL = "https://api.eu2.adobesign.com/oauth/v2/token"
|
||||||
|
|
||||||
|
|
||||||
|
def _adobe_redirect_uri() -> str:
|
||||||
|
return settings.adobe_redirect_uri
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_return_to(value: str | None) -> str:
|
||||||
|
if value and value.startswith("/#/"):
|
||||||
|
return value
|
||||||
|
if value and value.startswith("#/"):
|
||||||
|
return f"/{value}"
|
||||||
|
return "/#/templates"
|
||||||
|
|
||||||
|
|
||||||
|
def _build_adobe_authorization_url(state: str) -> str:
|
||||||
|
return (
|
||||||
|
f"{_ADOBE_AUTH_URL}"
|
||||||
|
f"?response_type=code"
|
||||||
|
f"&client_id={settings.adobe_client_id}"
|
||||||
|
f"&redirect_uri={_adobe_redirect_uri()}"
|
||||||
|
f"&scope=library_read:self+library_write:self+user_read:self"
|
||||||
|
f"&state={state}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _refresh_adobe_session_token(refresh_token: str) -> dict:
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
resp = await client.post(
|
||||||
|
_ADOBE_TOKEN_URL,
|
||||||
|
data={
|
||||||
|
"grant_type": "refresh_token",
|
||||||
|
"refresh_token": refresh_token,
|
||||||
|
"client_id": settings.adobe_client_id,
|
||||||
|
"client_secret": settings.adobe_client_secret,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if not resp.is_success:
|
||||||
|
raise RuntimeError(f"Adobe Sign token refresh failed ({resp.status_code})")
|
||||||
|
token_data = resp.json()
|
||||||
|
if "error" in token_data:
|
||||||
|
raise RuntimeError(token_data.get("error_description", token_data["error"]))
|
||||||
|
return token_data
|
||||||
|
|
||||||
|
|
||||||
|
async def _fetch_adobe_profile(access_token: str) -> dict:
|
||||||
|
"""
|
||||||
|
Best-effort Adobe Sign profile lookup used only for nicer UI labels.
|
||||||
|
This should never block a successful connection if Adobe returns sparse data.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
resp = await client.get(
|
||||||
|
f"{settings.adobe_sign_base_url}/users/me",
|
||||||
|
headers={"Authorization": f"Bearer {access_token}"},
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
if not resp.is_success:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
data = resp.json() if resp.content else {}
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
company = data.get("company") or {}
|
||||||
|
if not isinstance(company, dict):
|
||||||
|
company = {"name": str(company)} if company else {}
|
||||||
|
account_name = (
|
||||||
|
company.get("name")
|
||||||
|
or data.get("companyName")
|
||||||
|
or data.get("accountName")
|
||||||
|
or data.get("name")
|
||||||
|
)
|
||||||
|
account_id = (
|
||||||
|
company.get("id")
|
||||||
|
or data.get("companyId")
|
||||||
|
or data.get("accountId")
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"adobe_user_name": data.get("name"),
|
||||||
|
"adobe_user_email": data.get("email"),
|
||||||
|
"adobe_account_name": account_name,
|
||||||
|
"adobe_account_id": account_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _merge_adobe_profile(session: dict, access_token: str) -> dict:
|
||||||
|
profile = await _fetch_adobe_profile(access_token)
|
||||||
|
if not profile:
|
||||||
|
return session
|
||||||
|
updated = dict(session)
|
||||||
|
for key, value in profile.items():
|
||||||
|
if value:
|
||||||
|
updated[key] = value
|
||||||
|
return updated
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Status
|
# Status
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -38,36 +140,49 @@ _ADOBE_TOKEN_URL = "https://api.eu2.adobesign.com/oauth/v2/token"
|
||||||
def auth_status(request: Request):
|
def auth_status(request: Request):
|
||||||
"""Returns which platforms the current session is connected to."""
|
"""Returns which platforms the current session is connected to."""
|
||||||
session = get_session(request)
|
session = get_session(request)
|
||||||
|
docusign_account = None
|
||||||
|
try:
|
||||||
|
docusign_account = current_account(session) if session.get("docusign_access_token") else None
|
||||||
|
except DocusignContextError:
|
||||||
|
docusign_account = None
|
||||||
return {
|
return {
|
||||||
"adobe": bool(session.get("adobe_access_token")),
|
**session_public_view(session),
|
||||||
"docusign": bool(session.get("docusign_access_token")),
|
"adobe_label": session.get("adobe_account_name") or session.get("adobe_user_name") or "Adobe Sign",
|
||||||
|
"docusign_label": session.get("docusign_user_name") or "Docusign",
|
||||||
|
"adobe_account_name": session.get("adobe_account_name"),
|
||||||
|
"adobe_account_id": session.get("adobe_account_id"),
|
||||||
|
"docusign_account_id": (docusign_account or {}).get("account_id"),
|
||||||
|
"docusign_account_name": (docusign_account or {}).get("account_name"),
|
||||||
|
"base_url": (docusign_account or {}).get("base_url", settings.docusign_base_url),
|
||||||
|
"docusign_account_selection_required": account_picker_required(session),
|
||||||
|
"is_admin": is_admin_session(session),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Adobe Sign — manual paste flow (matches CLI behaviour)
|
# Adobe Sign — OAuth Authorization Code Grant
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
@router.get("/adobe/url")
|
@router.get("/adobe/url")
|
||||||
def adobe_auth_url():
|
def adobe_auth_url(request: Request, return_to: str | None = None):
|
||||||
"""
|
session = get_session(request)
|
||||||
Return the Adobe Sign authorization URL for the frontend to open in a new tab.
|
state = secrets.token_urlsafe(24)
|
||||||
The user authorizes, lands on a failed page (nothing runs on :8080), copies
|
session["adobe_oauth_state"] = state
|
||||||
the URL, and submits it to POST /api/auth/adobe/exchange.
|
session["adobe_auth_mode"] = "authorization_pending"
|
||||||
"""
|
session["adobe_return_to"] = _sanitize_return_to(return_to)
|
||||||
params = (
|
response = JSONResponse({"url": _build_adobe_authorization_url(state)})
|
||||||
f"?response_type=code"
|
save_session(response, session)
|
||||||
f"&client_id={settings.adobe_client_id}"
|
return response
|
||||||
f"&redirect_uri={_ADOBE_REDIRECT_URI}"
|
|
||||||
f"&scope=library_read:self+library_write:self+user_read:self"
|
|
||||||
)
|
|
||||||
return {"url": _ADOBE_AUTH_URL + params}
|
|
||||||
|
|
||||||
|
|
||||||
class AdobeExchangeRequest(BaseModel):
|
class AdobeExchangeRequest(BaseModel):
|
||||||
redirect_url: str
|
redirect_url: str
|
||||||
|
|
||||||
|
|
||||||
|
class DocusignAccountSelectRequest(BaseModel):
|
||||||
|
account_id: str
|
||||||
|
|
||||||
|
|
||||||
@router.post("/adobe/exchange")
|
@router.post("/adobe/exchange")
|
||||||
async def adobe_exchange(body: AdobeExchangeRequest, request: Request):
|
async def adobe_exchange(body: AdobeExchangeRequest, request: Request):
|
||||||
"""
|
"""
|
||||||
|
|
@ -95,7 +210,7 @@ async def adobe_exchange(body: AdobeExchangeRequest, request: Request):
|
||||||
"grant_type": "authorization_code",
|
"grant_type": "authorization_code",
|
||||||
"client_id": settings.adobe_client_id,
|
"client_id": settings.adobe_client_id,
|
||||||
"client_secret": settings.adobe_client_secret,
|
"client_secret": settings.adobe_client_secret,
|
||||||
"redirect_uri": _ADOBE_REDIRECT_URI,
|
"redirect_uri": _adobe_redirect_uri(),
|
||||||
"code": code,
|
"code": code,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
@ -110,6 +225,14 @@ async def adobe_exchange(body: AdobeExchangeRequest, request: Request):
|
||||||
session = get_session(request)
|
session = get_session(request)
|
||||||
session["adobe_access_token"] = token_data.get("access_token")
|
session["adobe_access_token"] = token_data.get("access_token")
|
||||||
session["adobe_refresh_token"] = token_data.get("refresh_token")
|
session["adobe_refresh_token"] = token_data.get("refresh_token")
|
||||||
|
session["adobe_auth_mode"] = "session_oauth"
|
||||||
|
session = await _merge_adobe_profile(session, session["adobe_access_token"])
|
||||||
|
log_event(
|
||||||
|
request,
|
||||||
|
session,
|
||||||
|
"adobe_connected",
|
||||||
|
{"auth_mode": "session_oauth", "source": "manual_exchange"},
|
||||||
|
)
|
||||||
|
|
||||||
response = JSONResponse({"connected": True})
|
response = JSONResponse({"connected": True})
|
||||||
save_session(response, session)
|
save_session(response, session)
|
||||||
|
|
@ -117,113 +240,113 @@ async def adobe_exchange(body: AdobeExchangeRequest, request: Request):
|
||||||
|
|
||||||
|
|
||||||
@router.get("/adobe/connect")
|
@router.get("/adobe/connect")
|
||||||
def adobe_connect_env(request: Request):
|
async def adobe_connect(request: Request, force_oauth: bool = False, return_to: str | None = None):
|
||||||
"""
|
"""
|
||||||
Load Adobe Sign credentials directly from .env (ADOBE_ACCESS_TOKEN /
|
Obtain an Adobe Sign access token for this browser session.
|
||||||
ADOBE_REFRESH_TOKEN). Refreshes the token if needed. No browser login required
|
If session/env tokens are unavailable or force_oauth=true, return an
|
||||||
when a valid refresh token already exists from a previous CLI auth session.
|
authorization URL so the frontend can start a normal OAuth flow.
|
||||||
"""
|
"""
|
||||||
|
session = get_session(request)
|
||||||
|
token = session.get("adobe_access_token")
|
||||||
|
refresh_token = session.get("adobe_refresh_token")
|
||||||
|
|
||||||
|
if not force_oauth and not token and not refresh_token:
|
||||||
import sys
|
import sys
|
||||||
import os
|
import os
|
||||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "src"))
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "src"))
|
||||||
from adobe_api import _refresh_access_token
|
from adobe_api import _refresh_access_token
|
||||||
|
|
||||||
token = os.getenv("ADOBE_ACCESS_TOKEN")
|
env_token = os.getenv("ADOBE_ACCESS_TOKEN")
|
||||||
refresh_token = os.getenv("ADOBE_REFRESH_TOKEN")
|
env_refresh_token = os.getenv("ADOBE_REFRESH_TOKEN")
|
||||||
|
if env_token or env_refresh_token:
|
||||||
if not token and not refresh_token:
|
token = env_token
|
||||||
return JSONResponse(
|
refresh_token = env_refresh_token
|
||||||
{"error": "No Adobe Sign credentials found in .env. Run src/adobe_auth.py first."},
|
|
||||||
status_code=400,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Always refresh to ensure the token is fresh (access tokens expire in ~1h)
|
|
||||||
if refresh_token:
|
if refresh_token:
|
||||||
try:
|
try:
|
||||||
token = _refresh_access_token()
|
token = _refresh_access_token()
|
||||||
except RuntimeError as e:
|
except RuntimeError as e:
|
||||||
return JSONResponse({"error": str(e)}, status_code=500)
|
return JSONResponse({"error": str(e)}, status_code=500)
|
||||||
|
|
||||||
session = get_session(request)
|
|
||||||
session["adobe_access_token"] = token
|
session["adobe_access_token"] = token
|
||||||
session["adobe_refresh_token"] = refresh_token
|
session["adobe_refresh_token"] = refresh_token
|
||||||
|
session["adobe_auth_mode"] = "shared_env"
|
||||||
|
session = await _merge_adobe_profile(session, token)
|
||||||
|
log_event(
|
||||||
|
request,
|
||||||
|
session,
|
||||||
|
"adobe_connected",
|
||||||
|
{"auth_mode": "shared_env", "source": "server_env"},
|
||||||
|
)
|
||||||
response = JSONResponse({"connected": True})
|
response = JSONResponse({"connected": True})
|
||||||
save_session(response, session)
|
save_session(response, session)
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
if not force_oauth and not token and refresh_token:
|
||||||
@router.get("/adobe/disconnect")
|
|
||||||
def adobe_disconnect(request: Request):
|
|
||||||
session = get_session(request)
|
|
||||||
session.pop("adobe_access_token", None)
|
|
||||||
session.pop("adobe_refresh_token", None)
|
|
||||||
response = JSONResponse({"disconnected": "adobe"})
|
|
||||||
save_session(response, session)
|
|
||||||
return response
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# DocuSign — JWT grant (.env) or OAuth redirect
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
@router.get("/docusign/connect")
|
|
||||||
def docusign_connect(request: Request):
|
|
||||||
"""
|
|
||||||
Obtain a DocuSign access token via JWT grant using the credentials already
|
|
||||||
in .env (DOCUSIGN_CLIENT_ID, DOCUSIGN_USER_ID, DOCUSIGN_PRIVATE_KEY_PATH).
|
|
||||||
No browser sign-in needed — consent was already granted via the CLI setup.
|
|
||||||
"""
|
|
||||||
import sys
|
|
||||||
import os
|
|
||||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "src"))
|
|
||||||
from docusign_auth import get_access_token
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
token = get_access_token()
|
token_data = await _refresh_adobe_session_token(refresh_token)
|
||||||
except RuntimeError as e:
|
except RuntimeError as e:
|
||||||
return JSONResponse({"error": str(e)}, status_code=500)
|
return JSONResponse({"error": str(e)}, status_code=500)
|
||||||
|
token = token_data.get("access_token")
|
||||||
session = get_session(request)
|
session["adobe_access_token"] = token
|
||||||
session["docusign_access_token"] = token
|
session["adobe_refresh_token"] = token_data.get("refresh_token", refresh_token)
|
||||||
|
session["adobe_auth_mode"] = "session_oauth"
|
||||||
|
session = await _merge_adobe_profile(session, token)
|
||||||
|
log_event(
|
||||||
|
request,
|
||||||
|
session,
|
||||||
|
"adobe_connected",
|
||||||
|
{"auth_mode": "session_oauth", "source": "session_refresh"},
|
||||||
|
)
|
||||||
response = JSONResponse({"connected": True})
|
response = JSONResponse({"connected": True})
|
||||||
save_session(response, session)
|
save_session(response, session)
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
if not force_oauth and token:
|
||||||
|
response = JSONResponse({"connected": True})
|
||||||
|
save_session(response, session)
|
||||||
|
return response
|
||||||
|
|
||||||
@router.get("/docusign/start")
|
state = secrets.token_urlsafe(24)
|
||||||
def docusign_start():
|
session["adobe_oauth_state"] = state
|
||||||
"""Redirect to DocuSign OAuth (alternative to JWT grant)."""
|
session["adobe_auth_mode"] = "authorization_pending"
|
||||||
import base64 as _b64
|
session["adobe_return_to"] = _sanitize_return_to(return_to)
|
||||||
params = (
|
authorization_url = _build_adobe_authorization_url(state)
|
||||||
f"?response_type=code"
|
log_event(
|
||||||
f"&scope=signature"
|
request,
|
||||||
f"&client_id={settings.docusign_client_id}"
|
session,
|
||||||
f"&redirect_uri={settings.docusign_redirect_uri}"
|
"adobe_authorization_requested",
|
||||||
|
{"auth_mode": "authorization_pending"},
|
||||||
)
|
)
|
||||||
return RedirectResponse(f"https://{settings.docusign_auth_server}/oauth/auth" + params)
|
response = JSONResponse(
|
||||||
|
{
|
||||||
|
"connected": False,
|
||||||
|
"authorization_required": True,
|
||||||
|
"authorization_url": authorization_url,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
save_session(response, session)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
@router.get("/docusign/callback")
|
@router.get("/adobe/callback")
|
||||||
async def docusign_callback(request: Request, code: str = ""):
|
async def adobe_callback(request: Request, code: str = "", state: str = ""):
|
||||||
"""Handle DocuSign OAuth redirect callback."""
|
"""Handle Adobe Sign OAuth redirect callback."""
|
||||||
import base64
|
|
||||||
if not code:
|
if not code:
|
||||||
return JSONResponse({"error": "missing code"}, status_code=400)
|
return JSONResponse({"error": "missing code"}, status_code=400)
|
||||||
|
|
||||||
credentials = base64.b64encode(
|
session = get_session(request)
|
||||||
f"{settings.docusign_client_id}:{settings.docusign_client_secret}".encode()
|
expected_state = session.get("adobe_oauth_state")
|
||||||
).decode()
|
if not expected_state or state != expected_state:
|
||||||
|
return JSONResponse({"error": "invalid oauth state"}, status_code=400)
|
||||||
|
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
resp = await client.post(
|
resp = await client.post(
|
||||||
f"https://{settings.docusign_auth_server}/oauth/token",
|
_ADOBE_TOKEN_URL,
|
||||||
headers={"Authorization": f"Basic {credentials}"},
|
|
||||||
data={
|
data={
|
||||||
"grant_type": "authorization_code",
|
"grant_type": "authorization_code",
|
||||||
|
"client_id": settings.adobe_client_id,
|
||||||
|
"client_secret": settings.adobe_client_secret,
|
||||||
|
"redirect_uri": _adobe_redirect_uri(),
|
||||||
"code": code,
|
"code": code,
|
||||||
"redirect_uri": settings.docusign_redirect_uri,
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -231,11 +354,234 @@ async def docusign_callback(request: Request, code: str = ""):
|
||||||
return JSONResponse({"error": "token exchange failed", "detail": resp.text}, status_code=502)
|
return JSONResponse({"error": "token exchange failed", "detail": resp.text}, status_code=502)
|
||||||
|
|
||||||
token_data = resp.json()
|
token_data = resp.json()
|
||||||
session = get_session(request)
|
if "error" in token_data:
|
||||||
session["docusign_access_token"] = token_data.get("access_token")
|
return JSONResponse({"error": token_data.get("error_description", token_data["error"])}, status_code=400)
|
||||||
session["docusign_refresh_token"] = token_data.get("refresh_token")
|
|
||||||
|
|
||||||
response = RedirectResponse("/")
|
session["adobe_access_token"] = token_data.get("access_token")
|
||||||
|
session["adobe_refresh_token"] = token_data.get("refresh_token")
|
||||||
|
session["adobe_auth_mode"] = "session_oauth"
|
||||||
|
session.pop("adobe_oauth_state", None)
|
||||||
|
session = await _merge_adobe_profile(session, session["adobe_access_token"])
|
||||||
|
log_event(
|
||||||
|
request,
|
||||||
|
session,
|
||||||
|
"adobe_connected",
|
||||||
|
{"auth_mode": "session_oauth", "source": "browser_callback"},
|
||||||
|
)
|
||||||
|
|
||||||
|
response = RedirectResponse(session.pop("adobe_return_to", "#/templates"))
|
||||||
|
save_session(response, session)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/adobe/disconnect")
|
||||||
|
def adobe_disconnect(request: Request):
|
||||||
|
session = get_session(request)
|
||||||
|
previous_account_name = session.get("adobe_account_name")
|
||||||
|
session.pop("adobe_access_token", None)
|
||||||
|
session.pop("adobe_refresh_token", None)
|
||||||
|
session.pop("adobe_user_name", None)
|
||||||
|
session.pop("adobe_user_email", None)
|
||||||
|
session.pop("adobe_account_name", None)
|
||||||
|
session.pop("adobe_account_id", None)
|
||||||
|
session.pop("adobe_oauth_state", None)
|
||||||
|
session["adobe_auth_mode"] = "disconnected"
|
||||||
|
log_event(
|
||||||
|
request,
|
||||||
|
session,
|
||||||
|
"adobe_disconnected",
|
||||||
|
{"previous_account_name": previous_account_name},
|
||||||
|
)
|
||||||
|
response = JSONResponse({"disconnected": "adobe"})
|
||||||
|
save_session(response, session)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# DocuSign — Auth Code Grant + refresh token
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.get("/docusign/connect")
|
||||||
|
async def docusign_connect(request: Request, return_to: str | None = None):
|
||||||
|
"""
|
||||||
|
Obtain a DocuSign access token from the current browser session.
|
||||||
|
If the session has not been authorized yet, return an authorization URL so
|
||||||
|
the frontend can start an isolated OAuth flow for this tester.
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "src"))
|
||||||
|
from docusign_auth import (
|
||||||
|
build_authorization_url,
|
||||||
|
refresh_access_token,
|
||||||
|
session_from_token_data,
|
||||||
|
session_has_valid_access_token,
|
||||||
|
)
|
||||||
|
|
||||||
|
session = get_session(request)
|
||||||
|
try:
|
||||||
|
if session_has_valid_access_token(session):
|
||||||
|
token = session["docusign_access_token"]
|
||||||
|
elif session.get("docusign_refresh_token"):
|
||||||
|
token_data = refresh_access_token(session["docusign_refresh_token"])
|
||||||
|
session = session_from_token_data(token_data, session)
|
||||||
|
token = session["docusign_access_token"]
|
||||||
|
else:
|
||||||
|
raise RuntimeError("No DocuSign refresh token found for this browser session.")
|
||||||
|
except RuntimeError as e:
|
||||||
|
if "refresh token" in str(e).lower():
|
||||||
|
state = secrets.token_urlsafe(24)
|
||||||
|
session["docusign_oauth_state"] = state
|
||||||
|
session["docusign_auth_mode"] = "authorization_pending"
|
||||||
|
session["docusign_return_to"] = _sanitize_return_to(return_to)
|
||||||
|
authorization_url = build_authorization_url(state=state)
|
||||||
|
log_event(
|
||||||
|
request,
|
||||||
|
session,
|
||||||
|
"docusign_authorization_requested",
|
||||||
|
{"auth_mode": "authorization_pending"},
|
||||||
|
)
|
||||||
|
response = JSONResponse(
|
||||||
|
{
|
||||||
|
"connected": False,
|
||||||
|
"authorization_required": True,
|
||||||
|
"authorization_url": authorization_url,
|
||||||
|
},
|
||||||
|
status_code=200,
|
||||||
|
)
|
||||||
|
save_session(response, session)
|
||||||
|
return response
|
||||||
|
return JSONResponse({"error": str(e)}, status_code=500)
|
||||||
|
|
||||||
|
if not session.get("docusign_accounts"):
|
||||||
|
try:
|
||||||
|
session = merge_userinfo(session, await fetch_userinfo(token))
|
||||||
|
except DocusignContextError as e:
|
||||||
|
return JSONResponse({"error": str(e)}, status_code=e.status_code)
|
||||||
|
|
||||||
|
session["docusign_access_token"] = token
|
||||||
|
session["docusign_auth_mode"] = "session_oauth"
|
||||||
|
log_event(
|
||||||
|
request,
|
||||||
|
session,
|
||||||
|
"docusign_connected",
|
||||||
|
{
|
||||||
|
"auth_mode": "session_oauth",
|
||||||
|
"accounts_count": session.get("docusign_accounts_count", 0),
|
||||||
|
"selection_required": account_picker_required(session),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
response = JSONResponse({
|
||||||
|
"connected": True,
|
||||||
|
"account_selection_required": account_picker_required(session),
|
||||||
|
})
|
||||||
|
save_session(response, session)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/docusign/start")
|
||||||
|
def docusign_start(request: Request):
|
||||||
|
"""Redirect to the DocuSign OAuth authorization screen for this browser session."""
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "src"))
|
||||||
|
from docusign_auth import build_authorization_url
|
||||||
|
|
||||||
|
session = get_session(request)
|
||||||
|
state = secrets.token_urlsafe(24)
|
||||||
|
session["docusign_oauth_state"] = state
|
||||||
|
session["docusign_auth_mode"] = "authorization_pending"
|
||||||
|
session["docusign_return_to"] = "/#/templates"
|
||||||
|
authorization_url = build_authorization_url(state=state)
|
||||||
|
log_event(
|
||||||
|
request,
|
||||||
|
session,
|
||||||
|
"docusign_authorization_started",
|
||||||
|
{"auth_mode": "authorization_pending"},
|
||||||
|
)
|
||||||
|
response = RedirectResponse(authorization_url)
|
||||||
|
save_session(response, session)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/docusign/callback")
|
||||||
|
async def docusign_callback(request: Request, code: str = "", state: str = ""):
|
||||||
|
"""Handle DocuSign OAuth redirect callback."""
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "src"))
|
||||||
|
from docusign_auth import exchange_code_for_token, session_from_token_data
|
||||||
|
|
||||||
|
if not code:
|
||||||
|
return JSONResponse({"error": "missing code"}, status_code=400)
|
||||||
|
|
||||||
|
session = get_session(request)
|
||||||
|
expected_state = session.get("docusign_oauth_state")
|
||||||
|
if not expected_state or state != expected_state:
|
||||||
|
return JSONResponse({"error": "invalid oauth state"}, status_code=400)
|
||||||
|
|
||||||
|
try:
|
||||||
|
token_data = exchange_code_for_token(code)
|
||||||
|
session = session_from_token_data(token_data, session)
|
||||||
|
session = merge_userinfo(session, await fetch_userinfo(session["docusign_access_token"]))
|
||||||
|
except Exception as e:
|
||||||
|
return JSONResponse({"error": "token exchange failed", "detail": str(e)}, status_code=502)
|
||||||
|
|
||||||
|
session.pop("docusign_oauth_state", None)
|
||||||
|
session["docusign_auth_mode"] = "session_oauth"
|
||||||
|
log_event(
|
||||||
|
request,
|
||||||
|
session,
|
||||||
|
"docusign_connected",
|
||||||
|
{
|
||||||
|
"auth_mode": "session_oauth",
|
||||||
|
"accounts_count": session.get("docusign_accounts_count", 0),
|
||||||
|
"selection_required": account_picker_required(session),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
response = RedirectResponse(session.pop("docusign_return_to", "#/templates"))
|
||||||
|
save_session(response, session)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/docusign/accounts")
|
||||||
|
def docusign_accounts(request: Request):
|
||||||
|
session = get_session(request)
|
||||||
|
if not session.get("docusign_access_token"):
|
||||||
|
return JSONResponse({"error": "not authenticated to DocuSign"}, status_code=401)
|
||||||
|
return {
|
||||||
|
"accounts": session.get("docusign_accounts", []),
|
||||||
|
"selected_account_id": session.get("docusign_selected_account_id"),
|
||||||
|
"selection_required": account_picker_required(session),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/docusign/account-select")
|
||||||
|
def docusign_account_select(body: DocusignAccountSelectRequest, request: Request):
|
||||||
|
session = get_session(request)
|
||||||
|
if not session.get("docusign_access_token"):
|
||||||
|
return JSONResponse({"error": "not authenticated to DocuSign"}, status_code=401)
|
||||||
|
try:
|
||||||
|
session = select_account(session, body.account_id)
|
||||||
|
except DocusignContextError as e:
|
||||||
|
return JSONResponse({"error": str(e), "code": e.code}, status_code=e.status_code)
|
||||||
|
log_event(
|
||||||
|
request,
|
||||||
|
session,
|
||||||
|
"docusign_account_selected",
|
||||||
|
{
|
||||||
|
"selected_account_id": session.get("docusign_selected_account_id"),
|
||||||
|
"selected_account_name": session.get("docusign_selected_account_name"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
response = JSONResponse(
|
||||||
|
{
|
||||||
|
"selected_account_id": session.get("docusign_selected_account_id"),
|
||||||
|
"selected_account_name": session.get("docusign_selected_account_name"),
|
||||||
|
}
|
||||||
|
)
|
||||||
save_session(response, session)
|
save_session(response, session)
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
@ -243,8 +589,23 @@ async def docusign_callback(request: Request, code: str = ""):
|
||||||
@router.get("/docusign/disconnect")
|
@router.get("/docusign/disconnect")
|
||||||
def docusign_disconnect(request: Request):
|
def docusign_disconnect(request: Request):
|
||||||
session = get_session(request)
|
session = get_session(request)
|
||||||
|
previous_account_name = session.get("docusign_selected_account_name")
|
||||||
session.pop("docusign_access_token", None)
|
session.pop("docusign_access_token", None)
|
||||||
session.pop("docusign_refresh_token", None)
|
session.pop("docusign_refresh_token", None)
|
||||||
|
session.pop("docusign_token_expiry", None)
|
||||||
|
session.pop("docusign_oauth_state", None)
|
||||||
|
session.pop("docusign_user_name", None)
|
||||||
|
session.pop("docusign_user_email", None)
|
||||||
|
session.pop("docusign_accounts", None)
|
||||||
|
session.pop("docusign_accounts_count", None)
|
||||||
|
clear_selected_account(session)
|
||||||
|
session["docusign_auth_mode"] = "disconnected"
|
||||||
|
log_event(
|
||||||
|
request,
|
||||||
|
session,
|
||||||
|
"docusign_disconnected",
|
||||||
|
{"previous_account_name": previous_account_name},
|
||||||
|
)
|
||||||
response = JSONResponse({"disconnected": "docusign"})
|
response = JSONResponse({"disconnected": "docusign"})
|
||||||
save_session(response, session)
|
save_session(response, session)
|
||||||
return response
|
return response
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,9 @@ from fastapi import APIRouter, Request
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from web.audit import log_context_event, log_event, request_context
|
||||||
from web.config import settings
|
from web.config import settings
|
||||||
|
from web.docusign_context import DocusignContextError, current_account
|
||||||
from web.session import get_session
|
from web.session import get_session
|
||||||
|
|
||||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "src"))
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "src"))
|
||||||
|
|
@ -69,6 +71,20 @@ def _save_history(records: list) -> None:
|
||||||
json.dump(records, f, indent=2)
|
json.dump(records, f, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
def _session_scope(session: dict) -> str:
|
||||||
|
return session.get("_session_id") or "legacy"
|
||||||
|
|
||||||
|
|
||||||
|
def _scope_record(record: dict, session_scope: str) -> dict:
|
||||||
|
scoped = dict(record)
|
||||||
|
scoped["owner_session_id"] = session_scope
|
||||||
|
return scoped
|
||||||
|
|
||||||
|
|
||||||
|
def _filter_history_for_session(records: list, session_scope: str) -> list:
|
||||||
|
return [record for record in records if record.get("owner_session_id", "legacy") == session_scope]
|
||||||
|
|
||||||
|
|
||||||
def _load_compose():
|
def _load_compose():
|
||||||
"""Dynamically load compose_template from src/."""
|
"""Dynamically load compose_template from src/."""
|
||||||
import importlib.util
|
import importlib.util
|
||||||
|
|
@ -143,6 +159,8 @@ async def _migrate_one(
|
||||||
adobe_id: str,
|
adobe_id: str,
|
||||||
adobe_access_token: str,
|
adobe_access_token: str,
|
||||||
docusign_access_token: str,
|
docusign_access_token: str,
|
||||||
|
docusign_account_id: str,
|
||||||
|
docusign_base_url: str,
|
||||||
options: MigrationOptions,
|
options: MigrationOptions,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Run the full pipeline for one Adobe template. Returns a result record."""
|
"""Run the full pipeline for one Adobe template. Returns a result record."""
|
||||||
|
|
@ -257,7 +275,7 @@ async def _migrate_one(
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"Accept": "application/json",
|
"Accept": "application/json",
|
||||||
}
|
}
|
||||||
list_url = f"{settings.docusign_base_url}/v2.1/accounts/{settings.docusign_account_id}/templates"
|
list_url = f"{docusign_base_url}/v2.1/accounts/{docusign_account_id}/templates"
|
||||||
|
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
# Duplicate detection
|
# Duplicate detection
|
||||||
|
|
@ -337,33 +355,57 @@ async def run_migration(body: MigrateRequest, request: Request):
|
||||||
return JSONResponse({"error": "not authenticated to Adobe Sign"}, status_code=401)
|
return JSONResponse({"error": "not authenticated to Adobe Sign"}, status_code=401)
|
||||||
if not session.get("docusign_access_token"):
|
if not session.get("docusign_access_token"):
|
||||||
return JSONResponse({"error": "not authenticated to DocuSign"}, status_code=401)
|
return JSONResponse({"error": "not authenticated to DocuSign"}, status_code=401)
|
||||||
|
try:
|
||||||
|
account = current_account(session)
|
||||||
|
except DocusignContextError as e:
|
||||||
|
return JSONResponse({"error": str(e), "code": e.code}, status_code=e.status_code)
|
||||||
|
|
||||||
ids = body.resolved_ids()
|
ids = body.resolved_ids()
|
||||||
if not ids:
|
if not ids:
|
||||||
return JSONResponse({"error": "no template IDs provided"}, status_code=400)
|
return JSONResponse({"error": "no template IDs provided"}, status_code=400)
|
||||||
|
session_scope = _session_scope(session)
|
||||||
|
|
||||||
tasks = [
|
tasks = [
|
||||||
_migrate_one(
|
_migrate_one(
|
||||||
aid,
|
aid,
|
||||||
session["adobe_access_token"],
|
session["adobe_access_token"],
|
||||||
session["docusign_access_token"],
|
session["docusign_access_token"],
|
||||||
|
account["account_id"],
|
||||||
|
account["base_url"],
|
||||||
body.options,
|
body.options,
|
||||||
)
|
)
|
||||||
for aid in ids
|
for aid in ids
|
||||||
]
|
]
|
||||||
results = await asyncio.gather(*tasks)
|
results = await asyncio.gather(*tasks)
|
||||||
|
scoped_results = [_scope_record(result, session_scope) for result in results]
|
||||||
|
|
||||||
history = _load_history()
|
history = _load_history()
|
||||||
history.extend(results)
|
history.extend(scoped_results)
|
||||||
_save_history(history)
|
_save_history(history)
|
||||||
|
log_event(
|
||||||
|
request,
|
||||||
|
session,
|
||||||
|
"migration_run",
|
||||||
|
{
|
||||||
|
"template_count": len(ids),
|
||||||
|
"dry_run": body.options.dry_run,
|
||||||
|
"overwrite_if_exists": body.options.overwrite_if_exists,
|
||||||
|
"include_documents": body.options.include_documents,
|
||||||
|
"success_count": sum(1 for result in results if result["status"] == "success"),
|
||||||
|
"failed_count": sum(1 for result in results if result["status"] in ("failed", "blocked")),
|
||||||
|
"skipped_count": sum(1 for result in results if result["status"] == "skipped"),
|
||||||
|
"dry_run_count": sum(1 for result in results if result["status"] == "dry_run"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
return {"results": list(results)}
|
return {"results": list(scoped_results)}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/history")
|
@router.get("/history")
|
||||||
def migration_history():
|
def migration_history(request: Request):
|
||||||
"""Return all past migration records."""
|
"""Return all past migration records."""
|
||||||
return {"history": _load_history()}
|
session_scope = _session_scope(get_session(request))
|
||||||
|
return {"history": _filter_history_for_session(_load_history(), session_scope)}
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -372,9 +414,14 @@ def migration_history():
|
||||||
|
|
||||||
async def _run_batch_job(
|
async def _run_batch_job(
|
||||||
job_id: str,
|
job_id: str,
|
||||||
|
owner_session_id: str,
|
||||||
|
request_info: dict,
|
||||||
|
session_snapshot: dict,
|
||||||
ids: List[str],
|
ids: List[str],
|
||||||
adobe_token: str,
|
adobe_token: str,
|
||||||
ds_token: str,
|
ds_token: str,
|
||||||
|
ds_account_id: str,
|
||||||
|
ds_base_url: str,
|
||||||
options: MigrationOptions,
|
options: MigrationOptions,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Background coroutine that processes a batch job and updates _batch_jobs."""
|
"""Background coroutine that processes a batch job and updates _batch_jobs."""
|
||||||
|
|
@ -384,11 +431,11 @@ async def _run_batch_job(
|
||||||
|
|
||||||
for i, adobe_id in enumerate(ids):
|
for i, adobe_id in enumerate(ids):
|
||||||
job["progress"] = {"completed": i, "total": len(ids), "current_id": adobe_id}
|
job["progress"] = {"completed": i, "total": len(ids), "current_id": adobe_id}
|
||||||
result = await _migrate_one(adobe_id, adobe_token, ds_token, options)
|
result = await _migrate_one(adobe_id, adobe_token, ds_token, ds_account_id, ds_base_url, options)
|
||||||
|
|
||||||
# Retry once on transient failures (network errors, not validation blockers)
|
# Retry once on transient failures (network errors, not validation blockers)
|
||||||
if result["status"] == "failed" and "upload failed" in (result.get("error") or ""):
|
if result["status"] == "failed" and "upload failed" in (result.get("error") or ""):
|
||||||
result = await _migrate_one(adobe_id, adobe_token, ds_token, options)
|
result = await _migrate_one(adobe_id, adobe_token, ds_token, ds_account_id, ds_base_url, options)
|
||||||
if result["status"] != "failed":
|
if result["status"] != "failed":
|
||||||
result["retried"] = True
|
result["retried"] = True
|
||||||
|
|
||||||
|
|
@ -397,7 +444,7 @@ async def _run_batch_job(
|
||||||
|
|
||||||
# Persist to history
|
# Persist to history
|
||||||
history = _load_history()
|
history = _load_history()
|
||||||
history.extend(results)
|
history.extend(_scope_record(result, owner_session_id) for result in results)
|
||||||
_save_history(history)
|
_save_history(history)
|
||||||
|
|
||||||
success = sum(1 for r in results if r["status"] == "success")
|
success = sum(1 for r in results if r["status"] == "success")
|
||||||
|
|
@ -414,6 +461,19 @@ async def _run_batch_job(
|
||||||
"skipped": skipped,
|
"skipped": skipped,
|
||||||
"dry_run": dry_runs,
|
"dry_run": dry_runs,
|
||||||
}
|
}
|
||||||
|
log_context_event(
|
||||||
|
request_info,
|
||||||
|
session_snapshot,
|
||||||
|
"migration_batch_completed",
|
||||||
|
{
|
||||||
|
"job_id": job_id,
|
||||||
|
"template_count": len(ids),
|
||||||
|
"success": success,
|
||||||
|
"failed": failed,
|
||||||
|
"skipped": skipped,
|
||||||
|
"dry_run": dry_runs,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/batch")
|
@router.post("/batch")
|
||||||
|
|
@ -427,14 +487,20 @@ async def run_batch_migration(body: MigrateRequest, request: Request):
|
||||||
return JSONResponse({"error": "not authenticated to Adobe Sign"}, status_code=401)
|
return JSONResponse({"error": "not authenticated to Adobe Sign"}, status_code=401)
|
||||||
if not session.get("docusign_access_token"):
|
if not session.get("docusign_access_token"):
|
||||||
return JSONResponse({"error": "not authenticated to DocuSign"}, status_code=401)
|
return JSONResponse({"error": "not authenticated to DocuSign"}, status_code=401)
|
||||||
|
try:
|
||||||
|
account = current_account(session)
|
||||||
|
except DocusignContextError as e:
|
||||||
|
return JSONResponse({"error": str(e), "code": e.code}, status_code=e.status_code)
|
||||||
|
|
||||||
ids = body.resolved_ids()
|
ids = body.resolved_ids()
|
||||||
if not ids:
|
if not ids:
|
||||||
return JSONResponse({"error": "no template IDs provided"}, status_code=400)
|
return JSONResponse({"error": "no template IDs provided"}, status_code=400)
|
||||||
|
session_scope = _session_scope(session)
|
||||||
|
|
||||||
job_id = str(uuid.uuid4())
|
job_id = str(uuid.uuid4())
|
||||||
_batch_jobs[job_id] = {
|
_batch_jobs[job_id] = {
|
||||||
"job_id": job_id,
|
"job_id": job_id,
|
||||||
|
"owner_session_id": session_scope,
|
||||||
"status": "queued",
|
"status": "queued",
|
||||||
"total": len(ids),
|
"total": len(ids),
|
||||||
"results": [],
|
"results": [],
|
||||||
|
|
@ -442,12 +508,26 @@ async def run_batch_migration(body: MigrateRequest, request: Request):
|
||||||
"summary": None,
|
"summary": None,
|
||||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||||
}
|
}
|
||||||
|
log_event(
|
||||||
|
request,
|
||||||
|
session,
|
||||||
|
"migration_batch_started",
|
||||||
|
{
|
||||||
|
"job_id": job_id,
|
||||||
|
"template_count": len(ids),
|
||||||
|
"dry_run": body.options.dry_run,
|
||||||
|
"overwrite_if_exists": body.options.overwrite_if_exists,
|
||||||
|
"include_documents": body.options.include_documents,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
asyncio.create_task(
|
asyncio.create_task(
|
||||||
_run_batch_job(
|
_run_batch_job(
|
||||||
job_id, ids,
|
job_id, session_scope, request_context(request), dict(session), ids,
|
||||||
session["adobe_access_token"],
|
session["adobe_access_token"],
|
||||||
session["docusign_access_token"],
|
session["docusign_access_token"],
|
||||||
|
account["account_id"],
|
||||||
|
account["base_url"],
|
||||||
body.options,
|
body.options,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
@ -456,9 +536,12 @@ async def run_batch_migration(body: MigrateRequest, request: Request):
|
||||||
|
|
||||||
|
|
||||||
@router.get("/batch/{job_id}")
|
@router.get("/batch/{job_id}")
|
||||||
def get_batch_status(job_id: str):
|
def get_batch_status(job_id: str, request: Request):
|
||||||
"""Poll the status of a batch migration job."""
|
"""Poll the status of a batch migration job."""
|
||||||
job = _batch_jobs.get(job_id)
|
job = _batch_jobs.get(job_id)
|
||||||
if not job:
|
if not job:
|
||||||
return JSONResponse({"error": "batch job not found"}, status_code=404)
|
return JSONResponse({"error": "batch job not found"}, status_code=404)
|
||||||
|
session_scope = _session_scope(get_session(request))
|
||||||
|
if job.get("owner_session_id") != session_scope:
|
||||||
|
return JSONResponse({"error": "batch job not found"}, status_code=404)
|
||||||
return job
|
return job
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,12 @@ Template listing endpoints for Adobe Sign and DocuSign.
|
||||||
Computes per-template migration status for the side-by-side UI.
|
Computes per-template migration status for the side-by-side UI.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import os
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
import tempfile
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
@ -14,10 +18,15 @@ from fastapi import APIRouter, Request
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
from web.config import settings
|
from web.config import settings
|
||||||
|
from web.docusign_context import DocusignContextError, current_account
|
||||||
from web.session import get_session
|
from web.session import get_session
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
_HISTORY_FILE = os.path.abspath(os.path.join(
|
||||||
|
os.path.dirname(__file__), "..", "..", "migration-output", ".history.json"
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
def _require_adobe(session: dict) -> Optional[JSONResponse]:
|
def _require_adobe(session: dict) -> Optional[JSONResponse]:
|
||||||
if not session.get("adobe_access_token"):
|
if not session.get("adobe_access_token"):
|
||||||
|
|
@ -28,6 +37,10 @@ def _require_adobe(session: dict) -> Optional[JSONResponse]:
|
||||||
def _require_docusign(session: dict) -> Optional[JSONResponse]:
|
def _require_docusign(session: dict) -> Optional[JSONResponse]:
|
||||||
if not session.get("docusign_access_token"):
|
if not session.get("docusign_access_token"):
|
||||||
return JSONResponse({"error": "not authenticated to DocuSign"}, status_code=401)
|
return JSONResponse({"error": "not authenticated to DocuSign"}, status_code=401)
|
||||||
|
try:
|
||||||
|
current_account(session)
|
||||||
|
except DocusignContextError as e:
|
||||||
|
return JSONResponse({"error": str(e), "code": e.code}, status_code=e.status_code)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -69,10 +82,11 @@ async def list_docusign_templates(request: Request):
|
||||||
err = _require_docusign(session)
|
err = _require_docusign(session)
|
||||||
if err:
|
if err:
|
||||||
return err
|
return err
|
||||||
|
account = current_account(session)
|
||||||
|
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
resp = await client.get(
|
resp = await client.get(
|
||||||
f"{settings.docusign_base_url}/v2.1/accounts/{settings.docusign_account_id}/templates",
|
f"{account['base_url']}/v2.1/accounts/{account['account_id']}/templates",
|
||||||
headers={"Authorization": f"Bearer {session['docusign_access_token']}"},
|
headers={"Authorization": f"Bearer {session['docusign_access_token']}"},
|
||||||
params={"count": 100},
|
params={"count": 100},
|
||||||
)
|
)
|
||||||
|
|
@ -107,6 +121,7 @@ async def template_status(request: Request):
|
||||||
err = _require_adobe(session) or _require_docusign(session)
|
err = _require_adobe(session) or _require_docusign(session)
|
||||||
if err:
|
if err:
|
||||||
return err
|
return err
|
||||||
|
account = current_account(session)
|
||||||
|
|
||||||
# Fetch both lists concurrently
|
# Fetch both lists concurrently
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
|
|
@ -117,7 +132,7 @@ async def template_status(request: Request):
|
||||||
params={"pageSize": 100},
|
params={"pageSize": 100},
|
||||||
),
|
),
|
||||||
client.get(
|
client.get(
|
||||||
f"{settings.docusign_base_url}/v2.1/accounts/{settings.docusign_account_id}/templates",
|
f"{account['base_url']}/v2.1/accounts/{account['account_id']}/templates",
|
||||||
headers={"Authorization": f"Bearer {session['docusign_access_token']}"},
|
headers={"Authorization": f"Bearer {session['docusign_access_token']}"},
|
||||||
params={"count": 100},
|
params={"count": 100},
|
||||||
),
|
),
|
||||||
|
|
@ -152,7 +167,14 @@ async def template_status(request: Request):
|
||||||
# needs_update if Adobe was modified after the DS template
|
# needs_update if Adobe was modified after the DS template
|
||||||
status = "needs_update" if adobe_modified > ds_modified else "migrated"
|
status = "needs_update" if adobe_modified > ds_modified else "migrated"
|
||||||
|
|
||||||
blockers, warnings = _get_validation(t.get("id", ""), name)
|
analysis = _get_template_analysis(t.get("id", ""), name)
|
||||||
|
if not _has_analysis_issues(analysis):
|
||||||
|
history_analysis = _get_history_analysis(
|
||||||
|
t.get("id", ""),
|
||||||
|
name,
|
||||||
|
session.get("_session_id") or "legacy",
|
||||||
|
)
|
||||||
|
analysis = _merge_analysis(analysis, history_analysis)
|
||||||
|
|
||||||
results.append({
|
results.append({
|
||||||
"adobe_id": t.get("id"),
|
"adobe_id": t.get("id"),
|
||||||
|
|
@ -161,36 +183,169 @@ async def template_status(request: Request):
|
||||||
"docusign_id": ds_match.get("templateId") if ds_match else None,
|
"docusign_id": ds_match.get("templateId") if ds_match else None,
|
||||||
"docusign_modified": ds_match.get("lastModified") if ds_match else None,
|
"docusign_modified": ds_match.get("lastModified") if ds_match else None,
|
||||||
"status": status,
|
"status": status,
|
||||||
"blockers": blockers,
|
"blockers": analysis["blockers"],
|
||||||
"warnings": warnings,
|
"warnings": analysis["warnings"],
|
||||||
|
"field_issues": analysis["field_issues"],
|
||||||
|
"analysis_status": analysis["status"],
|
||||||
})
|
})
|
||||||
|
|
||||||
return {"templates": results}
|
return {"templates": results}
|
||||||
|
|
||||||
|
|
||||||
def _get_validation(template_id: str, template_name: str) -> tuple[list, list]:
|
def _get_template_analysis(template_id: str, template_name: str) -> dict:
|
||||||
"""Return (blockers, warnings) if the template has been downloaded; else ([], [])."""
|
"""
|
||||||
|
Return validation and composition issues for a downloaded template.
|
||||||
|
|
||||||
|
Validation blockers/warnings answer "can this migrate at all?"
|
||||||
|
Field issues answer "what mapping caveats would migration introduce?"
|
||||||
|
If the template has not been downloaded yet, there is no local field data to analyze.
|
||||||
|
"""
|
||||||
|
analysis = {
|
||||||
|
"blockers": [],
|
||||||
|
"warnings": [],
|
||||||
|
"field_issues": [],
|
||||||
|
"status": "not_downloaded",
|
||||||
|
}
|
||||||
try:
|
try:
|
||||||
from src.services.mapping_service import adobe_folder_to_normalized
|
from src.services.mapping_service import adobe_folder_to_normalized
|
||||||
from src.services.validation_service import validate_template
|
from src.services.validation_service import validate_template
|
||||||
|
from src.compose_docusign_template import compose_template
|
||||||
|
|
||||||
downloads_dir = Path(settings.downloads_dir) if hasattr(settings, "downloads_dir") else Path("downloads")
|
template_dir = _find_downloaded_template(template_id, template_name)
|
||||||
# Match folder by name__id or name pattern
|
if not template_dir:
|
||||||
|
return analysis
|
||||||
|
|
||||||
|
normalized, _ = adobe_folder_to_normalized(str(template_dir), include_documents=False)
|
||||||
|
result = validate_template(normalized)
|
||||||
|
analysis["blockers"] = result.blockers
|
||||||
|
analysis["warnings"] = result.warnings
|
||||||
|
|
||||||
|
try:
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
output_path = Path(tmpdir) / "docusign-template.json"
|
||||||
|
_, _compose_warnings, field_issues = compose_template(str(template_dir), str(output_path))
|
||||||
|
analysis["field_issues"] = field_issues
|
||||||
|
except Exception as exc:
|
||||||
|
analysis["warnings"] = _dedupe([
|
||||||
|
*analysis["warnings"],
|
||||||
|
f"Field mapping analysis unavailable: {exc}",
|
||||||
|
])
|
||||||
|
|
||||||
|
analysis["status"] = "analyzed"
|
||||||
|
return analysis
|
||||||
|
except Exception as exc:
|
||||||
|
analysis["warnings"] = [f"Template analysis unavailable: {exc}"]
|
||||||
|
analysis["status"] = "error"
|
||||||
|
return analysis
|
||||||
|
|
||||||
|
|
||||||
|
def _find_downloaded_template(template_id: str, template_name: str) -> Path | None:
|
||||||
|
downloads_dir = Path(settings.downloads_dir)
|
||||||
candidates = list(downloads_dir.glob(f"*__{template_id}"))
|
candidates = list(downloads_dir.glob(f"*__{template_id}"))
|
||||||
if not candidates:
|
if not candidates:
|
||||||
# Try matching by sanitised name prefix
|
|
||||||
safe = template_name.replace("/", "_").replace("\\", "_")
|
safe = template_name.replace("/", "_").replace("\\", "_")
|
||||||
candidates = list(downloads_dir.glob(f"{safe}*"))
|
candidates = list(downloads_dir.glob(f"{safe}*"))
|
||||||
|
return next((c for c in candidates if c.is_dir()), None)
|
||||||
|
|
||||||
if not candidates or not candidates[0].is_dir():
|
|
||||||
return [], []
|
|
||||||
|
|
||||||
normalized = adobe_folder_to_normalized(str(candidates[0]))
|
def _get_history_analysis(template_id: str, template_name: str, session_scope: str) -> dict:
|
||||||
result = validate_template(normalized)
|
"""
|
||||||
return result.blockers, result.warnings
|
Return the latest issue details captured during migration for this template.
|
||||||
|
|
||||||
|
The production web migration flow downloads Adobe template data to a temp
|
||||||
|
directory, so the Templates page may not have persistent local downloads to
|
||||||
|
re-analyze. Migration history is the source of truth for issues discovered
|
||||||
|
during an actual migration attempt.
|
||||||
|
"""
|
||||||
|
analysis = {
|
||||||
|
"blockers": [],
|
||||||
|
"warnings": [],
|
||||||
|
"field_issues": [],
|
||||||
|
"status": "not_found",
|
||||||
|
}
|
||||||
|
matching_records = [
|
||||||
|
record for record in _load_history()
|
||||||
|
if record.get("owner_session_id", "legacy") == session_scope
|
||||||
|
and (
|
||||||
|
record.get("adobe_template_id") == template_id
|
||||||
|
or record.get("adobe_template_name") == template_name
|
||||||
|
)
|
||||||
|
]
|
||||||
|
if not matching_records:
|
||||||
|
return analysis
|
||||||
|
|
||||||
|
matching_records.sort(key=lambda record: record.get("timestamp", ""), reverse=True)
|
||||||
|
for record in matching_records:
|
||||||
|
blockers = record.get("blockers") or []
|
||||||
|
warnings = _template_warnings(record.get("warnings") or [])
|
||||||
|
field_issues = record.get("field_issues") or []
|
||||||
|
if blockers or warnings or field_issues:
|
||||||
|
analysis["blockers"] = blockers
|
||||||
|
analysis["warnings"] = warnings
|
||||||
|
analysis["field_issues"] = field_issues
|
||||||
|
analysis["status"] = "history"
|
||||||
|
return analysis
|
||||||
|
|
||||||
|
analysis["status"] = "history_clean"
|
||||||
|
return analysis
|
||||||
|
|
||||||
|
|
||||||
|
def _load_history() -> list:
|
||||||
|
if not os.path.exists(_HISTORY_FILE):
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
with open(_HISTORY_FILE, encoding="utf-8") as f:
|
||||||
|
return json.load(f)
|
||||||
except Exception:
|
except Exception:
|
||||||
return [], []
|
return []
|
||||||
|
|
||||||
|
|
||||||
# asyncio needed for gather — import at top of module
|
def _template_warnings(warnings: list[str]) -> list[str]:
|
||||||
import asyncio
|
"""Remove operational migration messages that should not make a template look risky."""
|
||||||
|
return [
|
||||||
|
warning for warning in warnings
|
||||||
|
if not str(warning).startswith("Skipped: template already exists")
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _has_analysis_issues(analysis: dict) -> bool:
|
||||||
|
return bool(analysis["blockers"] or analysis["warnings"] or analysis["field_issues"])
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_analysis(primary: dict, fallback: dict) -> dict:
|
||||||
|
if fallback["status"] in ("not_found", "history_clean"):
|
||||||
|
return primary
|
||||||
|
merged = {
|
||||||
|
"blockers": _dedupe([*primary["blockers"], *fallback["blockers"]]),
|
||||||
|
"warnings": _dedupe([*primary["warnings"], *fallback["warnings"]]),
|
||||||
|
"field_issues": _dedupe_field_issues([*primary["field_issues"], *fallback["field_issues"]]),
|
||||||
|
"status": fallback["status"] if primary["status"] == "not_downloaded" else primary["status"],
|
||||||
|
}
|
||||||
|
return merged
|
||||||
|
|
||||||
|
|
||||||
|
def _dedupe_field_issues(items: list[dict]) -> list[dict]:
|
||||||
|
seen = set()
|
||||||
|
result = []
|
||||||
|
for item in items:
|
||||||
|
key = (
|
||||||
|
item.get("code"),
|
||||||
|
item.get("field_name"),
|
||||||
|
item.get("message"),
|
||||||
|
)
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
result.append(item)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _dedupe(items: list[str]) -> list[str]:
|
||||||
|
seen = set()
|
||||||
|
result = []
|
||||||
|
for item in items:
|
||||||
|
if item in seen:
|
||||||
|
continue
|
||||||
|
seen.add(item)
|
||||||
|
result.append(item)
|
||||||
|
return result
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,8 @@ from fastapi import APIRouter, Request
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from web.config import settings
|
from web.audit import log_event
|
||||||
|
from web.docusign_context import DocusignContextError, current_account
|
||||||
from web.session import get_session
|
from web.session import get_session
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
@ -31,6 +32,10 @@ class VoidRequest(BaseModel):
|
||||||
def _require_docusign(session: dict) -> Optional[JSONResponse]:
|
def _require_docusign(session: dict) -> Optional[JSONResponse]:
|
||||||
if not session.get("docusign_access_token"):
|
if not session.get("docusign_access_token"):
|
||||||
return JSONResponse({"error": "not authenticated to DocuSign"}, status_code=401)
|
return JSONResponse({"error": "not authenticated to DocuSign"}, status_code=401)
|
||||||
|
try:
|
||||||
|
current_account(session)
|
||||||
|
except DocusignContextError as e:
|
||||||
|
return JSONResponse({"error": str(e), "code": e.code}, status_code=e.status_code)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -41,12 +46,13 @@ async def send_test_envelope(body: SendRequest, request: Request):
|
||||||
err = _require_docusign(session)
|
err = _require_docusign(session)
|
||||||
if err:
|
if err:
|
||||||
return err
|
return err
|
||||||
|
account = current_account(session)
|
||||||
|
|
||||||
headers = {
|
headers = {
|
||||||
"Authorization": f"Bearer {session['docusign_access_token']}",
|
"Authorization": f"Bearer {session['docusign_access_token']}",
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
}
|
}
|
||||||
base = f"{settings.docusign_base_url}/v2.1/accounts/{settings.docusign_account_id}"
|
base = f"{account['base_url']}/v2.1/accounts/{account['account_id']}"
|
||||||
|
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
# Fetch template to discover actual role names
|
# Fetch template to discover actual role names
|
||||||
|
|
@ -87,6 +93,17 @@ async def send_test_envelope(body: SendRequest, request: Request):
|
||||||
)
|
)
|
||||||
|
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
|
log_event(
|
||||||
|
request,
|
||||||
|
session,
|
||||||
|
"verification_sent",
|
||||||
|
{
|
||||||
|
"template_id": body.template_id,
|
||||||
|
"recipient_email": body.recipient_email,
|
||||||
|
"recipient_name": body.recipient_name,
|
||||||
|
"envelope_id": data.get("envelopeId"),
|
||||||
|
},
|
||||||
|
)
|
||||||
return {"envelope_id": data.get("envelopeId"), "roles": role_names}
|
return {"envelope_id": data.get("envelopeId"), "roles": role_names}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -97,10 +114,11 @@ async def envelope_status(envelope_id: str, request: Request):
|
||||||
err = _require_docusign(session)
|
err = _require_docusign(session)
|
||||||
if err:
|
if err:
|
||||||
return err
|
return err
|
||||||
|
account = current_account(session)
|
||||||
|
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
resp = await client.get(
|
resp = await client.get(
|
||||||
f"{settings.docusign_base_url}/v2.1/accounts/{settings.docusign_account_id}/envelopes/{envelope_id}",
|
f"{account['base_url']}/v2.1/accounts/{account['account_id']}/envelopes/{envelope_id}",
|
||||||
headers={"Authorization": f"Bearer {session['docusign_access_token']}"},
|
headers={"Authorization": f"Bearer {session['docusign_access_token']}"},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -126,10 +144,11 @@ async def void_envelope(envelope_id: str, body: VoidRequest, request: Request):
|
||||||
err = _require_docusign(session)
|
err = _require_docusign(session)
|
||||||
if err:
|
if err:
|
||||||
return err
|
return err
|
||||||
|
account = current_account(session)
|
||||||
|
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
resp = await client.put(
|
resp = await client.put(
|
||||||
f"{settings.docusign_base_url}/v2.1/accounts/{settings.docusign_account_id}/envelopes/{envelope_id}",
|
f"{account['base_url']}/v2.1/accounts/{account['account_id']}/envelopes/{envelope_id}",
|
||||||
headers={
|
headers={
|
||||||
"Authorization": f"Bearer {session['docusign_access_token']}",
|
"Authorization": f"Bearer {session['docusign_access_token']}",
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
|
|
@ -143,4 +162,10 @@ async def void_envelope(envelope_id: str, body: VoidRequest, request: Request):
|
||||||
status_code=502,
|
status_code=502,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
log_event(
|
||||||
|
request,
|
||||||
|
session,
|
||||||
|
"verification_voided",
|
||||||
|
{"envelope_id": envelope_id, "reason": body.reason},
|
||||||
|
)
|
||||||
return {"voided": True, "envelope_id": envelope_id}
|
return {"voided": True, "envelope_id": envelope_id}
|
||||||
|
|
|
||||||
158
web/session.py
158
web/session.py
|
|
@ -1,45 +1,177 @@
|
||||||
"""
|
"""
|
||||||
web/session.py
|
web/session.py
|
||||||
--------------
|
--------------
|
||||||
Session helpers using signed cookies (itsdangerous).
|
Session helpers backed by a signed session-id cookie plus server-side JSON files.
|
||||||
Stores Adobe Sign and DocuSign tokens server-side in the cookie payload.
|
|
||||||
|
|
||||||
Sessions are short-lived (1 hour) and signed but not encrypted.
|
This keeps OAuth refresh tokens off the client and allows multiple testers to use
|
||||||
Do not store sensitive secrets here beyond access tokens.
|
their own browser sessions concurrently against the same deployment.
|
||||||
|
|
||||||
|
Backward compatibility:
|
||||||
|
- Older signed-cookie payloads that stored the full session dict are still read.
|
||||||
|
- Any write upgrades the browser to the server-side session-store format.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from itsdangerous import URLSafeTimedSerializer, BadSignature, SignatureExpired
|
from itsdangerous import URLSafeTimedSerializer, BadSignature, SignatureExpired
|
||||||
from fastapi import Request, Response
|
from fastapi import Request, Response
|
||||||
|
|
||||||
from web.config import settings
|
from web.config import settings
|
||||||
|
|
||||||
_serializer = URLSafeTimedSerializer(settings.session_secret_key)
|
_serializer = URLSafeTimedSerializer(settings.session_secret_key)
|
||||||
_COOKIE_NAME = "migrator_session"
|
_COOKIE_NAME = "migrator_session"
|
||||||
_MAX_AGE = 3600 # 1 hour
|
_MAX_AGE = 3600 # 1 hour
|
||||||
|
_SESSION_ID_KEY = "_session_id"
|
||||||
|
|
||||||
|
|
||||||
def get_session(request: Request) -> dict:
|
def _session_store_dir() -> str:
|
||||||
"""Read and verify the session cookie. Returns an empty dict if missing or invalid."""
|
return settings.session_store_dir
|
||||||
|
|
||||||
|
|
||||||
|
def _session_path(session_id: str) -> str:
|
||||||
|
return os.path.join(_session_store_dir(), f"{session_id}.json")
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_store_dir() -> None:
|
||||||
|
os.makedirs(_session_store_dir(), exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _new_session_id() -> str:
|
||||||
|
return secrets.token_urlsafe(24)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_server_session(session_id: str) -> dict:
|
||||||
|
path = _session_path(session_id)
|
||||||
|
if not os.path.exists(path):
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
with open(path) as f:
|
||||||
|
data = json.load(f)
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
return {}
|
||||||
|
return data if isinstance(data, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _write_server_session(session_id: str, data: dict) -> None:
|
||||||
|
_ensure_store_dir()
|
||||||
|
path = _session_path(session_id)
|
||||||
|
tmp_path = f"{path}.tmp"
|
||||||
|
with open(tmp_path, "w") as f:
|
||||||
|
json.dump(data, f)
|
||||||
|
os.replace(tmp_path, path)
|
||||||
|
|
||||||
|
|
||||||
|
def _delete_server_session(session_id: str | None) -> None:
|
||||||
|
if not session_id:
|
||||||
|
return
|
||||||
|
path = _session_path(session_id)
|
||||||
|
try:
|
||||||
|
os.remove(path)
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _attach_session_id(data: dict, session_id: str) -> dict:
|
||||||
|
payload = dict(data)
|
||||||
|
payload[_SESSION_ID_KEY] = session_id
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_cookie(request: Request) -> dict | str | None:
|
||||||
raw = request.cookies.get(_COOKIE_NAME)
|
raw = request.cookies.get(_COOKIE_NAME)
|
||||||
if not raw:
|
if not raw:
|
||||||
return {}
|
return None
|
||||||
try:
|
try:
|
||||||
return _serializer.loads(raw, max_age=_MAX_AGE)
|
return _serializer.loads(raw, max_age=_MAX_AGE)
|
||||||
except (BadSignature, SignatureExpired):
|
except (BadSignature, SignatureExpired):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_session(request: Request) -> dict:
|
||||||
|
"""Return session data for the current request."""
|
||||||
|
decoded = _decode_cookie(request)
|
||||||
|
if decoded is None:
|
||||||
|
return {}
|
||||||
|
if isinstance(decoded, dict):
|
||||||
|
# Legacy cookie format from earlier app versions.
|
||||||
|
return decoded
|
||||||
|
if isinstance(decoded, str):
|
||||||
|
return _attach_session_id(_load_server_session(decoded), decoded)
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
def save_session(response: Response, data: dict) -> None:
|
def get_session_id(request: Request) -> str | None:
|
||||||
"""Sign and write session data into a cookie on the response."""
|
session = get_session(request)
|
||||||
signed = _serializer.dumps(data)
|
return session.get(_SESSION_ID_KEY)
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_session_id(data: dict) -> str:
|
||||||
|
sid = data.get(_SESSION_ID_KEY)
|
||||||
|
if sid:
|
||||||
|
return sid
|
||||||
|
sid = _new_session_id()
|
||||||
|
data[_SESSION_ID_KEY] = sid
|
||||||
|
return sid
|
||||||
|
|
||||||
|
|
||||||
|
def create_test_session(data: dict, session_id: str | None = None) -> str:
|
||||||
|
"""
|
||||||
|
Test helper: create a server-side session and return a valid cookie value.
|
||||||
|
"""
|
||||||
|
sid = session_id or _new_session_id()
|
||||||
|
payload = dict(data)
|
||||||
|
payload.pop(_SESSION_ID_KEY, None)
|
||||||
|
_write_server_session(sid, payload)
|
||||||
|
return _serializer.dumps(sid)
|
||||||
|
|
||||||
|
|
||||||
|
def save_session(response: Response, data: dict, session_id: str | None = None) -> str:
|
||||||
|
"""Persist session data server-side and set the signed session-id cookie."""
|
||||||
|
sid = session_id or ensure_session_id(data)
|
||||||
|
payload = dict(data)
|
||||||
|
payload.pop(_SESSION_ID_KEY, None)
|
||||||
|
_write_server_session(sid, payload)
|
||||||
response.set_cookie(
|
response.set_cookie(
|
||||||
_COOKIE_NAME,
|
_COOKIE_NAME,
|
||||||
signed,
|
_serializer.dumps(sid),
|
||||||
max_age=_MAX_AGE,
|
max_age=_MAX_AGE,
|
||||||
httponly=True,
|
httponly=True,
|
||||||
samesite="lax",
|
samesite="lax",
|
||||||
)
|
)
|
||||||
|
return sid
|
||||||
|
|
||||||
|
|
||||||
def clear_session(response: Response) -> None:
|
def clear_session(response: Response, request: Request | None = None) -> None:
|
||||||
"""Delete the session cookie."""
|
"""Delete the browser session cookie and remove server-side session data when possible."""
|
||||||
|
session_id = None
|
||||||
|
if request is not None:
|
||||||
|
session_id = get_session_id(request)
|
||||||
|
_delete_server_session(session_id)
|
||||||
response.delete_cookie(_COOKIE_NAME)
|
response.delete_cookie(_COOKIE_NAME)
|
||||||
|
|
||||||
|
|
||||||
|
def session_public_view(session: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Return the subset of session data that is useful for UI/debug responses.
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"session_id": session.get(_SESSION_ID_KEY),
|
||||||
|
"adobe": bool(session.get("adobe_access_token")),
|
||||||
|
"docusign": bool(session.get("docusign_access_token")),
|
||||||
|
"adobe_auth_mode": session.get("adobe_auth_mode", "disconnected"),
|
||||||
|
"adobe_user_name": session.get("adobe_user_name"),
|
||||||
|
"adobe_user_email": session.get("adobe_user_email"),
|
||||||
|
"adobe_account_name": session.get("adobe_account_name"),
|
||||||
|
"adobe_account_id": session.get("adobe_account_id"),
|
||||||
|
"docusign_auth_mode": session.get("docusign_auth_mode", "disconnected"),
|
||||||
|
"docusign_user_name": session.get("docusign_user_name"),
|
||||||
|
"docusign_user_email": session.get("docusign_user_email"),
|
||||||
|
"docusign_selected_account_id": session.get("docusign_selected_account_id"),
|
||||||
|
"docusign_selected_account_name": session.get("docusign_selected_account_name"),
|
||||||
|
"docusign_accounts_count": session.get("docusign_accounts_count", 0),
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -169,6 +169,11 @@ tr:hover td { background: #FAFBFC; }
|
||||||
.tag { display: inline-block; padding: 1px 7px; border-radius: var(--radius-sm); font-size: var(--font-size-xs); font-weight: 600; background: var(--ecru); color: var(--text-muted); margin-right: 4px; }
|
.tag { display: inline-block; padding: 1px 7px; border-radius: var(--radius-sm); font-size: var(--font-size-xs); font-weight: 600; background: var(--ecru); color: var(--text-muted); margin-right: 4px; }
|
||||||
.cb { width: 15px; height: 15px; accent-color: var(--cobalt); cursor: pointer; flex-shrink: 0; }
|
.cb { width: 15px; height: 15px; accent-color: var(--cobalt); cursor: pointer; flex-shrink: 0; }
|
||||||
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; }
|
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; }
|
||||||
|
.activity-details { min-width: 320px; font-size: var(--font-size-sm); color: var(--text-muted); }
|
||||||
|
.admin-status-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 12px 20px; }
|
||||||
|
.admin-status-row { display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
.admin-status-label { font-size: var(--font-size-xs); font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-muted); }
|
||||||
|
.admin-status-value { font-size: var(--font-size-base); color: var(--text); word-break: break-word; }
|
||||||
|
|
||||||
/* ── Empty state ── */
|
/* ── Empty state ── */
|
||||||
.empty-state {
|
.empty-state {
|
||||||
|
|
@ -269,11 +274,57 @@ tr:hover td { background: #FAFBFC; }
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Help / onboarding ── */
|
||||||
|
.help-layout { grid-template-columns: minmax(0, 1.3fr) minmax(280px, 0.7fr); }
|
||||||
|
.help-card { margin-bottom: 16px; }
|
||||||
|
.help-step-list,
|
||||||
|
.help-tip-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.help-step-item,
|
||||||
|
.help-tip-item,
|
||||||
|
.help-mini-card {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: #FAFBFC;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 12px 14px;
|
||||||
|
}
|
||||||
|
.help-step-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.help-step-title,
|
||||||
|
.help-mini-title {
|
||||||
|
font-size: var(--font-size-base);
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.help-step-desc,
|
||||||
|
.help-mini-text,
|
||||||
|
.help-tip-item {
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
color: var(--text-muted);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.help-mini-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Responsive ── */
|
/* ── Responsive ── */
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.stat-grid { grid-template-columns: repeat(3, 1fr); }
|
.stat-grid { grid-template-columns: repeat(3, 1fr); }
|
||||||
.two-col { grid-template-columns: 1fr; }
|
.two-col { grid-template-columns: 1fr; }
|
||||||
|
.help-layout { grid-template-columns: 1fr; }
|
||||||
}
|
}
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
.stat-grid { grid-template-columns: repeat(2, 1fr); }
|
.stat-grid { grid-template-columns: repeat(2, 1fr); }
|
||||||
|
.help-mini-grid { grid-template-columns: 1fr; }
|
||||||
|
.help-step-item { flex-direction: column; align-items: flex-start; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-box.modal-lg { width: min(720px, 94vw); }
|
.modal-box.modal-lg { width: min(720px, 94vw); }
|
||||||
|
.modal-box.modal-box-wide { width: min(900px, 96vw); }
|
||||||
.modal-box.modal-sm { width: min(380px, 94vw); }
|
.modal-box.modal-sm { width: min(380px, 94vw); }
|
||||||
|
|
||||||
/* ── Modal sections ── */
|
/* ── Modal sections ── */
|
||||||
|
|
@ -190,3 +191,52 @@
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── DocuSign account picker ── */
|
||||||
|
.docusign-account-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
max-height: 420px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.docusign-account-item {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--card-bg);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 12px 14px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.5fr) minmax(0, 1fr) minmax(0, 1fr);
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.docusign-account-item:hover {
|
||||||
|
background: var(--ecru);
|
||||||
|
}
|
||||||
|
.docusign-account-item.selected {
|
||||||
|
border-color: var(--cobalt);
|
||||||
|
background: var(--cobalt-light);
|
||||||
|
}
|
||||||
|
.docusign-account-name {
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.docusign-account-meta {
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
color: var(--text-muted);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.docusign-account-item {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -199,6 +199,12 @@
|
||||||
background: var(--card-bg);
|
background: var(--card-bg);
|
||||||
}
|
}
|
||||||
.conn-pill:hover { background: var(--ecru); }
|
.conn-pill:hover { background: var(--ecru); }
|
||||||
|
.conn-pill-label { white-space: nowrap; }
|
||||||
|
.conn-caret {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-left: 2px;
|
||||||
|
}
|
||||||
.conn-dot {
|
.conn-dot {
|
||||||
width: 7px;
|
width: 7px;
|
||||||
height: 7px;
|
height: 7px;
|
||||||
|
|
@ -209,6 +215,50 @@
|
||||||
.conn-pill.disconnected .conn-dot { background: var(--error); }
|
.conn-pill.disconnected .conn-dot { background: var(--error); }
|
||||||
.conn-pill.connecting .conn-dot { background: var(--warning-amber); }
|
.conn-pill.connecting .conn-dot { background: var(--warning-amber); }
|
||||||
|
|
||||||
|
.auth-chip-menu {
|
||||||
|
position: fixed;
|
||||||
|
width: 220px;
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
padding: 8px;
|
||||||
|
z-index: 1200;
|
||||||
|
}
|
||||||
|
.auth-chip-menu-title {
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 6px 8px 8px;
|
||||||
|
}
|
||||||
|
.auth-chip-menu-item {
|
||||||
|
width: 100%;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
text-align: left;
|
||||||
|
padding: 10px 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.auth-chip-menu-item:hover {
|
||||||
|
background: var(--ecru);
|
||||||
|
}
|
||||||
|
.auth-chip-menu-label {
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.auth-chip-menu-help {
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
color: var(--text-muted);
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Router outlet ── */
|
/* ── Router outlet ── */
|
||||||
#router-outlet {
|
#router-outlet {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
|
|
||||||
|
|
@ -4,13 +4,13 @@
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>docusign — Template Migration Console</title>
|
<title>docusign — Template Migration Console</title>
|
||||||
<link rel="stylesheet" href="/static/css/tokens.css" />
|
<link rel="stylesheet" href="/static/css/tokens.css?v={{ASSET_VERSION}}" />
|
||||||
<link rel="stylesheet" href="/static/css/base.css" />
|
<link rel="stylesheet" href="/static/css/base.css?v={{ASSET_VERSION}}" />
|
||||||
<link rel="stylesheet" href="/static/css/nav.css" />
|
<link rel="stylesheet" href="/static/css/nav.css?v={{ASSET_VERSION}}" />
|
||||||
<link rel="stylesheet" href="/static/css/cards.css" />
|
<link rel="stylesheet" href="/static/css/cards.css?v={{ASSET_VERSION}}" />
|
||||||
<link rel="stylesheet" href="/static/css/modals.css" />
|
<link rel="stylesheet" href="/static/css/modals.css?v={{ASSET_VERSION}}" />
|
||||||
<link rel="stylesheet" href="/static/css/tables.css" />
|
<link rel="stylesheet" href="/static/css/tables.css?v={{ASSET_VERSION}}" />
|
||||||
<link rel="stylesheet" href="/static/css/forms.css" />
|
<link rel="stylesheet" href="/static/css/forms.css?v={{ASSET_VERSION}}" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
|
|
@ -91,6 +91,12 @@
|
||||||
<span class="nav-label">History & Audit</span>
|
<span class="nav-label">History & Audit</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<a class="nav-item" data-route="#/activity" href="#/activity">
|
||||||
|
<span class="nav-icon">🧾</span>
|
||||||
|
<span class="nav-label">Activity</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
<li class="nav-section-label">Admin</li>
|
<li class="nav-section-label">Admin</li>
|
||||||
<li>
|
<li>
|
||||||
|
|
@ -99,6 +105,18 @@
|
||||||
<span class="nav-label">Settings</span>
|
<span class="nav-label">Settings</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li id="nav-admin-status-item" hidden>
|
||||||
|
<a class="nav-item" data-route="#/admin" href="#/admin">
|
||||||
|
<span class="nav-icon">🛠</span>
|
||||||
|
<span class="nav-label">Admin Status</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a class="nav-item" data-route="#/help" href="#/help">
|
||||||
|
<span class="nav-icon">❔</span>
|
||||||
|
<span class="nav-label">Help</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<!-- Bottom: customer context -->
|
<!-- Bottom: customer context -->
|
||||||
|
|
@ -133,7 +151,7 @@
|
||||||
<span class="conn-dot"></span>Docusign
|
<span class="conn-dot"></span>Docusign
|
||||||
</button>
|
</button>
|
||||||
<!-- User avatar -->
|
<!-- User avatar -->
|
||||||
<div class="avatar" title="Logged in" aria-label="User">M</div>
|
<div class="avatar" id="topbar-avatar" title="User" aria-label="User">?</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|
@ -160,7 +178,7 @@
|
||||||
<!-- ═══════════════════════════════════════════════════════════════
|
<!-- ═══════════════════════════════════════════════════════════════
|
||||||
APP ENTRY POINT
|
APP ENTRY POINT
|
||||||
═══════════════════════════════════════════════════════════════ -->
|
═══════════════════════════════════════════════════════════════ -->
|
||||||
<script type="module" src="/static/js/app.js"></script>
|
<script type="module" src="/static/js/app.js?v={{ASSET_VERSION}}"></script>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,134 @@
|
||||||
|
// Recent activity view for tester/admin auditing
|
||||||
|
|
||||||
|
import { api } from './api.js';
|
||||||
|
import { state } from './state.js';
|
||||||
|
import { escHtml, formatDateTime } from './utils.js';
|
||||||
|
|
||||||
|
const ACTION_LABELS = {
|
||||||
|
adobe_connected: 'Adobe connected',
|
||||||
|
adobe_disconnected: 'Adobe disconnected',
|
||||||
|
docusign_authorization_requested: 'DocuSign auth requested',
|
||||||
|
docusign_authorization_started: 'DocuSign auth started',
|
||||||
|
docusign_connected: 'DocuSign connected',
|
||||||
|
docusign_account_selected: 'DocuSign account selected',
|
||||||
|
docusign_disconnected: 'DocuSign disconnected',
|
||||||
|
migration_run: 'Migration run',
|
||||||
|
migration_batch_started: 'Batch migration started',
|
||||||
|
migration_batch_completed: 'Batch migration completed',
|
||||||
|
verification_sent: 'Verification sent',
|
||||||
|
verification_voided: 'Verification voided',
|
||||||
|
};
|
||||||
|
const ACTIVITY_SCOPE_KEY = 'migrator_activity_scope';
|
||||||
|
|
||||||
|
export async function renderActivity() {
|
||||||
|
const outlet = document.getElementById('router-outlet');
|
||||||
|
outlet.innerHTML = `<div class="empty-state"><div class="spinner"></div></div>`;
|
||||||
|
const showAll = state.auth.isAdmin && localStorage.getItem(ACTIVITY_SCOPE_KEY) === 'all';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await api.audit.recent(150, showAll);
|
||||||
|
const events = data.events || [];
|
||||||
|
const isAdmin = !!data.is_admin;
|
||||||
|
const scope = data.scope || 'session';
|
||||||
|
|
||||||
|
outlet.innerHTML = `
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<div class="page-title">Recent Activity</div>
|
||||||
|
<div class="page-subtitle">${scope === 'all'
|
||||||
|
? 'Admin view of all tester activity.'
|
||||||
|
: 'Your activity in this browser session.'}</div>
|
||||||
|
</div>
|
||||||
|
${isAdmin ? `
|
||||||
|
<div class="page-actions">
|
||||||
|
<button class="btn btn-secondary btn-sm" id="btn-toggle-activity-scope">${scope === 'all' ? 'Show My Activity' : 'Show All Activity'}</button>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${events.length === 0 ? `
|
||||||
|
<div class="empty-state">
|
||||||
|
<div class="empty-state-icon">🧾</div>
|
||||||
|
<div class="empty-state-title">No activity yet</div>
|
||||||
|
<div class="empty-state-sub">Recent tester actions will appear here after people connect and use the app.</div>
|
||||||
|
</div>
|
||||||
|
` : `
|
||||||
|
<div class="card">
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Time</th>
|
||||||
|
<th>Action</th>
|
||||||
|
<th>DocuSign</th>
|
||||||
|
<th>Adobe</th>
|
||||||
|
<th>Session</th>
|
||||||
|
<th>IP</th>
|
||||||
|
<th>Details</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
${events.map(renderEventRow).join('')}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`}
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.getElementById('btn-toggle-activity-scope')?.addEventListener('click', () => {
|
||||||
|
localStorage.setItem(ACTIVITY_SCOPE_KEY, scope === 'all' ? 'session' : 'all');
|
||||||
|
renderActivity();
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
outlet.innerHTML = `<div class="callout error"><span class="callout-icon">❌</span>Failed to load activity: ${escHtml(e.message)}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderEventRow(event) {
|
||||||
|
const docusignLabel = event.docusign_account_name || event.docusign_user_name || event.docusign_user_email || '—';
|
||||||
|
const adobeLabel = event.adobe_account_name || event.adobe_user_name || event.adobe_user_email || '—';
|
||||||
|
const sessionId = event.session_id ? `${event.session_id.slice(0, 10)}…` : '—';
|
||||||
|
const detailText = formatDetails(event.details);
|
||||||
|
|
||||||
|
return `
|
||||||
|
<tr>
|
||||||
|
<td style="white-space: nowrap">${escHtml(formatDateTime(event.timestamp))}</td>
|
||||||
|
<td><span class="badge badge-blue">${escHtml(ACTION_LABELS[event.action] || event.action || 'Activity')}</span></td>
|
||||||
|
<td>
|
||||||
|
<div class="table-name">${escHtml(docusignLabel)}</div>
|
||||||
|
<div class="table-sub">${escHtml(event.docusign_account_id || event.docusign_user_email || '')}</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="table-name">${escHtml(adobeLabel)}</div>
|
||||||
|
<div class="table-sub">${escHtml(event.adobe_account_id || event.adobe_user_email || '')}</div>
|
||||||
|
</td>
|
||||||
|
<td class="mono">${escHtml(sessionId)}</td>
|
||||||
|
<td>${escHtml(event.ip || '—')}</td>
|
||||||
|
<td class="activity-details">${escHtml(detailText)}</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDetails(details) {
|
||||||
|
if (!details || typeof details !== 'object') {
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = Object.entries(details)
|
||||||
|
.filter(([, value]) => value !== null && value !== undefined && value !== '')
|
||||||
|
.map(([key, value]) => `${humanizeKey(key)}: ${formatValue(value)}`);
|
||||||
|
|
||||||
|
return parts.length ? parts.join(' | ') : '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
function humanizeKey(key) {
|
||||||
|
return key.replace(/_/g, ' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatValue(value) {
|
||||||
|
if (typeof value === 'boolean') {
|
||||||
|
return value ? 'yes' : 'no';
|
||||||
|
}
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,69 @@
|
||||||
|
import { api } from './api.js';
|
||||||
|
import { escHtml } from './utils.js';
|
||||||
|
|
||||||
|
export async function renderAdminStatus() {
|
||||||
|
const outlet = document.getElementById('router-outlet');
|
||||||
|
outlet.innerHTML = `<div class="empty-state"><div class="spinner"></div></div>`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await api.admin.status();
|
||||||
|
const session = data.session || {};
|
||||||
|
const env = data.environment || {};
|
||||||
|
|
||||||
|
outlet.innerHTML = `
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<div class="page-title">Admin Status</div>
|
||||||
|
<div class="page-subtitle">Lightweight deploy, environment, and current-session status for admins.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header"><span class="card-title">Application</span></div>
|
||||||
|
<div class="card-body admin-status-grid">
|
||||||
|
${statusRow('Version', data.version)}
|
||||||
|
${statusRow('Build ID', data.build_id, true)}
|
||||||
|
${statusRow('Asset Version', data.asset_version, true)}
|
||||||
|
${statusRow('Server Time (UTC)', data.timestamp_utc, true)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header"><span class="card-title">Current Session</span></div>
|
||||||
|
<div class="card-body admin-status-grid">
|
||||||
|
${statusRow('Session ID', session.session_id, true)}
|
||||||
|
${statusRow('Adobe', session.adobe ? 'Connected' : 'Disconnected')}
|
||||||
|
${statusRow('DocuSign', session.docusign ? 'Connected' : 'Disconnected')}
|
||||||
|
${statusRow('Adobe Auth Mode', session.adobe_auth_mode, true)}
|
||||||
|
${statusRow('DocuSign Auth Mode', session.docusign_auth_mode, true)}
|
||||||
|
${statusRow('Adobe Account', session.adobe_account_name || session.adobe_user_email || '—')}
|
||||||
|
${statusRow('DocuSign Account', session.docusign_selected_account_name || session.docusign_user_email || '—')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header"><span class="card-title">Environment</span></div>
|
||||||
|
<div class="card-body admin-status-grid">
|
||||||
|
${statusRow('DocuSign Base URL', env.docusign_base_url, true)}
|
||||||
|
${statusRow('DocuSign Auth Server', env.docusign_auth_server, true)}
|
||||||
|
${statusRow('DocuSign Redirect URI', env.docusign_redirect_uri, true)}
|
||||||
|
${statusRow('Adobe Base URL', env.adobe_sign_base_url, true)}
|
||||||
|
${statusRow('Adobe Redirect URI', env.adobe_redirect_uri, true)}
|
||||||
|
${statusRow('Session Store', env.session_store_dir, true)}
|
||||||
|
${statusRow('Audit Log', env.audit_log_file, true)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
} catch (e) {
|
||||||
|
outlet.innerHTML = `<div class="callout error"><span class="callout-icon">❌</span>Failed to load admin status: ${escHtml(e.message)}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusRow(label, value, mono = false) {
|
||||||
|
return `
|
||||||
|
<div class="admin-status-row">
|
||||||
|
<div class="admin-status-label">${escHtml(label)}</div>
|
||||||
|
<div class="admin-status-value ${mono ? 'mono' : ''}">${escHtml(value || '—')}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
@ -26,8 +26,11 @@ export const api = {
|
||||||
status() {
|
status() {
|
||||||
return GET('/api/auth/status');
|
return GET('/api/auth/status');
|
||||||
},
|
},
|
||||||
connectAdobe() {
|
connectAdobe(forceOauth = false, returnTo = '#/templates') {
|
||||||
return GET('/api/auth/adobe/connect');
|
const params = new URLSearchParams();
|
||||||
|
if (forceOauth) params.set('force_oauth', 'true');
|
||||||
|
params.set('return_to', returnTo);
|
||||||
|
return GET(`/api/auth/adobe/connect?${params.toString()}`);
|
||||||
},
|
},
|
||||||
adobeUrl() {
|
adobeUrl() {
|
||||||
return GET('/api/auth/adobe/url');
|
return GET('/api/auth/adobe/url');
|
||||||
|
|
@ -35,8 +38,14 @@ export const api = {
|
||||||
exchangeAdobe(redirectUrl) {
|
exchangeAdobe(redirectUrl) {
|
||||||
return POST('/api/auth/adobe/exchange', { redirect_url: redirectUrl });
|
return POST('/api/auth/adobe/exchange', { redirect_url: redirectUrl });
|
||||||
},
|
},
|
||||||
connectDocusign() {
|
connectDocusign(returnTo = '#/templates') {
|
||||||
return GET('/api/auth/docusign/connect');
|
return GET(`/api/auth/docusign/connect?return_to=${encodeURIComponent(returnTo)}`);
|
||||||
|
},
|
||||||
|
docusignAccounts() {
|
||||||
|
return GET('/api/auth/docusign/accounts');
|
||||||
|
},
|
||||||
|
selectDocusignAccount(accountId) {
|
||||||
|
return POST('/api/auth/docusign/account-select', { account_id: accountId });
|
||||||
},
|
},
|
||||||
disconnect(platform) {
|
disconnect(platform) {
|
||||||
return GET(`/api/auth/${platform}/disconnect`);
|
return GET(`/api/auth/${platform}/disconnect`);
|
||||||
|
|
@ -89,4 +98,18 @@ export const api = {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ── Activity Audit ───────────────────────────────────────────────────────
|
||||||
|
audit: {
|
||||||
|
recent(limit = 100, all = false) {
|
||||||
|
return GET(`/api/audit/recent?limit=${encodeURIComponent(limit)}&all=${all ? 'true' : 'false'}`);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Admin ────────────────────────────────────────────────────────────────
|
||||||
|
admin: {
|
||||||
|
status() {
|
||||||
|
return GET('/api/admin/status');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -36,11 +36,26 @@ router.register('#/history', async () => {
|
||||||
await renderHistory();
|
await renderHistory();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.register('#/activity', async () => {
|
||||||
|
const { renderActivity } = await import('./activity.js');
|
||||||
|
await renderActivity();
|
||||||
|
});
|
||||||
|
|
||||||
router.register('#/settings', async () => {
|
router.register('#/settings', async () => {
|
||||||
const { renderSettings } = await import('./settings.js');
|
const { renderSettings } = await import('./settings.js');
|
||||||
renderSettings();
|
renderSettings();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.register('#/admin', async () => {
|
||||||
|
const { renderAdminStatus } = await import('./admin.js');
|
||||||
|
await renderAdminStatus();
|
||||||
|
});
|
||||||
|
|
||||||
|
router.register('#/help', async () => {
|
||||||
|
const { renderHelp } = await import('./help.js');
|
||||||
|
renderHelp();
|
||||||
|
});
|
||||||
|
|
||||||
// ── Nav badge subscriptions ───────────────────────────────────────────────
|
// ── Nav badge subscriptions ───────────────────────────────────────────────
|
||||||
|
|
||||||
subscribe('issueCount', count => {
|
subscribe('issueCount', count => {
|
||||||
|
|
@ -54,7 +69,7 @@ subscribe('issueCount', count => {
|
||||||
subscribe('templates', templates => {
|
subscribe('templates', templates => {
|
||||||
const caveats = (templates || []).filter(t =>
|
const caveats = (templates || []).filter(t =>
|
||||||
(!t.blockers || t.blockers.length === 0) &&
|
(!t.blockers || t.blockers.length === 0) &&
|
||||||
t.warnings && t.warnings.length > 0
|
((t.warnings || []).length > 0 || (t.field_issues || []).length > 0)
|
||||||
).length;
|
).length;
|
||||||
const badge = document.getElementById('nav-badge-caveats');
|
const badge = document.getElementById('nav-badge-caveats');
|
||||||
if (badge) {
|
if (badge) {
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
// Auth: connect/disconnect Adobe Sign and Docusign, auth status chips
|
// Auth: connect/disconnect Adobe Sign and Docusign, account picker, auth chips
|
||||||
|
|
||||||
import { api } from './api.js';
|
import { api } from './api.js';
|
||||||
import { state, setState } from './state.js';
|
import { state, setState } from './state.js';
|
||||||
import { escHtml } from './utils.js';
|
import { escHtml, initials } from './utils.js';
|
||||||
|
|
||||||
// ── Refresh auth state and update chips ────────────────────────────────────
|
// ── Refresh auth state and update chips ────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -13,93 +13,174 @@ export async function refreshAuth() {
|
||||||
adobe: !!data.adobe,
|
adobe: !!data.adobe,
|
||||||
docusign: !!data.docusign,
|
docusign: !!data.docusign,
|
||||||
adobeLabel: data.adobe_label || 'Adobe Sign',
|
adobeLabel: data.adobe_label || 'Adobe Sign',
|
||||||
|
adobeAccountId: data.adobe_account_id || null,
|
||||||
|
adobeAccountName: data.adobe_account_name || null,
|
||||||
docusignLabel: data.docusign_label || 'Docusign',
|
docusignLabel: data.docusign_label || 'Docusign',
|
||||||
|
docusignAccountId: data.docusign_account_id || null,
|
||||||
|
docusignAccountName: data.docusign_account_name || null,
|
||||||
|
docusignAccountsCount: data.docusign_accounts_count || 0,
|
||||||
|
docusignAccountSelectionRequired: !!data.docusign_account_selection_required,
|
||||||
|
isAdmin: !!data.is_admin,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('Auth status failed:', e.message);
|
console.warn('Auth status failed:', e.message);
|
||||||
}
|
}
|
||||||
renderAuthChips();
|
renderAuthChips();
|
||||||
|
if (state.auth.docusign && state.auth.docusignAccountSelectionRequired) {
|
||||||
|
showDocusignAccountPicker();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Render connection pills in top bar ─────────────────────────────────────
|
// ── Render connection pills in top bar ─────────────────────────────────────
|
||||||
|
|
||||||
export function renderAuthChips() {
|
export function renderAuthChips() {
|
||||||
renderChip('chip-adobe', state.auth.adobe, 'Adobe Sign', onClickAdobe);
|
renderChip(
|
||||||
renderChip('chip-docusign', state.auth.docusign, 'Docusign', onClickDocusign);
|
'chip-adobe',
|
||||||
|
state.auth.adobe,
|
||||||
|
state.auth.adobe ? `Adobe: ${state.auth.adobeAccountName || state.auth.adobeLabel || 'Connected'}` : 'Adobe Sign',
|
||||||
|
onClickAdobe
|
||||||
|
);
|
||||||
|
renderChip(
|
||||||
|
'chip-docusign',
|
||||||
|
state.auth.docusign,
|
||||||
|
state.auth.docusign ? `Docusign: ${state.auth.docusignAccountName || state.auth.docusignLabel || 'Connected'}` : 'Docusign',
|
||||||
|
onClickDocusign
|
||||||
|
);
|
||||||
|
renderAvatar();
|
||||||
|
renderAdminNav();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAvatar() {
|
||||||
|
const el = document.getElementById('topbar-avatar');
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
const name = state.auth.docusignLabel && state.auth.docusignLabel !== 'Docusign'
|
||||||
|
? state.auth.docusignLabel
|
||||||
|
: state.auth.docusignAccountName || '';
|
||||||
|
|
||||||
|
el.textContent = name ? initials(name) : '?';
|
||||||
|
el.title = name || 'User';
|
||||||
|
el.setAttribute('aria-label', name ? `User ${name}` : 'User');
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderChip(id, connected, label, onClick) {
|
function renderChip(id, connected, label, onClick) {
|
||||||
const el = document.getElementById(id);
|
const el = document.getElementById(id);
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
el.className = 'conn-pill ' + (connected ? 'connected' : 'disconnected');
|
el.className = 'conn-pill ' + (connected ? 'connected' : 'disconnected');
|
||||||
el.innerHTML = `<span class="conn-dot"></span>${escHtml(label)}`;
|
el.innerHTML = `<span class="conn-dot"></span><span class="conn-pill-label">${escHtml(label)}</span>${connected ? '<span class="conn-caret">▾</span>' : ''}`;
|
||||||
el.onclick = onClick;
|
el.onclick = onClick;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderAdminNav() {
|
||||||
|
const adminItem = document.getElementById('nav-admin-status-item');
|
||||||
|
if (!adminItem) return;
|
||||||
|
adminItem.hidden = !state.auth.isAdmin;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Click handlers ─────────────────────────────────────────────────────────
|
// ── Click handlers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function onClickAdobe() {
|
async function onClickAdobe() {
|
||||||
if (state.auth.adobe) {
|
if (state.auth.adobe) {
|
||||||
await disconnect('adobe');
|
showAuthMenu('adobe', 'chip-adobe');
|
||||||
} else {
|
} else {
|
||||||
await connectAdobeEnv();
|
await connectAdobe();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onClickDocusign() {
|
async function onClickDocusign() {
|
||||||
if (state.auth.docusign) {
|
if (state.auth.docusign) {
|
||||||
await disconnect('docusign');
|
showAuthMenu('docusign', 'chip-docusign');
|
||||||
} else {
|
} else {
|
||||||
await connectDocusign();
|
await connectDocusign();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function disconnect(platform) {
|
export async function disconnectPlatform(platform, opts = {}) {
|
||||||
|
const { silent = false, skipRefresh = false } = opts;
|
||||||
|
closeAuthMenu();
|
||||||
|
closeDocusignAccountPicker();
|
||||||
setChipConnecting(platform === 'adobe' ? 'chip-adobe' : 'chip-docusign');
|
setChipConnecting(platform === 'adobe' ? 'chip-adobe' : 'chip-docusign');
|
||||||
try {
|
try {
|
||||||
await api.auth.disconnect(platform);
|
await api.auth.disconnect(platform);
|
||||||
setState('auth', { ...state.auth, [platform]: false });
|
if (platform === 'docusign') {
|
||||||
|
setState('auth', {
|
||||||
|
...state.auth,
|
||||||
|
docusign: false,
|
||||||
|
docusignAccountId: null,
|
||||||
|
docusignAccountName: null,
|
||||||
|
docusignAccountsCount: 0,
|
||||||
|
docusignAccountSelectionRequired: false,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setState('auth', {
|
||||||
|
...state.auth,
|
||||||
|
adobe: false,
|
||||||
|
adobeAccountId: null,
|
||||||
|
adobeAccountName: null,
|
||||||
|
adobeLabel: 'Adobe Sign',
|
||||||
|
});
|
||||||
|
}
|
||||||
renderAuthChips();
|
renderAuthChips();
|
||||||
// Reload templates (they'll be empty without auth)
|
if (!skipRefresh) {
|
||||||
const { refreshTemplates } = await import('./templates.js');
|
const { refreshTemplates } = await import('./templates.js');
|
||||||
refreshTemplates();
|
refreshTemplates();
|
||||||
|
}
|
||||||
|
if (!silent) {
|
||||||
|
showToast(`${platform === 'adobe' ? 'Adobe Sign' : 'Docusign'} disconnected.`, 'info');
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Disconnect failed:', e.message);
|
console.error('Disconnect failed:', e.message);
|
||||||
renderAuthChips();
|
renderAuthChips();
|
||||||
|
if (!silent) showToast(`Disconnect failed: ${e.message}`, 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function connectAdobeEnv() {
|
export async function switchAccount(platform) {
|
||||||
|
closeAuthMenu();
|
||||||
|
if (platform === 'docusign') {
|
||||||
|
await showDocusignAccountPicker({ forceRefresh: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await disconnectPlatform(platform, { silent: true, skipRefresh: true });
|
||||||
|
showToast('Starting a fresh Adobe Sign authorization…', 'info');
|
||||||
|
await connectAdobe(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function connectAdobe(forceOauth = false) {
|
||||||
|
closeAuthMenu();
|
||||||
setChipConnecting('chip-adobe');
|
setChipConnecting('chip-adobe');
|
||||||
try {
|
try {
|
||||||
const data = await api.auth.connectAdobe();
|
const data = await api.auth.connectAdobe(forceOauth, window.location.hash || '#/templates');
|
||||||
if (data.connected) {
|
if (data.connected) {
|
||||||
setState('auth', { ...state.auth, adobe: true });
|
setState('auth', { ...state.auth, adobe: true });
|
||||||
renderAuthChips();
|
renderAuthChips();
|
||||||
const { refreshTemplates } = await import('./templates.js');
|
const { refreshTemplates } = await import('./templates.js');
|
||||||
refreshTemplates();
|
refreshTemplates();
|
||||||
} else if (data.error && data.error.includes('No Adobe Sign credentials')) {
|
} else if (data.authorization_required && data.authorization_url) {
|
||||||
renderAuthChips();
|
window.location.href = data.authorization_url;
|
||||||
showAdobeOAuthDialog();
|
|
||||||
} else {
|
} else {
|
||||||
renderAuthChips();
|
renderAuthChips();
|
||||||
showToast('Adobe Sign error: ' + (data.error || 'unknown'), 'error');
|
showToast('Adobe Sign error: ' + (data.error || 'unknown'), 'error');
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
renderAuthChips();
|
renderAuthChips();
|
||||||
showAdobeOAuthDialog();
|
showToast('Adobe Sign connection failed: ' + e.message, 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function connectDocusign() {
|
async function connectDocusign() {
|
||||||
|
closeAuthMenu();
|
||||||
setChipConnecting('chip-docusign');
|
setChipConnecting('chip-docusign');
|
||||||
try {
|
try {
|
||||||
const data = await api.auth.connectDocusign();
|
const data = await api.auth.connectDocusign(window.location.hash || '#/templates');
|
||||||
if (data.connected) {
|
if (data.connected) {
|
||||||
setState('auth', { ...state.auth, docusign: true });
|
await refreshAuth();
|
||||||
renderAuthChips();
|
if (!data.account_selection_required) {
|
||||||
const { refreshTemplates } = await import('./templates.js');
|
const { refreshTemplates } = await import('./templates.js');
|
||||||
refreshTemplates();
|
refreshTemplates();
|
||||||
|
}
|
||||||
|
} else if (data.authorization_required && data.authorization_url) {
|
||||||
|
window.location.href = data.authorization_url;
|
||||||
} else {
|
} else {
|
||||||
renderAuthChips();
|
renderAuthChips();
|
||||||
showToast('Docusign error: ' + (data.error || 'unknown'), 'error');
|
showToast('Docusign error: ' + (data.error || 'unknown'), 'error');
|
||||||
|
|
@ -117,70 +198,193 @@ function setChipConnecting(id) {
|
||||||
el.innerHTML = `<span class="conn-dot"></span><span class="spinner spinner-sm"></span>`;
|
el.innerHTML = `<span class="conn-dot"></span><span class="spinner spinner-sm"></span>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Adobe OAuth dialog (manual redirect URL paste) ─────────────────────────
|
// ── Top-bar menu ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function showAdobeOAuthDialog() {
|
function closeAuthMenu() {
|
||||||
const { url } = await api.auth.adobeUrl().catch(() => ({ url: '#' }));
|
document.getElementById('auth-chip-menu')?.remove();
|
||||||
|
document.removeEventListener('click', onDocumentClickCloseMenu, true);
|
||||||
|
document.removeEventListener('keydown', onEscapeCloseMenu, true);
|
||||||
|
}
|
||||||
|
|
||||||
const existing = document.getElementById('adobe-auth-dialog');
|
function onDocumentClickCloseMenu(event) {
|
||||||
if (existing) existing.remove();
|
const menu = document.getElementById('auth-chip-menu');
|
||||||
|
if (!menu) return;
|
||||||
|
if (menu.contains(event.target)) return;
|
||||||
|
if (event.target.closest('.conn-pill')) return;
|
||||||
|
closeAuthMenu();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onEscapeCloseMenu(event) {
|
||||||
|
if (event.key === 'Escape') closeAuthMenu();
|
||||||
|
}
|
||||||
|
|
||||||
|
function showAuthMenu(platform, anchorId) {
|
||||||
|
const anchor = document.getElementById(anchorId);
|
||||||
|
if (!anchor) return;
|
||||||
|
|
||||||
|
const existing = document.getElementById('auth-chip-menu');
|
||||||
|
if (existing && existing.dataset.platform === platform && existing.dataset.anchorId === anchorId) {
|
||||||
|
closeAuthMenu();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
closeAuthMenu();
|
||||||
|
|
||||||
|
const rect = anchor.getBoundingClientRect();
|
||||||
|
const menu = document.createElement('div');
|
||||||
|
menu.id = 'auth-chip-menu';
|
||||||
|
menu.dataset.platform = platform;
|
||||||
|
menu.dataset.anchorId = anchorId;
|
||||||
|
menu.className = 'auth-chip-menu';
|
||||||
|
menu.style.top = `${rect.bottom + 8}px`;
|
||||||
|
menu.style.left = `${Math.max(12, rect.right - 220)}px`;
|
||||||
|
|
||||||
|
const accountLabel = platform === 'docusign' ? 'Docusign' : 'Adobe Sign';
|
||||||
|
const switchLabel = platform === 'docusign' ? 'Switch Account' : 'Reconnect';
|
||||||
|
const switchHelp = platform === 'docusign'
|
||||||
|
? 'Pick a different DocuSign account from your account list.'
|
||||||
|
: 'Disconnect and reconnect Adobe Sign.';
|
||||||
|
|
||||||
|
menu.innerHTML = `
|
||||||
|
<div class="auth-chip-menu-title">${escHtml(accountLabel)}</div>
|
||||||
|
<button class="auth-chip-menu-item" data-action="disconnect">
|
||||||
|
<span class="auth-chip-menu-label">Disconnect</span>
|
||||||
|
<span class="auth-chip-menu-help">Clear this app session.</span>
|
||||||
|
</button>
|
||||||
|
<button class="auth-chip-menu-item" data-action="switch">
|
||||||
|
<span class="auth-chip-menu-label">${escHtml(switchLabel)}</span>
|
||||||
|
<span class="auth-chip-menu-help">${escHtml(switchHelp)}</span>
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
|
||||||
|
menu.querySelector('[data-action="disconnect"]')?.addEventListener('click', async () => {
|
||||||
|
closeAuthMenu();
|
||||||
|
await disconnectPlatform(platform);
|
||||||
|
});
|
||||||
|
menu.querySelector('[data-action="switch"]')?.addEventListener('click', async () => {
|
||||||
|
await switchAccount(platform);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.body.appendChild(menu);
|
||||||
|
document.addEventListener('click', onDocumentClickCloseMenu, true);
|
||||||
|
document.addEventListener('keydown', onEscapeCloseMenu, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── DocuSign account picker ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function showDocusignAccountPicker(opts = {}) {
|
||||||
|
const { forceRefresh = false } = opts;
|
||||||
|
if (!forceRefresh && document.getElementById('docusign-account-dialog')) return;
|
||||||
|
|
||||||
|
let data;
|
||||||
|
try {
|
||||||
|
data = await api.auth.docusignAccounts();
|
||||||
|
} catch (e) {
|
||||||
|
showToast('Failed to load DocuSign accounts: ' + e.message, 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const accounts = [...(data.accounts || [])].sort((a, b) => {
|
||||||
|
const nameCmp = (a.account_name || '').localeCompare(b.account_name || '', undefined, { sensitivity: 'base' });
|
||||||
|
return nameCmp || (a.account_id || '').localeCompare(b.account_id || '', undefined, { sensitivity: 'base' });
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!accounts.length) {
|
||||||
|
showToast('No DocuSign accounts were returned for this user.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (accounts.length === 1) {
|
||||||
|
await selectDocusignAccount(accounts[0].account_id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
closeDocusignAccountPicker();
|
||||||
const dialog = document.createElement('div');
|
const dialog = document.createElement('div');
|
||||||
dialog.id = 'adobe-auth-dialog';
|
dialog.id = 'docusign-account-dialog';
|
||||||
dialog.innerHTML = `
|
dialog.innerHTML = `
|
||||||
<div class="modal-backdrop"></div>
|
<div class="modal-backdrop"></div>
|
||||||
<div class="modal-box">
|
<div class="modal-box modal-box-wide">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<span class="modal-title">Connect Adobe Sign</span>
|
<span class="modal-title">Choose DocuSign Account</span>
|
||||||
<button class="btn btn-ghost btn-icon" id="adobe-dialog-close">✕</button>
|
<button class="btn btn-ghost btn-icon" id="docusign-account-close">✕</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<ol style="padding-left:18px;line-height:1.8;margin-bottom:14px;font-size:13px">
|
<div style="display:flex;gap:12px;align-items:center;justify-content:space-between;margin-bottom:14px;flex-wrap:wrap">
|
||||||
<li><a href="${escHtml(url)}" target="_blank" rel="noopener" style="color:var(--cobalt)">Click here to authorize in Adobe Sign ↗</a></li>
|
<div style="font-size:13px;color:var(--text-muted)">
|
||||||
<li>After authorizing, your browser will show a page that fails to load — that's expected.</li>
|
${accounts.length} account${accounts.length === 1 ? '' : 's'} found. Choose the account this session should use.
|
||||||
<li>Copy the full URL from the address bar and paste it below.</li>
|
</div>
|
||||||
</ol>
|
<input type="text" id="docusign-account-search" class="form-input" placeholder="Search accounts..." style="max-width:320px" />
|
||||||
<input type="text" id="adobe-redirect-input" class="form-input"
|
</div>
|
||||||
placeholder="https://localhost:8080/callback?code=…" />
|
<div id="docusign-account-error" style="color:var(--error);font-size:12px;min-height:18px;margin-bottom:8px"></div>
|
||||||
<div id="adobe-dialog-error" style="color:var(--error);font-size:12px;min-height:18px;margin-top:6px"></div>
|
<div id="docusign-account-list" class="docusign-account-list"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button class="btn btn-secondary" id="adobe-dialog-cancel">Cancel</button>
|
<button class="btn btn-secondary" id="docusign-account-cancel">Close</button>
|
||||||
<button class="btn btn-primary" id="adobe-dialog-submit">Connect</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
document.body.appendChild(dialog);
|
document.body.appendChild(dialog);
|
||||||
|
|
||||||
document.getElementById('adobe-dialog-close').onclick = () => dialog.remove();
|
const listEl = document.getElementById('docusign-account-list');
|
||||||
document.getElementById('adobe-dialog-cancel').onclick = () => dialog.remove();
|
const searchEl = document.getElementById('docusign-account-search');
|
||||||
document.getElementById('adobe-dialog-submit').onclick = () => submitAdobeCode(dialog);
|
const errorEl = document.getElementById('docusign-account-error');
|
||||||
document.getElementById('adobe-redirect-input').addEventListener('keydown', e => {
|
|
||||||
if (e.key === 'Enter') submitAdobeCode(dialog);
|
const renderList = () => {
|
||||||
|
const query = (searchEl?.value || '').trim().toLowerCase();
|
||||||
|
const filtered = accounts.filter(acc => {
|
||||||
|
const haystack = `${acc.account_name || ''} ${acc.account_id || ''} ${acc.organization_name || ''}`.toLowerCase();
|
||||||
|
return !query || haystack.includes(query);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!filtered.length) {
|
||||||
|
listEl.innerHTML = `<div class="empty-state" style="padding:24px 12px"><div class="empty-state-title">No matching accounts</div></div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
listEl.innerHTML = filtered.map(acc => `
|
||||||
|
<button class="docusign-account-item ${data.selected_account_id === acc.account_id ? 'selected' : ''}" data-account-id="${escHtml(acc.account_id)}">
|
||||||
|
<span class="docusign-account-name">${escHtml(acc.account_name || acc.account_id)}</span>
|
||||||
|
<span class="docusign-account-meta mono">${escHtml(acc.account_id)}</span>
|
||||||
|
<span class="docusign-account-meta">${escHtml(acc.organization_name || '')}</span>
|
||||||
|
</button>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
listEl.querySelectorAll('.docusign-account-item').forEach(btn => {
|
||||||
|
btn.addEventListener('click', async () => {
|
||||||
|
errorEl.textContent = '';
|
||||||
|
await selectDocusignAccount(btn.dataset.accountId, errorEl);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
searchEl?.addEventListener('input', renderList);
|
||||||
|
document.getElementById('docusign-account-close')?.addEventListener('click', closeDocusignAccountPicker);
|
||||||
|
document.getElementById('docusign-account-cancel')?.addEventListener('click', closeDocusignAccountPicker);
|
||||||
|
renderList();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submitAdobeCode(dialog) {
|
function closeDocusignAccountPicker() {
|
||||||
const url = document.getElementById('adobe-redirect-input').value.trim();
|
document.getElementById('docusign-account-dialog')?.remove();
|
||||||
if (!url) return;
|
}
|
||||||
|
|
||||||
const submitBtn = document.getElementById('adobe-dialog-submit');
|
|
||||||
const errorEl = document.getElementById('adobe-dialog-error');
|
|
||||||
submitBtn.disabled = true;
|
|
||||||
submitBtn.textContent = 'Connecting…';
|
|
||||||
errorEl.textContent = '';
|
|
||||||
|
|
||||||
|
async function selectDocusignAccount(accountId, errorEl = null) {
|
||||||
try {
|
try {
|
||||||
const data = await api.auth.exchangeAdobe(url);
|
await api.auth.selectDocusignAccount(accountId);
|
||||||
dialog.remove();
|
closeDocusignAccountPicker();
|
||||||
setState('auth', { ...state.auth, adobe: true });
|
await refreshAuth();
|
||||||
renderAuthChips();
|
setState('templatesError', null);
|
||||||
const { refreshTemplates } = await import('./templates.js');
|
const { refreshTemplates, renderTemplates } = await import('./templates.js');
|
||||||
refreshTemplates();
|
await refreshTemplates();
|
||||||
|
if ((window.location.hash || '#/templates').startsWith('#/templates')) {
|
||||||
|
await renderTemplates();
|
||||||
|
}
|
||||||
|
showToast('DocuSign account selected.', 'success');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
errorEl.textContent = e.data?.error || e.message || 'Connection failed.';
|
if (errorEl) {
|
||||||
submitBtn.disabled = false;
|
errorEl.textContent = e.data?.error || e.message || 'Failed to select account.';
|
||||||
submitBtn.textContent = 'Connect';
|
} else {
|
||||||
|
showToast('Failed to select account: ' + e.message, 'error');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -199,10 +403,10 @@ export function showToast(message, type = 'info') {
|
||||||
const borders = { info: 'var(--cobalt)', error: 'var(--error)', success: 'var(--success)' };
|
const borders = { info: 'var(--cobalt)', error: 'var(--error)', success: 'var(--success)' };
|
||||||
toast.style.cssText = `
|
toast.style.cssText = `
|
||||||
padding:10px 16px;border-radius:6px;font-size:13px;font-weight:500;
|
padding:10px 16px;border-radius:6px;font-size:13px;font-weight:500;
|
||||||
background:${colors[type]||colors.info};border:1px solid ${borders[type]||borders.info};
|
background:${colors[type] || colors.info};border:1px solid ${borders[type] || borders.info};
|
||||||
box-shadow:var(--shadow-md);max-width:360px;animation:fadeIn 0.2s ease;
|
box-shadow:var(--shadow-md);max-width:420px;animation:fadeIn 0.2s ease;
|
||||||
`;
|
`;
|
||||||
toast.textContent = message;
|
toast.textContent = message;
|
||||||
container.appendChild(toast);
|
container.appendChild(toast);
|
||||||
setTimeout(() => toast.remove(), 4000);
|
setTimeout(() => toast.remove(), 4500);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,211 @@
|
||||||
|
import { state } from './state.js';
|
||||||
|
import { navigate } from './router.js';
|
||||||
|
|
||||||
|
const QUICK_START_KEY = 'migrator_quick_start_dismissed';
|
||||||
|
|
||||||
|
export function shouldShowQuickStart() {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(QUICK_START_KEY) !== '1';
|
||||||
|
} catch {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dismissQuickStart() {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(QUICK_START_KEY, '1');
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetQuickStart() {
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(QUICK_START_KEY);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onboardingStatus() {
|
||||||
|
return {
|
||||||
|
adobeConnected: !!state.auth.adobe,
|
||||||
|
docusignConnected: !!state.auth.docusign,
|
||||||
|
docusignAccountChosen: !!state.auth.docusign && !state.auth.docusignAccountSelectionRequired,
|
||||||
|
templatesLoaded: (state.templates || []).length > 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function stepBadge(done, pendingLabel = 'Next') {
|
||||||
|
if (done) return '<span class="badge badge-green">Done</span>';
|
||||||
|
return `<span class="badge badge-amber">${pendingLabel}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function quickStartStepsMarkup() {
|
||||||
|
const s = onboardingStatus();
|
||||||
|
return `
|
||||||
|
<div class="help-step-list">
|
||||||
|
<div class="help-step-item">
|
||||||
|
<div>
|
||||||
|
<div class="help-step-title">1. Connect Adobe Sign</div>
|
||||||
|
<div class="help-step-desc">Use the Adobe Sign chip in the top bar so the app can load your source templates.</div>
|
||||||
|
</div>
|
||||||
|
${stepBadge(s.adobeConnected)}
|
||||||
|
</div>
|
||||||
|
<div class="help-step-item">
|
||||||
|
<div>
|
||||||
|
<div class="help-step-title">2. Connect DocuSign</div>
|
||||||
|
<div class="help-step-desc">Use the Docusign chip to sign in and authorize the app for this browser session.</div>
|
||||||
|
</div>
|
||||||
|
${stepBadge(s.docusignConnected, s.adobeConnected ? 'Next' : 'After Adobe')}
|
||||||
|
</div>
|
||||||
|
<div class="help-step-item">
|
||||||
|
<div>
|
||||||
|
<div class="help-step-title">3. Choose the right DocuSign account</div>
|
||||||
|
<div class="help-step-desc">If your login has multiple accounts, select the exact target account before migrating anything.</div>
|
||||||
|
</div>
|
||||||
|
${stepBadge(s.docusignAccountChosen, s.docusignConnected ? 'Next' : 'After Sign-In')}
|
||||||
|
</div>
|
||||||
|
<div class="help-step-item">
|
||||||
|
<div>
|
||||||
|
<div class="help-step-title">4. Review templates and blockers</div>
|
||||||
|
<div class="help-step-desc">Start on Templates, scan the readiness badges, and use dry-run or single-template migration first.</div>
|
||||||
|
</div>
|
||||||
|
${stepBadge(s.templatesLoaded, s.docusignAccountChosen && s.adobeConnected ? 'Next' : 'Pending')}
|
||||||
|
</div>
|
||||||
|
<div class="help-step-item">
|
||||||
|
<div>
|
||||||
|
<div class="help-step-title">5. Verify after migration</div>
|
||||||
|
<div class="help-step-desc">Use Verification to send a test envelope and History to confirm what succeeded or failed.</div>
|
||||||
|
</div>
|
||||||
|
<span class="badge badge-blue">Recommended</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function quickStartCardMarkup() {
|
||||||
|
return `
|
||||||
|
<div class="card help-card" id="quick-start-card">
|
||||||
|
<div class="card-header">
|
||||||
|
<div>
|
||||||
|
<div class="card-title">Quick Start</div>
|
||||||
|
<div class="page-subtitle">New here? This is the shortest safe path through the tool.</div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
||||||
|
<button class="btn btn-secondary btn-sm" id="btn-open-help-guide">Open Full Help</button>
|
||||||
|
<button class="btn btn-ghost btn-sm" id="btn-hide-quick-start">Hide</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="callout info" style="margin-bottom:16px">
|
||||||
|
<span class="callout-icon">ℹ️</span>
|
||||||
|
This app helps you migrate templates from Adobe Sign into DocuSign, review blockers and warnings, and send verification envelopes after migration.
|
||||||
|
</div>
|
||||||
|
${quickStartStepsMarkup()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bindQuickStartCard(root = document) {
|
||||||
|
root.getElementById?.('btn-open-help-guide')?.addEventListener('click', () => navigate('#/help'));
|
||||||
|
root.getElementById?.('btn-hide-quick-start')?.addEventListener('click', () => {
|
||||||
|
dismissQuickStart();
|
||||||
|
root.getElementById('quick-start-card')?.remove();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderHelp() {
|
||||||
|
const outlet = document.getElementById('router-outlet');
|
||||||
|
outlet.innerHTML = `
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<div class="page-title">Help & Quick Start</div>
|
||||||
|
<div class="page-subtitle">What this tool does, how to use it, and where to go next.</div>
|
||||||
|
</div>
|
||||||
|
<div class="page-actions">
|
||||||
|
<button class="btn btn-primary btn-sm" id="btn-help-go-templates">Go to Templates</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="callout info">
|
||||||
|
<span class="callout-icon">🧭</span>
|
||||||
|
This app helps you migrate templates from Adobe Sign into DocuSign, review blockers and warnings, and send verification envelopes after migration.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="two-col help-layout">
|
||||||
|
<div>
|
||||||
|
<div class="card help-card">
|
||||||
|
<div class="card-header">
|
||||||
|
<span class="card-title">Recommended First-Time Flow</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
${quickStartStepsMarkup()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card help-card">
|
||||||
|
<div class="card-header">
|
||||||
|
<span class="card-title">What Each Screen Is For</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="help-mini-grid">
|
||||||
|
<div class="help-mini-card">
|
||||||
|
<div class="help-mini-title">Templates</div>
|
||||||
|
<div class="help-mini-text">Your main workspace. Review Adobe templates, readiness badges, blockers, and launch migrations.</div>
|
||||||
|
</div>
|
||||||
|
<div class="help-mini-card">
|
||||||
|
<div class="help-mini-title">Migration Results</div>
|
||||||
|
<div class="help-mini-text">See the most recent migration outcomes, including successes, partials, and failures.</div>
|
||||||
|
</div>
|
||||||
|
<div class="help-mini-card">
|
||||||
|
<div class="help-mini-title">Issues & Warnings</div>
|
||||||
|
<div class="help-mini-text">Focus on templates that need manual review before you migrate them confidently.</div>
|
||||||
|
</div>
|
||||||
|
<div class="help-mini-card">
|
||||||
|
<div class="help-mini-title">Verification</div>
|
||||||
|
<div class="help-mini-text">Send a test envelope after migration to validate the template works in DocuSign.</div>
|
||||||
|
</div>
|
||||||
|
<div class="help-mini-card">
|
||||||
|
<div class="help-mini-title">History & Audit</div>
|
||||||
|
<div class="help-mini-text">Review what was migrated, when it happened, and what account/session produced it.</div>
|
||||||
|
</div>
|
||||||
|
<div class="help-mini-card">
|
||||||
|
<div class="help-mini-title">Settings</div>
|
||||||
|
<div class="help-mini-text">Manage connections, default verification recipients, and reopen quick-start guidance.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="card help-card">
|
||||||
|
<div class="card-header">
|
||||||
|
<span class="card-title">Tips</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="help-tip-list">
|
||||||
|
<div class="help-tip-item"><strong>Pick the right DocuSign account.</strong> If you have many accounts, migrations go into the one you selected for this browser session.</div>
|
||||||
|
<div class="help-tip-item"><strong>Start with one template.</strong> Migrate a single clean template first before running a larger batch.</div>
|
||||||
|
<div class="help-tip-item"><strong>Use readiness badges.</strong> Blocked and Caveats are there to save you time before migration.</div>
|
||||||
|
<div class="help-tip-item"><strong>Verify afterward.</strong> A successful migration does not replace a real signing test.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card help-card">
|
||||||
|
<div class="card-header">
|
||||||
|
<span class="card-title">If Something Looks Wrong</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="help-tip-list">
|
||||||
|
<div class="help-tip-item">If no templates appear, reconnect Adobe Sign and refresh the Templates page.</div>
|
||||||
|
<div class="help-tip-item">If DocuSign signs you into the wrong place, use <strong>Choose Account</strong> or <strong>Switch Account</strong>.</div>
|
||||||
|
<div class="help-tip-item">If the Templates page shows an error banner, fix that first before trying to migrate.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.getElementById('btn-help-go-templates')?.addEventListener('click', () => navigate('#/templates'));
|
||||||
|
}
|
||||||
|
|
@ -1,16 +1,16 @@
|
||||||
// Issues & Warnings view — surfaces all validation problems before migration
|
// Issues & Warnings view — surfaces all validation problems before migration
|
||||||
|
|
||||||
import { state } from './state.js';
|
import { state } from './state.js';
|
||||||
import { escHtml, formatDate } from './utils.js';
|
import { escHtml, formatDate, renderFieldIssues, bindFieldIssueToggles } from './utils.js';
|
||||||
import { navigate } from './router.js';
|
import { navigate } from './router.js';
|
||||||
|
|
||||||
export function renderIssues() {
|
export function renderIssues() {
|
||||||
const outlet = document.getElementById('router-outlet');
|
const outlet = document.getElementById('router-outlet');
|
||||||
const templates = state.templates || [];
|
const templates = state.templates || [];
|
||||||
|
|
||||||
const blocked = templates.filter(t => t.blockers && t.blockers.length > 0);
|
const blocked = templates.filter(t => hasBlockers(t));
|
||||||
const warnings = templates.filter(t =>
|
const warnings = templates.filter(t =>
|
||||||
(!t.blockers || t.blockers.length === 0) && t.warnings && t.warnings.length > 0
|
!hasBlockers(t) && (hasWarnings(t) || hasFieldIssues(t))
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!state.auth.adobe || !state.auth.docusign) {
|
if (!state.auth.adobe || !state.auth.docusign) {
|
||||||
|
|
@ -32,7 +32,7 @@ export function renderIssues() {
|
||||||
<span class="callout-icon">🎉</span>
|
<span class="callout-icon">🎉</span>
|
||||||
<div>
|
<div>
|
||||||
<strong>All templates are ready!</strong>
|
<strong>All templates are ready!</strong>
|
||||||
<div style="margin-top:4px">No blockers or warnings found across ${templates.length} template${templates.length !== 1 ? 's' : ''}.</div>
|
<div style="margin-top:4px">No validation blockers, warnings, or field mapping caveats found across ${templates.length} template${templates.length !== 1 ? 's' : ''}.</div>
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
return;
|
return;
|
||||||
|
|
@ -66,7 +66,7 @@ export function renderIssues() {
|
||||||
${warnings.length ? `
|
${warnings.length ? `
|
||||||
<div>
|
<div>
|
||||||
<div style="font-size:14px;font-weight:700;color:var(--warning);margin-bottom:10px">
|
<div style="font-size:14px;font-weight:700;color:var(--warning);margin-bottom:10px">
|
||||||
⚠ Warnings — ${warnings.length} template${warnings.length > 1 ? 's' : ''} will migrate with caveats
|
⚠ Caveats — ${warnings.length} template${warnings.length > 1 ? 's' : ''} should be reviewed
|
||||||
</div>
|
</div>
|
||||||
<div class="attention-list">
|
<div class="attention-list">
|
||||||
${warnings.map(t => _warningItem(t)).join('')}
|
${warnings.map(t => _warningItem(t)).join('')}
|
||||||
|
|
@ -85,6 +85,8 @@ export function renderIssues() {
|
||||||
document.querySelectorAll('.btn-view-template').forEach(btn => {
|
document.querySelectorAll('.btn-view-template').forEach(btn => {
|
||||||
btn.addEventListener('click', () => navigate(`#/templates/${btn.dataset.id}`));
|
btn.addEventListener('click', () => navigate(`#/templates/${btn.dataset.id}`));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
bindFieldIssueToggles(outlet);
|
||||||
}
|
}
|
||||||
|
|
||||||
function _blockerItem(t) {
|
function _blockerItem(t) {
|
||||||
|
|
@ -106,6 +108,7 @@ function _blockerItem(t) {
|
||||||
|
|
||||||
function _warningItem(t) {
|
function _warningItem(t) {
|
||||||
const warnings = t.warnings || [];
|
const warnings = t.warnings || [];
|
||||||
|
const fieldIssues = t.field_issues || [];
|
||||||
return `
|
return `
|
||||||
<div class="attention-item warning">
|
<div class="attention-item warning">
|
||||||
<span class="attention-icon">⚠️</span>
|
<span class="attention-icon">⚠️</span>
|
||||||
|
|
@ -113,6 +116,7 @@ function _warningItem(t) {
|
||||||
<div class="attention-name">${escHtml(t.name)}</div>
|
<div class="attention-name">${escHtml(t.name)}</div>
|
||||||
${warnings.slice(0, 3).map(w => `<div class="attention-detail">• ${escHtml(w)}</div>`).join('')}
|
${warnings.slice(0, 3).map(w => `<div class="attention-detail">• ${escHtml(w)}</div>`).join('')}
|
||||||
${warnings.length > 3 ? `<div class="attention-detail" style="color:var(--text-muted)">… +${warnings.length - 3} more</div>` : ''}
|
${warnings.length > 3 ? `<div class="attention-detail" style="color:var(--text-muted)">… +${warnings.length - 3} more</div>` : ''}
|
||||||
|
${fieldIssues.length ? renderFieldIssues(fieldIssues) : ''}
|
||||||
<div style="margin-top:6px;font-size:11px;color:var(--text-muted)">Modified ${formatDate(t.adobe_modified)}</div>
|
<div style="margin-top:6px;font-size:11px;color:var(--text-muted)">Modified ${formatDate(t.adobe_modified)}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="attention-action" style="display:flex;flex-direction:column;gap:6px;align-items:flex-end">
|
<div class="attention-action" style="display:flex;flex-direction:column;gap:6px;align-items:flex-end">
|
||||||
|
|
@ -122,3 +126,15 @@ function _warningItem(t) {
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasBlockers(t) {
|
||||||
|
return (t.blockers || []).length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasWarnings(t) {
|
||||||
|
return (t.warnings || []).length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasFieldIssues(t) {
|
||||||
|
return (t.field_issues || []).length > 0;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,11 +8,53 @@ import { refreshTemplates } from './templates.js';
|
||||||
|
|
||||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const _RESULTS_STORAGE_KEY = 'migrator_last_batch_results';
|
||||||
|
|
||||||
function getSettings() {
|
function getSettings() {
|
||||||
try { return JSON.parse(localStorage.getItem('migrator_settings')) || {}; }
|
try { return JSON.parse(localStorage.getItem('migrator_settings')) || {}; }
|
||||||
catch { return {}; }
|
catch { return {}; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function persistLastResults(results) {
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem(_RESULTS_STORAGE_KEY, JSON.stringify(results));
|
||||||
|
} catch {
|
||||||
|
// Best-effort only.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadPersistedResults() {
|
||||||
|
try {
|
||||||
|
const raw = sessionStorage.getItem(_RESULTS_STORAGE_KEY);
|
||||||
|
return raw ? JSON.parse(raw) : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildResultsFromHistory(history) {
|
||||||
|
if (!history || !history.length) return null;
|
||||||
|
|
||||||
|
const sorted = [...history].sort((a, b) => String(b.timestamp || '').localeCompare(String(a.timestamp || '')));
|
||||||
|
const newestTimestamp = sorted[0]?.timestamp;
|
||||||
|
const recentBatch = sorted.filter(item => item.timestamp === newestTimestamp);
|
||||||
|
if (!recentBatch.length) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 'completed',
|
||||||
|
completed_at: newestTimestamp,
|
||||||
|
results: recentBatch,
|
||||||
|
summary: {
|
||||||
|
total: recentBatch.length,
|
||||||
|
success: recentBatch.filter(r => r.status === 'success').length,
|
||||||
|
failed: recentBatch.filter(r => r.status === 'failed' || r.status === 'blocked').length,
|
||||||
|
skipped: recentBatch.filter(r => r.status === 'skipped').length,
|
||||||
|
dry_run: recentBatch.filter(r => r.status === 'dry_run').length,
|
||||||
|
},
|
||||||
|
recovered_from_history: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// ── Options modal ──────────────────────────────────────────────────────────
|
// ── Options modal ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function showOptionsModal(ids) {
|
export function showOptionsModal(ids) {
|
||||||
|
|
@ -230,6 +272,7 @@ export async function pollJob(jobId, onProgress) {
|
||||||
|
|
||||||
if (data.status === 'done' || data.status === 'complete' || data.status === 'completed') {
|
if (data.status === 'done' || data.status === 'complete' || data.status === 'completed') {
|
||||||
setState('lastMigrationResults', data);
|
setState('lastMigrationResults', data);
|
||||||
|
persistLastResults(data);
|
||||||
resolve(data);
|
resolve(data);
|
||||||
} else if (data.status === 'failed') {
|
} else if (data.status === 'failed') {
|
||||||
reject(new Error('Migration job failed'));
|
reject(new Error('Migration job failed'));
|
||||||
|
|
@ -248,16 +291,30 @@ export async function pollJob(jobId, onProgress) {
|
||||||
|
|
||||||
// ── Results view ───────────────────────────────────────────────────────────
|
// ── Results view ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function renderResults() {
|
export async function renderResults() {
|
||||||
const outlet = document.getElementById('router-outlet');
|
const outlet = document.getElementById('router-outlet');
|
||||||
const results = state.lastMigrationResults;
|
let results = state.lastMigrationResults || loadPersistedResults();
|
||||||
|
|
||||||
|
if (!results) {
|
||||||
|
try {
|
||||||
|
const data = await api.migrate.history();
|
||||||
|
results = buildResultsFromHistory(data.history || []);
|
||||||
|
} catch {
|
||||||
|
results = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (results) {
|
||||||
|
setState('lastMigrationResults', results);
|
||||||
|
persistLastResults(results);
|
||||||
|
}
|
||||||
|
|
||||||
if (!results) {
|
if (!results) {
|
||||||
outlet.innerHTML = `
|
outlet.innerHTML = `
|
||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
<div class="empty-state-icon">📊</div>
|
<div class="empty-state-icon">📊</div>
|
||||||
<div class="empty-state-title">No migration results yet</div>
|
<div class="empty-state-title">No migration results yet</div>
|
||||||
<div class="empty-state-sub">Run a migration from the <a href="#/templates" style="color:var(--cobalt)">Templates</a> view to see results here.</div>
|
<div class="empty-state-sub">Run a migration from the <a href="#/templates" style="color:var(--cobalt)">Templates</a> view to see results here. If templates already exist in DocuSign, use History & Audit to review older runs.</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -288,6 +345,13 @@ export function renderResults() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
${results.recovered_from_history ? `
|
||||||
|
<div class="callout info">
|
||||||
|
<span class="callout-icon">ℹ️</span>
|
||||||
|
These results were recovered from recent migration history after the page state was reset.
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
<!-- Summary stat cards -->
|
<!-- Summary stat cards -->
|
||||||
<div class="stat-grid" style="grid-template-columns:repeat(${summary.dry_run ? 6 : 5},1fr)">
|
<div class="stat-grid" style="grid-template-columns:repeat(${summary.dry_run ? 6 : 5},1fr)">
|
||||||
<div class="stat-card green">
|
<div class="stat-card green">
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
// Settings view — verification defaults, migration defaults, connection info
|
// Settings view — verification defaults, migration defaults, connection info
|
||||||
|
|
||||||
import { api } from './api.js';
|
import { api } from './api.js';
|
||||||
import { state } from './state.js';
|
|
||||||
import { escHtml } from './utils.js';
|
import { escHtml } from './utils.js';
|
||||||
|
import { disconnectPlatform, showDocusignAccountPicker, switchAccount } from './auth.js';
|
||||||
|
import { navigate } from './router.js';
|
||||||
|
import { resetQuickStart } from './help.js';
|
||||||
|
|
||||||
const SETTINGS_KEY = 'migrator_settings';
|
const SETTINGS_KEY = 'migrator_settings';
|
||||||
|
|
||||||
|
|
@ -31,13 +33,13 @@ export function renderSettings() {
|
||||||
<div class="settings-section">
|
<div class="settings-section">
|
||||||
<div class="settings-section-header">
|
<div class="settings-section-header">
|
||||||
<div class="settings-section-title">Verification</div>
|
<div class="settings-section-title">Verification</div>
|
||||||
<div class="settings-section-sub">Default recipient for test envelopes</div>
|
<div class="settings-section-sub">Default recipient for test envelopes. The same name and email are used for every recipient role on the template during a test send.</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-section-body">
|
<div class="settings-section-body">
|
||||||
<div class="setting-row">
|
<div class="setting-row">
|
||||||
<div class="setting-body">
|
<div class="setting-body">
|
||||||
<div class="setting-label">Test Recipient Name</div>
|
<div class="setting-label">Test Recipient Name</div>
|
||||||
<div class="setting-desc">Pre-filled in the Send Test dialog on the Verification screen</div>
|
<div class="setting-desc">Pre-filled in the Send Test dialog on the Verification screen and reused for all recipient roles in the template.</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="setting-control" style="min-width:240px">
|
<div class="setting-control" style="min-width:240px">
|
||||||
<input type="text" class="form-input" id="set-recipient-name"
|
<input type="text" class="form-input" id="set-recipient-name"
|
||||||
|
|
@ -48,7 +50,7 @@ export function renderSettings() {
|
||||||
<div class="setting-row">
|
<div class="setting-row">
|
||||||
<div class="setting-body">
|
<div class="setting-body">
|
||||||
<div class="setting-label">Test Recipient Email</div>
|
<div class="setting-label">Test Recipient Email</div>
|
||||||
<div class="setting-desc">Pre-filled in the Send Test dialog</div>
|
<div class="setting-desc">Pre-filled in the Send Test dialog and used on every recipient role when sending a verification envelope.</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="setting-control" style="min-width:240px">
|
<div class="setting-control" style="min-width:240px">
|
||||||
<input type="email" class="form-input" id="set-recipient-email"
|
<input type="email" class="form-input" id="set-recipient-email"
|
||||||
|
|
@ -103,13 +105,40 @@ export function renderSettings() {
|
||||||
<div class="settings-section">
|
<div class="settings-section">
|
||||||
<div class="settings-section-header">
|
<div class="settings-section-header">
|
||||||
<div class="settings-section-title">Connections</div>
|
<div class="settings-section-title">Connections</div>
|
||||||
<div class="settings-section-sub">Current platform connection status (connect via top bar)</div>
|
<div class="settings-section-sub">Current platform connection status and account actions</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-section-body" id="settings-conn-info">
|
<div class="settings-section-body" id="settings-conn-info">
|
||||||
<div style="padding:8px 0;font-size:13px;color:var(--text-muted)">Loading…</div>
|
<div style="padding:8px 0;font-size:13px;color:var(--text-muted)">Loading…</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-section">
|
||||||
|
<div class="settings-section-header">
|
||||||
|
<div class="settings-section-title">Help & Onboarding</div>
|
||||||
|
<div class="settings-section-sub">Make it easy to get oriented again or share the app with a first-time tester</div>
|
||||||
|
</div>
|
||||||
|
<div class="settings-section-body">
|
||||||
|
<div class="setting-row">
|
||||||
|
<div class="setting-body">
|
||||||
|
<div class="setting-label">Open Full Help</div>
|
||||||
|
<div class="setting-desc">See the in-app guide with the recommended first-time flow and screen-by-screen overview.</div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-control">
|
||||||
|
<button class="btn btn-secondary" id="btn-open-help">Open Help</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-row">
|
||||||
|
<div class="setting-body">
|
||||||
|
<div class="setting-label">Show Quick Start Again</div>
|
||||||
|
<div class="setting-desc">Re-enable the Templates quick-start card if you dismissed it earlier.</div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-control">
|
||||||
|
<button class="btn btn-primary" id="btn-reset-quick-start">Show Quick Start</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Save -->
|
<!-- Save -->
|
||||||
<div style="display:flex;gap:8px;align-items:center">
|
<div style="display:flex;gap:8px;align-items:center">
|
||||||
<button class="btn btn-primary" id="btn-save-settings">Save Settings</button>
|
<button class="btn btn-primary" id="btn-save-settings">Save Settings</button>
|
||||||
|
|
@ -142,6 +171,15 @@ export function renderSettings() {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.getElementById('btn-open-help')?.addEventListener('click', () => {
|
||||||
|
navigate('#/help');
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('btn-reset-quick-start')?.addEventListener('click', () => {
|
||||||
|
resetQuickStart();
|
||||||
|
navigate('#/templates');
|
||||||
|
});
|
||||||
|
|
||||||
// Load connection info
|
// Load connection info
|
||||||
_loadConnInfo();
|
_loadConnInfo();
|
||||||
}
|
}
|
||||||
|
|
@ -155,18 +193,64 @@ async function _loadConnInfo() {
|
||||||
connEl.innerHTML = `
|
connEl.innerHTML = `
|
||||||
<div class="conn-info-row">
|
<div class="conn-info-row">
|
||||||
<span class="conn-info-label">Adobe Sign</span>
|
<span class="conn-info-label">Adobe Sign</span>
|
||||||
<span class="conn-info-value">${data.adobe ? 'Connected' : 'Not connected'}</span>
|
<span class="conn-info-value">${data.adobe ? `Connected${data.adobe_account_name ? ` — ${escHtml(data.adobe_account_name)}` : ''}` : 'Not connected'}</span>
|
||||||
<span class="conn-info-status">
|
<span class="conn-info-status">
|
||||||
<span class="badge ${data.adobe ? 'badge-green' : 'badge-gray'}">${data.adobe ? '● Connected' : '○ Disconnected'}</span>
|
<span class="badge ${data.adobe ? 'badge-green' : 'badge-gray'}">${data.adobe ? '● Connected' : '○ Disconnected'}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="conn-info-row">
|
<div class="conn-info-row">
|
||||||
<span class="conn-info-label">Docusign</span>
|
<span class="conn-info-label">Docusign</span>
|
||||||
<span class="conn-info-value">${data.docusign ? 'Connected' : 'Not connected'}</span>
|
<span class="conn-info-value">${data.docusign ? (data.docusign_account_selection_required ? 'Connected — account selection required' : `Connected${data.docusign_account_name ? ` — ${escHtml(data.docusign_account_name)}` : ''}`) : 'Not connected'}</span>
|
||||||
<span class="conn-info-status">
|
<span class="conn-info-status">
|
||||||
<span class="badge ${data.docusign ? 'badge-green' : 'badge-gray'}">${data.docusign ? '● Connected' : '○ Disconnected'}</span>
|
<span class="badge ${data.docusign ? (data.docusign_account_selection_required ? 'badge-amber' : 'badge-green') : 'badge-gray'}">${data.docusign ? (data.docusign_account_selection_required ? '● Choose account' : '● Connected') : '○ Disconnected'}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="conn-info-row conn-info-actions">
|
||||||
|
<span class="conn-info-label">Adobe Actions</span>
|
||||||
|
<span class="conn-info-value">
|
||||||
|
<button class="btn btn-secondary" id="btn-disconnect-adobe" ${data.adobe ? '' : 'disabled'}>Disconnect Adobe Sign</button>
|
||||||
|
</span>
|
||||||
|
<span class="conn-info-status"></span>
|
||||||
|
</div>
|
||||||
|
<div class="conn-info-row conn-info-actions">
|
||||||
|
<span class="conn-info-label">Docusign Actions</span>
|
||||||
|
<span class="conn-info-value" style="display:flex;gap:8px;flex-wrap:wrap">
|
||||||
|
<button class="btn btn-secondary" id="btn-disconnect-docusign" ${data.docusign ? '' : 'disabled'}>Disconnect Docusign</button>
|
||||||
|
<button class="btn btn-primary" id="btn-switch-docusign" ${data.docusign ? '' : 'disabled'}>Switch Docusign Account</button>
|
||||||
|
<button class="btn btn-secondary" id="btn-choose-docusign-account" ${data.docusign ? '' : 'disabled'}>Choose Account</button>
|
||||||
|
</span>
|
||||||
|
<span class="conn-info-status"></span>
|
||||||
|
</div>
|
||||||
|
<div class="conn-info-row">
|
||||||
|
<span class="conn-info-label">Adobe Auth Mode</span>
|
||||||
|
<span class="conn-info-value mono">${escHtml(data.adobe_auth_mode || '—')}</span>
|
||||||
|
<span class="conn-info-status"></span>
|
||||||
|
</div>
|
||||||
|
<div class="conn-info-row">
|
||||||
|
<span class="conn-info-label">Docusign Auth Mode</span>
|
||||||
|
<span class="conn-info-value mono">${escHtml(data.docusign_auth_mode || '—')}</span>
|
||||||
|
<span class="conn-info-status"></span>
|
||||||
|
</div>
|
||||||
|
<div class="conn-info-row">
|
||||||
|
<span class="conn-info-label">Browser Session ID</span>
|
||||||
|
<span class="conn-info-value mono">${escHtml(data.session_id || '—')}</span>
|
||||||
|
<span class="conn-info-status"></span>
|
||||||
|
</div>
|
||||||
|
<div class="conn-info-row">
|
||||||
|
<span class="conn-info-label">Admin Access</span>
|
||||||
|
<span class="conn-info-value">${data.is_admin ? 'Yes — this session can view all audit logs.' : 'No — this session can only view its own audit logs.'}</span>
|
||||||
|
<span class="conn-info-status"></span>
|
||||||
|
</div>
|
||||||
|
<div class="conn-info-row">
|
||||||
|
<span class="conn-info-label">Switch Account Note</span>
|
||||||
|
<span class="conn-info-value">Use <strong>Choose Account</strong> or <strong>Switch Docusign Account</strong> to select from the DocuSign accounts available to this login. The picker is sorted alphabetically and supports search.</span>
|
||||||
|
<span class="conn-info-status"></span>
|
||||||
|
</div>
|
||||||
|
<div class="conn-info-row">
|
||||||
|
<span class="conn-info-label">Adobe Account ID</span>
|
||||||
|
<span class="conn-info-value mono">${escHtml(data.adobe_account_id || '—')}</span>
|
||||||
|
<span class="conn-info-status"></span>
|
||||||
|
</div>
|
||||||
<div class="conn-info-row">
|
<div class="conn-info-row">
|
||||||
<span class="conn-info-label">Docusign Account ID</span>
|
<span class="conn-info-label">Docusign Account ID</span>
|
||||||
<span class="conn-info-value mono">${escHtml(data.docusign_account_id || '—')}</span>
|
<span class="conn-info-value mono">${escHtml(data.docusign_account_id || '—')}</span>
|
||||||
|
|
@ -180,5 +264,25 @@ async function _loadConnInfo() {
|
||||||
`;
|
`;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
connEl.innerHTML = `<div class="callout error"><span class="callout-icon">❌</span>Failed to load connection info: ${escHtml(e.message)}</div>`;
|
connEl.innerHTML = `<div class="callout error"><span class="callout-icon">❌</span>Failed to load connection info: ${escHtml(e.message)}</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('btn-disconnect-adobe')?.addEventListener('click', async () => {
|
||||||
|
await disconnectPlatform('adobe');
|
||||||
|
await _loadConnInfo();
|
||||||
|
});
|
||||||
|
document.getElementById('btn-disconnect-docusign')?.addEventListener('click', async () => {
|
||||||
|
await disconnectPlatform('docusign');
|
||||||
|
await _loadConnInfo();
|
||||||
|
});
|
||||||
|
document.getElementById('btn-switch-docusign')?.addEventListener('click', async () => {
|
||||||
|
await switchAccount('docusign');
|
||||||
|
});
|
||||||
|
document.getElementById('btn-choose-docusign-account')?.addEventListener('click', async () => {
|
||||||
|
await showDocusignAccountPicker({ forceRefresh: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
if (data.docusign && data.docusign_account_selection_required) {
|
||||||
|
await showDocusignAccountPicker();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,17 @@ export const state = {
|
||||||
adobe: false,
|
adobe: false,
|
||||||
docusign: false,
|
docusign: false,
|
||||||
adobeLabel: 'Adobe Sign',
|
adobeLabel: 'Adobe Sign',
|
||||||
|
adobeAccountId: null,
|
||||||
|
adobeAccountName: null,
|
||||||
docusignLabel: 'Docusign',
|
docusignLabel: 'Docusign',
|
||||||
|
docusignAccountId: null,
|
||||||
|
docusignAccountName: null,
|
||||||
|
docusignAccountsCount: 0,
|
||||||
|
docusignAccountSelectionRequired: false,
|
||||||
|
isAdmin: false,
|
||||||
},
|
},
|
||||||
templates: [], // [{ adobe_id, name, status, blockers, warnings, ... }]
|
templates: [], // [{ adobe_id, name, status, blockers, warnings, field_issues, ... }]
|
||||||
|
templatesError: null, // Visible error state for template loading failures
|
||||||
selectedIds: new Set(),
|
selectedIds: new Set(),
|
||||||
lastMigrationResults: null, // final batch job results
|
lastMigrationResults: null, // final batch job results
|
||||||
issueCount: 0, // blocked template count (drives nav badge)
|
issueCount: 0, // blocked template count (drives nav badge)
|
||||||
|
|
@ -38,6 +46,10 @@ export function setState(key, value) {
|
||||||
|
|
||||||
// Recompute derived values after template list updates
|
// Recompute derived values after template list updates
|
||||||
export function updateDerivedState() {
|
export function updateDerivedState() {
|
||||||
const blocked = state.templates.filter(t => t.blockers && t.blockers.length > 0).length;
|
const issueCount = state.templates.filter(t =>
|
||||||
setState('issueCount', blocked);
|
(t.blockers || []).length > 0 ||
|
||||||
|
(t.warnings || []).length > 0 ||
|
||||||
|
(t.field_issues || []).length > 0
|
||||||
|
).length;
|
||||||
|
setState('issueCount', issueCount);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { api } from './api.js';
|
||||||
import { state, setState, updateDerivedState } from './state.js';
|
import { state, setState, updateDerivedState } from './state.js';
|
||||||
import { escHtml, formatDate, formatRelative, debounce, renderFieldIssues, bindFieldIssueToggles } from './utils.js';
|
import { escHtml, formatDate, formatRelative, debounce, renderFieldIssues, bindFieldIssueToggles } from './utils.js';
|
||||||
import { navigate } from './router.js';
|
import { navigate } from './router.js';
|
||||||
|
import { bindQuickStartCard, quickStartCardMarkup, shouldShowQuickStart } from './help.js';
|
||||||
|
|
||||||
// ── Readiness badge ────────────────────────────────────────────────────────
|
// ── Readiness badge ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -11,15 +12,18 @@ function readiness(t) {
|
||||||
if (t.blockers && t.blockers.length > 0) {
|
if (t.blockers && t.blockers.length > 0) {
|
||||||
return { key: 'blocked', label: 'Blocked', cls: 'badge-blocked' };
|
return { key: 'blocked', label: 'Blocked', cls: 'badge-blocked' };
|
||||||
}
|
}
|
||||||
|
if (hasFieldIssues(t)) {
|
||||||
|
return { key: 'field-caveats', label: 'Caveats', cls: 'badge-caveats' };
|
||||||
|
}
|
||||||
if (t.status === 'migrated') {
|
if (t.status === 'migrated') {
|
||||||
return t.warnings && t.warnings.length > 0
|
return hasWarnings(t)
|
||||||
? { key: 'migrated-warn', label: 'Migrated', cls: 'badge-migrated' }
|
? { key: 'migrated-warn', label: 'Migrated', cls: 'badge-migrated' }
|
||||||
: { key: 'migrated', label: 'Migrated', cls: 'badge-migrated' };
|
: { key: 'migrated', label: 'Migrated', cls: 'badge-migrated' };
|
||||||
}
|
}
|
||||||
if (t.status === 'needs_update') {
|
if (t.status === 'needs_update') {
|
||||||
return { key: 'needs-update', label: 'Needs Update', cls: 'badge-needs-update' };
|
return { key: 'needs-update', label: 'Needs Update', cls: 'badge-needs-update' };
|
||||||
}
|
}
|
||||||
if (t.warnings && t.warnings.length > 0) {
|
if (hasWarnings(t)) {
|
||||||
return { key: 'caveats', label: 'Caveats', cls: 'badge-caveats' };
|
return { key: 'caveats', label: 'Caveats', cls: 'badge-caveats' };
|
||||||
}
|
}
|
||||||
return { key: 'ready', label: 'Ready', cls: 'badge-ready' };
|
return { key: 'ready', label: 'Ready', cls: 'badge-ready' };
|
||||||
|
|
@ -30,15 +34,20 @@ function readiness(t) {
|
||||||
export async function refreshTemplates() {
|
export async function refreshTemplates() {
|
||||||
if (!state.auth.adobe || !state.auth.docusign) {
|
if (!state.auth.adobe || !state.auth.docusign) {
|
||||||
setState('templates', []);
|
setState('templates', []);
|
||||||
|
setState('templatesError', null);
|
||||||
updateDerivedState();
|
updateDerivedState();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const data = await api.templates.status();
|
const data = await api.templates.status();
|
||||||
setState('templates', data.templates || []);
|
setState('templates', data.templates || []);
|
||||||
|
setState('templatesError', null);
|
||||||
updateDerivedState();
|
updateDerivedState();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('refreshTemplates failed:', e.message);
|
console.warn('refreshTemplates failed:', e.message);
|
||||||
|
setState('templates', []);
|
||||||
|
setState('templatesError', e.data?.error || e.message || 'Failed to load templates.');
|
||||||
|
updateDerivedState();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -84,6 +93,15 @@ function _render() {
|
||||||
</div>
|
</div>
|
||||||
` : ''}
|
` : ''}
|
||||||
|
|
||||||
|
${state.templatesError ? `
|
||||||
|
<div class="callout error">
|
||||||
|
<span class="callout-icon">❌</span>
|
||||||
|
Template loading failed: ${escHtml(state.templatesError)}
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
${shouldShowQuickStart() ? quickStartCardMarkup() : ''}
|
||||||
|
|
||||||
<!-- Filter bar -->
|
<!-- Filter bar -->
|
||||||
<div class="filter-bar">
|
<div class="filter-bar">
|
||||||
<input type="search" class="search-input" id="template-search"
|
<input type="search" class="search-input" id="template-search"
|
||||||
|
|
@ -131,7 +149,7 @@ function _render() {
|
||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
<div class="empty-state-icon">📄</div>
|
<div class="empty-state-icon">📄</div>
|
||||||
<div class="empty-state-title">${state.templates.length ? 'No templates match your filter' : 'No templates found'}</div>
|
<div class="empty-state-title">${state.templates.length ? 'No templates match your filter' : 'No templates found'}</div>
|
||||||
<div class="empty-state-sub">${state.templates.length ? 'Try clearing the search or filter.' : 'Connect Adobe Sign to load templates.'}</div>
|
<div class="empty-state-sub">${state.templates.length ? 'Try clearing the search or filter.' : (state.templatesError ? 'The template load failed. Check the error message above.' : 'Connect Adobe Sign to load templates.')}</div>
|
||||||
</div>
|
</div>
|
||||||
</td></tr>`
|
</td></tr>`
|
||||||
}
|
}
|
||||||
|
|
@ -165,10 +183,13 @@ function _templateRow(t) {
|
||||||
const selected = state.selectedIds.has(t.adobe_id);
|
const selected = state.selectedIds.has(t.adobe_id);
|
||||||
const warnCount = (t.warnings || []).length;
|
const warnCount = (t.warnings || []).length;
|
||||||
const blockCount = (t.blockers || []).length;
|
const blockCount = (t.blockers || []).length;
|
||||||
const issueClass = blockCount > 0 ? 'blocked' : (warnCount > 0 ? 'has-issues' : 'no-issues');
|
const fieldIssueCount = (t.field_issues || []).length;
|
||||||
|
const issueClass = blockCount > 0 ? 'blocked' : (warnCount > 0 || fieldIssueCount > 0 ? 'has-issues' : 'no-issues');
|
||||||
const issueLabel = blockCount > 0
|
const issueLabel = blockCount > 0
|
||||||
? `🚫 ${blockCount} blocker${blockCount > 1 ? 's' : ''}`
|
? `🚫 ${blockCount} blocker${blockCount > 1 ? 's' : ''}`
|
||||||
: (warnCount > 0 ? `⚠ ${warnCount} warning${warnCount > 1 ? 's' : ''}` : '✓ Clean');
|
: (warnCount > 0 || fieldIssueCount > 0
|
||||||
|
? `⚠ ${warnCount + fieldIssueCount} caveat${warnCount + fieldIssueCount > 1 ? 's' : ''}`
|
||||||
|
: '✓ Clean');
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<tr class="${selected ? 'row-selected' : ''}" data-id="${escHtml(t.adobe_id)}">
|
<tr class="${selected ? 'row-selected' : ''}" data-id="${escHtml(t.adobe_id)}">
|
||||||
|
|
@ -202,7 +223,7 @@ function _statusCounts(templates) {
|
||||||
migrated: templates.filter(t => t.status === 'migrated').length,
|
migrated: templates.filter(t => t.status === 'migrated').length,
|
||||||
needs_update: templates.filter(t => t.status === 'needs_update').length,
|
needs_update: templates.filter(t => t.status === 'needs_update').length,
|
||||||
blocked: templates.filter(t => t.blockers && t.blockers.length > 0).length,
|
blocked: templates.filter(t => t.blockers && t.blockers.length > 0).length,
|
||||||
caveats: templates.filter(t => (!t.blockers || !t.blockers.length) && t.warnings && t.warnings.length > 0).length,
|
caveats: templates.filter(t => !hasBlockers(t) && (hasWarnings(t) || hasFieldIssues(t))).length,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -218,9 +239,9 @@ function _applyFilter(templates) {
|
||||||
// Status / readiness filter
|
// Status / readiness filter
|
||||||
if (_filter.status !== 'all') {
|
if (_filter.status !== 'all') {
|
||||||
if (_filter.status === 'blocked') {
|
if (_filter.status === 'blocked') {
|
||||||
list = list.filter(t => t.blockers && t.blockers.length > 0);
|
list = list.filter(t => hasBlockers(t));
|
||||||
} else if (_filter.status === 'caveats') {
|
} else if (_filter.status === 'caveats') {
|
||||||
list = list.filter(t => (!t.blockers || !t.blockers.length) && t.warnings && t.warnings.length > 0);
|
list = list.filter(t => !hasBlockers(t) && (hasWarnings(t) || hasFieldIssues(t)));
|
||||||
} else {
|
} else {
|
||||||
list = list.filter(t => t.status === _filter.status);
|
list = list.filter(t => t.status === _filter.status);
|
||||||
}
|
}
|
||||||
|
|
@ -231,7 +252,7 @@ function _applyFilter(templates) {
|
||||||
let va = a[_sort.col] || '';
|
let va = a[_sort.col] || '';
|
||||||
let vb = b[_sort.col] || '';
|
let vb = b[_sort.col] || '';
|
||||||
if (_sort.col === 'readiness') { va = readiness(a).key; vb = readiness(b).key; }
|
if (_sort.col === 'readiness') { va = readiness(a).key; vb = readiness(b).key; }
|
||||||
if (_sort.col === 'warnings') { va = (a.blockers||[]).length + (a.warnings||[]).length; vb = (b.blockers||[]).length + (b.warnings||[]).length; }
|
if (_sort.col === 'warnings') { va = totalIssueCount(a); vb = totalIssueCount(b); }
|
||||||
if (typeof va === 'number') return _sort.dir === 'asc' ? va - vb : vb - va;
|
if (typeof va === 'number') return _sort.dir === 'asc' ? va - vb : vb - va;
|
||||||
return _sort.dir === 'asc' ? String(va).localeCompare(String(vb)) : String(vb).localeCompare(String(va));
|
return _sort.dir === 'asc' ? String(va).localeCompare(String(vb)) : String(vb).localeCompare(String(va));
|
||||||
});
|
});
|
||||||
|
|
@ -242,6 +263,8 @@ function _applyFilter(templates) {
|
||||||
// ── Event wiring ───────────────────────────────────────────────────────────
|
// ── Event wiring ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function _bindEvents() {
|
function _bindEvents() {
|
||||||
|
bindQuickStartCard(document);
|
||||||
|
|
||||||
// Search
|
// Search
|
||||||
const searchEl = document.getElementById('template-search');
|
const searchEl = document.getElementById('template-search');
|
||||||
if (searchEl) {
|
if (searchEl) {
|
||||||
|
|
@ -339,6 +362,7 @@ export async function renderTemplateDetail(adobeId) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const r = readiness(t);
|
const r = readiness(t);
|
||||||
|
const issueCount = totalIssueCount(t);
|
||||||
outlet.innerHTML = `
|
outlet.innerHTML = `
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -354,8 +378,8 @@ export async function renderTemplateDetail(adobeId) {
|
||||||
|
|
||||||
<div class="tabs" id="detail-tabs">
|
<div class="tabs" id="detail-tabs">
|
||||||
<div class="tab active" data-tab="overview">Overview</div>
|
<div class="tab active" data-tab="overview">Overview</div>
|
||||||
<div class="tab" data-tab="issues">Issues ${(t.blockers||[]).length + (t.warnings||[]).length > 0
|
<div class="tab" data-tab="issues">Issues ${issueCount > 0
|
||||||
? `<span class="nav-badge" style="position:static;display:inline">${(t.blockers||[]).length + (t.warnings||[]).length}</span>` : ''}</div>
|
? `<span class="nav-badge" style="position:static;display:inline">${issueCount}</span>` : ''}</div>
|
||||||
<div class="tab" data-tab="history">Migration History</div>
|
<div class="tab" data-tab="history">Migration History</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -409,10 +433,15 @@ function _renderDetailTab(t, tabKey) {
|
||||||
} else if (tabKey === 'issues') {
|
} else if (tabKey === 'issues') {
|
||||||
const blockers = t.blockers || [];
|
const blockers = t.blockers || [];
|
||||||
const warnings = t.warnings || [];
|
const warnings = t.warnings || [];
|
||||||
if (!blockers.length && !warnings.length) {
|
const fieldIssues = t.field_issues || [];
|
||||||
|
if (!blockers.length && !warnings.length && !fieldIssues.length) {
|
||||||
content.innerHTML = `<div class="callout success"><span class="callout-icon">✓</span>No issues found. This template is ready to migrate.</div>`;
|
content.innerHTML = `<div class="callout success"><span class="callout-icon">✓</span>No issues found. This template is ready to migrate.</div>`;
|
||||||
} else {
|
} else {
|
||||||
content.innerHTML = `
|
content.innerHTML = `
|
||||||
|
<div class="callout info">
|
||||||
|
<span class="callout-icon">ℹ️</span>
|
||||||
|
This view combines pre-migration validation with field mapping caveats. Field caveats are the same kinds of issues shown after migration.
|
||||||
|
</div>
|
||||||
${blockers.length ? `
|
${blockers.length ? `
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header"><span class="card-title" style="color:var(--error)">🚫 Blockers (${blockers.length})</span></div>
|
<div class="card-header"><span class="card-title" style="color:var(--error)">🚫 Blockers (${blockers.length})</span></div>
|
||||||
|
|
@ -424,6 +453,13 @@ function _renderDetailTab(t, tabKey) {
|
||||||
</div>`).join('')}
|
</div>`).join('')}
|
||||||
</div>
|
</div>
|
||||||
</div>` : ''}
|
</div>` : ''}
|
||||||
|
${fieldIssues.length ? `
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header"><span class="card-title" style="color:var(--warning)">⚠ Field Mapping Caveats (${fieldIssues.length})</span></div>
|
||||||
|
<div class="card-body">
|
||||||
|
${renderFieldIssues(fieldIssues)}
|
||||||
|
</div>
|
||||||
|
</div>` : ''}
|
||||||
${warnings.length ? `
|
${warnings.length ? `
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header"><span class="card-title" style="color:var(--warning)">⚠ Warnings (${warnings.length})</span></div>
|
<div class="card-header"><span class="card-title" style="color:var(--warning)">⚠ Warnings (${warnings.length})</span></div>
|
||||||
|
|
@ -435,6 +471,7 @@ function _renderDetailTab(t, tabKey) {
|
||||||
</div>`).join('')}
|
</div>`).join('')}
|
||||||
</div>
|
</div>
|
||||||
</div>` : ''}`;
|
</div>` : ''}`;
|
||||||
|
bindFieldIssueToggles(content);
|
||||||
}
|
}
|
||||||
} else if (tabKey === 'history') {
|
} else if (tabKey === 'history') {
|
||||||
api.migrate.history().then(data => {
|
api.migrate.history().then(data => {
|
||||||
|
|
@ -500,3 +537,19 @@ function _renderDetailTab(t, tabKey) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasBlockers(t) {
|
||||||
|
return (t.blockers || []).length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasWarnings(t) {
|
||||||
|
return (t.warnings || []).length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasFieldIssues(t) {
|
||||||
|
return (t.field_issues || []).length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function totalIssueCount(t) {
|
||||||
|
return (t.blockers || []).length + (t.warnings || []).length + (t.field_issues || []).length;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -154,6 +154,10 @@ function _showSendDialog(t, settings) {
|
||||||
<div style="font-size:13px;color:var(--text-muted);margin-bottom:14px">
|
<div style="font-size:13px;color:var(--text-muted);margin-bottom:14px">
|
||||||
Template: <strong>${escHtml(t.name)}</strong>
|
Template: <strong>${escHtml(t.name)}</strong>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="callout info" style="margin-bottom:14px">
|
||||||
|
<span class="callout-icon">ℹ️</span>
|
||||||
|
This test send uses the same recipient name and email for every recipient role in the template. That is expected for a basic verification run.
|
||||||
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label" for="sd-name">Recipient Name</label>
|
<label class="form-label" for="sd-name">Recipient Name</label>
|
||||||
<input type="text" class="form-input" id="sd-name"
|
<input type="text" class="form-input" id="sd-name"
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue