Compare commits

..

No commits in common. "master" and "ui-redesign" have entirely different histories.

50 changed files with 546 additions and 4443 deletions

View File

@ -13,20 +13,27 @@ 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 used for the Auth Code Grant and refresh-token exchange
# Client secret — only needed for the one-time Auth Code Grant consent flow
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
@ -35,24 +42,10 @@ 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
DOCUSIGN_REDIRECT_URI=http://localhost:8000/api/auth/docusign/callback
# Redirect URI registered in your DocuSign app (used only during one-time consent flow)
DOCUSIGN_REDIRECT_URI=http://localhost:8080/callback
# Auto-written by src/docusign_auth.py after the initial authorization flow.
# Auto-written by src/docusign_auth.py to cache the JWT access token.
# 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=

2
.gitignore vendored
View File

@ -9,7 +9,5 @@ __pycache__/
*.b64
downloads/
migration-output/
.session-store/
.audit-log.jsonl
*.pdf
private.key

View File

@ -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`) — OAuth auth, template upsert
- **DocuSign Client** (`src/upload_docusign_template.py`, `src/docusign_auth.py`) — JWT 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; 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`
- **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
- **Frontend** (`web/static/`) — side-by-side template browser, migration UI
#### Service Separation
@ -34,22 +34,16 @@ 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/
retry.py # exponential backoff retry helpers
log_sanitizer.py # secret redaction from logs
pdf_coords.py # coordinate normalization helpers
```
> 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
View File

@ -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 OAuth Authorization Code Grant (one-time browser login, then refresh-token based)
4. **Authenticates** with DocuSign via JWT grant (one-time browser consent, then fully automated)
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 OAuth app client ID and client secret
- A DocuSign developer account with an integration key and RSA keypair
---
@ -39,20 +39,21 @@ 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 for CLI use):
**3. Authenticate with Adobe Sign** (one-time):
```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. Authorize DocuSign** (CLI, one-time per machine/user):
**4. Grant consent for DocuSign** (one-time per user):
```bash
python3 src/docusign_auth.py --authorize
python3 src/docusign_auth.py --consent
```
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.
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.
---
@ -107,7 +108,6 @@ 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,24 +119,6 @@ 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 |
@ -152,10 +134,6 @@ Important behavior:
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
@ -177,79 +155,6 @@ 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
@ -369,7 +274,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 auth-code + refresh-token helper
docusign_auth.py # DocuSign JWT auth + one-time consent flow
upload_docusign_template.py # Upsert upload: PUT if exists, POST if not
migrate_template.py # End-to-end CLI runner (download → convert → upload)

View File

@ -13,15 +13,12 @@ DocuSign API reference:
"""
import os
import sys
import csv
import json
import argparse
import requests
from dotenv import load_dotenv
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
from docusign_auth import get_access_token
from auth_helper import get_access_token # reuses existing JWT auth
load_dotenv()

View File

@ -1,277 +1,68 @@
# Architecture & Design — Adobe Sign → DocuSign Migrator
# Architecture & Design Overview
*Last updated: 2026-04-23*
## 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
## System Overview
The migrator is a Python toolkit with two interfaces that share the same core pipeline:
- **CLI** (`src/`) — shell scripts for one-off or scripted migrations
- **Web UI** (`web/`) — FastAPI + vanilla JS SPA for browser-based, multi-user migrations
Both interfaces execute the same sequence: authenticate → download → normalize → validate → compose → upload → report.
---
## 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
```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
---
## Pipeline Stages
## v2 Architecture — Web UI (2026-04-17)
### 1. Authentication
The pipeline is extended with a FastAPI web layer that wraps all existing src/ modules.
| 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])
```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]
```
`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.
**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)
### 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
**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.
---
## 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.*
*Update as architecture/requirements change. Generated by Cleo (2026-04-14). Updated 2026-04-17.*

View File

@ -1,215 +0,0 @@
# 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
- Dont overwrite `.env`, `.env-adobe`, or `private.key` casually
- Dont casually delete `.session-store/` while testers are active
- If the site breaks, check `journalctl -u adobe-migrator`

View File

@ -1,535 +0,0 @@
# 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 projects 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 Lets Encrypt
- deployment script (`deploy.sh`)
- backup/rollback script
- CI/CD from Gitea
- separate staging environment
- branch-based preview deploys

View File

@ -80,32 +80,6 @@ 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` 46. 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.

View File

@ -10,9 +10,6 @@ uvicorn[standard]
itsdangerous
httpx
# PDF generation (sample template tooling)
reportlab
# Testing
responses
respx

View File

@ -1,15 +1,9 @@
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)
@ -42,7 +36,6 @@ 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")
@ -54,11 +47,10 @@ 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 {})
raise_for_retryable_status(resp)
resp.raise_for_status()
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")
@ -74,11 +66,10 @@ def adobe_api_post_json(endpoint, body):
token = _refresh_access_token()
headers["Authorization"] = f"Bearer {token}"
resp = requests.post(url, headers=headers, json=body)
raise_for_retryable_status(resp)
resp.raise_for_status()
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")
@ -94,11 +85,10 @@ def adobe_api_put_json(endpoint, body):
token = _refresh_access_token()
headers["Authorization"] = f"Bearer {token}"
resp = requests.put(url, headers=headers, json=body)
raise_for_retryable_status(resp)
resp.raise_for_status()
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")
@ -113,11 +103,10 @@ def adobe_api_get_bytes(endpoint):
headers["Authorization"] = f"Bearer {token}"
resp = requests.get(url, headers=headers)
raise_for_retryable_status(resp)
resp.raise_for_status()
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")
@ -136,7 +125,7 @@ def adobe_api_get(endpoint, params=None):
headers["Authorization"] = f"Bearer {token}"
resp = requests.get(url, headers=headers, params=params)
raise_for_retryable_status(resp)
resp.raise_for_status()
return resp.json()

View File

@ -1,23 +1,31 @@
"""
docusign_auth.py
----------------
Handles DocuSign OAuth using the Authorization Code Grant.
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.
Usage:
python3 src/docusign_auth.py --authorize # one-time browser login
python3 src/docusign_auth.py # print a fresh access token
python3 src/docusign_auth.py --consent # one-time browser consent
python3 src/docusign_auth.py # print a fresh access token (smoke test)
Required .env keys:
DOCUSIGN_CLIENT_ID
DOCUSIGN_CLIENT_SECRET
DOCUSIGN_AUTH_SERVER
DOCUSIGN_REDIRECT_URI
DOCUSIGN_BASE_URL
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)
Auto-written to .env after authorization:
DOCUSIGN_ACCESS_TOKEN
DOCUSIGN_REFRESH_TOKEN
DOCUSIGN_TOKEN_EXPIRY
For --consent only:
DOCUSIGN_CLIENT_SECRET OAuth client secret
DOCUSIGN_REDIRECT_URI Must match your app config (default: http://localhost:8080/callback)
"""
import argparse
@ -25,36 +33,89 @@ import os
import sys
import time
import webbrowser
from urllib.parse import parse_qs, urlencode, urlparse
from urllib.parse import urlencode, urlparse, parse_qs
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
DOCUSIGN_SCOPE = "signature"
TOKEN_EXPIRY_BUFFER = 120 # refresh token 2 minutes before it expires
def _required_env(name: str) -> str:
value = os.getenv(name)
if not value:
raise RuntimeError(f"{name} must be set in .env")
return value
# ---------------------------------------------------------------------------
# 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 _auth_server() -> str:
return os.getenv("DOCUSIGN_AUTH_SERVER", "account-d.docusign.com")
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 _redirect_uri() -> str:
return os.getenv("DOCUSIGN_REDIRECT_URI", "http://localhost:8000/api/auth/docusign/callback")
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")
if cached_token and cached_expiry:
if int(time.time()) < int(cached_expiry) - TOKEN_EXPIRY_BUFFER:
return cached_token
def _persist_token_data(token_data: dict) -> str:
token_data = _request_jwt_token()
access_token = token_data["access_token"]
refresh_token = token_data.get("refresh_token")
expiry = int(time.time()) + int(token_data.get("expires_in", 3600))
abs_env = os.path.abspath(ENV_FILE)
@ -63,36 +124,42 @@ def _persist_token_data(token_data: dict) -> 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
def build_authorization_url(state: str | None = None) -> str:
client_id = _required_env("DOCUSIGN_CLIENT_ID")
# ---------------------------------------------------------------------------
# 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")
params = {
"response_type": "code",
"scope": DOCUSIGN_SCOPE,
"scope": "signature impersonation",
"client_id": client_id,
"redirect_uri": _redirect_uri(),
"redirect_uri": redirect_uri,
}
if state:
params["state"] = state
return f"https://{_auth_server()}/oauth/auth?{urlencode(params)}"
return f"https://{auth_server}/oauth/auth?{urlencode(params)}"
def exchange_code_for_token(code: str) -> dict:
client_id = _required_env("DOCUSIGN_CLIENT_ID")
client_secret = _required_env("DOCUSIGN_CLIENT_SECRET")
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")
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),
)
@ -100,77 +167,18 @@ def exchange_code_for_token(code: str) -> dict:
return resp.json()
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:
def run_consent_flow():
"""
Merge DocuSign OAuth token data into a web-session dict without writing .env.
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.
"""
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...")
url = _build_consent_url()
print("\nOpening browser for DocuSign consent...")
print(f"\nIf the browser doesn't open, go to:\n{url}\n")
webbrowser.open(url)
print("Log in, approve access, then paste the full redirect URL here.\n")
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")
redirected_url = input("Paste the redirect URL: ").strip()
parsed = urlparse(redirected_url)
@ -181,32 +189,37 @@ def run_authorize_flow():
print(f"ERROR: {error}")
sys.exit(1)
code_list = params.get("code")
if not code_list:
if "code" not in params:
print("ERROR: No authorization code found in the URL.")
sys.exit(1)
code = params["code"][0]
print("Exchanging code for token...")
save_code_token_exchange(code_list[0])
print("Authorization complete. Access and refresh tokens were saved to .env.")
token_data = _exchange_code(code)
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(
"--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",
)
parser.add_argument("--consent", action="store_true",
help="Run the Auth Code Grant consent flow (required once per user/app)")
args = parser.parse_args()
if args.authorize or args.consent:
run_authorize_flow()
if args.consent:
run_consent_flow()
else:
token = get_access_token()
print(f"Access token: {token[:20]}... (valid for ~1 hour)")

View File

@ -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.
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
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
"""
import base64

View File

@ -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, field_issues = compose_template(str(template_dir), str(output_path))
template_dict, warnings = compose_template(str(template_dir), str(output_path))
print(f" Written: {output_path}")
if warnings:

View File

@ -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}")

View File

@ -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,10 +21,14 @@ sys.path.insert(0, str(Path(__file__).parent))
from compose_docusign_template import compose_template
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))
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),
)
template = json.loads(output_path.read_text())
@ -32,9 +36,10 @@ def test_onboarding_mapping(tmp_path):
assert "status" not in template, "Template must not have a top-level 'status' field"
signers = template["recipients"]["signers"]
assert len(signers) == 1, f"Expected 1 signer, got {len(signers)}"
assert len(signers) == 2, f"Expected 2 signers, got {len(signers)}"
signer0_tabs = signers[0]["tabs"]
signer1_tabs = signers[1]["tabs"]
# -- No email/name on role placeholders --
for s in signers:
@ -48,6 +53,8 @@ def test_onboarding_mapping(tmp_path):
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", []):
@ -66,7 +73,7 @@ def test_onboarding_mapping(tmp_path):
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"]) >= 1, "Expected at least one radio option"
assert len(radio_tab["radios"]) == 3, f"Expected 3 radios, got {len(radio_tab['radios'])}"
for r in radio_tab["radios"]:
assert "pageNumber" in r, "radio missing pageNumber"
assert "xPosition" in r, "radio missing xPosition"
@ -80,11 +87,18 @@ def test_onboarding_mapping(tmp_path):
+ 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__":
with tempfile.TemporaryDirectory() as tmpdir:
test_onboarding_mapping(Path(tmpdir))
test_onboarding_mapping()

View File

@ -2,7 +2,7 @@
upload_docusign_template.py
---------------------------
Uploads a DocuSign template JSON file to DocuSign via the REST API.
Authenticates using DocuSign OAuth tokens stored in .env.
Authenticates using JWT grant (no Node.js dependency required).
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 --authorize # authorize once
python3 src/docusign_auth.py --consent # grant consent once
python3 src/upload_docusign_template.py --file <path>
Required .env keys (see docusign_auth.py for full list):
DOCUSIGN_CLIENT_ID, DOCUSIGN_CLIENT_SECRET, DOCUSIGN_ACCOUNT_ID,
DOCUSIGN_AUTH_SERVER, DOCUSIGN_BASE_URL, DOCUSIGN_REDIRECT_URI
DOCUSIGN_CLIENT_ID, DOCUSIGN_USER_ID, DOCUSIGN_ACCOUNT_ID,
DOCUSIGN_PRIVATE_KEY_PATH, DOCUSIGN_AUTH_SERVER, DOCUSIGN_BASE_URL
"""
import argparse
@ -34,9 +34,6 @@ 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:
@ -71,10 +68,6 @@ 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
@ -91,7 +84,6 @@ 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.
@ -131,7 +123,10 @@ def upload_template(file_path: str, force_create: bool = False) -> str:
headers = _refresh_token_once(headers)
resp = requests.put(url, headers=headers, json=template)
raise_for_retryable_status(resp)
if not resp.ok:
print(f"ERROR: Update failed ({resp.status_code})")
print(resp.text)
sys.exit(1)
print(f"Template updated: {existing_id}")
return existing_id
@ -144,7 +139,10 @@ def upload_template(file_path: str, force_create: bool = False) -> str:
headers = _refresh_token_once(headers)
resp = requests.post(url, headers=headers, json=template)
raise_for_retryable_status(resp)
if not resp.ok:
print(f"ERROR: Upload failed ({resp.status_code})")
print(resp.text)
sys.exit(1)
result = resp.json()
template_id = result.get("templateId")

View File

@ -21,21 +21,6 @@ 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,

View File

@ -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 → redirects through OAuth if needed, then chip turns green
- [ ] Click "DocuSign" chip → connects via JWT grant → chip turns green
- [ ] Disconnecting either chip → chip turns red → templates clear
## 4. Templates View

View File

@ -1,37 +0,0 @@
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

View File

@ -1,173 +0,0 @@
"""
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"]

View File

@ -12,42 +12,10 @@ 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={})
@ -65,8 +33,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"]
assert "redirect_uri=http://localhost:8000/api/auth/adobe/callback" in data["url"]
assert resp.cookies.get("migrator_session") is not None
# Must use the registered redirect URI
assert "localhost%3A8080" in data["url"] or "localhost:8080" in data["url"]
def test_adobe_connect_env_stores_token(monkeypatch):
@ -75,13 +43,7 @@ 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"), \
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",
}):
with patch("adobe_api._refresh_access_token", return_value="refreshed-token"):
resp = client.get("/api/auth/adobe/connect")
assert resp.status_code == 200
@ -89,19 +51,15 @@ 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_requests_authorization_without_credentials(monkeypatch):
"""GET /api/auth/adobe/connect with no .env tokens returns an auth URL."""
def test_adobe_connect_env_fails_without_credentials(monkeypatch):
"""GET /api/auth/adobe/connect with no .env tokens → 400."""
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 == 200
assert resp.json()["authorization_required"] is True
assert "/api/auth/adobe/callback" in resp.json()["authorization_url"]
assert resp.status_code == 400
assert "No Adobe Sign credentials" in resp.json()["error"]
@respx.mock
@ -128,68 +86,6 @@ 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(
@ -200,151 +96,21 @@ def test_adobe_exchange_rejects_missing_code():
def test_docusign_connect_stores_token():
"""GET /api/auth/docusign/connect refreshes the current session's token."""
from unittest.mock import AsyncMock, patch
"""GET /api/auth/docusign/connect uses JWT grant from .env → session connected."""
from unittest.mock import patch
import web.routers.auth as auth_module
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})
with patch("docusign_auth.get_access_token", return_value="ds-jwt-token"):
resp = client.get("/api/auth/docusign/connect")
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
@ -357,7 +123,7 @@ def test_disconnect_clears_token():
# Connect Adobe via exchange
connect_resp = client.post(
"/api/auth/adobe/exchange",
json={"redirect_url": "http://localhost:8000/api/auth/adobe/callback?code=abc"},
json={"redirect_url": "https://localhost:8080/callback?code=abc"},
)
session_cookie = connect_resp.cookies["migrator_session"]

View File

@ -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 issue-analysis keys."""
"""Each template in the status response has blockers and warnings keys."""
respx.get(f"{ADOBE_BASE}/libraryDocuments").mock(
return_value=httpx.Response(200, json={
"libraryDocumentList": [
@ -175,16 +175,13 @@ 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 → no local template analysis issues."""
"""Template not in downloads dir → blockers and warnings are empty lists."""
respx.get(f"{ADOBE_BASE}/libraryDocuments").mock(
return_value=httpx.Response(200, json={
"libraryDocumentList": [
@ -199,8 +196,6 @@ 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
@ -234,101 +229,3 @@ 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"

View File

@ -11,26 +11,11 @@ From the project root.
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse, HTMLResponse
import logging
from fastapi.responses import FileResponse
import os
from web.config import settings
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"
)
from web.routers import auth, templates, migrate, verify
app = FastAPI(
title="Adobe Sign → DocuSign Migrator",
@ -43,8 +28,6 @@ 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")
@ -61,8 +44,5 @@ def health():
def index():
index_path = os.path.join(_static_dir, "index.html")
if os.path.exists(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 FileResponse(index_path)
return {"message": "Adobe Sign → DocuSign Migrator API", "docs": "/api/docs"}

View File

@ -1,134 +0,0 @@
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

View File

@ -6,34 +6,11 @@ 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", "")
@ -51,29 +28,9 @@ 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()

View File

@ -1,160 +0,0 @@
"""
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",
)

View File

@ -1,35 +0,0 @@
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,
},
}

View File

@ -1,22 +0,0 @@
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),
}

View File

@ -3,12 +3,15 @@ web/routers/auth.py
-------------------
OAuth endpoints for Adobe Sign and DocuSign.
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.
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.
"""
import secrets
from urllib.parse import urlparse, parse_qs
import httpx
@ -16,122 +19,17 @@ 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.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
from web.session import get_session, save_session
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
# ---------------------------------------------------------------------------
@ -140,49 +38,36 @@ async def _merge_adobe_profile(session: dict, access_token: str) -> dict:
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 {
**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": bool(session.get("adobe_access_token")),
"docusign": bool(session.get("docusign_access_token")),
}
# ---------------------------------------------------------------------------
# Adobe Sign — OAuth Authorization Code Grant
# Adobe Sign — manual paste flow (matches CLI behaviour)
# ---------------------------------------------------------------------------
@router.get("/adobe/url")
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
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}
class AdobeExchangeRequest(BaseModel):
redirect_url: str
class DocusignAccountSelectRequest(BaseModel):
account_id: str
@router.post("/adobe/exchange")
async def adobe_exchange(body: AdobeExchangeRequest, request: Request):
"""
@ -210,7 +95,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,
},
)
@ -225,14 +110,6 @@ 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)
@ -240,136 +117,38 @@ async def adobe_exchange(body: AdobeExchangeRequest, request: Request):
@router.get("/adobe/connect")
async def adobe_connect(request: Request, force_oauth: bool = False, return_to: str | None = None):
def adobe_connect_env(request: Request):
"""
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.
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.
"""
session = get_session(request)
token = session.get("adobe_access_token")
refresh_token = session.get("adobe_refresh_token")
if not force_oauth and not token and not refresh_token:
import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "src"))
from adobe_api import _refresh_access_token
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
token = os.getenv("ADOBE_ACCESS_TOKEN")
refresh_token = os.getenv("ADOBE_REFRESH_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,
)
# Always refresh to ensure the token is fresh (access tokens expire in ~1h)
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
if not force_oauth and not token and refresh_token:
try:
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
if not force_oauth and token:
response = JSONResponse({"connected": True})
save_session(response, session)
return response
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"},
)
response = JSONResponse(
{
"connected": False,
"authorization_required": True,
"authorization_url": authorization_url,
}
)
save_session(response, session)
return response
@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)
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)
session["adobe_access_token"] = token
session["adobe_refresh_token"] = refresh_token
async with httpx.AsyncClient() as client:
resp = await client.post(
_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,
},
)
if not resp.is_success:
return JSONResponse({"error": "token exchange failed", "detail": resp.text}, status_code=502)
token_data = resp.json()
if "error" in token_data:
return JSONResponse({"error": token_data.get("error_description", token_data["error"])}, status_code=400)
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"))
response = JSONResponse({"connected": True})
save_session(response, session)
return response
@ -377,211 +156,86 @@ async def adobe_callback(request: Request, code: str = "", state: str = ""):
@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
# DocuSign — JWT grant (.env) or OAuth redirect
# ---------------------------------------------------------------------------
@router.get("/docusign/connect")
async def docusign_connect(request: Request, return_to: str | None = None):
def docusign_connect(request: Request):
"""
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.
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 (
build_authorization_url,
refresh_access_token,
session_from_token_data,
session_has_valid_access_token,
)
from docusign_auth import get_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.")
token = get_access_token()
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 = get_session(request)
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),
})
response = JSONResponse({"connected": True})
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"},
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}"
)
response = RedirectResponse(authorization_url)
save_session(response, session)
return response
return RedirectResponse(f"https://{settings.docusign_auth_server}/oauth/auth" + params)
@router.get("/docusign/callback")
async def docusign_callback(request: Request, code: str = "", state: str = ""):
async def docusign_callback(request: Request, code: 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
import base64
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)
credentials = base64.b64encode(
f"{settings.docusign_client_id}:{settings.docusign_client_secret}".encode()
).decode()
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),
async with httpx.AsyncClient() as client:
resp = await client.post(
f"https://{settings.docusign_auth_server}/oauth/token",
headers={"Authorization": f"Basic {credentials}"},
data={
"grant_type": "authorization_code",
"code": code,
"redirect_uri": settings.docusign_redirect_uri,
},
)
response = RedirectResponse(session.pop("docusign_return_to", "#/templates"))
save_session(response, session)
return response
if not resp.is_success:
return JSONResponse({"error": "token exchange failed", "detail": resp.text}, status_code=502)
@router.get("/docusign/accounts")
def docusign_accounts(request: Request):
token_data = resp.json()
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),
}
session["docusign_access_token"] = token_data.get("access_token")
session["docusign_refresh_token"] = token_data.get("refresh_token")
@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"),
}
)
response = RedirectResponse("/")
save_session(response, session)
return response
@ -589,23 +243,8 @@ def docusign_account_select(body: DocusignAccountSelectRequest, request: Request
@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

View File

@ -23,9 +23,7 @@ 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"))
@ -71,20 +69,6 @@ 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
@ -159,8 +143,6 @@ 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."""
@ -275,7 +257,7 @@ async def _migrate_one(
"Content-Type": "application/json",
"Accept": "application/json",
}
list_url = f"{docusign_base_url}/v2.1/accounts/{docusign_account_id}/templates"
list_url = f"{settings.docusign_base_url}/v2.1/accounts/{settings.docusign_account_id}/templates"
async with httpx.AsyncClient() as client:
# Duplicate detection
@ -355,57 +337,33 @@ 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(scoped_results)
history.extend(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(scoped_results)}
return {"results": list(results)}
@router.get("/history")
def migration_history(request: Request):
def migration_history():
"""Return all past migration records."""
session_scope = _session_scope(get_session(request))
return {"history": _filter_history_for_session(_load_history(), session_scope)}
return {"history": _load_history()}
# ---------------------------------------------------------------------------
@ -414,14 +372,9 @@ def migration_history(request: Request):
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."""
@ -431,11 +384,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, ds_account_id, ds_base_url, options)
result = await _migrate_one(adobe_id, adobe_token, ds_token, 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, ds_account_id, ds_base_url, options)
result = await _migrate_one(adobe_id, adobe_token, ds_token, options)
if result["status"] != "failed":
result["retried"] = True
@ -444,7 +397,7 @@ async def _run_batch_job(
# Persist to history
history = _load_history()
history.extend(_scope_record(result, owner_session_id) for result in results)
history.extend(results)
_save_history(history)
success = sum(1 for r in results if r["status"] == "success")
@ -461,19 +414,6 @@ 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")
@ -487,20 +427,14 @@ 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": [],
@ -508,26 +442,12 @@ 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, session_scope, request_context(request), dict(session), ids,
job_id, ids,
session["adobe_access_token"],
session["docusign_access_token"],
account["account_id"],
account["base_url"],
body.options,
)
)
@ -536,12 +456,9 @@ async def run_batch_migration(body: MigrateRequest, request: Request):
@router.get("/batch/{job_id}")
def get_batch_status(job_id: str, request: Request):
def get_batch_status(job_id: str):
"""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

View File

@ -5,12 +5,8 @@ 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
@ -18,15 +14,10 @@ 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"):
@ -37,10 +28,6 @@ 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
@ -82,11 +69,10 @@ 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"{account['base_url']}/v2.1/accounts/{account['account_id']}/templates",
f"{settings.docusign_base_url}/v2.1/accounts/{settings.docusign_account_id}/templates",
headers={"Authorization": f"Bearer {session['docusign_access_token']}"},
params={"count": 100},
)
@ -121,7 +107,6 @@ 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:
@ -132,7 +117,7 @@ async def template_status(request: Request):
params={"pageSize": 100},
),
client.get(
f"{account['base_url']}/v2.1/accounts/{account['account_id']}/templates",
f"{settings.docusign_base_url}/v2.1/accounts/{settings.docusign_account_id}/templates",
headers={"Authorization": f"Bearer {session['docusign_access_token']}"},
params={"count": 100},
),
@ -167,14 +152,7 @@ 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"
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)
blockers, warnings = _get_validation(t.get("id", ""), name)
results.append({
"adobe_id": t.get("id"),
@ -183,169 +161,36 @@ 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": analysis["blockers"],
"warnings": analysis["warnings"],
"field_issues": analysis["field_issues"],
"analysis_status": analysis["status"],
"blockers": blockers,
"warnings": warnings,
})
return {"templates": results}
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",
}
def _get_validation(template_id: str, template_name: str) -> tuple[list, list]:
"""Return (blockers, warnings) if the template has been downloaded; else ([], [])."""
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
template_dir = _find_downloaded_template(template_id, template_name)
if not template_dir:
return analysis
normalized, _ = adobe_folder_to_normalized(str(template_dir), include_documents=False)
result = validate_template(normalized)
analysis["blockers"] = result.blockers
analysis["warnings"] = result.warnings
try:
with tempfile.TemporaryDirectory() as tmpdir:
output_path = Path(tmpdir) / "docusign-template.json"
_, _compose_warnings, field_issues = compose_template(str(template_dir), str(output_path))
analysis["field_issues"] = field_issues
except Exception as exc:
analysis["warnings"] = _dedupe([
*analysis["warnings"],
f"Field mapping analysis unavailable: {exc}",
])
analysis["status"] = "analyzed"
return analysis
except Exception as exc:
analysis["warnings"] = [f"Template analysis unavailable: {exc}"]
analysis["status"] = "error"
return analysis
def _find_downloaded_template(template_id: str, template_name: str) -> Path | None:
downloads_dir = Path(settings.downloads_dir)
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}*"))
return next((c for c in candidates if c.is_dir()), None)
if not candidates or not candidates[0].is_dir():
return [], []
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)
normalized = adobe_folder_to_normalized(str(candidates[0]))
result = validate_template(normalized)
return result.blockers, result.warnings
except Exception:
return []
return [], []
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
# asyncio needed for gather — import at top of module
import asyncio

View File

@ -12,8 +12,7 @@ from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from web.audit import log_event
from web.docusign_context import DocusignContextError, current_account
from web.config import settings
from web.session import get_session
router = APIRouter()
@ -32,10 +31,6 @@ 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
@ -46,13 +41,12 @@ 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"{account['base_url']}/v2.1/accounts/{account['account_id']}"
base = f"{settings.docusign_base_url}/v2.1/accounts/{settings.docusign_account_id}"
async with httpx.AsyncClient() as client:
# Fetch template to discover actual role names
@ -93,17 +87,6 @@ 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}
@ -114,11 +97,10 @@ 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"{account['base_url']}/v2.1/accounts/{account['account_id']}/envelopes/{envelope_id}",
f"{settings.docusign_base_url}/v2.1/accounts/{settings.docusign_account_id}/envelopes/{envelope_id}",
headers={"Authorization": f"Bearer {session['docusign_access_token']}"},
)
@ -144,11 +126,10 @@ 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"{account['base_url']}/v2.1/accounts/{account['account_id']}/envelopes/{envelope_id}",
f"{settings.docusign_base_url}/v2.1/accounts/{settings.docusign_account_id}/envelopes/{envelope_id}",
headers={
"Authorization": f"Bearer {session['docusign_access_token']}",
"Content-Type": "application/json",
@ -162,10 +143,4 @@ 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}

View File

@ -1,177 +1,45 @@
"""
web/session.py
--------------
Session helpers backed by a signed session-id cookie plus server-side JSON files.
Session helpers using signed cookies (itsdangerous).
Stores Adobe Sign and DocuSign tokens server-side in the cookie payload.
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.
Sessions are short-lived (1 hour) and signed but not encrypted.
Do not store sensitive secrets here beyond access tokens.
"""
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 _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 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:
"""Read and verify the session cookie. Returns an empty dict if missing or invalid."""
raw = request.cookies.get(_COOKIE_NAME)
if not raw:
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)
try:
return _serializer.loads(raw, max_age=_MAX_AGE)
except (BadSignature, SignatureExpired):
return {}
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)
def save_session(response: Response, data: dict) -> None:
"""Sign and write session data into a cookie on the response."""
signed = _serializer.dumps(data)
response.set_cookie(
_COOKIE_NAME,
_serializer.dumps(sid),
signed,
max_age=_MAX_AGE,
httponly=True,
samesite="lax",
)
return sid
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)
def clear_session(response: Response) -> None:
"""Delete the session cookie."""
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),
}

View File

@ -169,11 +169,6 @@ 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 {
@ -274,57 +269,11 @@ 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; }
}

View File

@ -32,7 +32,6 @@
}
.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 ── */
@ -191,52 +190,3 @@
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;
}
}

View File

@ -199,12 +199,6 @@
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;
@ -215,50 +209,6 @@
.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;

View File

@ -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?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}}" />
<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" />
</head>
<body>
@ -91,12 +91,6 @@
<span class="nav-label">History &amp; 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>
@ -105,18 +99,6 @@
<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 -->
@ -151,7 +133,7 @@
<span class="conn-dot"></span>Docusign
</button>
<!-- User avatar -->
<div class="avatar" id="topbar-avatar" title="User" aria-label="User">?</div>
<div class="avatar" title="Logged in" aria-label="User">M</div>
</div>
</header>
@ -178,7 +160,7 @@
<!-- ═══════════════════════════════════════════════════════════════
APP ENTRY POINT
═══════════════════════════════════════════════════════════════ -->
<script type="module" src="/static/js/app.js?v={{ASSET_VERSION}}"></script>
<script type="module" src="/static/js/app.js"></script>
</body>
</html>

View File

@ -1,134 +0,0 @@
// 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);
}

View File

@ -1,69 +0,0 @@
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>
`;
}

View File

@ -26,11 +26,8 @@ export const api = {
status() {
return GET('/api/auth/status');
},
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()}`);
connectAdobe() {
return GET('/api/auth/adobe/connect');
},
adobeUrl() {
return GET('/api/auth/adobe/url');
@ -38,14 +35,8 @@ export const api = {
exchangeAdobe(redirectUrl) {
return POST('/api/auth/adobe/exchange', { redirect_url: redirectUrl });
},
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 });
connectDocusign() {
return GET('/api/auth/docusign/connect');
},
disconnect(platform) {
return GET(`/api/auth/${platform}/disconnect`);
@ -98,18 +89,4 @@ 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');
},
},
};

View File

@ -36,26 +36,11 @@ 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 => {
@ -69,7 +54,7 @@ subscribe('issueCount', count => {
subscribe('templates', templates => {
const caveats = (templates || []).filter(t =>
(!t.blockers || t.blockers.length === 0) &&
((t.warnings || []).length > 0 || (t.field_issues || []).length > 0)
t.warnings && t.warnings.length > 0
).length;
const badge = document.getElementById('nav-badge-caveats');
if (badge) {

View File

@ -1,8 +1,8 @@
// Auth: connect/disconnect Adobe Sign and Docusign, account picker, auth chips
// Auth: connect/disconnect Adobe Sign and Docusign, auth status chips
import { api } from './api.js';
import { state, setState } from './state.js';
import { escHtml, initials } from './utils.js';
import { escHtml } from './utils.js';
// ── Refresh auth state and update chips ────────────────────────────────────
@ -13,174 +13,93 @@ export async function refreshAuth() {
adobe: !!data.adobe,
docusign: !!data.docusign,
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,
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');
renderChip('chip-adobe', state.auth.adobe, 'Adobe Sign', onClickAdobe);
renderChip('chip-docusign', state.auth.docusign, 'Docusign', onClickDocusign);
}
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><span class="conn-pill-label">${escHtml(label)}</span>${connected ? '<span class="conn-caret">▾</span>' : ''}`;
el.innerHTML = `<span class="conn-dot"></span>${escHtml(label)}`;
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) {
showAuthMenu('adobe', 'chip-adobe');
await disconnect('adobe');
} else {
await connectAdobe();
await connectAdobeEnv();
}
}
async function onClickDocusign() {
if (state.auth.docusign) {
showAuthMenu('docusign', 'chip-docusign');
await disconnect('docusign');
} else {
await connectDocusign();
}
}
export async function disconnectPlatform(platform, opts = {}) {
const { silent = false, skipRefresh = false } = opts;
closeAuthMenu();
closeDocusignAccountPicker();
async function disconnect(platform) {
setChipConnecting(platform === 'adobe' ? 'chip-adobe' : 'chip-docusign');
try {
await api.auth.disconnect(platform);
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',
});
}
setState('auth', { ...state.auth, [platform]: false });
renderAuthChips();
if (!skipRefresh) {
// Reload templates (they'll be empty without auth)
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');
}
}
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();
async function connectAdobeEnv() {
setChipConnecting('chip-adobe');
try {
const data = await api.auth.connectAdobe(forceOauth, window.location.hash || '#/templates');
const data = await api.auth.connectAdobe();
if (data.connected) {
setState('auth', { ...state.auth, adobe: true });
renderAuthChips();
const { refreshTemplates } = await import('./templates.js');
refreshTemplates();
} else if (data.authorization_required && data.authorization_url) {
window.location.href = data.authorization_url;
} else if (data.error && data.error.includes('No Adobe Sign credentials')) {
renderAuthChips();
showAdobeOAuthDialog();
} else {
renderAuthChips();
showToast('Adobe Sign error: ' + (data.error || 'unknown'), 'error');
}
} catch (e) {
renderAuthChips();
showToast('Adobe Sign connection failed: ' + e.message, 'error');
showAdobeOAuthDialog();
}
}
async function connectDocusign() {
closeAuthMenu();
setChipConnecting('chip-docusign');
try {
const data = await api.auth.connectDocusign(window.location.hash || '#/templates');
const data = await api.auth.connectDocusign();
if (data.connected) {
await refreshAuth();
if (!data.account_selection_required) {
setState('auth', { ...state.auth, docusign: true });
renderAuthChips();
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');
@ -198,193 +117,70 @@ function setChipConnecting(id) {
el.innerHTML = `<span class="conn-dot"></span><span class="spinner spinner-sm"></span>`;
}
// ── Top-bar menu ───────────────────────────────────────────────────────────
// ── Adobe OAuth dialog (manual redirect URL paste) ─────────────────────────
function closeAuthMenu() {
document.getElementById('auth-chip-menu')?.remove();
document.removeEventListener('click', onDocumentClickCloseMenu, true);
document.removeEventListener('keydown', onEscapeCloseMenu, true);
}
async function showAdobeOAuthDialog() {
const { url } = await api.auth.adobeUrl().catch(() => ({ url: '#' }));
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();
}
const existing = document.getElementById('adobe-auth-dialog');
if (existing) existing.remove();
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 = 'docusign-account-dialog';
dialog.id = 'adobe-auth-dialog';
dialog.innerHTML = `
<div class="modal-backdrop"></div>
<div class="modal-box modal-box-wide">
<div class="modal-box">
<div class="modal-header">
<span class="modal-title">Choose DocuSign Account</span>
<button class="btn btn-ghost btn-icon" id="docusign-account-close"></button>
<span class="modal-title">Connect Adobe Sign</span>
<button class="btn btn-ghost btn-icon" id="adobe-dialog-close"></button>
</div>
<div class="modal-body">
<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>
<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>
<div class="modal-footer">
<button class="btn btn-secondary" id="docusign-account-cancel">Close</button>
<button class="btn btn-secondary" id="adobe-dialog-cancel">Cancel</button>
<button class="btn btn-primary" id="adobe-dialog-submit">Connect</button>
</div>
</div>
`;
document.body.appendChild(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);
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);
});
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('');
async function submitAdobeCode(dialog) {
const url = document.getElementById('adobe-redirect-input').value.trim();
if (!url) return;
listEl.querySelectorAll('.docusign-account-item').forEach(btn => {
btn.addEventListener('click', async () => {
const submitBtn = document.getElementById('adobe-dialog-submit');
const errorEl = document.getElementById('adobe-dialog-error');
submitBtn.disabled = true;
submitBtn.textContent = 'Connecting…';
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();
}
function closeDocusignAccountPicker() {
document.getElementById('docusign-account-dialog')?.remove();
}
async function selectDocusignAccount(accountId, errorEl = null) {
try {
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');
const data = await api.auth.exchangeAdobe(url);
dialog.remove();
setState('auth', { ...state.auth, adobe: true });
renderAuthChips();
const { refreshTemplates } = await import('./templates.js');
refreshTemplates();
} catch (e) {
if (errorEl) {
errorEl.textContent = e.data?.error || e.message || 'Failed to select account.';
} else {
showToast('Failed to select account: ' + e.message, 'error');
}
errorEl.textContent = e.data?.error || e.message || 'Connection failed.';
submitBtn.disabled = false;
submitBtn.textContent = 'Connect';
}
}
@ -404,9 +200,9 @@ export function showToast(message, type = 'info') {
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:420px;animation:fadeIn 0.2s ease;
box-shadow:var(--shadow-md);max-width:360px;animation:fadeIn 0.2s ease;
`;
toast.textContent = message;
container.appendChild(toast);
setTimeout(() => toast.remove(), 4500);
setTimeout(() => toast.remove(), 4000);
}

View File

@ -1,211 +0,0 @@
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'));
}

View File

@ -1,16 +1,16 @@
// Issues & Warnings view — surfaces all validation problems before migration
import { state } from './state.js';
import { escHtml, formatDate, renderFieldIssues, bindFieldIssueToggles } from './utils.js';
import { escHtml, formatDate } 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 => hasBlockers(t));
const blocked = templates.filter(t => t.blockers && t.blockers.length > 0);
const warnings = templates.filter(t =>
!hasBlockers(t) && (hasWarnings(t) || hasFieldIssues(t))
(!t.blockers || t.blockers.length === 0) && t.warnings && t.warnings.length > 0
);
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 validation blockers, warnings, or field mapping caveats found across ${templates.length} template${templates.length !== 1 ? 's' : ''}.</div>
<div style="margin-top:4px">No blockers or warnings 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">
Caveats ${warnings.length} template${warnings.length > 1 ? 's' : ''} should be reviewed
Warnings ${warnings.length} template${warnings.length > 1 ? 's' : ''} will migrate with caveats
</div>
<div class="attention-list">
${warnings.map(t => _warningItem(t)).join('')}
@ -85,8 +85,6 @@ export function renderIssues() {
document.querySelectorAll('.btn-view-template').forEach(btn => {
btn.addEventListener('click', () => navigate(`#/templates/${btn.dataset.id}`));
});
bindFieldIssueToggles(outlet);
}
function _blockerItem(t) {
@ -108,7 +106,6 @@ 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>
@ -116,7 +113,6 @@ 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">
@ -126,15 +122,3 @@ 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;
}

View File

@ -8,53 +8,11 @@ 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) {
@ -272,7 +230,6 @@ 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'));
@ -291,30 +248,16 @@ export async function pollJob(jobId, onProgress) {
// ── Results view ───────────────────────────────────────────────────────────
export async function renderResults() {
export function renderResults() {
const outlet = document.getElementById('router-outlet');
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);
}
const results = state.lastMigrationResults;
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. If templates already exist in DocuSign, use History &amp; Audit to review older runs.</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>`;
return;
}
@ -345,13 +288,6 @@ export async 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">

View File

@ -1,10 +1,8 @@
// 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';
@ -33,13 +31,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. The same name and email are used for every recipient role on the template during a test send.</div>
<div class="settings-section-sub">Default recipient for test envelopes</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 and reused for all recipient roles in the template.</div>
<div class="setting-desc">Pre-filled in the Send Test dialog on the Verification screen</div>
</div>
<div class="setting-control" style="min-width:240px">
<input type="text" class="form-input" id="set-recipient-name"
@ -50,7 +48,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 and used on every recipient role when sending a verification envelope.</div>
<div class="setting-desc">Pre-filled in the Send Test dialog</div>
</div>
<div class="setting-control" style="min-width:240px">
<input type="email" class="form-input" id="set-recipient-email"
@ -105,40 +103,13 @@ 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 and account actions</div>
<div class="settings-section-sub">Current platform connection status (connect via top bar)</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>
@ -171,15 +142,6 @@ 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();
}
@ -193,64 +155,18 @@ 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${data.adobe_account_name ? `${escHtml(data.adobe_account_name)}` : ''}` : 'Not connected'}</span>
<span class="conn-info-value">${data.adobe ? 'Connected' : '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 ? (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-value">${data.docusign ? 'Connected' : 'Not connected'}</span>
<span class="conn-info-status">
<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 class="badge ${data.docusign ? 'badge-green' : 'badge-gray'}">${data.docusign ? '● 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>
@ -264,25 +180,5 @@ 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();
}
}

View File

@ -8,17 +8,9 @@ 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, field_issues, ... }]
templatesError: null, // Visible error state for template loading failures
templates: [], // [{ adobe_id, name, status, blockers, warnings, ... }]
selectedIds: new Set(),
lastMigrationResults: null, // final batch job results
issueCount: 0, // blocked template count (drives nav badge)
@ -46,10 +38,6 @@ export function setState(key, value) {
// Recompute derived values after template list updates
export function updateDerivedState() {
const issueCount = state.templates.filter(t =>
(t.blockers || []).length > 0 ||
(t.warnings || []).length > 0 ||
(t.field_issues || []).length > 0
).length;
setState('issueCount', issueCount);
const blocked = state.templates.filter(t => t.blockers && t.blockers.length > 0).length;
setState('issueCount', blocked);
}

View File

@ -4,7 +4,6 @@ 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 ────────────────────────────────────────────────────────
@ -12,18 +11,15 @@ 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 hasWarnings(t)
return t.warnings && t.warnings.length > 0
? { 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 (hasWarnings(t)) {
if (t.warnings && t.warnings.length > 0) {
return { key: 'caveats', label: 'Caveats', cls: 'badge-caveats' };
}
return { key: 'ready', label: 'Ready', cls: 'badge-ready' };
@ -34,20 +30,15 @@ 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();
}
}
@ -93,15 +84,6 @@ 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"
@ -149,7 +131,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.' : (state.templatesError ? 'The template load failed. Check the error message above.' : 'Connect Adobe Sign to load templates.')}</div>
<div class="empty-state-sub">${state.templates.length ? 'Try clearing the search or filter.' : 'Connect Adobe Sign to load templates.'}</div>
</div>
</td></tr>`
}
@ -183,13 +165,10 @@ function _templateRow(t) {
const selected = state.selectedIds.has(t.adobe_id);
const warnCount = (t.warnings || []).length;
const blockCount = (t.blockers || []).length;
const fieldIssueCount = (t.field_issues || []).length;
const issueClass = blockCount > 0 ? 'blocked' : (warnCount > 0 || fieldIssueCount > 0 ? 'has-issues' : 'no-issues');
const issueClass = blockCount > 0 ? 'blocked' : (warnCount > 0 ? 'has-issues' : 'no-issues');
const issueLabel = blockCount > 0
? `🚫 ${blockCount} blocker${blockCount > 1 ? 's' : ''}`
: (warnCount > 0 || fieldIssueCount > 0
? `${warnCount + fieldIssueCount} caveat${warnCount + fieldIssueCount > 1 ? 's' : ''}`
: '✓ Clean');
: (warnCount > 0 ? `${warnCount} warning${warnCount > 1 ? 's' : ''}` : '✓ Clean');
return `
<tr class="${selected ? 'row-selected' : ''}" data-id="${escHtml(t.adobe_id)}">
@ -223,7 +202,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 => !hasBlockers(t) && (hasWarnings(t) || hasFieldIssues(t))).length,
caveats: templates.filter(t => (!t.blockers || !t.blockers.length) && t.warnings && t.warnings.length > 0).length,
};
}
@ -239,9 +218,9 @@ function _applyFilter(templates) {
// Status / readiness filter
if (_filter.status !== 'all') {
if (_filter.status === 'blocked') {
list = list.filter(t => hasBlockers(t));
list = list.filter(t => t.blockers && t.blockers.length > 0);
} else if (_filter.status === 'caveats') {
list = list.filter(t => !hasBlockers(t) && (hasWarnings(t) || hasFieldIssues(t)));
list = list.filter(t => (!t.blockers || !t.blockers.length) && t.warnings && t.warnings.length > 0);
} else {
list = list.filter(t => t.status === _filter.status);
}
@ -252,7 +231,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 = totalIssueCount(a); vb = totalIssueCount(b); }
if (_sort.col === 'warnings') { va = (a.blockers||[]).length + (a.warnings||[]).length; vb = (b.blockers||[]).length + (b.warnings||[]).length; }
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));
});
@ -263,8 +242,6 @@ function _applyFilter(templates) {
// ── Event wiring ───────────────────────────────────────────────────────────
function _bindEvents() {
bindQuickStartCard(document);
// Search
const searchEl = document.getElementById('template-search');
if (searchEl) {
@ -362,7 +339,6 @@ export async function renderTemplateDetail(adobeId) {
}
const r = readiness(t);
const issueCount = totalIssueCount(t);
outlet.innerHTML = `
<div class="page-header">
<div>
@ -378,8 +354,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 ${issueCount > 0
? `<span class="nav-badge" style="position:static;display:inline">${issueCount}</span>` : ''}</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="history">Migration History</div>
</div>
@ -433,15 +409,10 @@ function _renderDetailTab(t, tabKey) {
} else if (tabKey === 'issues') {
const blockers = t.blockers || [];
const warnings = t.warnings || [];
const fieldIssues = t.field_issues || [];
if (!blockers.length && !warnings.length && !fieldIssues.length) {
if (!blockers.length && !warnings.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>
@ -453,13 +424,6 @@ 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>
@ -471,7 +435,6 @@ function _renderDetailTab(t, tabKey) {
</div>`).join('')}
</div>
</div>` : ''}`;
bindFieldIssueToggles(content);
}
} else if (tabKey === 'history') {
api.migrate.history().then(data => {
@ -537,19 +500,3 @@ 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;
}

View File

@ -154,10 +154,6 @@ 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"