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_REFRESH_TOKEN=
|
||||
ADOBE_SIGN_BASE_URL=https://api.eu2.adobesign.com/api/rest/v6
|
||||
ADOBE_REDIRECT_URI=http://localhost:8000/api/auth/adobe/callback
|
||||
|
||||
# ─── DocuSign ────────────────────────────────────────────────────────────────
|
||||
|
||||
# Integration key (client ID) from the DocuSign developer console
|
||||
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
|
||||
|
||||
# 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
|
||||
# Found in the DocuSign admin UI under Settings → Account Profile
|
||||
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
|
||||
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)
|
||||
DOCUSIGN_BASE_URL=https://demo.docusign.net/restapi
|
||||
|
||||
# Redirect URI registered in your DocuSign app (used only during one-time consent flow)
|
||||
DOCUSIGN_REDIRECT_URI=http://localhost:8080/callback
|
||||
# Redirect URI registered in your DocuSign app
|
||||
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.
|
||||
DOCUSIGN_ACCESS_TOKEN=
|
||||
DOCUSIGN_REFRESH_TOKEN=
|
||||
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
|
||||
downloads/
|
||||
migration-output/
|
||||
.session-store/
|
||||
.audit-log.jsonl
|
||||
*.pdf
|
||||
private.key
|
||||
|
|
|
|||
|
|
@ -20,13 +20,13 @@ Develop an agent/toolkit that can programmatically extract template data and fie
|
|||
|
||||
#### Components
|
||||
- **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
|
||||
- **Mapping Service** (`src/services/mapping_service.py`) — field type, recipient role, coordinate translation
|
||||
- **Validation Service** (`src/services/validation_service.py`) — field count comparison, recipient checks, missing role detection
|
||||
- **Migration Service** (`src/services/migration_service.py`) — orchestrates download → normalize → validate → compose → upload
|
||||
- **Report Builder** (`src/reports/report_builder.py`) — structured success/warning/error output
|
||||
- **Web API** (`web/`) — FastAPI endpoints for browser-based orchestration
|
||||
- **Mapping Service** (`src/services/mapping_service.py`) — field type, recipient role, coordinate translation; produces `NormalizedTemplate`
|
||||
- **Validation Service** (`src/services/validation_service.py`) — blocker and warning checks on the normalized schema
|
||||
- **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 per template
|
||||
- **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
|
||||
|
||||
#### Service Separation
|
||||
|
|
@ -34,16 +34,22 @@ Develop an agent/toolkit that can programmatically extract template data and fie
|
|||
src/
|
||||
models/
|
||||
normalized_template.py # intermediate schema
|
||||
field_issue.py # structured field-issue model + issue codes
|
||||
services/
|
||||
migration_service.py # pipeline orchestration
|
||||
mapping_service.py # field/role/coord transformations
|
||||
validation_service.py # pre/post migration checks
|
||||
reports/
|
||||
report_builder.py # structured report output
|
||||
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
|
||||
|
|
|
|||
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`)
|
||||
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
|
||||
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
|
||||
|
||||
---
|
||||
|
|
@ -19,7 +19,7 @@ It downloads templates via the Adobe Sign API, converts them to DocuSign format,
|
|||
|
||||
- Python 3.10+
|
||||
- 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
|
||||
`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
|
||||
python3 src/adobe_auth.py
|
||||
```
|
||||
Opens a browser. After authorizing, paste the redirect URL back into the terminal.
|
||||
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
|
||||
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
|
||||
redirect URL back into the terminal. This grants the `impersonation` scope required
|
||||
for JWT grant. After this runs once, all subsequent API calls use JWT automatically —
|
||||
no further browser interaction needed.
|
||||
Opens a browser for the DocuSign OAuth screen. After approving, paste the
|
||||
redirect URL back into the terminal. The app stores both access and refresh
|
||||
tokens in `.env`, and later API calls refresh access tokens automatically.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -108,6 +107,7 @@ shell, multi-customer project context, and a full migration workflow.
|
|||
**Additional `.env` keys required for the web UI:**
|
||||
```
|
||||
SESSION_SECRET_KEY=<any random string>
|
||||
SESSION_STORE_DIR=/absolute/path/for/browser-session-files
|
||||
DOCUSIGN_CLIENT_SECRET=<your DocuSign app client secret>
|
||||
DOCUSIGN_REDIRECT_URI=http://localhost:8000/api/auth/docusign/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.
|
||||
|
||||
### 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
|
||||
|
||||
| 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.
|
||||
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:
|
||||
- **Ready** (green) — no issues, safe to migrate
|
||||
- **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
|
||||
|
||||
```bash
|
||||
|
|
@ -274,7 +369,7 @@ src/
|
|||
adobe_api.py # Adobe Sign API client (auto token refresh)
|
||||
download_templates.py # List and download templates from Adobe Sign
|
||||
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
|
||||
migrate_template.py # End-to-end CLI runner (download → convert → upload)
|
||||
|
||||
|
|
|
|||
|
|
@ -13,12 +13,15 @@ DocuSign API reference:
|
|||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import csv
|
||||
import json
|
||||
import argparse
|
||||
import requests
|
||||
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()
|
||||
|
||||
|
|
|
|||
|
|
@ -1,68 +1,277 @@
|
|||
# Architecture & Design Overview
|
||||
# Architecture & Design — Adobe Sign → DocuSign Migrator
|
||||
|
||||
## System Components
|
||||
- **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
|
||||
*Last updated: 2026-04-23*
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
graph TD
|
||||
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]
|
||||
```
|
||||
- **CLI** (`src/`) — shell scripts for one-off or scripted migrations
|
||||
- **Web UI** (`web/`) — FastAPI + vanilla JS SPA for browser-based, multi-user migrations
|
||||
|
||||
**New layers:**
|
||||
- `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.
|
||||
Both interfaces execute the same sequence: authenticate → download → normalize → validate → compose → upload → report.
|
||||
|
||||
---
|
||||
|
||||
*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
|
||||
`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
|
||||
|
||||
Adobe Sign `conditionalAction` → DocuSign `conditionalParentLabel` + `conditionalParentValue` on the dependent tab.
|
||||
|
|
|
|||
|
|
@ -10,6 +10,9 @@ uvicorn[standard]
|
|||
itsdangerous
|
||||
httpx
|
||||
|
||||
# PDF generation (sample template tooling)
|
||||
reportlab
|
||||
|
||||
# Testing
|
||||
responses
|
||||
respx
|
||||
|
|
|
|||
|
|
@ -1,9 +1,15 @@
|
|||
import os
|
||||
import sys
|
||||
import requests
|
||||
from dotenv import load_dotenv, set_key
|
||||
|
||||
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"
|
||||
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)
|
||||
|
|
@ -36,6 +42,7 @@ def _refresh_access_token():
|
|||
return new_token
|
||||
|
||||
|
||||
@retry_with_backoff(**_RETRY)
|
||||
def adobe_api_post_multipart(endpoint, files, data=None):
|
||||
"""Upload a file via multipart/form-data (e.g. transient documents)."""
|
||||
token = os.getenv("ADOBE_ACCESS_TOKEN")
|
||||
|
|
@ -47,10 +54,11 @@ def adobe_api_post_multipart(endpoint, files, data=None):
|
|||
token = _refresh_access_token()
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
resp = requests.post(url, headers=headers, files=files, data=data or {})
|
||||
resp.raise_for_status()
|
||||
raise_for_retryable_status(resp)
|
||||
return resp.json()
|
||||
|
||||
|
||||
@retry_with_backoff(**_RETRY)
|
||||
def adobe_api_post_json(endpoint, body):
|
||||
"""POST JSON body to an Adobe Sign endpoint."""
|
||||
token = os.getenv("ADOBE_ACCESS_TOKEN")
|
||||
|
|
@ -66,10 +74,11 @@ def adobe_api_post_json(endpoint, body):
|
|||
token = _refresh_access_token()
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
resp = requests.post(url, headers=headers, json=body)
|
||||
resp.raise_for_status()
|
||||
raise_for_retryable_status(resp)
|
||||
return resp.json()
|
||||
|
||||
|
||||
@retry_with_backoff(**_RETRY)
|
||||
def adobe_api_put_json(endpoint, body):
|
||||
"""PUT JSON body to an Adobe Sign endpoint."""
|
||||
token = os.getenv("ADOBE_ACCESS_TOKEN")
|
||||
|
|
@ -85,10 +94,11 @@ def adobe_api_put_json(endpoint, body):
|
|||
token = _refresh_access_token()
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
resp = requests.put(url, headers=headers, json=body)
|
||||
resp.raise_for_status()
|
||||
raise_for_retryable_status(resp)
|
||||
return resp.json()
|
||||
|
||||
|
||||
@retry_with_backoff(**_RETRY)
|
||||
def adobe_api_get_bytes(endpoint):
|
||||
"""Download binary content (e.g. PDF files) from the Adobe Sign API."""
|
||||
token = os.getenv("ADOBE_ACCESS_TOKEN")
|
||||
|
|
@ -103,10 +113,11 @@ def adobe_api_get_bytes(endpoint):
|
|||
headers["Authorization"] = f"Bearer {token}"
|
||||
resp = requests.get(url, headers=headers)
|
||||
|
||||
resp.raise_for_status()
|
||||
raise_for_retryable_status(resp)
|
||||
return resp.content
|
||||
|
||||
|
||||
@retry_with_backoff(**_RETRY)
|
||||
def adobe_api_get(endpoint, params=None):
|
||||
token = os.getenv("ADOBE_ACCESS_TOKEN")
|
||||
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}"
|
||||
resp = requests.get(url, headers=headers, params=params)
|
||||
|
||||
resp.raise_for_status()
|
||||
raise_for_retryable_status(resp)
|
||||
return resp.json()
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,31 +1,23 @@
|
|||
"""
|
||||
docusign_auth.py
|
||||
----------------
|
||||
Handles DocuSign authentication for the migration toolkit.
|
||||
|
||||
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.
|
||||
Handles DocuSign OAuth using the Authorization Code Grant.
|
||||
|
||||
Usage:
|
||||
python3 src/docusign_auth.py --consent # one-time browser consent
|
||||
python3 src/docusign_auth.py # print a fresh access token (smoke test)
|
||||
python3 src/docusign_auth.py --authorize # one-time browser login
|
||||
python3 src/docusign_auth.py # print a fresh access token
|
||||
|
||||
Required .env keys:
|
||||
DOCUSIGN_CLIENT_ID Integration key from your DocuSign app
|
||||
DOCUSIGN_USER_ID GUID of the DocuSign user the app will act as
|
||||
DOCUSIGN_ACCOUNT_ID Your DocuSign account ID
|
||||
DOCUSIGN_PRIVATE_KEY_PATH Path to your RSA private key (.pem or .key)
|
||||
DOCUSIGN_AUTH_SERVER account-d.docusign.com (sandbox)
|
||||
or account.docusign.com (production)
|
||||
DOCUSIGN_BASE_URL https://demo.docusign.net/restapi (sandbox)
|
||||
or https://na3.docusign.net/restapi (prod, check your account)
|
||||
DOCUSIGN_CLIENT_ID
|
||||
DOCUSIGN_CLIENT_SECRET
|
||||
DOCUSIGN_AUTH_SERVER
|
||||
DOCUSIGN_REDIRECT_URI
|
||||
DOCUSIGN_BASE_URL
|
||||
|
||||
For --consent only:
|
||||
DOCUSIGN_CLIENT_SECRET OAuth client secret
|
||||
DOCUSIGN_REDIRECT_URI Must match your app config (default: http://localhost:8080/callback)
|
||||
Auto-written to .env after authorization:
|
||||
DOCUSIGN_ACCESS_TOKEN
|
||||
DOCUSIGN_REFRESH_TOKEN
|
||||
DOCUSIGN_TOKEN_EXPIRY
|
||||
"""
|
||||
|
||||
import argparse
|
||||
|
|
@ -33,89 +25,36 @@ import os
|
|||
import sys
|
||||
import time
|
||||
import webbrowser
|
||||
from urllib.parse import urlencode, urlparse, parse_qs
|
||||
from urllib.parse import parse_qs, urlencode, urlparse
|
||||
|
||||
import jwt
|
||||
import requests
|
||||
from dotenv import load_dotenv, set_key
|
||||
|
||||
load_dotenv()
|
||||
|
||||
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"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# JWT Grant
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _load_private_key():
|
||||
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 _required_env(name: str) -> str:
|
||||
value = os.getenv(name)
|
||||
if not value:
|
||||
raise RuntimeError(f"{name} must be set in .env")
|
||||
return value
|
||||
|
||||
|
||||
def _request_jwt_token():
|
||||
"""Exchange a JWT assertion for a DocuSign access token."""
|
||||
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 _auth_server() -> str:
|
||||
return os.getenv("DOCUSIGN_AUTH_SERVER", "account-d.docusign.com")
|
||||
|
||||
|
||||
def get_access_token() -> str:
|
||||
"""
|
||||
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")
|
||||
def _redirect_uri() -> str:
|
||||
return os.getenv("DOCUSIGN_REDIRECT_URI", "http://localhost:8000/api/auth/docusign/callback")
|
||||
|
||||
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"]
|
||||
refresh_token = token_data.get("refresh_token")
|
||||
expiry = int(time.time()) + int(token_data.get("expires_in", 3600))
|
||||
|
||||
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_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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auth Code Grant — consent flow
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
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")
|
||||
|
||||
def build_authorization_url(state: str | None = None) -> str:
|
||||
client_id = _required_env("DOCUSIGN_CLIENT_ID")
|
||||
params = {
|
||||
"response_type": "code",
|
||||
"scope": "signature impersonation",
|
||||
"scope": DOCUSIGN_SCOPE,
|
||||
"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):
|
||||
client_id = os.getenv("DOCUSIGN_CLIENT_ID")
|
||||
client_secret = os.getenv("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")
|
||||
def exchange_code_for_token(code: str) -> dict:
|
||||
client_id = _required_env("DOCUSIGN_CLIENT_ID")
|
||||
client_secret = _required_env("DOCUSIGN_CLIENT_SECRET")
|
||||
|
||||
resp = requests.post(
|
||||
f"https://{auth_server}/oauth/token",
|
||||
f"https://{_auth_server()}/oauth/token",
|
||||
data={
|
||||
"grant_type": "authorization_code",
|
||||
"code": code,
|
||||
"redirect_uri": redirect_uri,
|
||||
"redirect_uri": _redirect_uri(),
|
||||
},
|
||||
auth=(client_id, client_secret),
|
||||
)
|
||||
|
|
@ -167,18 +100,77 @@ def _exchange_code(code: str):
|
|||
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
|
||||
an initial access token. After this succeeds, JWT grant will work.
|
||||
Merge DocuSign OAuth token data into a web-session dict without writing .env.
|
||||
"""
|
||||
url = _build_consent_url()
|
||||
print("\nOpening browser for DocuSign consent...")
|
||||
session = dict(current_session or {})
|
||||
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")
|
||||
webbrowser.open(url)
|
||||
|
||||
print("Log in and click Allow. The browser will redirect to your redirect URI")
|
||||
print("(the page may show an error — that's fine). Copy the full URL and paste it here.\n")
|
||||
print("Log in, approve access, then paste the full redirect URL here.\n")
|
||||
redirected_url = input("Paste the redirect URL: ").strip()
|
||||
|
||||
parsed = urlparse(redirected_url)
|
||||
|
|
@ -189,37 +181,32 @@ def run_consent_flow():
|
|||
print(f"ERROR: {error}")
|
||||
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.")
|
||||
sys.exit(1)
|
||||
|
||||
code = params["code"][0]
|
||||
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():
|
||||
parser = argparse.ArgumentParser(description="DocuSign authentication helper")
|
||||
parser.add_argument("--consent", action="store_true",
|
||||
help="Run the Auth Code Grant consent flow (required once per user/app)")
|
||||
parser.add_argument(
|
||||
"--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()
|
||||
|
||||
if args.consent:
|
||||
run_consent_flow()
|
||||
if args.authorize or args.consent:
|
||||
run_authorize_flow()
|
||||
else:
|
||||
token = get_access_token()
|
||||
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
|
||||
so that tab positions map to visible labels on the document.
|
||||
|
||||
Adobe rect coordinates are top-left origin; DocuSign yPosition is bottom-left.
|
||||
Formula: docusign_y = PAGE_HEIGHT - adobe_top - adobe_height
|
||||
To place a *label* just above a field: label_y = PAGE_HEIGHT - adobe_top + 2
|
||||
Both Adobe Sign and DocuSign use top-left origin with y increasing downward — no
|
||||
coordinate inversion is needed. DocuSign xPosition = adobe left, yPosition = adobe top.
|
||||
To place a *label* just above a field in this PDF: label_y = page_height - adobe_top + 2
|
||||
"""
|
||||
|
||||
import base64
|
||||
|
|
|
|||
|
|
@ -115,7 +115,7 @@ def run_migration(template_dir: Path) -> Path:
|
|||
|
||||
output_path = MIGRATION_OUTPUT_DIR / template_dir.name / "docusign-template.json"
|
||||
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}")
|
||||
if warnings:
|
||||
|
|
|
|||
|
|
@ -121,7 +121,7 @@ def download_template(template) -> Path:
|
|||
def convert_template(template_dir: Path) -> Path:
|
||||
output_path = OUTPUT_DIR / template_dir.name / "docusign-template.json"
|
||||
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}")
|
||||
for w in warnings:
|
||||
print(f" WARNING: {w}")
|
||||
|
|
|
|||
|
|
@ -11,8 +11,8 @@ Validates that compose_docusign_template.py produces correctly structured output
|
|||
"""
|
||||
import json
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from pprint import pprint
|
||||
|
||||
# Support running from src/ or project root
|
||||
BASE = Path(__file__).parent.parent
|
||||
|
|
@ -21,14 +21,10 @@ sys.path.insert(0, str(Path(__file__).parent))
|
|||
from compose_docusign_template import compose_template
|
||||
|
||||
|
||||
def test_onboarding_mapping():
|
||||
output_path = BASE / "validation" / "compose-doc-template-complete.json"
|
||||
compose_template(
|
||||
fields_path=str(BASE / "sample-templates" / "onboarding-template-formfields.json"),
|
||||
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),
|
||||
)
|
||||
def test_onboarding_mapping(tmp_path):
|
||||
template_dir = BASE / "downloads" / "David Tag Demo Form__CBJCHBCA"
|
||||
output_path = tmp_path / "compose-doc-template-complete.json"
|
||||
compose_template(str(template_dir), str(output_path))
|
||||
|
||||
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"
|
||||
|
||||
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"]
|
||||
signer1_tabs = signers[1]["tabs"]
|
||||
|
||||
# -- No email/name on role placeholders --
|
||||
for s in signers:
|
||||
|
|
@ -53,8 +48,6 @@ def test_onboarding_mapping():
|
|||
assert "checkboxTabs" in signer0_tabs, "Signer 0 missing checkboxTabs"
|
||||
assert "radioGroupTabs" in signer0_tabs, "Signer 0 missing radioGroupTabs"
|
||||
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 --
|
||||
for tab in signer0_tabs.get("textTabs", []):
|
||||
|
|
@ -73,7 +66,7 @@ def test_onboarding_mapping():
|
|||
radio_tab = signer0_tabs["radioGroupTabs"][0]
|
||||
assert "groupName" in radio_tab, "radioGroupTab missing groupName"
|
||||
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"]:
|
||||
assert "pageNumber" in r, "radio missing pageNumber"
|
||||
assert "xPosition" in r, "radio missing xPosition"
|
||||
|
|
@ -87,18 +80,11 @@ def test_onboarding_mapping():
|
|||
+ signer0_tabs.get("signHereTabs", [])
|
||||
+ signer0_tabs.get("listTabs", [])
|
||||
+ signer0_tabs.get("checkboxTabs", [])
|
||||
+ signer1_tabs.get("textTabs", [])
|
||||
+ signer1_tabs.get("signHereTabs", [])
|
||||
)
|
||||
for tab in all_single_tabs:
|
||||
for field in ("documentId", "pageNumber", "xPosition", "yPosition"):
|
||||
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__":
|
||||
test_onboarding_mapping()
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
test_onboarding_mapping(Path(tmpdir))
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
upload_docusign_template.py
|
||||
---------------------------
|
||||
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,
|
||||
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
|
||||
|
||||
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>
|
||||
|
||||
Required .env keys (see docusign_auth.py for full list):
|
||||
DOCUSIGN_CLIENT_ID, DOCUSIGN_USER_ID, DOCUSIGN_ACCOUNT_ID,
|
||||
DOCUSIGN_PRIVATE_KEY_PATH, DOCUSIGN_AUTH_SERVER, DOCUSIGN_BASE_URL
|
||||
DOCUSIGN_CLIENT_ID, DOCUSIGN_CLIENT_SECRET, DOCUSIGN_ACCOUNT_ID,
|
||||
DOCUSIGN_AUTH_SERVER, DOCUSIGN_BASE_URL, DOCUSIGN_REDIRECT_URI
|
||||
"""
|
||||
|
||||
import argparse
|
||||
|
|
@ -34,6 +34,9 @@ load_dotenv()
|
|||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
|
||||
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:
|
||||
|
|
@ -68,6 +71,10 @@ def find_existing_template(
|
|||
headers.update(_refresh_token_once(headers))
|
||||
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:
|
||||
return None
|
||||
|
||||
|
|
@ -84,6 +91,7 @@ def find_existing_template(
|
|||
return exact[0]["templateId"]
|
||||
|
||||
|
||||
@retry_with_backoff(**_RETRY)
|
||||
def upload_template(file_path: str, force_create: bool = False) -> str:
|
||||
"""
|
||||
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)
|
||||
resp = requests.put(url, headers=headers, json=template)
|
||||
|
||||
if not resp.ok:
|
||||
print(f"ERROR: Update failed ({resp.status_code})")
|
||||
print(resp.text)
|
||||
sys.exit(1)
|
||||
raise_for_retryable_status(resp)
|
||||
|
||||
print(f"Template updated: {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)
|
||||
resp = requests.post(url, headers=headers, json=template)
|
||||
|
||||
if not resp.ok:
|
||||
print(f"ERROR: Upload failed ({resp.status_code})")
|
||||
print(resp.text)
|
||||
sys.exit(1)
|
||||
raise_for_retryable_status(resp)
|
||||
|
||||
result = resp.json()
|
||||
template_id = result.get("templateId")
|
||||
|
|
|
|||
|
|
@ -21,6 +21,21 @@ T = TypeVar("T")
|
|||
_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(
|
||||
max_retries: int = 3,
|
||||
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"
|
||||
- [ ] 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
|
||||
|
||||
## 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.routers.auth import _ADOBE_TOKEN_URL
|
||||
from web.session import create_test_session
|
||||
|
||||
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():
|
||||
"""Fresh session → both platforms disconnected."""
|
||||
resp = client.get("/api/auth/status", cookies={})
|
||||
|
|
@ -33,8 +65,8 @@ def test_adobe_url_returns_auth_url():
|
|||
assert "url" in data
|
||||
assert "adobesign.com" in data["url"]
|
||||
assert "response_type=code" in data["url"]
|
||||
# Must use the registered redirect URI
|
||||
assert "localhost%3A8080" in data["url"] or "localhost:8080" in data["url"]
|
||||
assert "redirect_uri=http://localhost:8000/api/auth/adobe/callback" in data["url"]
|
||||
assert resp.cookies.get("migrator_session") is not None
|
||||
|
||||
|
||||
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")
|
||||
|
||||
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")
|
||||
|
||||
assert resp.status_code == 200
|
||||
|
|
@ -51,15 +89,19 @@ def test_adobe_connect_env_stores_token(monkeypatch):
|
|||
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_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):
|
||||
"""GET /api/auth/adobe/connect with no .env tokens → 400."""
|
||||
def test_adobe_connect_requests_authorization_without_credentials(monkeypatch):
|
||||
"""GET /api/auth/adobe/connect with no .env tokens returns an auth URL."""
|
||||
monkeypatch.delenv("ADOBE_ACCESS_TOKEN", raising=False)
|
||||
monkeypatch.delenv("ADOBE_REFRESH_TOKEN", raising=False)
|
||||
resp = client.get("/api/auth/adobe/connect")
|
||||
assert resp.status_code == 400
|
||||
assert "No Adobe Sign credentials" in resp.json()["error"]
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["authorization_required"] is True
|
||||
assert "/api/auth/adobe/callback" in resp.json()["authorization_url"]
|
||||
|
||||
|
||||
@respx.mock
|
||||
|
|
@ -86,6 +128,68 @@ def test_adobe_exchange_stores_token():
|
|||
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():
|
||||
"""POST /api/auth/adobe/exchange with no code in URL → 400."""
|
||||
resp = client.post(
|
||||
|
|
@ -96,21 +200,151 @@ def test_adobe_exchange_rejects_missing_code():
|
|||
|
||||
|
||||
def test_docusign_connect_stores_token():
|
||||
"""GET /api/auth/docusign/connect uses JWT grant from .env → session connected."""
|
||||
from unittest.mock import patch
|
||||
import web.routers.auth as auth_module
|
||||
"""GET /api/auth/docusign/connect refreshes the current session's token."""
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
with patch("docusign_auth.get_access_token", return_value="ds-jwt-token"):
|
||||
resp = client.get("/api/auth/docusign/connect")
|
||||
cookie = create_test_session({"docusign_refresh_token": "refresh-123"})
|
||||
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.json()["connected"] is True
|
||||
assert resp.json()["account_selection_required"] is True
|
||||
|
||||
session_cookie = resp.cookies.get("migrator_session")
|
||||
assert session_cookie is not None
|
||||
|
||||
status_resp = client.get("/api/auth/status", cookies={"migrator_session": session_cookie})
|
||||
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
|
||||
|
|
@ -123,7 +357,7 @@ def test_disconnect_clears_token():
|
|||
# Connect Adobe via exchange
|
||||
connect_resp = client.post(
|
||||
"/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"]
|
||||
|
||||
|
|
|
|||
|
|
@ -159,7 +159,7 @@ def test_status_needs_update():
|
|||
|
||||
@respx.mock
|
||||
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(
|
||||
return_value=httpx.Response(200, json={
|
||||
"libraryDocumentList": [
|
||||
|
|
@ -175,13 +175,16 @@ def test_status_includes_blockers_and_warnings_fields():
|
|||
t = resp.json()["templates"][0]
|
||||
assert "blockers" in t
|
||||
assert "warnings" in t
|
||||
assert "field_issues" in t
|
||||
assert "analysis_status" in t
|
||||
assert isinstance(t["blockers"], list)
|
||||
assert isinstance(t["warnings"], list)
|
||||
assert isinstance(t["field_issues"], list)
|
||||
|
||||
|
||||
@respx.mock
|
||||
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(
|
||||
return_value=httpx.Response(200, json={
|
||||
"libraryDocumentList": [
|
||||
|
|
@ -196,6 +199,8 @@ def test_status_empty_blockers_when_not_downloaded():
|
|||
t = resp.json()["templates"][0]
|
||||
assert t["blockers"] == []
|
||||
assert t["warnings"] == []
|
||||
assert t["field_issues"] == []
|
||||
assert t["analysis_status"] == "not_downloaded"
|
||||
|
||||
|
||||
@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)
|
||||
assert isinstance(t["blockers"], 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.staticfiles import StaticFiles
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.responses import FileResponse, HTMLResponse
|
||||
import logging
|
||||
import os
|
||||
|
||||
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(
|
||||
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(migrate.router, prefix="/api/migrate", tags=["migrate"])
|
||||
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_dir = os.path.join(os.path.dirname(__file__), "static")
|
||||
|
|
@ -44,5 +61,8 @@ def health():
|
|||
def index():
|
||||
index_path = os.path.join(_static_dir, "index.html")
|
||||
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"}
|
||||
|
|
|
|||
|
|
@ -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 subprocess
|
||||
from dotenv import 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:
|
||||
# Adobe Sign OAuth
|
||||
adobe_client_id: str = os.getenv("ADOBE_CLIENT_ID", "")
|
||||
|
|
@ -28,9 +51,29 @@ class Settings:
|
|||
|
||||
# Session
|
||||
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
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
Adobe Sign uses the same redirect URI as the CLI (https://localhost:8080/callback).
|
||||
Since nothing runs on that port, the browser lands on a failed page. The user copies
|
||||
the URL and submits it via POST /api/auth/adobe/exchange — identical to the CLI flow.
|
||||
|
||||
DocuSign uses a standard redirect callback handled directly by this server.
|
||||
|
||||
Tokens are stored in a signed session cookie.
|
||||
Both providers now support standard browser redirect callbacks handled directly by
|
||||
this server. Tokens are stored in a server-side session keyed by a signed browser
|
||||
cookie.
|
||||
"""
|
||||
|
||||
import secrets
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
|
||||
import httpx
|
||||
|
|
@ -19,17 +16,122 @@ from fastapi import APIRouter, Request
|
|||
from fastapi.responses import JSONResponse, RedirectResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from web.audit import is_admin_session, log_event
|
||||
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()
|
||||
|
||||
# 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_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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -38,36 +140,49 @@ _ADOBE_TOKEN_URL = "https://api.eu2.adobesign.com/oauth/v2/token"
|
|||
def auth_status(request: Request):
|
||||
"""Returns which platforms the current session is connected to."""
|
||||
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 {
|
||||
"adobe": bool(session.get("adobe_access_token")),
|
||||
"docusign": bool(session.get("docusign_access_token")),
|
||||
**session_public_view(session),
|
||||
"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")
|
||||
def adobe_auth_url():
|
||||
"""
|
||||
Return the Adobe Sign authorization URL for the frontend to open in a new tab.
|
||||
The user authorizes, lands on a failed page (nothing runs on :8080), copies
|
||||
the URL, and submits it to POST /api/auth/adobe/exchange.
|
||||
"""
|
||||
params = (
|
||||
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"
|
||||
)
|
||||
return {"url": _ADOBE_AUTH_URL + params}
|
||||
def adobe_auth_url(request: Request, return_to: str | None = None):
|
||||
session = get_session(request)
|
||||
state = secrets.token_urlsafe(24)
|
||||
session["adobe_oauth_state"] = state
|
||||
session["adobe_auth_mode"] = "authorization_pending"
|
||||
session["adobe_return_to"] = _sanitize_return_to(return_to)
|
||||
response = JSONResponse({"url": _build_adobe_authorization_url(state)})
|
||||
save_session(response, session)
|
||||
return response
|
||||
|
||||
|
||||
class AdobeExchangeRequest(BaseModel):
|
||||
redirect_url: str
|
||||
|
||||
|
||||
class DocusignAccountSelectRequest(BaseModel):
|
||||
account_id: str
|
||||
|
||||
|
||||
@router.post("/adobe/exchange")
|
||||
async def adobe_exchange(body: AdobeExchangeRequest, request: Request):
|
||||
"""
|
||||
|
|
@ -95,7 +210,7 @@ async def adobe_exchange(body: AdobeExchangeRequest, request: Request):
|
|||
"grant_type": "authorization_code",
|
||||
"client_id": settings.adobe_client_id,
|
||||
"client_secret": settings.adobe_client_secret,
|
||||
"redirect_uri": _ADOBE_REDIRECT_URI,
|
||||
"redirect_uri": _adobe_redirect_uri(),
|
||||
"code": code,
|
||||
},
|
||||
)
|
||||
|
|
@ -110,6 +225,14 @@ async def adobe_exchange(body: AdobeExchangeRequest, request: Request):
|
|||
session = get_session(request)
|
||||
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 = 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})
|
||||
save_session(response, session)
|
||||
|
|
@ -117,113 +240,113 @@ async def adobe_exchange(body: AdobeExchangeRequest, request: Request):
|
|||
|
||||
|
||||
@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 /
|
||||
ADOBE_REFRESH_TOKEN). Refreshes the token if needed. No browser login required
|
||||
when a valid refresh token already exists from a previous CLI auth session.
|
||||
Obtain an Adobe Sign access token for this browser session.
|
||||
If session/env tokens are unavailable or force_oauth=true, return an
|
||||
authorization URL so the frontend can start a normal OAuth flow.
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "src"))
|
||||
from adobe_api import _refresh_access_token
|
||||
session = get_session(request)
|
||||
token = session.get("adobe_access_token")
|
||||
refresh_token = session.get("adobe_refresh_token")
|
||||
|
||||
token = os.getenv("ADOBE_ACCESS_TOKEN")
|
||||
refresh_token = os.getenv("ADOBE_REFRESH_TOKEN")
|
||||
if not force_oauth and not token and not refresh_token:
|
||||
import sys
|
||||
import os
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "src"))
|
||||
from adobe_api import _refresh_access_token
|
||||
|
||||
if not token and not refresh_token:
|
||||
return JSONResponse(
|
||||
{"error": "No Adobe Sign credentials found in .env. Run src/adobe_auth.py first."},
|
||||
status_code=400,
|
||||
)
|
||||
env_token = os.getenv("ADOBE_ACCESS_TOKEN")
|
||||
env_refresh_token = os.getenv("ADOBE_REFRESH_TOKEN")
|
||||
if env_token or env_refresh_token:
|
||||
token = env_token
|
||||
refresh_token = env_refresh_token
|
||||
if refresh_token:
|
||||
try:
|
||||
token = _refresh_access_token()
|
||||
except RuntimeError as e:
|
||||
return JSONResponse({"error": str(e)}, status_code=500)
|
||||
session["adobe_access_token"] = 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})
|
||||
save_session(response, session)
|
||||
return response
|
||||
|
||||
# Always refresh to ensure the token is fresh (access tokens expire in ~1h)
|
||||
if refresh_token:
|
||||
if not force_oauth and not token and refresh_token:
|
||||
try:
|
||||
token = _refresh_access_token()
|
||||
token_data = await _refresh_adobe_session_token(refresh_token)
|
||||
except RuntimeError as e:
|
||||
return JSONResponse({"error": str(e)}, status_code=500)
|
||||
token = token_data.get("access_token")
|
||||
session["adobe_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})
|
||||
save_session(response, session)
|
||||
return response
|
||||
|
||||
session = get_session(request)
|
||||
session["adobe_access_token"] = token
|
||||
session["adobe_refresh_token"] = refresh_token
|
||||
if not force_oauth and token:
|
||||
response = JSONResponse({"connected": True})
|
||||
save_session(response, session)
|
||||
return response
|
||||
|
||||
response = JSONResponse({"connected": True})
|
||||
save_session(response, session)
|
||||
return response
|
||||
|
||||
|
||||
@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:
|
||||
token = get_access_token()
|
||||
except RuntimeError as e:
|
||||
return JSONResponse({"error": str(e)}, status_code=500)
|
||||
|
||||
session = get_session(request)
|
||||
session["docusign_access_token"] = token
|
||||
|
||||
response = JSONResponse({"connected": True})
|
||||
save_session(response, session)
|
||||
return response
|
||||
|
||||
|
||||
@router.get("/docusign/start")
|
||||
def docusign_start():
|
||||
"""Redirect to DocuSign OAuth (alternative to JWT grant)."""
|
||||
import base64 as _b64
|
||||
params = (
|
||||
f"?response_type=code"
|
||||
f"&scope=signature"
|
||||
f"&client_id={settings.docusign_client_id}"
|
||||
f"&redirect_uri={settings.docusign_redirect_uri}"
|
||||
state = secrets.token_urlsafe(24)
|
||||
session["adobe_oauth_state"] = state
|
||||
session["adobe_auth_mode"] = "authorization_pending"
|
||||
session["adobe_return_to"] = _sanitize_return_to(return_to)
|
||||
authorization_url = _build_adobe_authorization_url(state)
|
||||
log_event(
|
||||
request,
|
||||
session,
|
||||
"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")
|
||||
async def docusign_callback(request: Request, code: str = ""):
|
||||
"""Handle DocuSign OAuth redirect callback."""
|
||||
import base64
|
||||
@router.get("/adobe/callback")
|
||||
async def adobe_callback(request: Request, code: str = "", state: str = ""):
|
||||
"""Handle Adobe Sign OAuth redirect callback."""
|
||||
if not code:
|
||||
return JSONResponse({"error": "missing code"}, status_code=400)
|
||||
|
||||
credentials = base64.b64encode(
|
||||
f"{settings.docusign_client_id}:{settings.docusign_client_secret}".encode()
|
||||
).decode()
|
||||
session = get_session(request)
|
||||
expected_state = session.get("adobe_oauth_state")
|
||||
if not expected_state or state != expected_state:
|
||||
return JSONResponse({"error": "invalid oauth state"}, status_code=400)
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.post(
|
||||
f"https://{settings.docusign_auth_server}/oauth/token",
|
||||
headers={"Authorization": f"Basic {credentials}"},
|
||||
_ADOBE_TOKEN_URL,
|
||||
data={
|
||||
"grant_type": "authorization_code",
|
||||
"client_id": settings.adobe_client_id,
|
||||
"client_secret": settings.adobe_client_secret,
|
||||
"redirect_uri": _adobe_redirect_uri(),
|
||||
"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)
|
||||
|
||||
token_data = resp.json()
|
||||
session = get_session(request)
|
||||
session["docusign_access_token"] = token_data.get("access_token")
|
||||
session["docusign_refresh_token"] = token_data.get("refresh_token")
|
||||
if "error" in token_data:
|
||||
return JSONResponse({"error": token_data.get("error_description", token_data["error"])}, status_code=400)
|
||||
|
||||
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)
|
||||
return response
|
||||
|
||||
|
|
@ -243,8 +589,23 @@ async def docusign_callback(request: Request, code: str = ""):
|
|||
@router.get("/docusign/disconnect")
|
||||
def docusign_disconnect(request: Request):
|
||||
session = get_session(request)
|
||||
previous_account_name = session.get("docusign_selected_account_name")
|
||||
session.pop("docusign_access_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"})
|
||||
save_session(response, session)
|
||||
return response
|
||||
|
|
|
|||
|
|
@ -23,7 +23,9 @@ from fastapi import APIRouter, Request
|
|||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from web.audit import log_context_event, log_event, request_context
|
||||
from web.config import settings
|
||||
from web.docusign_context import DocusignContextError, current_account
|
||||
from web.session import get_session
|
||||
|
||||
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)
|
||||
|
||||
|
||||
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():
|
||||
"""Dynamically load compose_template from src/."""
|
||||
import importlib.util
|
||||
|
|
@ -143,6 +159,8 @@ async def _migrate_one(
|
|||
adobe_id: str,
|
||||
adobe_access_token: str,
|
||||
docusign_access_token: str,
|
||||
docusign_account_id: str,
|
||||
docusign_base_url: str,
|
||||
options: MigrationOptions,
|
||||
) -> dict:
|
||||
"""Run the full pipeline for one Adobe template. Returns a result record."""
|
||||
|
|
@ -257,7 +275,7 @@ async def _migrate_one(
|
|||
"Content-Type": "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:
|
||||
# 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)
|
||||
if not session.get("docusign_access_token"):
|
||||
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()
|
||||
if not ids:
|
||||
return JSONResponse({"error": "no template IDs provided"}, status_code=400)
|
||||
session_scope = _session_scope(session)
|
||||
|
||||
tasks = [
|
||||
_migrate_one(
|
||||
aid,
|
||||
session["adobe_access_token"],
|
||||
session["docusign_access_token"],
|
||||
account["account_id"],
|
||||
account["base_url"],
|
||||
body.options,
|
||||
)
|
||||
for aid in ids
|
||||
]
|
||||
results = await asyncio.gather(*tasks)
|
||||
scoped_results = [_scope_record(result, session_scope) for result in results]
|
||||
|
||||
history = _load_history()
|
||||
history.extend(results)
|
||||
history.extend(scoped_results)
|
||||
_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")
|
||||
def migration_history():
|
||||
def migration_history(request: Request):
|
||||
"""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(
|
||||
job_id: str,
|
||||
owner_session_id: str,
|
||||
request_info: dict,
|
||||
session_snapshot: dict,
|
||||
ids: List[str],
|
||||
adobe_token: str,
|
||||
ds_token: str,
|
||||
ds_account_id: str,
|
||||
ds_base_url: str,
|
||||
options: MigrationOptions,
|
||||
) -> None:
|
||||
"""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):
|
||||
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)
|
||||
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":
|
||||
result["retried"] = True
|
||||
|
||||
|
|
@ -397,7 +444,7 @@ async def _run_batch_job(
|
|||
|
||||
# Persist to history
|
||||
history = _load_history()
|
||||
history.extend(results)
|
||||
history.extend(_scope_record(result, owner_session_id) for result in results)
|
||||
_save_history(history)
|
||||
|
||||
success = sum(1 for r in results if r["status"] == "success")
|
||||
|
|
@ -414,6 +461,19 @@ async def _run_batch_job(
|
|||
"skipped": skipped,
|
||||
"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")
|
||||
|
|
@ -427,14 +487,20 @@ async def run_batch_migration(body: MigrateRequest, request: Request):
|
|||
return JSONResponse({"error": "not authenticated to Adobe Sign"}, status_code=401)
|
||||
if not session.get("docusign_access_token"):
|
||||
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()
|
||||
if not ids:
|
||||
return JSONResponse({"error": "no template IDs provided"}, status_code=400)
|
||||
session_scope = _session_scope(session)
|
||||
|
||||
job_id = str(uuid.uuid4())
|
||||
_batch_jobs[job_id] = {
|
||||
"job_id": job_id,
|
||||
"owner_session_id": session_scope,
|
||||
"status": "queued",
|
||||
"total": len(ids),
|
||||
"results": [],
|
||||
|
|
@ -442,12 +508,26 @@ async def run_batch_migration(body: MigrateRequest, request: Request):
|
|||
"summary": None,
|
||||
"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(
|
||||
_run_batch_job(
|
||||
job_id, ids,
|
||||
job_id, session_scope, request_context(request), dict(session), ids,
|
||||
session["adobe_access_token"],
|
||||
session["docusign_access_token"],
|
||||
account["account_id"],
|
||||
account["base_url"],
|
||||
body.options,
|
||||
)
|
||||
)
|
||||
|
|
@ -456,9 +536,12 @@ async def run_batch_migration(body: MigrateRequest, request: Request):
|
|||
|
||||
|
||||
@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."""
|
||||
job = _batch_jobs.get(job_id)
|
||||
if not job:
|
||||
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
|
||||
|
|
|
|||
|
|
@ -5,8 +5,12 @@ Template listing endpoints for Adobe Sign and DocuSign.
|
|||
Computes per-template migration status for the side-by-side UI.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
|
|
@ -14,10 +18,15 @@ from fastapi import APIRouter, Request
|
|||
from fastapi.responses import JSONResponse
|
||||
|
||||
from web.config import settings
|
||||
from web.docusign_context import DocusignContextError, current_account
|
||||
from web.session import get_session
|
||||
|
||||
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]:
|
||||
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]:
|
||||
if not session.get("docusign_access_token"):
|
||||
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
|
||||
|
||||
|
||||
|
|
@ -69,10 +82,11 @@ async def list_docusign_templates(request: Request):
|
|||
err = _require_docusign(session)
|
||||
if err:
|
||||
return err
|
||||
account = current_account(session)
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
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']}"},
|
||||
params={"count": 100},
|
||||
)
|
||||
|
|
@ -107,6 +121,7 @@ async def template_status(request: Request):
|
|||
err = _require_adobe(session) or _require_docusign(session)
|
||||
if err:
|
||||
return err
|
||||
account = current_account(session)
|
||||
|
||||
# Fetch both lists concurrently
|
||||
async with httpx.AsyncClient() as client:
|
||||
|
|
@ -117,7 +132,7 @@ async def template_status(request: Request):
|
|||
params={"pageSize": 100},
|
||||
),
|
||||
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']}"},
|
||||
params={"count": 100},
|
||||
),
|
||||
|
|
@ -152,7 +167,14 @@ async def template_status(request: Request):
|
|||
# needs_update if Adobe was modified after the DS template
|
||||
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({
|
||||
"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_modified": ds_match.get("lastModified") if ds_match else None,
|
||||
"status": status,
|
||||
"blockers": blockers,
|
||||
"warnings": warnings,
|
||||
"blockers": analysis["blockers"],
|
||||
"warnings": analysis["warnings"],
|
||||
"field_issues": analysis["field_issues"],
|
||||
"analysis_status": analysis["status"],
|
||||
})
|
||||
|
||||
return {"templates": results}
|
||||
|
||||
|
||||
def _get_validation(template_id: str, template_name: str) -> tuple[list, list]:
|
||||
"""Return (blockers, warnings) if the template has been downloaded; else ([], [])."""
|
||||
def _get_template_analysis(template_id: str, template_name: str) -> dict:
|
||||
"""
|
||||
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:
|
||||
from src.services.mapping_service import adobe_folder_to_normalized
|
||||
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")
|
||||
# Match folder by name__id or name pattern
|
||||
candidates = list(downloads_dir.glob(f"*__{template_id}"))
|
||||
if not candidates:
|
||||
# Try matching by sanitised name prefix
|
||||
safe = template_name.replace("/", "_").replace("\\", "_")
|
||||
candidates = list(downloads_dir.glob(f"{safe}*"))
|
||||
template_dir = _find_downloaded_template(template_id, template_name)
|
||||
if not template_dir:
|
||||
return analysis
|
||||
|
||||
if not candidates or not candidates[0].is_dir():
|
||||
return [], []
|
||||
|
||||
normalized = adobe_folder_to_normalized(str(candidates[0]))
|
||||
normalized, _ = adobe_folder_to_normalized(str(template_dir), include_documents=False)
|
||||
result = validate_template(normalized)
|
||||
return result.blockers, result.warnings
|
||||
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}"))
|
||||
if not candidates:
|
||||
safe = template_name.replace("/", "_").replace("\\", "_")
|
||||
candidates = list(downloads_dir.glob(f"{safe}*"))
|
||||
return next((c for c in candidates if c.is_dir()), None)
|
||||
|
||||
|
||||
def _get_history_analysis(template_id: str, template_name: str, session_scope: str) -> dict:
|
||||
"""
|
||||
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:
|
||||
return [], []
|
||||
return []
|
||||
|
||||
|
||||
# asyncio needed for gather — import at top of module
|
||||
import asyncio
|
||||
def _template_warnings(warnings: list[str]) -> list[str]:
|
||||
"""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 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
|
||||
|
||||
router = APIRouter()
|
||||
|
|
@ -31,6 +32,10 @@ class VoidRequest(BaseModel):
|
|||
def _require_docusign(session: dict) -> Optional[JSONResponse]:
|
||||
if not session.get("docusign_access_token"):
|
||||
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
|
||||
|
||||
|
||||
|
|
@ -41,12 +46,13 @@ async def send_test_envelope(body: SendRequest, request: Request):
|
|||
err = _require_docusign(session)
|
||||
if err:
|
||||
return err
|
||||
account = current_account(session)
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {session['docusign_access_token']}",
|
||||
"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:
|
||||
# Fetch template to discover actual role names
|
||||
|
|
@ -87,6 +93,17 @@ async def send_test_envelope(body: SendRequest, request: Request):
|
|||
)
|
||||
|
||||
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}
|
||||
|
||||
|
||||
|
|
@ -97,10 +114,11 @@ async def envelope_status(envelope_id: str, request: Request):
|
|||
err = _require_docusign(session)
|
||||
if err:
|
||||
return err
|
||||
account = current_account(session)
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
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']}"},
|
||||
)
|
||||
|
||||
|
|
@ -126,10 +144,11 @@ async def void_envelope(envelope_id: str, body: VoidRequest, request: Request):
|
|||
err = _require_docusign(session)
|
||||
if err:
|
||||
return err
|
||||
account = current_account(session)
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
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={
|
||||
"Authorization": f"Bearer {session['docusign_access_token']}",
|
||||
"Content-Type": "application/json",
|
||||
|
|
@ -143,4 +162,10 @@ async def void_envelope(envelope_id: str, body: VoidRequest, request: Request):
|
|||
status_code=502,
|
||||
)
|
||||
|
||||
log_event(
|
||||
request,
|
||||
session,
|
||||
"verification_voided",
|
||||
{"envelope_id": envelope_id, "reason": body.reason},
|
||||
)
|
||||
return {"voided": True, "envelope_id": envelope_id}
|
||||
|
|
|
|||
158
web/session.py
158
web/session.py
|
|
@ -1,45 +1,177 @@
|
|||
"""
|
||||
web/session.py
|
||||
--------------
|
||||
Session helpers using signed cookies (itsdangerous).
|
||||
Stores Adobe Sign and DocuSign tokens server-side in the cookie payload.
|
||||
Session helpers backed by a signed session-id cookie plus server-side JSON files.
|
||||
|
||||
Sessions are short-lived (1 hour) and signed but not encrypted.
|
||||
Do not store sensitive secrets here beyond access tokens.
|
||||
This keeps OAuth refresh tokens off the client and allows multiple testers to use
|
||||
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 fastapi import Request, Response
|
||||
|
||||
from web.config import settings
|
||||
|
||||
_serializer = URLSafeTimedSerializer(settings.session_secret_key)
|
||||
_COOKIE_NAME = "migrator_session"
|
||||
_MAX_AGE = 3600 # 1 hour
|
||||
_SESSION_ID_KEY = "_session_id"
|
||||
|
||||
|
||||
def get_session(request: Request) -> dict:
|
||||
"""Read and verify the session cookie. Returns an empty dict if missing or invalid."""
|
||||
def _session_store_dir() -> str:
|
||||
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)
|
||||
if not raw:
|
||||
return {}
|
||||
return None
|
||||
try:
|
||||
return _serializer.loads(raw, max_age=_MAX_AGE)
|
||||
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 {}
|
||||
|
||||
|
||||
def save_session(response: Response, data: dict) -> None:
|
||||
"""Sign and write session data into a cookie on the response."""
|
||||
signed = _serializer.dumps(data)
|
||||
def get_session_id(request: Request) -> str | None:
|
||||
session = get_session(request)
|
||||
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(
|
||||
_COOKIE_NAME,
|
||||
signed,
|
||||
_serializer.dumps(sid),
|
||||
max_age=_MAX_AGE,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
)
|
||||
return sid
|
||||
|
||||
|
||||
def clear_session(response: Response) -> None:
|
||||
"""Delete the session cookie."""
|
||||
def clear_session(response: Response, request: Request | None = None) -> None:
|
||||
"""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)
|
||||
|
||||
|
||||
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; }
|
||||
.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; }
|
||||
.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 {
|
||||
|
|
@ -269,11 +274,57 @@ tr:hover td { background: #FAFBFC; }
|
|||
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 ── */
|
||||
@media (max-width: 900px) {
|
||||
.stat-grid { grid-template-columns: repeat(3, 1fr); }
|
||||
.two-col { grid-template-columns: 1fr; }
|
||||
.help-layout { grid-template-columns: 1fr; }
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.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-box-wide { width: min(900px, 96vw); }
|
||||
.modal-box.modal-sm { width: min(380px, 94vw); }
|
||||
|
||||
/* ── Modal sections ── */
|
||||
|
|
@ -190,3 +191,52 @@
|
|||
font-weight: 700;
|
||||
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);
|
||||
}
|
||||
.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 {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
|
|
@ -209,6 +215,50 @@
|
|||
.conn-pill.disconnected .conn-dot { background: var(--error); }
|
||||
.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 {
|
||||
flex: 1;
|
||||
|
|
|
|||
|
|
@ -4,13 +4,13 @@
|
|||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>docusign — Template Migration Console</title>
|
||||
<link rel="stylesheet" href="/static/css/tokens.css" />
|
||||
<link rel="stylesheet" href="/static/css/base.css" />
|
||||
<link rel="stylesheet" href="/static/css/nav.css" />
|
||||
<link rel="stylesheet" href="/static/css/cards.css" />
|
||||
<link rel="stylesheet" href="/static/css/modals.css" />
|
||||
<link rel="stylesheet" href="/static/css/tables.css" />
|
||||
<link rel="stylesheet" href="/static/css/forms.css" />
|
||||
<link rel="stylesheet" href="/static/css/tokens.css?v={{ASSET_VERSION}}" />
|
||||
<link rel="stylesheet" href="/static/css/base.css?v={{ASSET_VERSION}}" />
|
||||
<link rel="stylesheet" href="/static/css/nav.css?v={{ASSET_VERSION}}" />
|
||||
<link rel="stylesheet" href="/static/css/cards.css?v={{ASSET_VERSION}}" />
|
||||
<link rel="stylesheet" href="/static/css/modals.css?v={{ASSET_VERSION}}" />
|
||||
<link rel="stylesheet" href="/static/css/tables.css?v={{ASSET_VERSION}}" />
|
||||
<link rel="stylesheet" href="/static/css/forms.css?v={{ASSET_VERSION}}" />
|
||||
</head>
|
||||
<body>
|
||||
|
||||
|
|
@ -91,6 +91,12 @@
|
|||
<span class="nav-label">History & Audit</span>
|
||||
</a>
|
||||
</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>
|
||||
|
|
@ -99,6 +105,18 @@
|
|||
<span class="nav-label">Settings</span>
|
||||
</a>
|
||||
</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>
|
||||
|
||||
<!-- Bottom: customer context -->
|
||||
|
|
@ -133,7 +151,7 @@
|
|||
<span class="conn-dot"></span>Docusign
|
||||
</button>
|
||||
<!-- 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>
|
||||
</header>
|
||||
|
||||
|
|
@ -160,7 +178,7 @@
|
|||
<!-- ═══════════════════════════════════════════════════════════════
|
||||
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>
|
||||
</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() {
|
||||
return GET('/api/auth/status');
|
||||
},
|
||||
connectAdobe() {
|
||||
return GET('/api/auth/adobe/connect');
|
||||
connectAdobe(forceOauth = false, returnTo = '#/templates') {
|
||||
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() {
|
||||
return GET('/api/auth/adobe/url');
|
||||
|
|
@ -35,8 +38,14 @@ export const api = {
|
|||
exchangeAdobe(redirectUrl) {
|
||||
return POST('/api/auth/adobe/exchange', { redirect_url: redirectUrl });
|
||||
},
|
||||
connectDocusign() {
|
||||
return GET('/api/auth/docusign/connect');
|
||||
connectDocusign(returnTo = '#/templates') {
|
||||
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) {
|
||||
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();
|
||||
});
|
||||
|
||||
router.register('#/activity', async () => {
|
||||
const { renderActivity } = await import('./activity.js');
|
||||
await renderActivity();
|
||||
});
|
||||
|
||||
router.register('#/settings', async () => {
|
||||
const { renderSettings } = await import('./settings.js');
|
||||
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 ───────────────────────────────────────────────
|
||||
|
||||
subscribe('issueCount', count => {
|
||||
|
|
@ -54,7 +69,7 @@ subscribe('issueCount', count => {
|
|||
subscribe('templates', templates => {
|
||||
const caveats = (templates || []).filter(t =>
|
||||
(!t.blockers || t.blockers.length === 0) &&
|
||||
t.warnings && t.warnings.length > 0
|
||||
((t.warnings || []).length > 0 || (t.field_issues || []).length > 0)
|
||||
).length;
|
||||
const badge = document.getElementById('nav-badge-caveats');
|
||||
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 { state, setState } from './state.js';
|
||||
import { escHtml } from './utils.js';
|
||||
import { escHtml, initials } from './utils.js';
|
||||
|
||||
// ── Refresh auth state and update chips ────────────────────────────────────
|
||||
|
||||
|
|
@ -10,96 +10,177 @@ export async function refreshAuth() {
|
|||
try {
|
||||
const data = await api.auth.status();
|
||||
setState('auth', {
|
||||
adobe: !!data.adobe,
|
||||
adobe: !!data.adobe,
|
||||
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',
|
||||
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) {
|
||||
console.warn('Auth status failed:', e.message);
|
||||
}
|
||||
renderAuthChips();
|
||||
if (state.auth.docusign && state.auth.docusignAccountSelectionRequired) {
|
||||
showDocusignAccountPicker();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Render connection pills in top bar ─────────────────────────────────────
|
||||
|
||||
export function renderAuthChips() {
|
||||
renderChip('chip-adobe', state.auth.adobe, 'Adobe Sign', onClickAdobe);
|
||||
renderChip('chip-docusign', state.auth.docusign, 'Docusign', onClickDocusign);
|
||||
renderChip(
|
||||
'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) {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
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;
|
||||
}
|
||||
|
||||
function renderAdminNav() {
|
||||
const adminItem = document.getElementById('nav-admin-status-item');
|
||||
if (!adminItem) return;
|
||||
adminItem.hidden = !state.auth.isAdmin;
|
||||
}
|
||||
|
||||
// ── Click handlers ─────────────────────────────────────────────────────────
|
||||
|
||||
async function onClickAdobe() {
|
||||
if (state.auth.adobe) {
|
||||
await disconnect('adobe');
|
||||
showAuthMenu('adobe', 'chip-adobe');
|
||||
} else {
|
||||
await connectAdobeEnv();
|
||||
await connectAdobe();
|
||||
}
|
||||
}
|
||||
|
||||
async function onClickDocusign() {
|
||||
if (state.auth.docusign) {
|
||||
await disconnect('docusign');
|
||||
showAuthMenu('docusign', 'chip-docusign');
|
||||
} else {
|
||||
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');
|
||||
try {
|
||||
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();
|
||||
// Reload templates (they'll be empty without auth)
|
||||
const { refreshTemplates } = await import('./templates.js');
|
||||
refreshTemplates();
|
||||
if (!skipRefresh) {
|
||||
const { refreshTemplates } = await import('./templates.js');
|
||||
refreshTemplates();
|
||||
}
|
||||
if (!silent) {
|
||||
showToast(`${platform === 'adobe' ? 'Adobe Sign' : 'Docusign'} disconnected.`, 'info');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Disconnect failed:', e.message);
|
||||
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');
|
||||
try {
|
||||
const data = await api.auth.connectAdobe();
|
||||
const data = await api.auth.connectAdobe(forceOauth, window.location.hash || '#/templates');
|
||||
if (data.connected) {
|
||||
setState('auth', { ...state.auth, adobe: true });
|
||||
renderAuthChips();
|
||||
const { refreshTemplates } = await import('./templates.js');
|
||||
refreshTemplates();
|
||||
} else if (data.error && data.error.includes('No Adobe Sign credentials')) {
|
||||
renderAuthChips();
|
||||
showAdobeOAuthDialog();
|
||||
} else if (data.authorization_required && data.authorization_url) {
|
||||
window.location.href = data.authorization_url;
|
||||
} else {
|
||||
renderAuthChips();
|
||||
showToast('Adobe Sign error: ' + (data.error || 'unknown'), 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
renderAuthChips();
|
||||
showAdobeOAuthDialog();
|
||||
showToast('Adobe Sign connection failed: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function connectDocusign() {
|
||||
closeAuthMenu();
|
||||
setChipConnecting('chip-docusign');
|
||||
try {
|
||||
const data = await api.auth.connectDocusign();
|
||||
const data = await api.auth.connectDocusign(window.location.hash || '#/templates');
|
||||
if (data.connected) {
|
||||
setState('auth', { ...state.auth, docusign: true });
|
||||
renderAuthChips();
|
||||
const { refreshTemplates } = await import('./templates.js');
|
||||
refreshTemplates();
|
||||
await refreshAuth();
|
||||
if (!data.account_selection_required) {
|
||||
const { refreshTemplates } = await import('./templates.js');
|
||||
refreshTemplates();
|
||||
}
|
||||
} else if (data.authorization_required && data.authorization_url) {
|
||||
window.location.href = data.authorization_url;
|
||||
} else {
|
||||
renderAuthChips();
|
||||
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>`;
|
||||
}
|
||||
|
||||
// ── Adobe OAuth dialog (manual redirect URL paste) ─────────────────────────
|
||||
// ── Top-bar menu ───────────────────────────────────────────────────────────
|
||||
|
||||
async function showAdobeOAuthDialog() {
|
||||
const { url } = await api.auth.adobeUrl().catch(() => ({ url: '#' }));
|
||||
function closeAuthMenu() {
|
||||
document.getElementById('auth-chip-menu')?.remove();
|
||||
document.removeEventListener('click', onDocumentClickCloseMenu, true);
|
||||
document.removeEventListener('keydown', onEscapeCloseMenu, true);
|
||||
}
|
||||
|
||||
const existing = document.getElementById('adobe-auth-dialog');
|
||||
if (existing) existing.remove();
|
||||
function onDocumentClickCloseMenu(event) {
|
||||
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');
|
||||
dialog.id = 'adobe-auth-dialog';
|
||||
dialog.id = 'docusign-account-dialog';
|
||||
dialog.innerHTML = `
|
||||
<div class="modal-backdrop"></div>
|
||||
<div class="modal-box">
|
||||
<div class="modal-box modal-box-wide">
|
||||
<div class="modal-header">
|
||||
<span class="modal-title">Connect Adobe Sign</span>
|
||||
<button class="btn btn-ghost btn-icon" id="adobe-dialog-close">✕</button>
|
||||
<span class="modal-title">Choose DocuSign Account</span>
|
||||
<button class="btn btn-ghost btn-icon" id="docusign-account-close">✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<ol style="padding-left:18px;line-height:1.8;margin-bottom:14px;font-size:13px">
|
||||
<li><a href="${escHtml(url)}" target="_blank" rel="noopener" style="color:var(--cobalt)">Click here to authorize in Adobe Sign ↗</a></li>
|
||||
<li>After authorizing, your browser will show a page that fails to load — that's expected.</li>
|
||||
<li>Copy the full URL from the address bar and paste it below.</li>
|
||||
</ol>
|
||||
<input type="text" id="adobe-redirect-input" class="form-input"
|
||||
placeholder="https://localhost:8080/callback?code=…" />
|
||||
<div id="adobe-dialog-error" style="color:var(--error);font-size:12px;min-height:18px;margin-top:6px"></div>
|
||||
<div style="display:flex;gap:12px;align-items:center;justify-content:space-between;margin-bottom:14px;flex-wrap:wrap">
|
||||
<div style="font-size:13px;color:var(--text-muted)">
|
||||
${accounts.length} account${accounts.length === 1 ? '' : 's'} found. Choose the account this session should use.
|
||||
</div>
|
||||
<input type="text" id="docusign-account-search" class="form-input" placeholder="Search accounts..." style="max-width:320px" />
|
||||
</div>
|
||||
<div id="docusign-account-error" style="color:var(--error);font-size:12px;min-height:18px;margin-bottom:8px"></div>
|
||||
<div id="docusign-account-list" class="docusign-account-list"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" id="adobe-dialog-cancel">Cancel</button>
|
||||
<button class="btn btn-primary" id="adobe-dialog-submit">Connect</button>
|
||||
<button class="btn btn-secondary" id="docusign-account-cancel">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(dialog);
|
||||
|
||||
document.getElementById('adobe-dialog-close').onclick = () => dialog.remove();
|
||||
document.getElementById('adobe-dialog-cancel').onclick = () => dialog.remove();
|
||||
document.getElementById('adobe-dialog-submit').onclick = () => submitAdobeCode(dialog);
|
||||
document.getElementById('adobe-redirect-input').addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter') submitAdobeCode(dialog);
|
||||
});
|
||||
const listEl = document.getElementById('docusign-account-list');
|
||||
const searchEl = document.getElementById('docusign-account-search');
|
||||
const errorEl = document.getElementById('docusign-account-error');
|
||||
|
||||
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) {
|
||||
const url = document.getElementById('adobe-redirect-input').value.trim();
|
||||
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 = '';
|
||||
function closeDocusignAccountPicker() {
|
||||
document.getElementById('docusign-account-dialog')?.remove();
|
||||
}
|
||||
|
||||
async function selectDocusignAccount(accountId, errorEl = null) {
|
||||
try {
|
||||
const data = await api.auth.exchangeAdobe(url);
|
||||
dialog.remove();
|
||||
setState('auth', { ...state.auth, adobe: true });
|
||||
renderAuthChips();
|
||||
const { refreshTemplates } = await import('./templates.js');
|
||||
refreshTemplates();
|
||||
await api.auth.selectDocusignAccount(accountId);
|
||||
closeDocusignAccountPicker();
|
||||
await refreshAuth();
|
||||
setState('templatesError', null);
|
||||
const { refreshTemplates, renderTemplates } = await import('./templates.js');
|
||||
await refreshTemplates();
|
||||
if ((window.location.hash || '#/templates').startsWith('#/templates')) {
|
||||
await renderTemplates();
|
||||
}
|
||||
showToast('DocuSign account selected.', 'success');
|
||||
} catch (e) {
|
||||
errorEl.textContent = e.data?.error || e.message || 'Connection failed.';
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = 'Connect';
|
||||
if (errorEl) {
|
||||
errorEl.textContent = e.data?.error || e.message || 'Failed to select account.';
|
||||
} 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)' };
|
||||
toast.style.cssText = `
|
||||
padding:10px 16px;border-radius:6px;font-size:13px;font-weight:500;
|
||||
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;
|
||||
background:${colors[type] || colors.info};border:1px solid ${borders[type] || borders.info};
|
||||
box-shadow:var(--shadow-md);max-width:420px;animation:fadeIn 0.2s ease;
|
||||
`;
|
||||
toast.textContent = message;
|
||||
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
|
||||
|
||||
import { state } from './state.js';
|
||||
import { escHtml, formatDate } from './utils.js';
|
||||
import { escHtml, formatDate, renderFieldIssues, bindFieldIssueToggles } from './utils.js';
|
||||
import { navigate } from './router.js';
|
||||
|
||||
export function renderIssues() {
|
||||
const outlet = document.getElementById('router-outlet');
|
||||
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 =>
|
||||
(!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) {
|
||||
|
|
@ -32,7 +32,7 @@ export function renderIssues() {
|
|||
<span class="callout-icon">🎉</span>
|
||||
<div>
|
||||
<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>`;
|
||||
return;
|
||||
|
|
@ -66,7 +66,7 @@ export function renderIssues() {
|
|||
${warnings.length ? `
|
||||
<div>
|
||||
<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 class="attention-list">
|
||||
${warnings.map(t => _warningItem(t)).join('')}
|
||||
|
|
@ -85,6 +85,8 @@ export function renderIssues() {
|
|||
document.querySelectorAll('.btn-view-template').forEach(btn => {
|
||||
btn.addEventListener('click', () => navigate(`#/templates/${btn.dataset.id}`));
|
||||
});
|
||||
|
||||
bindFieldIssueToggles(outlet);
|
||||
}
|
||||
|
||||
function _blockerItem(t) {
|
||||
|
|
@ -106,6 +108,7 @@ function _blockerItem(t) {
|
|||
|
||||
function _warningItem(t) {
|
||||
const warnings = t.warnings || [];
|
||||
const fieldIssues = t.field_issues || [];
|
||||
return `
|
||||
<div class="attention-item warning">
|
||||
<span class="attention-icon">⚠️</span>
|
||||
|
|
@ -113,6 +116,7 @@ function _warningItem(t) {
|
|||
<div class="attention-name">${escHtml(t.name)}</div>
|
||||
${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>` : ''}
|
||||
${fieldIssues.length ? renderFieldIssues(fieldIssues) : ''}
|
||||
<div style="margin-top:6px;font-size:11px;color:var(--text-muted)">Modified ${formatDate(t.adobe_modified)}</div>
|
||||
</div>
|
||||
<div class="attention-action" style="display:flex;flex-direction:column;gap:6px;align-items:flex-end">
|
||||
|
|
@ -122,3 +126,15 @@ function _warningItem(t) {
|
|||
</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 ────────────────────────────────────────────────────────────────
|
||||
|
||||
const _RESULTS_STORAGE_KEY = 'migrator_last_batch_results';
|
||||
|
||||
function getSettings() {
|
||||
try { return JSON.parse(localStorage.getItem('migrator_settings')) || {}; }
|
||||
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 ──────────────────────────────────────────────────────────
|
||||
|
||||
export function showOptionsModal(ids) {
|
||||
|
|
@ -230,6 +272,7 @@ export async function pollJob(jobId, onProgress) {
|
|||
|
||||
if (data.status === 'done' || data.status === 'complete' || data.status === 'completed') {
|
||||
setState('lastMigrationResults', data);
|
||||
persistLastResults(data);
|
||||
resolve(data);
|
||||
} else if (data.status === 'failed') {
|
||||
reject(new Error('Migration job failed'));
|
||||
|
|
@ -248,16 +291,30 @@ export async function pollJob(jobId, onProgress) {
|
|||
|
||||
// ── Results view ───────────────────────────────────────────────────────────
|
||||
|
||||
export function renderResults() {
|
||||
export async function renderResults() {
|
||||
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) {
|
||||
outlet.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<div class="empty-state-icon">📊</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>`;
|
||||
return;
|
||||
}
|
||||
|
|
@ -288,6 +345,13 @@ export function renderResults() {
|
|||
</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 -->
|
||||
<div class="stat-grid" style="grid-template-columns:repeat(${summary.dry_run ? 6 : 5},1fr)">
|
||||
<div class="stat-card green">
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
// Settings view — verification defaults, migration defaults, connection info
|
||||
|
||||
import { api } from './api.js';
|
||||
import { state } from './state.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';
|
||||
|
||||
|
|
@ -31,13 +33,13 @@ export function renderSettings() {
|
|||
<div class="settings-section">
|
||||
<div class="settings-section-header">
|
||||
<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 class="settings-section-body">
|
||||
<div class="setting-row">
|
||||
<div class="setting-body">
|
||||
<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 class="setting-control" style="min-width:240px">
|
||||
<input type="text" class="form-input" id="set-recipient-name"
|
||||
|
|
@ -48,7 +50,7 @@ export function renderSettings() {
|
|||
<div class="setting-row">
|
||||
<div class="setting-body">
|
||||
<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 class="setting-control" style="min-width:240px">
|
||||
<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-header">
|
||||
<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 class="settings-section-body" id="settings-conn-info">
|
||||
<div style="padding:8px 0;font-size:13px;color:var(--text-muted)">Loading…</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 -->
|
||||
<div style="display:flex;gap:8px;align-items:center">
|
||||
<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
|
||||
_loadConnInfo();
|
||||
}
|
||||
|
|
@ -155,18 +193,64 @@ async function _loadConnInfo() {
|
|||
connEl.innerHTML = `
|
||||
<div class="conn-info-row">
|
||||
<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="badge ${data.adobe ? 'badge-green' : 'badge-gray'}">${data.adobe ? '● Connected' : '○ Disconnected'}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="conn-info-row">
|
||||
<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="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>
|
||||
</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">
|
||||
<span class="conn-info-label">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) {
|
||||
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,
|
||||
docusign: false,
|
||||
adobeLabel: 'Adobe Sign',
|
||||
adobeAccountId: null,
|
||||
adobeAccountName: null,
|
||||
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(),
|
||||
lastMigrationResults: null, // final batch job results
|
||||
issueCount: 0, // blocked template count (drives nav badge)
|
||||
|
|
@ -38,6 +46,10 @@ export function setState(key, value) {
|
|||
|
||||
// Recompute derived values after template list updates
|
||||
export function updateDerivedState() {
|
||||
const blocked = state.templates.filter(t => t.blockers && t.blockers.length > 0).length;
|
||||
setState('issueCount', blocked);
|
||||
const issueCount = state.templates.filter(t =>
|
||||
(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 { escHtml, formatDate, formatRelative, debounce, renderFieldIssues, bindFieldIssueToggles } from './utils.js';
|
||||
import { navigate } from './router.js';
|
||||
import { bindQuickStartCard, quickStartCardMarkup, shouldShowQuickStart } from './help.js';
|
||||
|
||||
// ── Readiness badge ────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -11,15 +12,18 @@ function readiness(t) {
|
|||
if (t.blockers && t.blockers.length > 0) {
|
||||
return { key: 'blocked', label: 'Blocked', cls: 'badge-blocked' };
|
||||
}
|
||||
if (hasFieldIssues(t)) {
|
||||
return { key: 'field-caveats', label: 'Caveats', cls: 'badge-caveats' };
|
||||
}
|
||||
if (t.status === 'migrated') {
|
||||
return t.warnings && t.warnings.length > 0
|
||||
return hasWarnings(t)
|
||||
? { key: 'migrated-warn', label: 'Migrated', cls: 'badge-migrated' }
|
||||
: { key: 'migrated', label: 'Migrated', cls: 'badge-migrated' };
|
||||
}
|
||||
if (t.status === '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: 'ready', label: 'Ready', cls: 'badge-ready' };
|
||||
|
|
@ -30,15 +34,20 @@ function readiness(t) {
|
|||
export async function refreshTemplates() {
|
||||
if (!state.auth.adobe || !state.auth.docusign) {
|
||||
setState('templates', []);
|
||||
setState('templatesError', null);
|
||||
updateDerivedState();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const data = await api.templates.status();
|
||||
setState('templates', data.templates || []);
|
||||
setState('templatesError', null);
|
||||
updateDerivedState();
|
||||
} catch (e) {
|
||||
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>
|
||||
` : ''}
|
||||
|
||||
${state.templatesError ? `
|
||||
<div class="callout error">
|
||||
<span class="callout-icon">❌</span>
|
||||
Template loading failed: ${escHtml(state.templatesError)}
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
${shouldShowQuickStart() ? quickStartCardMarkup() : ''}
|
||||
|
||||
<!-- Filter bar -->
|
||||
<div class="filter-bar">
|
||||
<input type="search" class="search-input" id="template-search"
|
||||
|
|
@ -131,7 +149,7 @@ function _render() {
|
|||
<div class="empty-state">
|
||||
<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-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>
|
||||
</td></tr>`
|
||||
}
|
||||
|
|
@ -165,10 +183,13 @@ function _templateRow(t) {
|
|||
const selected = state.selectedIds.has(t.adobe_id);
|
||||
const warnCount = (t.warnings || []).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
|
||||
? `🚫 ${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 `
|
||||
<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,
|
||||
needs_update: templates.filter(t => t.status === 'needs_update').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
|
||||
if (_filter.status !== 'all') {
|
||||
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') {
|
||||
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 {
|
||||
list = list.filter(t => t.status === _filter.status);
|
||||
}
|
||||
|
|
@ -231,7 +252,7 @@ function _applyFilter(templates) {
|
|||
let va = a[_sort.col] || '';
|
||||
let vb = b[_sort.col] || '';
|
||||
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;
|
||||
return _sort.dir === 'asc' ? String(va).localeCompare(String(vb)) : String(vb).localeCompare(String(va));
|
||||
});
|
||||
|
|
@ -242,6 +263,8 @@ function _applyFilter(templates) {
|
|||
// ── Event wiring ───────────────────────────────────────────────────────────
|
||||
|
||||
function _bindEvents() {
|
||||
bindQuickStartCard(document);
|
||||
|
||||
// Search
|
||||
const searchEl = document.getElementById('template-search');
|
||||
if (searchEl) {
|
||||
|
|
@ -339,6 +362,7 @@ export async function renderTemplateDetail(adobeId) {
|
|||
}
|
||||
|
||||
const r = readiness(t);
|
||||
const issueCount = totalIssueCount(t);
|
||||
outlet.innerHTML = `
|
||||
<div class="page-header">
|
||||
<div>
|
||||
|
|
@ -354,8 +378,8 @@ export async function renderTemplateDetail(adobeId) {
|
|||
|
||||
<div class="tabs" id="detail-tabs">
|
||||
<div class="tab active" data-tab="overview">Overview</div>
|
||||
<div class="tab" data-tab="issues">Issues ${(t.blockers||[]).length + (t.warnings||[]).length > 0
|
||||
? `<span class="nav-badge" style="position:static;display:inline">${(t.blockers||[]).length + (t.warnings||[]).length}</span>` : ''}</div>
|
||||
<div class="tab" data-tab="issues">Issues ${issueCount > 0
|
||||
? `<span class="nav-badge" style="position:static;display:inline">${issueCount}</span>` : ''}</div>
|
||||
<div class="tab" data-tab="history">Migration History</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -409,10 +433,15 @@ function _renderDetailTab(t, tabKey) {
|
|||
} else if (tabKey === 'issues') {
|
||||
const blockers = t.blockers || [];
|
||||
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>`;
|
||||
} else {
|
||||
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 ? `
|
||||
<div class="card">
|
||||
<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>
|
||||
</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 ? `
|
||||
<div class="card">
|
||||
<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>
|
||||
</div>` : ''}`;
|
||||
bindFieldIssueToggles(content);
|
||||
}
|
||||
} else if (tabKey === 'history') {
|
||||
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">
|
||||
Template: <strong>${escHtml(t.name)}</strong>
|
||||
</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">
|
||||
<label class="form-label" for="sd-name">Recipient Name</label>
|
||||
<input type="text" class="form-input" id="sd-name"
|
||||
|
|
|
|||
Loading…
Reference in New Issue