Compare commits

...

25 Commits

Author SHA1 Message Date
paulh 683a64158e Merge pull request 'Enterprise UI redesign — Phases 14–22 (Docusign-branded migration console)' (#1) from ui-redesign into master
Reviewed-on: https://paje.ca/git/paulh/adobe-to-docusign-migrator/pulls/1
2026-04-21 15:30:43 -05:00
Paul Huliganga 15c50f05e3 docs: update README, field-mapping, and EXECUTION-BOARD for Phase 23 and post-redesign fixes
Reflects all implementation from this session: 7 post-redesign bug fixes
(routing, polling, branding, verification role names, conditional parent 400s,
failure UX, template detail history expand), and Phase 23 structured field
issue reporting end-to-end. Test count updated to 119/119.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 15:34:06 -04:00
Paul Huliganga b2bbcac842 feat(issues): structured field-issue reporting throughout migration pipeline
Replaces flat warning strings with machine-readable FieldIssue objects
(code, field_name, message, severity) emitted during compose and surfaced
in all migration result paths via a new field_issues[] key.

Codes: CROSS_RECIPIENT_CONDITIONAL, UNSUPPORTED_OPERATOR, HIDE_ACTION,
MULTI_PREDICATE, INVALID_PARENT_TAB, FIELD_TYPE_SKIPPED, PARTIAL_FIELD_TYPE

Cross-recipient conditional detection: compose now builds a field→assignee
map and flags conditions where the trigger field belongs to a different
recipient — the main cause of the CONDITIONALTAB_HAS_INVALID_PARENT 400.

UI changes:
- Success rows with field_issues show ⚠️ icon + amber "partial" badge
- Results, History & Audit, and Template Detail history tab all show
  field issues grouped by code in collapsible sections within expanded rows

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 15:25:23 -04:00
Paul Huliganga 53eb206d89 fix(compose): strip invalid conditionalParentLabel refs before upload
DocuSign returns CONDITIONALTAB_HAS_INVALID_PARENT when a conditional tab
references a parent that doesn't exist or is a forbidden type (signature,
initial, auto-filled). Added _strip_invalid_conditionals() post-processing
pass that validates all conditionalParentLabel values against the actual
built tabs and removes any that won't pass DocuSign validation, logging a
warning for each. Also updated verify tests for the template role-fetch step.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 15:09:11 -04:00
Paul Huliganga 4f9cb43ac8 feat(templates): expandable failure details in detail history tab
Rows with errors, blockers, or warnings now show a '▶ click for details'
hint and expand inline on click, matching the behaviour in History & Audit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 15:03:18 -04:00
Paul Huliganga 1c5b131f19 feat(migration): show error detail under failed rows and summary hint
Failed/blocked rows now show the error message or first blocker in small
red text below the template name. On completion, if any templates failed
a count + "select View Results for details" hint appears above the footer.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 14:53:50 -04:00
Paul Huliganga 5eee7e0ab4 fix(verify): poll every 30s, timeout at 5 min, show Timed Out badge
DocuSign rate-limit guidance discourages polling more than once per 15 min
in production. For this manual quick-test flow, 30s intervals with a 5-min
ceiling is a reasonable middle ground. Production should migrate to DS Connect
(webhooks). Timed-out envelopes show an amber badge with Send Again action.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 14:51:37 -04:00
Paul Huliganga 374d1d10d0 fix(verify): look up actual template role names before sending envelope
Hardcoded "Signer" roleName caused envelopes to send without tags. Now
fetches template recipients first and assigns test recipient to every role,
falling back to "Signer" only if the template fetch fails.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 14:38:40 -04:00
Paul Huliganga 5cf415d38a fix(migration): stop polling when job status is 'completed'
Backend returns "completed" but pollJob only checked "done"/"complete",
causing infinite polling and oscillating UI state.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 14:26:28 -04:00
Paul Huliganga e521fd8e58 fix(router): correct parseHash slice indices for detail routes
parts.slice(0,3) was returning the full path as base; should be slice(0,2)
so '#/templates/abc123' yields base='#/templates', param='abc123'.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 14:21:50 -04:00
Paul Huliganga 6f684f330f fix(ui): rebrand DocuSign → Docusign throughout frontend (2024 brand)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 14:19:15 -04:00
Paul Huliganga e9f21b6c6d fix(ui): correct HTTP method for auth connect/disconnect endpoints
GET /api/auth/{adobe,docusign}/connect and /disconnect — not POST.
api.js was calling them with POST, causing 405 Method Not Allowed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 13:59:16 -04:00
Paul Huliganga 0dcf7193e0 feat(ui-phase-22): smoke test checklist, README update, execution board
118/118 backend tests passing (108 original + 7 verify + 3 templates).
UI-SMOKE-TEST.md: 11-section manual checklist covering project switcher,
auth, templates, dry run, real migration, issues view, verification,
history, and settings.
README: new Web UI section with navigation table, 7-step workflow guide,
and project/customer context explanation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 11:43:44 -04:00
Paul Huliganga 17e478e996 feat(ui-phase-21): settings view — verification defaults, migration defaults
Three sections: Verification (test recipient name/email, auto-void timer),
Migration Defaults (overwrite toggle, include documents toggle), Connections
(read-only auth status + account IDs from /api/auth/status). Save writes to
localStorage key 'migrator_settings'. Values pre-read by migration options
modal and verification send dialog.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 11:41:45 -04:00
Paul Huliganga 5bf2cc756a feat(ui-phase-20): history & audit view — filters, pagination, CSV export
Filterable by template name, status (success/error/dry_run/skipped), and
date range. Sortable by all columns. Expandable rows show blockers/warnings.
Checksum displayed as first 8 chars with full hash on hover tooltip.
Client-side CSV export. 50 records per page with prev/next pagination.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 11:41:05 -04:00
Paul Huliganga 11b646d3b7 feat(ui-phase-19): verification — send/status/void API + frontend polling
Backend (web/routers/verify.py): POST /send (creates envelope from template),
GET /status/{id} (polls envelope state), POST /void/{id} (voids test envelope).
Registered in app.py. 7 tests passing.

Frontend (verification.js): table of migrated templates, Send Test button opens
dialog with pre-filled name/email from settings, polling every 5s, per-row
status updates (Sent → Delivered → Verified), Void button for cleanup.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 11:40:19 -04:00
Paul Huliganga 329edc39d2 feat(ui-phase-18): issues & warnings view with nav badge
Dedicated view surfacing all templates with blockers (migration will fail)
and warnings (migration with caveats). Each blocker item shows all error
messages; each warning item has a Migrate Anyway button and View Detail link.
Nav badge count driven by state.issueCount (updated when templates load).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 11:38:33 -04:00
Paul Huliganga 587104d520 feat(ui-phase-17): migration workflow — options modal, progress polling, results view
Options modal: dry_run, overwrite_if_exists, include_documents toggles, target
folder input. Launches POST /api/migrate/batch and polls GET /api/migrate/batch/{id}
every 2s with per-template status icons. Results view: 5-stat summary grid,
expandable per-template result rows, CSV export, Verify Templates button.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 11:38:04 -04:00
Paul Huliganga 023c3928f3 feat(ui-phase-16): templates view — readiness badges, filter bar, detail tabs
Backend: add blockers[] and warnings[] to GET /api/templates/status. Calls
validate_template() on downloaded templates; returns empty lists if not
downloaded. 3 new tests (10 total, all passing).

Frontend (templates.js): filterable/sortable table with readiness badges
(Blocked/Caveats/Ready/Migrated/Needs Update), bulk-select toolbar,
per-row migrate/detail buttons, and template detail view with 3 tabs
(Overview, Issues, Migration History).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 11:26:49 -04:00
Paul Huliganga 85f82eaabf feat(ui-phase-15): project switcher — localStorage CRUD, first-run modal
Projects stored in localStorage (key: migrator_projects). CRUD: create,
list, setActive, delete. Switcher modal opens automatically on first run
when no projects exist. Active project name displayed in nav footer and
project button. Deleting a project requires confirmation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 11:24:40 -04:00
Paul Huliganga 516af313a1 feat(ui-phase-14): app shell — Docusign nav, router, state, brand tokens
Replace monolithic app.js/style.css with a modular CSS+JS architecture:

CSS: tokens.css (Docusign 2024 brand tokens), base.css (reset, typography,
buttons, badges, cards, toggles), nav.css (Inkwell sidebar, topbar, auth
chips), cards.css (readiness badges, filter bar, bulk toolbar, issue/result
rows), modals.css (modal shell, options panel, project switcher), tables.css
(sortable headers, pagination, checksum display), forms.css (inputs, setting
rows, connection info).

JS: utils.js (escHtml, formatDate, downloadCsv, uuid), state.js (global
reactive state with pub/sub), api.js (fetch wrappers for all endpoints),
router.js (hash-based SPA router), auth.js (connect/disconnect chips,
Adobe OAuth dialog, toast notifications), app.js (entry point — wires
router, auth, nav badges, project display).

index.html: full app shell with official docusign SVG logo, 7 nav links,
top bar with auth chips, router outlet.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 11:24:06 -04:00
Paul Huliganga 89382537b1 docs(ui-redesign): implementation plan for Phases 14–22 + execution board update
Full UI redesign plan covering 9 phases: app shell, project/customer context,
templates view with readiness badges, migration workflow, issues view,
verification (with new verify API), history/audit, settings, and smoke test
checklist. Only backend additions are Phase 16 (blockers/warnings in status
endpoint) and Phase 19 (verify router). All other phases are frontend-only.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 10:37:46 -04:00
Paul Huliganga aa5ab4b653 fix(ui-mockup): replace approximated logo with official Docusign SVG
Embedded the official Docusign full-color vector logo sourced from
Wikimedia Commons (Docusign_Full_Color.svg, viewBox 0 0 1200 241.4).
Wordmark paths changed to fill="#FFFFFF" for visibility on the Inkwell
(#130032) nav bar; Nexus icon retains brand colors (Cobalt #4C00FF,
Poppy #FF5252, white document shape). Brand name corrected to "docusign"
(capital D, lowercase s per 2024 rebrand).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 10:14:59 -04:00
Paul Huliganga 64b33357cf feat(ui-mockup): interactive migration console mockup with Docusign 2024 brand
Full multi-screen interactive HTML mockup for the Adobe Sign → Docusign
migration tool UI redesign. Built from scratch based on UX analysis of
the migration workflow.

Screens:
- Dashboard: migration progress stats, attention-required blockers,
  recent activity, overall progress bar
- Templates: paginated table (500+ scale), readiness badges
  (Ready/Caveats/Blocked/Migrated), multi-select bulk operations,
  filter tabs by status
- Template Detail: 4-tab view — Overview (recipients, DS target),
  Field Mapping (per-field Adobe→DS tab mapping with ✓/⚠/✕),
  Issues & Caveats (per-issue severity + manual fix guidance),
  Verification (post-migration test envelope results)
- Migration Results: per-template expandable result cards with
  warnings listed explicitly, blocked templates with fix guidance
- Issues & Warnings: aggregated view across all templates
- Verification: test envelope queue with configurable test recipient
- History & Audit: full migration run log with export
- Settings: platform connections, verification config (test
  recipient name/email, auto-void timer, API-only vs full envelope),
  Migration Project context

Key UX decisions documented:
- Readiness badge surfaces problem templates BEFORE migration attempt
- Workflow follows: Connect → Discover → Analyze → Execute → Verify
- Logs demoted to History; Issues promoted to primary nav
- Migration Project model for multi-customer support (project switcher
  modal with customer switcher — per-session but project-persistent)
- Unsupported features (conditional HIDE, calculated fields, JS
  validators) shown per-template with actionable fix guidance

Docusign 2024 brand applied:
- Wordmark: "docusign" all lowercase (April 2024 rebrand)
- Nexus icon: two converging lobe shapes (Mist + Cobalt) forming
  the inkwell / "shape of an agreement"
- Inkwell #130032 nav, Cobalt #4C00FF CTAs, Ecru #F8F3F0 background,
  Poppy #FF5252 errors, Mist #CBC2FF highlights
- Source: brand.docusign.com official guidelines

Also adds the original Blueprint .docx for reference.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 10:02:30 -04:00
Paul Huliganga 342e8c3471 feat(phases-8-13): blueprint alignment — normalized schema, validation, migration options, retry, security, batch
Phase 8: Normalized intermediate schema (src/models/normalized_template.py, src/services/mapping_service.py)
  - Platform-agnostic NormalizedTemplate as pipeline bridge
  - PDF SHA-256 checksums computed during normalization
  - 13 tests passing

Phase 9: Validation service + report builder (src/services/validation_service.py, src/reports/report_builder.py)
  - Blockers (no recipients, no documents) halt migration
  - Warnings (no fields, unassigned roles, unsupported features) logged
  - Structured MigrationReport with per-template status
  - 20 tests passing

Phase 10: Migration options API (web/routers/migrate.py)
  - source_template_ids, dry_run, overwrite_if_exists, include_documents options
  - Backward compatible with legacy adobe_template_ids field
  - 7 tests passing

Phase 11: Retry with exponential backoff (src/utils/retry.py)
  - retry_with_backoff and async_retry_with_backoff decorators
  - 429/5xx detection via check_response_retryable
  - 14 tests passing

Phase 12: Security hardening (src/utils/log_sanitizer.py)
  - SanitizingFilter redacts tokens, JWTs, base64 PDF content from logs
  - 15 tests passing

Phase 13: Batch migration API
  - POST /api/migrate/batch — async background job with job_id
  - GET /api/migrate/batch/{job_id} — poll progress and results
  - 6 tests passing

Full suite: 108/108 tests passing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 02:19:38 -04:00
58 changed files with 9444 additions and 714 deletions

View File

@ -1,9 +1,9 @@
# Initial Product Spec (Draft)
# Product Specification
## Project: Adobe Sign to DocuSign Template Migrator
### Purpose
Develop an agent/toolkit that can programmatically extract template data and field logic from Adobe Sign (“library documents”), map/transform into DocuSigns template model, and create new DocuSign templates to reduce manual migration effort.
Develop an agent/toolkit that can programmatically extract template data and field logic from Adobe Sign ("library documents"), map/transform into DocuSign's template model, and create new DocuSign templates to reduce manual migration effort.
---
@ -12,28 +12,249 @@ Develop an agent/toolkit that can programmatically extract template data and fie
- Generate best-approximation DocuSign templates programmatically
- Handle all basic field types and recipient roles
- Detect and warn on features needing special/manual handling (complex logic, custom validations, non-mappable features)
### Key Features (MVP)
- Connect to Adobe Sign and DocuSign APIs via credentials loaded from .env
- Extract template listing from Adobe Sign sandbox/account
- Pull all required endpoints: metadata, formFields, recipients, workflows
- Assemble complete data model for each imported template
- Mapping layer: field type/role/routing normalization (see field-mapping.md)
- Programmatically create equivalent template and roles in DocuSign
- Logging and reporting of success, errors, edge cases
### Stretch (Future)
- UI for side-by-side compare/QA
- Complex feature transform plugins
- Bulk mode & idempotent re-runs
- Support for in-place PDF field overlay (anchors/rects)
- Produce a structured migration report with successes, warnings, and manual-fix items
---
#### Out of Scope (MVP)
- Agreement instance migration (focus on templates only)
### Architecture
#### Components
- **Adobe Sign Client** (`src/adobe_api.py`) — authenticated API calls, template listing/download
- **DocuSign Client** (`src/upload_docusign_template.py`, `src/docusign_auth.py`) — JWT auth, template upsert
- **Normalized Schema Model** (`src/models/normalized_template.py`) — platform-agnostic intermediate representation
- **Mapping Service** (`src/services/mapping_service.py`) — field type, recipient role, coordinate translation
- **Validation Service** (`src/services/validation_service.py`) — field count comparison, recipient checks, missing role detection
- **Migration Service** (`src/services/migration_service.py`) — orchestrates download → normalize → validate → compose → upload
- **Report Builder** (`src/reports/report_builder.py`) — structured success/warning/error output
- **Web API** (`web/`) — FastAPI endpoints for browser-based orchestration
- **Frontend** (`web/static/`) — side-by-side template browser, migration UI
#### Service Separation
```
src/
models/
normalized_template.py # intermediate schema
services/
migration_service.py # pipeline orchestration
mapping_service.py # field/role/coord transformations
validation_service.py # pre/post migration checks
reports/
report_builder.py # structured report output
utils/
pdf_coords.py # coordinate normalization helpers
```
---
### High-Level Migration Flow
1. Authenticate to both Adobe Sign and DocuSign (OAuth)
2. List and select Adobe Sign templates
3. Extract: metadata, formFields, recipients, documents, workflows
4. **Normalize** into platform-agnostic intermediate schema
5. **Validate** normalized schema — blockers stop migration; warnings are logged
6. Map to DocuSign template payload
7. Upsert (create or update) in DocuSign
8. Generate migration report
---
### Internal Normalized Schema
Use an intermediate model so the tool is not tightly coupled to either platform. This enables future support for additional eSign platforms.
#### Schema Structure
```json
{
"template": {
"name": "Sales Agreement",
"description": "Migrated from Adobe Sign",
"emailSubject": "Please sign: Sales Agreement",
"emailMessage": "",
"documents": [],
"roles": [
{ "name": "Customer", "order": 1, "actionType": "SIGN" },
{ "name": "Company", "order": 2, "actionType": "SIGN" }
],
"fields": [
{
"type": "signature",
"page": 1,
"x": 120, "y": 540,
"width": 140, "height": 28,
"required": true,
"roleName": "Customer"
}
],
"reminderEnabled": false,
"expirationDays": null
}
}
```
---
### Core Entities to Migrate
| Entity | Adobe Sign Source | DocuSign Target |
|-------------------|-----------------------------|-----------------------------|
| Template name | `name` | `name` |
| Description | `message` | `description` |
| Documents (PDFs) | `libraryDocumentId` → bytes | `documents[]` |
| Recipient roles | `participantSetsInfo` | `recipients.signers[]` |
| Routing order | `participantSetsInfo.order` | `routingOrder` |
| Form fields | `formFields` | `tabs` per recipient |
| Email subject | `emailSubject` | `emailSubject` |
| Reminders | `reminderFrequency` | `reminders` |
| Expiration | `daysUntilSigningDeadline` | `expirationDateTime` |
---
### Mapping Logic
#### 1. Recipient and Role Mapping
- Map Adobe Sign participant sets → DocuSign template roles
- Preserve routing order
- Map action types: SIGN → signer, APPROVE → approver, CC → carbonCopy
#### 2. Field Type Mapping
```json
{
"SIGNATURE": "signHere",
"INITIALS": "initialHere",
"TEXT": "text",
"CHECKBOX": "checkbox",
"RADIO": "radioGroup",
"DROPDOWN": "list",
"DATE": "dateSigned",
"ATTACHMENT": "signerAttachment"
}
```
(Full mapping table: see `field-mapping.md`)
#### 3. Coordinate Mapping
- Normalize to PDF points
- Account for page rotation
- Transform coordinate origin if needed
- Validate field overlap after placement
#### 4. DocuSign Payload Fields
The tool must populate:
- Template name and description
- Email subject and message defaults
- Envelope/template documents (with document checksums)
- Template roles with routing order
- Tabs grouped by recipient
- Reminder and expiration settings where supported
---
### Unsupported / Flagged Features (Manual Review Required)
- Conditional recipient routing rules
- Advanced workflow branching
- Calculated fields
- Custom JavaScript validators
- Niche authentication methods (e.g., KBA, phone auth)
- Field validations with no direct DocuSign equivalent
- Webhook / event associations tied to template lifecycle
---
### Migration Options (API)
`POST /api/migrate` accepts:
```json
{
"sourceTemplateIds": ["tpl_1001", "tpl_1002"],
"targetFolder": "Migrated Templates",
"options": {
"overwriteIfExists": false,
"dryRun": true,
"includeDocuments": true
}
}
```
- **dryRun** — validate and report without creating DocuSign templates
- **overwriteIfExists** — when `false`, skip templates already migrated (default: false)
- **includeDocuments** — embed PDFs in DocuSign template (default: true)
- **targetFolder** — DocuSign folder for created templates
---
### Validation Layer
Pre-migration checks (blockers and warnings):
- Field count before vs. after mapping
- Recipient count and routing order integrity
- Fields missing role assignments
- Unsupported feature detection
- Document checksum comparison (before upload vs. after download confirmation)
Post-migration checks:
- DocuSign template field count vs. normalized schema count
- Recipient role count match
- Migration report includes pass/warn/fail per template
---
### Implementation Considerations
#### Authentication
- OAuth for both Adobe Sign and DocuSign (with token auto-refresh)
- Support admin-consent flows where required
- Securely store tokens (never in logs or plaintext files)
#### Rate Limits
- Batch API requests carefully
- Retries with exponential backoff on 429/5xx
- Use idempotency (upsert pattern) where possible
#### File Handling
- Preserve original PDFs locally in `downloads/`
- Checksum documents before and after upload
- Keep document-page metadata for accurate tab placement
#### Security
- Redact secrets and tokens from all log output
- Encrypt token storage where possible
- Maintain audit trail for all migration operations (template ID, timestamp, status, user)
---
### MVP Feature Set (Phase 1)
- Authenticate to both systems (CLI + Web)
- List and select Adobe Sign templates
- Migrate basic templates (standard roles + common fields)
- Normalized intermediate schema as pipeline bridge
- Validation layer (field/recipient counts, missing roles)
- Migration report (success / warning / error per template)
- Dry-run mode
- Idempotent re-runs (overwrite prevention)
### Phase 2 Features
- Batch migration (multiple templates in one request)
- Retry failed templates
- Coordinate validation preview
- Duplicate detection
- Folder / category mapping
- Audit logging
- Rate limit handling with backoff
### Phase 3 Features
- UI preview for field placements
- Manual correction workflow
- Side-by-side template comparison (visual diff)
- Webhook recreation
- Advanced workflow translation
---
### Out of Scope (MVP)
- Agreement instance migration (templates only)
- Custom integrations outside API surface
- Real-time collaborative editing
---
*Last updated: 2026-04-14 (scaffolded by Cleo)*
*Updated: 2026-04-21 (Blueprint alignment — added normalized schema, validation layer, migration options, security/rate-limit requirements, Phase 2/3 feature set, architecture detail)*

184
README.md
View File

@ -102,7 +102,8 @@ If multiple templates share the same name, the most recently modified one is use
## Web UI
The web UI provides a browser-based interface for connecting both platforms, browsing templates side-by-side, and running migrations with live status feedback.
The web UI is an enterprise-grade migration console with a Docusign-branded left-nav
shell, multi-customer project context, and a full migration workflow.
**Additional `.env` keys required for the web UI:**
```
@ -118,15 +119,37 @@ uvicorn web.app:app --reload --port 8000
```
Then open [http://localhost:8000](http://localhost:8000) in your browser.
**Using the UI:**
1. Click **Connect Adobe Sign** in the header — you'll be redirected to Adobe Sign OAuth. Authorize and you'll return to the app.
2. Click **Connect DocuSign** — same flow for DocuSign.
3. Your Adobe Sign templates appear on the left with status badges:
- **Not Migrated** (red) — no matching DocuSign template yet
- **Migrated** (green) — a DocuSign template with the same name exists and is up to date
- **Needs Update** (yellow) — the Adobe template was modified after the last migration
4. Check one or more templates and click **Migrate Selected**.
5. Migration results appear inline; the history table at the bottom logs all past runs.
### Navigation
| Screen | Path | Purpose |
|---|---|---|
| Templates | `#/templates` | Filterable table with readiness badges; bulk migration |
| Migration Results | `#/results` | Summary + per-template results from last migration |
| Issues & Warnings | `#/issues` | All templates with blockers or warnings |
| Verification | `#/verify` | Send test envelopes; confirm templates work end-to-end |
| History & Audit | `#/history` | Full migration history, filters, CSV export |
| Settings | `#/settings` | Verification defaults, migration defaults, connection info |
### Workflow
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.
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
- **Blocked** (red) — blockers found; migration will fail until resolved
- **Migrated** (cobalt) — successfully migrated and up to date
- **Needs Update** (amber) — Adobe template modified after last migration
4. **Resolve issues** — check Issues & Warnings before migrating blocked templates.
5. **Migrate** — select templates, click Migrate Selected, configure options (dry run, overwrite, target folder), monitor progress. Failed rows show the error inline; a summary hint appears if any templates fail.
6. **Review field issues** — successfully migrated templates may show an amber **partial** badge if features were dropped during migration (e.g. cross-recipient conditionals, unsupported operators). Expand any result row to see grouped field-issue details.
7. **Verify** — on the Verification screen, send test envelopes to confirm templates work end-to-end. Polling checks every 30 seconds and times out after 5 minutes; production deployments should use Docusign Connect (webhooks) instead.
8. **Audit** — History & Audit logs every migration with checksums and export. Rows with field issues show the same grouped breakdown on expand.
### Project / customer context
The project switcher (nav footer) stores per-customer migration context in `localStorage`.
Create one project per customer to keep history and settings separate.
**API docs:** [http://localhost:8000/api/docs](http://localhost:8000/api/docs)
@ -135,7 +158,7 @@ Then open [http://localhost:8000](http://localhost:8000) in your browser.
## Running tests
```bash
pytest tests/ -v # full suite (29 tests)
pytest tests/ -v # full suite (119 tests)
pytest tests/test_regression.py -v # compose regression only
pytest tests/test_regression.py --update-snapshots # regenerate snapshots after intentional changes
```
@ -154,10 +177,99 @@ unexpected API behaviors, and the fixes applied.
---
## Migration API options
`POST /api/migrate` accepts extended options (blueprint-aligned):
```json
{
"source_template_ids": ["tpl_001", "tpl_002"],
"target_folder": "Migrated Templates",
"options": {
"dry_run": false,
"overwrite_if_exists": false,
"include_documents": true
}
}
```
| Option | Default | Description |
|---|---|---|
| `dry_run` | `false` | Validate and compose without creating DocuSign templates |
| `overwrite_if_exists` | `false` | If `false`, skip templates that already exist in DocuSign |
| `include_documents` | `true` | Embed PDFs in the DocuSign template |
**Batch migration** (`POST /api/migrate/batch`) runs the same pipeline for multiple templates as a background job:
```bash
# Start batch
curl -X POST /api/migrate/batch -d '{"source_template_ids": ["id1", "id2"]}'
# → {"job_id": "abc-123", "status": "queued"}
# Poll status
curl /api/migrate/batch/abc-123
# → {"status": "running", "progress": {"completed": 1, "total": 2}, ...}
```
---
## Normalized intermediate schema
The migration pipeline uses a platform-agnostic `NormalizedTemplate` model as a bridge between Adobe Sign and DocuSign. This decouples extraction from composition and enables the validation layer.
See `src/models/normalized_template.py` and `src/services/mapping_service.py`.
---
## Validation
Each template is validated before migration:
- **Blockers** (halt migration): no recipients, no documents
- **Warnings** (logged but continue): no signature fields, unassigned fields, unsupported features
Unsupported features flagged for manual review: conditional HIDE actions, JavaScript validators, calculated fields, webhook associations, niche authentication methods.
## Field issues (partial migration)
Beyond blockers and warnings, the compose step emits structured **field issues** when a field migrates successfully but something was silently dropped or approximated. Each issue has a machine-readable code:
| Code | Meaning |
|---|---|
| `CROSS_RECIPIENT_CONDITIONAL` | Show/hide condition references a field on a different recipient — DocuSign does not support cross-recipient conditional logic |
| `UNSUPPORTED_OPERATOR` | Condition uses NOT_EQUALS / GT / LT etc. — only EQUALS is supported |
| `HIDE_ACTION` | Adobe HIDE condition has no DocuSign equivalent — field will always be visible |
| `MULTI_PREDICATE` | AND/OR multi-condition logic reduced to first EQUALS predicate only |
| `INVALID_PARENT_TAB` | Conditional parent references a non-existent tab or a forbidden tab type (signature/auto-fill tabs cannot be parents) |
| `FIELD_TYPE_SKIPPED` | INLINE_IMAGE or PARTICIPATION_STAMP — no DocuSign equivalent, field dropped |
| `PARTIAL_FIELD_TYPE` | FILE_CHOOSER → signerAttachmentTabs, or STAMP → stampTabs — field migrated but behaviour differs |
Templates with field issues are marked with a **partial** badge in the UI. Field issues are stored in `field_issues[]` on every migration result record and displayed grouped by code in all result views.
---
## Security
- `src/utils/log_sanitizer.py` — install `SanitizingFilter` to redact tokens, keys, and base64 PDF content from all log output
- PDF checksums (SHA-256) are computed and stored with each migration record
- Tokens are never written to logs; see `src/utils/log_sanitizer.py`
---
## Project structure
```
src/
models/
normalized_template.py # Platform-agnostic intermediate schema
field_issue.py # Structured field-issue model + issue codes
services/
mapping_service.py # Adobe Sign → NormalizedTemplate converter
validation_service.py # Pre/post migration checks (blockers + warnings)
reports/
report_builder.py # Structured migration report per template
utils/
retry.py # Exponential backoff retry helpers
log_sanitizer.py # Secret redaction from logs
adobe_auth.py # One-time OAuth flow for Adobe Sign (CLI)
adobe_api.py # Adobe Sign API client (auto token refresh)
download_templates.py # List and download templates from Adobe Sign
@ -165,43 +277,63 @@ src/
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)
create_adobe_template.py # Utility: create a test template in Adobe Sign
generate_pdfs.py # Utility: generate sample PDFs for offline testing
web/
app.py # FastAPI entrypoint (uvicorn web.app:app)
config.py # Environment-based settings
session.py # Signed cookie session helpers
routers/
auth.py # Adobe Sign + DocuSign OAuth endpoints
auth.py # Adobe Sign + Docusign OAuth endpoints
templates.py # Template listing + migration status API
migrate.py # Migration trigger + history API
migrate.py # Migration trigger, batch, + history API
verify.py # Verification envelope send / status / void
static/
index.html # Web UI (side-by-side browser + migrate flow)
app.js # Vanilla JS frontend
style.css # Styles + status badge colours
index.html # SPA shell (left nav, router outlet, top bar)
css/
tokens.css # Docusign 2024 brand custom properties
base.css # Reset, typography, utility classes
nav.css # Left sidebar navigation
cards.css # Cards, badges, result rows, field-issue groups
modals.css # Modal dialogs, migration progress
tables.css # Sortable/filterable tables
forms.css # Form inputs, toggles
js/
app.js # Entry point — router, auth, nav badges
router.js # Hash-based SPA router (#/templates default)
state.js # Global state with pub/sub
api.js # Fetch wrappers for all backend endpoints
auth.js # Auth chips, OAuth flow, toast notifications
templates.js # Templates view + detail (overview/issues/history tabs)
migration.js # Migration modal, progress polling, results view
issues.js # Issues & Warnings view
verification.js # Verification view (send/poll/void envelopes)
history.js # History & Audit view
settings.js # Settings view
project.js # Project/customer context (localStorage)
utils.js # escHtml, formatDate, renderFieldIssues, etc.
tests/
test_normalized_schema.py # Normalized model + mapping service tests
test_validation_service.py # Validation service + report builder tests
test_migration_options.py # dryRun, overwriteIfExists, includeDocuments
test_batch_migration.py # Batch migration API tests
test_retry.py # Retry with backoff utility tests
test_security.py # Log sanitization + PDF checksum tests
test_upload_upsert.py # Upsert logic unit tests
test_api_health.py # Health endpoint
test_api_auth.py # OAuth endpoint tests
test_api_templates.py # Template listing + status tests
test_api_templates.py # Template listing + status tests (10 tests)
test_api_migrate.py # Migration API tests
test_api_verify.py # Verification envelope API tests (9 tests)
test_e2e.py # Full pipeline end-to-end test
test_regression.py # Compose output vs snapshots
fixtures/expected/ # Regression snapshots (3 real templates)
FIELD-TYPE-REGRESSION.md # Manual field type regression checklist
PLATFORM-QUIRKS.md # Known API bugs and workarounds
downloads/ # Downloaded Adobe Sign templates (gitignored)
migration-output/ # Converted DocuSign template JSONs + history
sample-templates/ # JSON fixtures for offline testing
field-mapping.md # Field type mapping table + edge case log
CLAUDE.md # Claude Code instructions for this project
docs/IMPLEMENTATION-PLAN.md # Feature design and test specifications
PRODUCT-SPEC.md # Full product specification (blueprint-aligned)
docs/agent-harness/
EXECUTION-BOARD.md # Living kanban board
AGENT-INSTRUCTIONS.md # Definition of done + conventions
requirements.txt # Python dependencies
```

573
docs/UI-REDESIGN-PLAN.md Normal file
View File

@ -0,0 +1,573 @@
# UI Redesign — Implementation Plan
*Branch: `ui-redesign` | Last updated: 2026-04-21*
---
## Overview
Replace the basic Phase 6 single-page app (`web/static/`) with the enterprise-grade
migration console designed in `docs/ui-mockup/mockup.html`.
The backend is complete (Phases 813, 108/108 tests passing). All new UI phases are
**frontend-only** unless noted. Existing FastAPI routes do not change except where
noted under Phase 16 (readiness data) and Phase 19 (Verification API).
### Design reference
Open `docs/ui-mockup/mockup.html` in a browser to see all 8 screens before starting.
### Docusign 2024 brand tokens
| Token | Value | Usage |
|---|---|---|
| Cobalt | `#4C00FF` | Primary CTA, active nav highlight |
| Inkwell | `#130032` | Left nav background |
| Ecru | `#F8F3F0` | Page background |
| Poppy | `#FF5252` | Error / Blocked badge |
| Slate | `#6B6B9A` | Secondary text, muted labels |
| White | `#FFFFFF` | Card surfaces |
Typography: `Inter` (Google Fonts), fallback `-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif`.
---
## Current state
`web/static/` — three files, ~600 lines total:
- `index.html` — 79 lines, single-page layout (header, two panels, history table)
- `app.js` — 343 lines, vanilla JS (auth, template list, migrate, history)
- `style.css` — 186 lines, basic styles, non-Docusign colours
---
## File structure after redesign
Keep no-build-step approach (vanilla JS ES modules, no bundler). Split monolith into
logical files served statically by FastAPI.
```
web/static/
index.html # app shell (nav, router outlet, modals)
css/
tokens.css # CSS custom properties (brand colours, spacing)
base.css # reset, typography, utility classes
nav.css # left sidebar nav + top bar
cards.css # template cards, readiness badges
modals.css # dialog / modal styles
tables.css # history and audit tables
forms.css # settings form inputs
js/
state.js # global app state (project, auth, templates)
router.js # hash-based client-side router
api.js # thin fetch wrappers for all backend endpoints
auth.js # auth status, connect/disconnect, Adobe dialog
project.js # project switcher modal, project CRUD (localStorage)
templates.js # template list view, readiness badges, filters
migration.js # options modal, progress polling, results view
verification.js # send test envelope, poll status
history.js # history & audit view
settings.js # settings screen
utils.js # escHtml, formatDate, debounce, etc.
```
`app.js` and `style.css` are **deleted** (replaced by the above).
`index.html` is **rewritten** as the app shell.
---
## Phase 14 — App Shell & Navigation
**Goal:** Branded shell that all other views live inside. No functional logic yet —
just the frame, router, and state container.
### index.html structure
```html
<body>
<nav id="app-nav"> <!-- left sidebar, 220px, Inkwell bg -->
<div id="nav-logo"></div> <!-- docusign SVG logo, white wordmark -->
<ul id="nav-links"></ul> <!-- 7 nav links with icons -->
<div id="nav-project"></div> <!-- project switcher footer -->
</nav>
<div id="app-body">
<header id="top-bar"></header> <!-- breadcrumb + auth chips -->
<main id="router-outlet"></main> <!-- views injected here -->
</div>
<!-- modal containers -->
<div id="modal-overlay" hidden></div>
</body>
```
### css/tokens.css
```css
:root {
--cobalt: #4C00FF;
--inkwell: #130032;
--ecru: #F8F3F0;
--poppy: #FF5252;
--slate: #6B6B9A;
--white: #FFFFFF;
--success: #28A745;
--warning: #F0A500;
--border: #E0DCF8;
--radius-sm: 4px;
--radius-md: 8px;
--shadow-sm: 0 1px 4px rgba(0,0,0,0.08);
--shadow-md: 0 4px 16px rgba(0,0,0,0.12);
}
```
### js/router.js
```js
const ROUTES = {
'#/dashboard': () => import('./templates.js').then(m => m.renderDashboard()),
'#/templates': () => import('./templates.js').then(m => m.renderTemplates()),
'#/results': () => import('./migration.js').then(m => m.renderResults()),
'#/issues': () => import('./issues.js').then(m => m.renderIssues()),
'#/verify': () => import('./verification.js').then(m => m.renderVerification()),
'#/history': () => import('./history.js').then(m => m.renderHistory()),
'#/settings': () => import('./settings.js').then(m => m.renderSettings()),
};
// Default route: #/templates
```
### js/state.js
```js
export const state = {
project: null, // { id, name } — loaded from localStorage
auth: { adobe: false, docusign: false },
templates: [], // array from /api/templates/status
selectedIds: new Set(),
lastMigrationResults: null, // results from most recent batch job
};
// Simple pub/sub: subscribe(key, fn) / publish(key)
```
### js/api.js — endpoint wrappers
All existing endpoints wrapped:
```js
export const api = {
auth: {
status: () => GET('/api/auth/status'),
connectAdobe: () => POST('/api/auth/adobe/connect'),
connectDocusign: () => POST('/api/auth/docusign/connect'),
exchangeAdobe: (url) => POST('/api/auth/adobe/exchange', { redirect_url: url }),
disconnect: (p) => POST(`/api/auth/${p}/disconnect`),
},
templates: {
status: () => GET('/api/templates/status'),
adobe: () => GET('/api/templates/adobe'),
docusign: () => GET('/api/templates/docusign'),
},
migrate: {
run: (body) => POST('/api/migrate', body),
batch: (body) => POST('/api/migrate/batch', body),
batchStatus: (id) => GET(`/api/migrate/batch/${id}`),
history: () => GET('/api/migrate/history'),
},
};
```
### js/utils.js
```js
export const escHtml = str => String(str).replace(/[&<>"]/g, c => …);
export const formatDate = iso => new Date(iso).toLocaleDateString(…);
export const formatRelative = iso => …;
export const debounce = (fn, ms) => { … };
export const uuid = () => crypto.randomUUID();
```
### Commit
`feat(ui-phase-14): app shell — nav, router, state, brand tokens`
---
## Phase 15 — Project / Customer Context
**Goal:** Project switcher so the same installation can manage migrations for
multiple customers without mixing history or credentials.
### Data model (localStorage only — no backend)
```js
// Stored in localStorage key: 'migrator_projects'
{
active: "uuid-1",
projects: [
{ id: "uuid-1", name: "Acme Corp", createdAt: "2026-04-21T…" },
{ id: "uuid-2", name: "Globex Inc", createdAt: "2026-04-22T…" },
]
}
```
Credentials remain in the server-side signed cookie session. Switching projects
triggers a fresh `/api/auth/status` check (session may still be valid if the user
didn't disconnect).
### js/project.js
```js
export function listProjects() { … } // returns projects array
export function createProject(name) { … } // generates uuid, saves, returns project
export function deleteProject(id) { … }
export function getActive() { … } // returns active project or null
export function setActive(id) { … } // updates localStorage + triggers nav refresh
```
### Project switcher modal
- Opened by clicking project name in nav footer
- Lists projects: name + creation date + "Activate" button
- "New Project" inline form (name field + Create button)
- Deleting a project requires confirmation ("Delete Acme Corp? This cannot be undone.")
- First run: modal opens automatically with welcome copy
### Nav footer display
Shows `▸ Acme Corp` (truncated to 18 chars). Clicking opens switcher modal.
No project → shows `▸ New Project` in amber.
### Commit
`feat(ui-phase-15): project switcher — localStorage CRUD, switcher modal`
---
## Phase 16 — Templates View with Readiness Badges
**Goal:** Replace the two-panel list with a filterable, sortable single table.
Each row shows a readiness badge computed from validation results.
### Readiness badge system
| Badge | Colour | Condition |
|---|---|---|
| Ready | `--success` green | `blockers=[]`, `warnings=[]` |
| Caveats | `--warning` amber | `blockers=[]`, `warnings.length > 0` |
| Blocked | `--poppy` red | `blockers.length > 0` |
| Migrated | `--cobalt` | `status=migrated` and no blockers |
| Needs Update | `--warning` amber | `status=needs_update` |
| Verified | green + ✓ | post-migration verification passed (Phase 19) |
### Backend update required: `web/routers/templates.py`
Add `blockers: list[str]` and `warnings: list[str]` to each template object in
`GET /api/templates/status`. Run `validate_template()` on the normalized form if the
template has been downloaded; otherwise return empty lists.
```python
# In templates.py status endpoint, for each adobe template:
normalized_dir = Path(settings.downloads_dir) / f"{template['name']}__{template['id']}"
if normalized_dir.exists():
normalized = adobe_folder_to_normalized(str(normalized_dir))
result = validate_template(normalized)
blockers = result.blockers
warnings = result.warnings
else:
blockers, warnings = [], []
```
Add 3 backend tests to `tests/test_api_templates.py`:
- `test_status_includes_blockers_and_warnings_fields`
- `test_status_blockers_populated_when_template_downloaded`
- `test_status_empty_when_not_downloaded`
### js/templates.js
```js
export function renderTemplates() {
// Fetches state.templates (or refreshes via api.templates.status())
// Renders filterable table into #router-outlet
// Columns: ☐ | Name | Readiness | Fields | Last Modified | DS Status | Actions
// Filter bar: search input + status dropdown + readiness dropdown
// Bulk toolbar (hidden until ≥1 selected): "Migrate X selected" button
}
export function renderTemplateDetail(adobeId) {
// 4-tab layout: Overview | Fields | Issues | Migration History
}
```
### Template detail view (`#/templates/:id`)
- **Overview tab:** name, description, roles, document count, last modified date
- **Fields tab:** table of fields — type, label, page, role, required, conditional
- **Issues tab:** blockers (red cards) + warnings (amber cards) from validation
- **Migration History tab:** records from `/api/migrate/history` filtered to this template
### Commit
`feat(ui-phase-16): templates view — readiness badges, filters, detail tabs, backend blockers/warnings`
---
## Phase 17 — Migration Workflow UI
**Goal:** Options modal → progress view → results view as a cohesive flow.
### Flow
```
Templates view → select ≥1 template → "Migrate Selected" button →
Options modal → "Run Migration" →
Progress view (replaces modal) →
Results view (#/results)
```
### js/migration.js
```js
export function showOptionsModal(selectedIds) {
// Renders modal with:
// - Dry run toggle (default: off)
// - Overwrite existing toggle (default: off, from settings)
// - Include documents toggle (default: on, from settings)
// - Target folder text input (optional)
// - Selected count display
// - "Run Migration" button
}
export async function runMigration(ids, options) {
// Calls POST /api/migrate/batch
// Returns job_id
}
export async function pollJob(jobId, onProgress, onComplete) {
// Polls GET /api/migrate/batch/{jobId} every 2s
// Calls onProgress({ completed, total, results })
// Calls onComplete(finalResults) when status === 'done'
}
export function renderResults(jobResults) {
// Navigates to #/results and renders:
// - Summary row: X Created | Y Updated | Z Skipped | W Blocked | V Errors
// - Per-template result table
// - "Verify Templates" button (pre-loads migrated IDs)
// - "Back to Templates" button
// - "Export CSV" button (client-side Blob download)
}
```
### Progress view (inline, inside modal)
After "Run Migration" is clicked:
- Modal content replaces with: progress bar + per-template status list
- Each template row: name → ⏳ spinning → ✅ success or ❌ error
- "View Results" button appears when job status === 'done'
### Commit
`feat(ui-phase-17): migration workflow — options modal, progress polling, results view`
---
## Phase 18 — Issues & Warnings View
**Goal:** A dedicated screen to review all validation problems before migrating.
### js/issues.js
```js
export function renderIssues() {
// Reads state.templates (already has blockers/warnings from Phase 16)
// Renders two sections:
// BLOCKERS — templates that will fail migration
// WARNINGS — templates that will migrate with caveats
// Each item: template name | issue message | suggested action link
// "Migrate Anyway" button on warning items → showOptionsModal([id])
// "View Template" link → #/templates/:id
}
```
### Nav badge
Left nav Issues link shows a red badge with count of blocked templates.
Updates whenever `state.templates` changes.
### Commit
`feat(ui-phase-18): issues view — blocked and warning templates, nav badge`
---
## Phase 19 — Verification View
**Goal:** Send test envelopes to confirm migrated templates work end-to-end.
### New backend: `web/routers/verify.py`
```python
POST /api/verify/send
body: { template_id: str, recipient_name: str, recipient_email: str }
action: GET /v2.1/accounts/{id}/envelopes (create via template)
returns: { envelope_id: str }
GET /api/verify/status/{envelope_id}
action: GET /v2.1/accounts/{id}/envelopes/{envelopeId}
returns: { status: str, completed_at: str | null }
POST /api/verify/void/{envelope_id}
body: { reason: str }
action: PUT envelope status to "voided"
returns: { voided: true }
```
Register router in `web/app.py`: `app.include_router(verify_router, prefix="/api/verify")`.
### tests/test_api_verify.py
Four tests (all mock DocuSign calls with respx):
- `test_send_requires_auth`
- `test_send_returns_envelope_id`
- `test_status_returns_envelope_state`
- `test_void_calls_docusign`
### js/verification.js
```js
export function renderVerification(preloadedTemplateIds = []) {
// Shows list of migrated templates (from history or passed-in IDs)
// Per-template row:
// - Template name + DS template ID
// - "Send Test Envelope" button → opens send dialog
// - Status chip (Not Tested | Sent | Delivered | Completed = Verified | Voided)
// Send dialog: recipient name + email (pre-filled from settings), "Send" button
// After send: row updates with status, "Void" button, polling every 5s
}
```
### Commit
`feat(ui-phase-19): verification view + verify API endpoints (send/status/void)`
---
## Phase 20 — History & Audit View
**Goal:** Filterable, exportable migration history.
### js/history.js
```js
export function renderHistory() {
// Calls GET /api/migrate/history
// Renders:
// - Filter bar: date range, template name search, status filter
// - Table: timestamp | template | action | status | DS ID | warnings | checksum
// - Expandable row: full blockers/warnings list, field count diff
// - "Export CSV" button (client-side)
}
```
SHA-256 checksum: first 8 chars displayed, full value in title attribute (tooltip).
### Commit
`feat(ui-phase-20): history & audit view — filters, export, checksum display`
---
## Phase 21 — Settings View
**Goal:** Central config screen for verification defaults and migration defaults.
### Settings (localStorage key: `migrator_settings`)
| Key | Default | UI control |
|---|---|---|
| `testRecipientName` | `""` | Text input |
| `testRecipientEmail` | `""` | Email input |
| `autoVoidHours` | `24` | Number input |
| `defaultOverwrite` | `false` | Toggle |
| `defaultIncludeDocs` | `true` | Toggle |
### js/settings.js
```js
export function renderSettings() {
// 3 sections:
// 1. Verification defaults (name, email, auto-void timer)
// 2. Migration defaults (overwrite, include documents)
// 3. Connection info (read-only: connected accounts, base URLs)
// Save button writes to localStorage
// Values pre-loaded into options modal (Phase 17) and send dialog (Phase 19)
}
```
### Commit
`feat(ui-phase-21): settings view — verification defaults, migration defaults`
---
## Phase 22 — Smoke Test Checklist & Cleanup
**Goal:** Validate the full redesigned UI works end-to-end, update docs.
### tests/UI-SMOKE-TEST.md
Manual checklist:
- [ ] First run: project switcher opens automatically
- [ ] Create project "Test Customer", verify it appears in nav footer
- [ ] Connect Adobe Sign via `.env` path → badge turns green
- [ ] Connect DocuSign via JWT path → badge turns green
- [ ] Templates view loads ≥1 template with correct readiness badge
- [ ] Select 2 templates → options modal opens → dry run → results show `dry_run` status
- [ ] Select 2 templates → real migration → progress bar counts up → results view
- [ ] Navigate to Verification → Send Test → status updates to Completed
- [ ] History view shows all migrations with correct counts and checksums
- [ ] Issues view shows blocked templates (use a fixture template with no recipients)
- [ ] Settings: save test recipient → reopen Settings → values persist
### Final tasks
- Run `pytest tests/ -v` — confirm all tests still pass (≥108 + new verify tests)
- Update `README.md` — new UI navigation guide section
- Update `docs/agent-harness/EXECUTION-BOARD.md` — Phases 1422 complete
- Push `ui-redesign` branch to Gitea
- Open PR to `master`
### Commit
`feat(ui-phase-22): smoke test checklist, README update, final cleanup`
---
## Dependency order
```
Phase 14 (Shell)
└── Phase 15 (Project)
└── Phase 16 (Templates + backend readiness data)
├── Phase 17 (Migration workflow)
│ └── Phase 18 (Issues view)
└── Phase 19 (Verification + verify API)
Phase 20 (History) ← depends on Phase 14 only, can run after Phase 14
Phase 21 (Settings) ← depends on Phase 14 only, can run after Phase 14
Phase 22 (Cleanup) ← depends on all phases complete
```
Phases 20 and 21 can be implemented in parallel with Phases 1719.
---
## What does NOT change
- All existing FastAPI routes (`auth.py`, `templates.py`, `migrate.py`)
- All backend Python source (`src/`)
- All 108 existing tests
- `.env` / credential handling
- The CLI pipeline (`src/migrate_template.py`)
Only backend additions:
1. **Phase 16:** `blockers` + `warnings` fields added to `GET /api/templates/status`
2. **Phase 19:** New `web/routers/verify.py` with 3 envelope endpoints

View File

@ -1,6 +1,6 @@
# Execution Board (Living Kanban)
*Last updated: 2026-04-17*
*Last updated: 2026-04-21 (post-redesign bug fixes + Phase 23)*
---
@ -79,9 +79,179 @@
---
## Phase 8 — Normalized Intermediate Schema ✅ (2026-04-21)
- [x] Create `src/models/` package with `__init__.py`
- [x] Implement `src/models/normalized_template.py` — pydantic model with NormalizedTemplate, NormalizedField, NormalizedRole, NormalizedDocument
- [x] Implement `src/services/` package with `__init__.py`
- [x] Implement `src/services/mapping_service.py` — Adobe Sign folder → NormalizedTemplate converter with checksums
- [x] Write `tests/test_normalized_schema.py` — 13 tests passing (model construction, serialization, real fixture round-trips)
- [x] Update README
---
## Phase 9 — Validation Service ✅ (2026-04-21)
- [x] Implement `src/services/validation_service.py``ValidationResult(blockers, warnings)`, checks for no recipients, no documents, no fields, missing roles, unsupported features
- [x] Implement `src/reports/report_builder.py``MigrationReport`, `TemplateReport`, `MigrationStatus` enum, factory functions
- [x] Integrate validation into migration pipeline (`_run_validation` in `web/routers/migrate.py`) — blocks on blockers
- [x] Implement `compare_field_counts(normalized, ds_template)` post-migration check
- [x] Write `tests/test_validation_service.py` — 20 tests passing
- [x] Update README
---
## Phase 10 — Migration Options API ✅ (2026-04-21)
- [x] Extend `POST /api/migrate` request body: `source_template_ids[]`, `target_folder`, `options.dry_run`, `options.overwrite_if_exists`, `options.include_documents`
- [x] Implement dry-run path — validate + compose without creating DocuSign templates (`status=dry_run`)
- [x] Implement `overwrite_if_exists=false` — skip already-migrated templates (`status=skipped`)
- [x] Implement `include_documents` toggle — strips `documentBase64` from payload when false
- [x] Keep backward compatibility with legacy `adobe_template_ids` field
- [x] Write `tests/test_migration_options.py` — 7 tests passing
- [x] Update README
---
## Phase 11 — Rate Limiting & Retry with Backoff ✅ (2026-04-21)
- [x] Implement `src/utils/retry.py``retry_with_backoff` (sync) and `async_retry_with_backoff` decorators with exponential backoff + max_delay cap
- [x] Implement `check_response_retryable(status_code)` — returns True for 429/500/502/503/504
- [x] Write `tests/test_retry.py` — 14 tests passing (exponential delay, max delay, exception filtering, async)
- [x] Update README
---
## Phase 12 — Security Hardening & Audit Trail ✅ (2026-04-21)
- [x] Implement `src/utils/log_sanitizer.py``redact()`, `redact_dict()`, `SanitizingFilter`, `install_sanitizing_filter()`
- [x] Redacts: Bearer tokens, JWT-style tokens, key=value secret assignments, long base64 payloads (PDF content)
- [x] PDF checksum (SHA-256) computed in `mapping_service.adobe_folder_to_normalized()` and stored in `NormalizedDocument.checksum_sha256`
- [x] Write `tests/test_security.py` — 15 tests passing
- [x] Update README
---
## Phase 13 — Batch Migration API ✅ (2026-04-21)
- [x] Implement `POST /api/migrate/batch` — async background job, returns `job_id` immediately
- [x] Implement `GET /api/migrate/batch/{job_id}` — poll job status, progress, results, summary
- [x] Implement retry for failed templates (one retry on upload failures)
- [x] In-memory job store with progress tracking (`_batch_jobs` dict)
- [x] Write `tests/test_batch_migration.py` — 6 tests passing
- [x] Update README
---
## Full Test Suite ✅ (2026-04-21)
**108/108 tests passing**
---
## UI Redesign — Phases 1422 (in progress)
*Full plan: `docs/UI-REDESIGN-PLAN.md`*
### Phase 14 — App Shell & Navigation ✅ (2026-04-21)
- [x] Rewrite `index.html` as app shell (left nav, router outlet, top bar)
- [x] `css/tokens.css` — Docusign 2024 brand custom properties
- [x] `css/base.css` — reset, Inter font, utility classes
- [x] `css/nav.css` — Inkwell sidebar, logo, nav links, project footer
- [x] `js/utils.js` — escHtml, formatDate, debounce, uuid
- [x] `js/router.js` — hash-based router (#/templates default)
- [x] `js/state.js` — global state with pub/sub
- [x] `js/api.js` — fetch wrappers for all existing endpoints
- [x] `js/auth.js` — auth chips, Adobe OAuth dialog, toast notifications
- [x] `js/app.js` — entry point wiring router, auth, nav badges
### Phase 15 — Project / Customer Context ✅ (2026-04-21)
- [x] `js/project.js` — project CRUD (localStorage)
- [x] Project switcher modal (list, create, delete, activate)
- [x] First-run experience (auto-open modal if no projects)
- [x] Active project name in nav footer
### Phase 16 — Templates View with Readiness Badges ✅ (2026-04-21)
- [x] Backend: add `blockers[]` + `warnings[]` to `GET /api/templates/status`
- [x] 3 new backend tests (10 total in test_api_templates.py)
- [x] `js/templates.js` — filterable/sortable table with readiness badges
- [x] Template detail view (3 tabs: Overview, Issues, Migration History)
- [x] `css/cards.css` — badge styles, table hover, bulk toolbar
### Phase 17 — Migration Workflow UI ✅ (2026-04-21)
- [x] Options modal (dry_run, overwrite, include_documents, target folder)
- [x] Progress view with batch job polling (every 2s)
- [x] `js/migration.js` — showOptionsModal, runMigration, pollJob, renderResults
- [x] Results view (#/results) with summary + export CSV
- [x] `css/modals.css`
### Phase 18 — Issues & Warnings View ✅ (2026-04-21)
- [x] `js/issues.js` — issues view (Blockers + Warnings sections)
- [x] Nav badge showing blocked template count
### Phase 19 — Verification View + API ✅ (2026-04-21)
- [x] `web/routers/verify.py` — POST /send, GET /status/{id}, POST /void/{id}
- [x] Register verify router in `web/app.py`
- [x] `tests/test_api_verify.py` — 7 tests passing
- [x] `js/verification.js` — send test envelope, poll status, void
### Phase 20 — History & Audit View ✅ (2026-04-21)
- [x] `js/history.js` — filterable history table, expand row, export CSV
- [x] Checksum display (first 8 chars, full on hover)
### Phase 21 — Settings View ✅ (2026-04-21)
- [x] `js/settings.js` — 3 sections (verification defaults, migration defaults, connection info)
- [x] `css/forms.css`
### Phase 22 — Smoke Test Checklist & Cleanup ✅ (2026-04-21)
- [x] `tests/UI-SMOKE-TEST.md` — manual test checklist (11 sections, 55 steps)
- [x] Full backend test suite: **118/118 tests passing**
- [x] Update `README.md` — new UI navigation guide, workflow, project context
- [x] Update EXECUTION-BOARD.md — all phases complete
- [x] Push `ui-redesign` branch to Gitea
- [x] Open PR to `master`
---
## Post-Redesign Bug Fixes ✅ (2026-04-21)
Bugs discovered during live testing after Phase 22.
- [x] **Docusign branding** — replaced all "DocuSign" with "Docusign" (2024 brand) across 8 frontend files
- [x] **Template detail routing**`router.js` `parseHash` used wrong slice indices (`slice(0,3)` instead of `slice(0,2)`), causing `#/templates/:id` to always fall through to the list view
- [x] **Migration polling infinite loop**`pollJob` only checked `'done'`/`'complete'` but backend emits `'completed'`; migration progress spinner never resolved
- [x] **Verification envelope role names** — hardcoded `roleName: "Signer"` meant envelopes sent without tags; now fetches actual template role names from Docusign API before sending, falls back to `"Signer"` only on fetch failure
- [x] **Verification polling rate** — changed from 5 s to 30 s per Docusign rate-limit guidance; added 5-minute timeout with amber "Timed Out" badge; note: production should use Docusign Connect webhooks
- [x] **CONDITIONALTAB_HAS_INVALID_PARENT (400)** — compose was emitting `conditionalParentLabel` pointing to signature/auto-fill tabs (forbidden as parents) or to fields on different recipients (cross-recipient). Fixed by post-processing strip pass in `_strip_invalid_conditionals`
- [x] **Migration modal failure UX** — failed/blocked rows now show the error message in small red text beneath the template name; completion summary shows count + "select View Results for details" hint
- [x] **Template detail history tab** — migration history rows with errors/blockers/warnings now expand inline (matching History & Audit behaviour)
---
## Phase 23 — Structured Field Issue Reporting ✅ (2026-04-21)
- [x] `src/models/field_issue.py``FieldIssue` dataclass with `code`, `field_name`, `message`, `severity`; 7 named codes: `CROSS_RECIPIENT_CONDITIONAL`, `UNSUPPORTED_OPERATOR`, `HIDE_ACTION`, `MULTI_PREDICATE`, `INVALID_PARENT_TAB`, `FIELD_TYPE_SKIPPED`, `PARTIAL_FIELD_TYPE`
- [x] `src/compose_docusign_template.py` — all warning paths now also emit structured `FieldIssue`; cross-recipient detection added (builds `{field_name → assignee}` map, checks predicate fieldName assignee before applying conditional); return signature changed to `(template, warnings, issues)`
- [x] `web/routers/migrate.py` — captures `field_issues` from compose result; all `_migrate_one` return paths include `field_issues: []`
- [x] `web/static/js/utils.js``renderFieldIssues()` groups issues by code in collapsible sections; `bindFieldIssueToggles()` wires expand/collapse
- [x] `web/static/js/migration.js` — results view: ⚠️ icon + amber **partial** badge for success-with-issues; field issue groups in expanded rows
- [x] `web/static/js/history.js` — amber **partial** badge + field issue groups in expanded rows
- [x] `web/static/js/templates.js` — template detail history tab shows field issues with partial badge per record
- [x] `web/static/css/cards.css``.field-issues-block`, `.field-issue-group`, `.field-issue-row` styles
- [x] `tests/test_regression.py` — updated for 3-tuple compose return
- [x] `tests/test_api_verify.py` — updated for template role-fetch + added fallback test (9 tests)
- [x] Full test suite: **119/119 tests passing**
- [x] Updated `README.md`, `field-mapping.md`, `EXECUTION-BOARD.md`
---
## Gitea
- [x] Committed and pushed all changes (2026-04-17)
- [x] Committed Phase 813 work (ui-redesign branch, 2026-04-21)
- [x] Committed UI mockup + Docusign 2024 brand (ui-redesign branch, 2026-04-21)
- [x] Committed Phases 1422 UI implementation (ui-redesign branch, 2026-04-21)
- [x] Pushed ui-redesign branch to Gitea; PR #1 open against master
---
@ -91,3 +261,10 @@
- (2026-04-15) Coordinate bug fixed — y is top-origin in both platforms, no conversion needed
- (2026-04-15) Paul Adobe Template created via API; Company/Title fields require manual UI fix (API limitation)
- (2026-04-17) v2 planning complete — idempotent upload + web UI implementation begins
- (2026-04-21) Blueprint comparison complete — added normalized schema, validation service, migration options, rate-limit/retry, security hardening, and batch migration phases (Phases 813)
- (2026-04-21) Phases 813 fully implemented — 108/108 tests passing on ui-redesign branch
- (2026-04-21) Enterprise UI mockup designed — 8 screens, Docusign 2024 branding, official SVG logo embedded
- (2026-04-21) UI Redesign plan written (Phases 1422) — frontend-only except Phase 16 (readiness data) and Phase 19 (verify API)
- (2026-04-21) Phases 1422 fully implemented — 118/118 tests passing, enterprise UI complete
- (2026-04-21) Post-redesign live testing found 7 bugs — all fixed (routing, polling, branding, verification role names, conditional parent 400s)
- (2026-04-21) Phase 23 complete — structured field issue reporting end-to-end; 119/119 tests passing; cross-recipient conditional now explicitly detected and described rather than silently producing a 400

1479
docs/ui-mockup/mockup.html Normal file

File diff suppressed because it is too large Load Diff

View File

@ -84,24 +84,39 @@ Tab types that do not merge (only first location used or handled specially):
Adobe Sign `conditionalAction` → DocuSign `conditionalParentLabel` + `conditionalParentValue` on the dependent tab.
| Adobe Sign | DocuSign | Notes |
|-----------------------------------|---------------------------------|-------|
| `predicates[].fieldName` | `conditionalParentLabel` | For radio groups, matches the group name |
| `predicates[].value` | `conditionalParentValue` | The value the trigger must equal to reveal the tab |
| `action: SHOW` | Supported | Tab is hidden until condition is met |
| `action: HIDE` | **Not supported** | No DocuSign equivalent — condition skipped, field always shown |
| `operator: EQUALS` | Supported | Only operator DocuSign supports |
| Other operators | **Not supported** | Condition skipped, warning logged |
| Multiple predicates (ANY/ALL) | **Partial** — first EQUALS only | Warning logged; remaining predicates ignored |
| Adobe Sign | DocuSign | Outcome | Notes |
|-----------------------------------|---------------------------------|---------|-------|
| `predicates[].fieldName` | `conditionalParentLabel` | Mapped | For radio groups, matches the group name |
| `predicates[].value` | `conditionalParentValue` | Mapped | The value the trigger must equal to reveal the tab |
| `action: SHOW` | Supported | Mapped | Tab is hidden until condition is met |
| `action: HIDE` | **Not supported** | Dropped | No DocuSign equivalent — field always shown. `HIDE_ACTION` issue emitted. |
| `operator: EQUALS` | Supported | Mapped | Only operator DocuSign supports |
| Other operators (NOT_EQUALS, etc.)| **Not supported** | Dropped | Condition skipped. `UNSUPPORTED_OPERATOR` issue emitted. |
| Multiple predicates (ANY/ALL) | **Partial** — first EQUALS only | Partial | `MULTI_PREDICATE` issue emitted; remaining predicates ignored |
| Trigger field on a different recipient | **Not supported** | Dropped | DocuSign `conditionalParentLabel` only works within the same recipient's tab set. `CROSS_RECIPIENT_CONDITIONAL` issue emitted. |
| Parent is signature/auto-fill tab | **Not supported** | Stripped | DocuSign forbids signature, initial, dateSign, fullName, email, title tabs as conditional parents. `INVALID_PARENT_TAB` issue emitted. |
## Known Gaps
- **Conditional HIDE**: Adobe Sign can conditionally hide a field. DocuSign only supports
revealing hidden fields — there is no native way to hide a visible field conditionally.
Templates with HIDE conditions will have those fields always visible after migration.
Emits a `HIDE_ACTION` field issue.
- **Cross-recipient conditionals**: Adobe Sign allows field B to appear/hide based on
the value of field A even when A and B belong to different recipients. DocuSign's
`conditionalParentLabel` only works within a single recipient's tab set.
Emits a `CROSS_RECIPIENT_CONDITIONAL` field issue; the condition is dropped.
- **Invalid or forbidden conditional parents**: If the trigger field maps to a signature,
initial, dateSign, fullName, email, or title tab — DocuSign forbids these as conditional
parents and returns `CONDITIONALTAB_HAS_INVALID_PARENT` (400). The compose pipeline
strips these conditions in a post-processing pass and emits an `INVALID_PARENT_TAB`
field issue.
- **Multi-predicate conditions**: Adobe Sign supports ANY/ALL of multiple predicates.
DocuSign only supports a single parent condition per tab. Only the first EQUALS
predicate is mapped; complex conditions require manual rework.
Emits a `MULTI_PREDICATE` field issue.
- **Unsupported operators**: NOT_EQUALS, GT, LT etc. have no DocuSign equivalent.
The condition is dropped. Emits an `UNSUPPORTED_OPERATOR` field issue.
- **DocuSign formula fields**: No Adobe Sign equivalent — flag for manual rewrite.
- **Advanced field validation**: Adobe regex/custom script validation is not mapped;
best-effort via standard DocuSign validation types only.
@ -109,8 +124,14 @@ Adobe Sign `conditionalAction` → DocuSign `conditionalParentLabel` + `conditio
DocuSign `radioGroupTabs` entry with per-location radio button coordinates.
- **Stamp tab account feature**: `stampTabs` requires the stamp/hanko feature to be
enabled on the DocuSign account. Verify before migrating templates that contain
Adobe Sign STAMP fields.
Adobe Sign STAMP fields. Emits a `PARTIAL_FIELD_TYPE` field issue.
- **FILE_CHOOSER → signerAttachmentTabs**: Docusign attachment tabs behave differently
from Adobe file upload fields (different UX, no file type restrictions).
Emits a `PARTIAL_FIELD_TYPE` field issue recommending manual review.
## To Do
- Add conditional logic/rule mapping table
- Document field mask and default value transforms
## Field Issue Codes
All dropped or approximated features are surfaced as structured `FieldIssue` objects
alongside human-readable warning strings. See `src/models/field_issue.py` for the full
list. The UI groups these by code in collapsed sections within migration result rows,
history rows, and the template detail Issues tab.

View File

@ -35,9 +35,19 @@ Conditional logic:
import base64
import json
import os
from pathlib import Path
from src.models.field_issue import (
FieldIssue,
CROSS_RECIPIENT_CONDITIONAL,
UNSUPPORTED_OPERATOR,
HIDE_ACTION,
MULTI_PREDICATE,
INVALID_PARENT_TAB,
FIELD_TYPE_SKIPPED,
PARTIAL_FIELD_TYPE,
)
DOCUMENT_ID = "1"
@ -154,7 +164,14 @@ def _sized_tabs(locations: list, label: str, extra: dict | None = None) -> list:
# Conditional logic
# ---------------------------------------------------------------------------
def _apply_conditional_to_tabs(tabs: dict, field: dict, warnings: list) -> dict:
def _apply_conditional_to_tabs(
tabs: dict,
field: dict,
warnings: list,
issues: list,
current_assignee: str = "",
field_assignee: dict | None = None,
) -> dict:
"""
Apply DocuSign conditionalParentLabel / conditionalParentValue to tabs based
on an Adobe Sign conditionalAction.
@ -169,6 +186,8 @@ def _apply_conditional_to_tabs(tabs: dict, field: dict, warnings: list) -> dict:
Mapping limitations:
- Only SHOW action is supported. DocuSign has no native HIDE condition skipped.
- Only EQUALS operator is supported. Others are skipped.
- Cross-recipient conditions not supported DocuSign conditionals only work within
a single recipient's tab set.
- Only one predicate is mapped. Multi-predicate ANY/ALL logic is not supported;
the first EQUALS predicate is used and a warning is logged.
"""
@ -184,33 +203,54 @@ def _apply_conditional_to_tabs(tabs: dict, field: dict, warnings: list) -> dict:
action = ca.get("action", "SHOW")
if action != "SHOW":
warnings.append(
f"Conditional '{label}': action={action} is not supported in DocuSign "
f"(only SHOW is supported) — condition skipped"
msg = (
f"Field '{label}' has a HIDE condition which DocuSign does not support — "
f"condition dropped. The field will always be visible."
)
warnings.append(msg)
issues.append(FieldIssue(HIDE_ACTION, label, msg).to_dict())
return tabs
predicate = next((p for p in predicates if p.get("operator") == "EQUALS"), None)
if not predicate:
warnings.append(
f"Conditional '{label}': no EQUALS predicate found "
f"(operators: {[p.get('operator') for p in predicates]}) — condition skipped"
ops = [p.get("operator") for p in predicates]
msg = (
f"Field '{label}' uses unsupported condition operator(s) {ops}"
f"only EQUALS is supported in DocuSign. Condition dropped; field will always be visible."
)
warnings.append(msg)
issues.append(FieldIssue(UNSUPPORTED_OPERATOR, label, msg).to_dict())
return tabs
if len(predicates) > 1:
warnings.append(
f"Conditional '{label}': {len(predicates)} predicates with "
f"anyOrAll={ca.get('anyOrAll')} — only first EQUALS predicate mapped, "
f"remaining conditions ignored"
)
parent_field_name = predicate["fieldName"]
# Cross-recipient check: DocuSign does not support conditionals across recipients
if field_assignee is not None and current_assignee:
parent_assignee = field_assignee.get(parent_field_name, "")
if parent_assignee and parent_assignee != current_assignee:
msg = (
f"Field '{label}' has a show/hide condition controlled by '{parent_field_name}', "
f"which belongs to a different recipient ({parent_assignee} vs {current_assignee}). "
f"DocuSign does not support cross-recipient conditional logic — condition dropped."
)
warnings.append(msg)
issues.append(FieldIssue(CROSS_RECIPIENT_CONDITIONAL, label, msg).to_dict())
return tabs
if len(predicates) > 1:
msg = (
f"Field '{label}' has {len(predicates)} conditions combined with "
f"anyOrAll={ca.get('anyOrAll')} — only the first EQUALS predicate was mapped. "
f"Remaining conditions were dropped."
)
warnings.append(msg)
issues.append(FieldIssue(MULTI_PREDICATE, label, msg).to_dict())
parent_label = predicate["fieldName"]
parent_value = predicate["value"]
for tab_list in tabs.values():
for tab in tab_list:
tab["conditionalParentLabel"] = parent_label
tab["conditionalParentLabel"] = parent_field_name
tab["conditionalParentValue"] = parent_value
return tabs
@ -220,11 +260,12 @@ def _apply_conditional_to_tabs(tabs: dict, field: dict, warnings: list) -> dict:
# Tab builder
# ---------------------------------------------------------------------------
def build_tabs_for_field(field: dict, warnings: list) -> dict:
def build_tabs_for_field(field: dict, warnings: list, issues: list) -> dict:
"""
Convert one Adobe Sign field into the correct DocuSign tabs structure.
Returns a dict of tab-group keys, e.g. {"textTabs": [...]}.
Unmappable fields are skipped and a warning is appended.
Unmappable fields are skipped; a warning string and a structured FieldIssue
are both appended so callers have both human-readable and machine-readable output.
"""
input_type = field.get("inputType", "")
label = field.get("name", "unnamed")
@ -240,22 +281,16 @@ def build_tabs_for_field(field: dict, warnings: list) -> dict:
if input_type == "TEXT_FIELD":
if content_type == "SIGNATURE_DATE":
# Auto-populated with the signing date
return {"dateSignedTabs": _sized_tabs(locations, label)}
elif content_type == "SIGNER_NAME":
# Auto-populated with the signer's full name
return {"fullNameTabs": _sized_tabs(locations, label)}
elif content_type == "SIGNER_EMAIL":
# Auto-populated with the signer's email address
return {"emailAddressTabs": _sized_tabs(locations, label)}
elif content_type in ("COMPANY", "SIGNER_COMPANY"):
# Auto-populated with the signer's company
return {"companyTabs": _sized_tabs(locations, label)}
elif content_type in ("TITLE", "SIGNER_TITLE"):
# Auto-populated with the signer's title
return {"titleTabs": _sized_tabs(locations, label)}
elif content_type == "DATA" and validation == "DATE":
# User-entered date field (not auto-signed date)
return {"dateTabs": _sized_tabs(locations, label, {"required": required_str, "locked": locked_str})}
elif content_type == "DATA" and validation == "NUMBER":
return {"numberTabs": _sized_tabs(locations, label, {"required": required_str, "locked": locked_str})}
@ -263,15 +298,12 @@ def build_tabs_for_field(field: dict, warnings: list) -> dict:
return {"textTabs": _sized_tabs(locations, label, {"required": required_str, "locked": locked_str})}
elif input_type == "SIGNATURE":
# Each signature/initials location is an independent signing action —
# emit one tab per location but do not size them (DocuSign controls size)
if content_type == "SIGNER_INITIALS":
return {"initialHereTabs": [_make_base_tab(loc, label) for loc in locations]}
else:
return {"signHereTabs": [_make_base_tab(loc, label) for loc in locations]}
elif input_type == "BLOCK" and content_type == "SIGNATURE_BLOCK":
# Composite signature block — map to signHere at block's location
return {"signHereTabs": [_make_base_tab(loc, label) for loc in locations]}
elif input_type == "DATE":
@ -286,7 +318,6 @@ def build_tabs_for_field(field: dict, warnings: list) -> dict:
return {"listTabs": _sized_tabs(locations, label, {"required": required_str, "listItems": list_items})}
elif input_type == "RADIO":
# Each location is one radio button within the group — not tab merging
options = field.get("hiddenOptions") or []
radios = []
for i, loc in enumerate(locations):
@ -296,26 +327,40 @@ def build_tabs_for_field(field: dict, warnings: list) -> dict:
return {"radioGroupTabs": [{"groupName": label, "documentId": DOCUMENT_ID, "radios": radios}]}
elif input_type == "FILE_CHOOSER":
warnings.append(f"FILE_CHOOSER '{label}' → mapped to signerAttachmentTabs (manual review recommended)")
msg = (
f"Field '{label}' is a FILE_CHOOSER — mapped to a signerAttachmentTabs tab. "
f"DocuSign attachment tabs behave differently from Adobe file upload fields; manual review recommended."
)
warnings.append(msg)
issues.append(FieldIssue(PARTIAL_FIELD_TYPE, label, msg).to_dict())
tab = _make_base_tab(locations[0], label, {"optional": "true" if not field.get("required") else "false"})
return {"signerAttachmentTabs": [tab]}
elif input_type == "INLINE_IMAGE":
warnings.append(f"INLINE_IMAGE '{label}' → skipped (no DocuSign equivalent)")
msg = f"Field '{label}' is an INLINE_IMAGE — skipped. There is no equivalent tab type in DocuSign."
warnings.append(msg)
issues.append(FieldIssue(FIELD_TYPE_SKIPPED, label, msg).to_dict())
return {}
elif input_type == "STAMP":
# DocuSign stampTabs — signer uploads or selects a hanko/seal stamp image.
# Requires the stamp feature to be enabled on the DocuSign account.
warnings.append(f"STAMP '{label}' → stampTabs (verify stamp feature is enabled on your DocuSign account)")
msg = (
f"Field '{label}' is a STAMP — mapped to stampTabs. "
f"This requires the stamp feature to be enabled on your DocuSign account."
)
warnings.append(msg)
issues.append(FieldIssue(PARTIAL_FIELD_TYPE, label, msg).to_dict())
return {"stampTabs": [_make_base_tab(loc, label) for loc in locations]}
elif input_type == "PARTICIPATION_STAMP":
warnings.append(f"PARTICIPATION_STAMP '{label}' → skipped (no DocuSign equivalent)")
msg = f"Field '{label}' is a PARTICIPATION_STAMP — skipped. There is no equivalent tab type in DocuSign."
warnings.append(msg)
issues.append(FieldIssue(FIELD_TYPE_SKIPPED, label, msg).to_dict())
return {}
else:
warnings.append(f"Unknown field type '{input_type}' (contentType='{content_type}') for field '{label}' → skipped")
msg = f"Field '{label}' has unknown type '{input_type}' (contentType='{content_type}') — skipped."
warnings.append(msg)
issues.append(FieldIssue(FIELD_TYPE_SKIPPED, label, msg).to_dict())
return {}
@ -325,11 +370,55 @@ def merge_tabs(acc: dict, new: dict) -> dict:
return acc
# Tab types DocuSign forbids as conditional parents (auto-filled or action tabs)
_INVALID_PARENT_TAB_TYPES = {
"signHereTabs", "initialHereTabs", "dateSignedTabs",
"fullNameTabs", "emailTabs", "titleTabs", "signerAttachmentTabs",
}
def _strip_invalid_conditionals(signers: list, warnings: list, issues: list) -> None:
"""
Remove conditionalParentLabel/Value from any tab whose parent label either
doesn't exist in the template or points to a tab type DocuSign forbids as a
parent (signature, initial, auto-filled). Mutates signers in place.
"""
for signer in signers:
tabs = signer.get("tabs", {})
# Collect valid parent labels: only tab types allowed as parents
valid_labels: set[str] = set()
for tab_type, tab_list in tabs.items():
if tab_type in _INVALID_PARENT_TAB_TYPES:
continue
for tab in tab_list:
lbl = tab.get("tabLabel") or tab.get("groupName")
if lbl:
valid_labels.add(lbl)
# Strip references to invalid/missing parents
for tab_list in tabs.values():
for tab in tab_list:
parent = tab.get("conditionalParentLabel")
if parent and parent not in valid_labels:
field_name = tab.get("tabLabel") or tab.get("groupName") or "?"
msg = (
f"Field '{field_name}' has a conditional that references parent "
f"'{parent}', which either does not exist as a tab or is a "
f"signature/auto-fill tab (forbidden as a DocuSign conditional parent). "
f"Condition stripped — field will always be visible."
)
warnings.append(msg)
issues.append(FieldIssue(INVALID_PARENT_TAB, field_name, msg).to_dict())
tab.pop("conditionalParentLabel", None)
tab.pop("conditionalParentValue", None)
# ---------------------------------------------------------------------------
# Main compose function
# ---------------------------------------------------------------------------
def compose_template(template_dir: str, output_path: str) -> tuple[dict, list[str]]:
def compose_template(template_dir: str, output_path: str) -> tuple[dict, list[str], list[dict]]:
"""
Build a DocuSign template JSON from a downloaded Adobe Sign template folder.
@ -339,10 +428,13 @@ def compose_template(template_dir: str, output_path: str) -> tuple[dict, list[st
output_path: where to write the resulting DocuSign template JSON
Returns:
(template_dict, warnings_list)
(template_dict, warnings_list, field_issues_list)
field_issues_list contains structured FieldIssue dicts describing properties
that were dropped or approximated during migration (see src/models/field_issue.py).
"""
template_dir = Path(template_dir)
warnings: list[str] = []
issues: list[dict] = []
# Load source files
metadata = json.loads((template_dir / "metadata.json").read_text())
@ -376,16 +468,28 @@ def compose_template(template_dir: str, output_path: str) -> tuple[dict, list[st
"tabs": {},
})
# Build field→assignee lookup for cross-recipient conditional detection
field_assignee: dict[str, str] = {}
for f in fields:
name = f.get("name", "")
assignee = f.get("assignee") or f"recipient{max(f.get('signerIndex', 0), 0)}"
if name:
field_assignee[name] = assignee
# Assign tabs to the correct signer
for field in fields:
assignee = field.get("assignee") or f"recipient{max(field.get('signerIndex', 0), 0)}"
idx = assignee_to_index(assignee, recipients)
if idx >= len(signers):
idx = 0
tabs = build_tabs_for_field(field, warnings)
tabs = _apply_conditional_to_tabs(tabs, field, warnings)
tabs = build_tabs_for_field(field, warnings, issues)
tabs = _apply_conditional_to_tabs(tabs, field, warnings, issues, assignee, field_assignee)
signers[idx]["tabs"] = merge_tabs(signers[idx]["tabs"], tabs)
# Post-process: strip conditionalParentLabel references that point to
# non-existent or invalid parents (signature/initial tabs can't be parents).
_strip_invalid_conditionals(signers, warnings, issues)
template = {
"name": metadata.get("name", template_dir.name),
"description": f"Migrated from Adobe Sign — original owner: {metadata.get('ownerEmail', '')}",
@ -406,7 +510,7 @@ def compose_template(template_dir: str, output_path: str) -> tuple[dict, list[st
with open(output_path, "w") as f:
json.dump(template, f, indent=2)
return template, warnings
return template, warnings, issues
# ---------------------------------------------------------------------------
@ -427,7 +531,7 @@ if __name__ == "__main__":
output_path = Path(__file__).parent.parent / "migration-output" / template_dir.name / "docusign-template.json"
print(f"\n--- {template_dir.name} ---")
try:
_, warnings = compose_template(str(template_dir), str(output_path))
_, warnings, issues = compose_template(str(template_dir), str(output_path))
print(f" Written: {output_path}")
for w in warnings:
print(f" WARNING: {w}")

0
src/models/__init__.py Normal file
View File

39
src/models/field_issue.py Normal file
View File

@ -0,0 +1,39 @@
"""
Structured field-level issue emitted during compose/migration.
Distinct from validation blockers a field issue means the field
migrated but something was silently dropped or approximated.
"""
from dataclasses import dataclass, asdict
# Machine-readable codes used in field_issues lists
CROSS_RECIPIENT_CONDITIONAL = "CROSS_RECIPIENT_CONDITIONAL"
UNSUPPORTED_OPERATOR = "UNSUPPORTED_OPERATOR"
HIDE_ACTION = "HIDE_ACTION"
MULTI_PREDICATE = "MULTI_PREDICATE"
INVALID_PARENT_TAB = "INVALID_PARENT_TAB"
FIELD_TYPE_SKIPPED = "FIELD_TYPE_SKIPPED"
PARTIAL_FIELD_TYPE = "PARTIAL_FIELD_TYPE"
# Human-readable labels for each code (used by the UI)
CODE_LABELS = {
CROSS_RECIPIENT_CONDITIONAL: "Cross-recipient conditional dropped",
UNSUPPORTED_OPERATOR: "Unsupported condition operator dropped",
HIDE_ACTION: "Hide condition dropped (no DocuSign equivalent)",
MULTI_PREDICATE: "Multi-condition logic simplified to first match",
INVALID_PARENT_TAB: "Conditional parent tab invalid or missing",
FIELD_TYPE_SKIPPED: "Field type skipped (no DocuSign equivalent)",
PARTIAL_FIELD_TYPE: "Field type approximated",
}
@dataclass
class FieldIssue:
code: str # one of the constants above
field_name: str # Adobe field name
message: str # human-readable description of what was dropped and why
severity: str = "warning" # "warning" | "info"
def to_dict(self) -> dict:
return asdict(self)

View File

@ -0,0 +1,78 @@
"""
normalized_template.py
-----------------------
Platform-agnostic intermediate schema that decouples Adobe Sign extraction
from DocuSign composition. Both platforms' data is converted to/from this
model so neither side is tightly coupled.
"""
from __future__ import annotations
from enum import Enum
from typing import Any, Optional
from pydantic import BaseModel, Field
class ActionType(str, Enum):
SIGN = "SIGN"
APPROVE = "APPROVE"
CC = "CC"
ACKNOWLEDGE = "ACKNOWLEDGE"
class NormalizedRole(BaseModel):
name: str
order: int
action_type: ActionType = ActionType.SIGN
class NormalizedField(BaseModel):
"""One form field in the normalized intermediate representation."""
type: str # e.g. "signature", "text", "checkbox"
label: str
page: int
x: float
y: float
width: float
height: float
required: bool = False
read_only: bool = False
role_name: str = "" # which role this field belongs to
options: list[str] = Field(default_factory=list) # for dropdown/radio
validation: str = "" # e.g. "DATE", "NUMBER"
content_type: str = "" # e.g. "SIGNATURE_DATE", "SIGNER_NAME"
conditional_parent_label: Optional[str] = None
conditional_parent_value: Optional[str] = None
raw: dict[str, Any] = Field(default_factory=dict) # original source data
class NormalizedDocument(BaseModel):
name: str
content_base64: str = "" # base64-encoded PDF bytes
checksum_sha256: str = "" # SHA-256 hex of raw bytes before encoding
source_path: str = ""
class NormalizedTemplate(BaseModel):
"""
Platform-agnostic representation of an eSignature template.
Used as the bridge between Adobe Sign and DocuSign.
"""
name: str
description: str = ""
email_subject: str = ""
email_message: str = ""
roles: list[NormalizedRole] = Field(default_factory=list)
documents: list[NormalizedDocument] = Field(default_factory=list)
fields: list[NormalizedField] = Field(default_factory=list)
reminder_enabled: bool = False
expiration_days: Optional[int] = None
source_id: str = "" # original Adobe Sign template ID
unsupported_features: list[str] = Field(default_factory=list)
def role_names(self) -> list[str]:
return [r.name for r in self.roles]
def fields_for_role(self, role_name: str) -> list[NormalizedField]:
return [f for f in self.fields if f.role_name == role_name]

0
src/reports/__init__.py Normal file
View File

View File

@ -0,0 +1,134 @@
"""
report_builder.py
-----------------
Builds structured migration reports per template and for batch runs.
"""
from __future__ import annotations
import json
from dataclasses import dataclass, field
from datetime import datetime, timezone
from enum import Enum
class MigrationStatus(str, Enum):
SUCCESS = "success"
SUCCESS_WITH_WARNINGS = "success_with_warnings"
SKIPPED = "skipped"
BLOCKED = "blocked"
ERROR = "error"
@dataclass
class TemplateReport:
template_name: str
source_id: str
status: MigrationStatus
docusign_template_id: str = ""
blockers: list[str] = field(default_factory=list)
warnings: list[str] = field(default_factory=list)
error: str = ""
timestamp: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
dry_run: bool = False
def to_dict(self) -> dict:
return {
"template_name": self.template_name,
"source_id": self.source_id,
"status": self.status.value,
"docusign_template_id": self.docusign_template_id,
"blockers": self.blockers,
"warnings": self.warnings,
"error": self.error,
"timestamp": self.timestamp,
"dry_run": self.dry_run,
}
@dataclass
class MigrationReport:
reports: list[TemplateReport] = field(default_factory=list)
def add(self, report: TemplateReport) -> None:
self.reports.append(report)
def summary(self) -> dict:
counts: dict[str, int] = {}
for r in self.reports:
counts[r.status.value] = counts.get(r.status.value, 0) + 1
return {
"total": len(self.reports),
**counts,
}
def to_dict(self) -> dict:
return {
"summary": self.summary(),
"templates": [r.to_dict() for r in self.reports],
}
def to_json(self, indent: int = 2) -> str:
return json.dumps(self.to_dict(), indent=indent)
def has_errors(self) -> bool:
return any(r.status in (MigrationStatus.BLOCKED, MigrationStatus.ERROR) for r in self.reports)
def build_success_report(
template_name: str,
source_id: str,
docusign_template_id: str,
warnings: list[str],
dry_run: bool = False,
) -> TemplateReport:
status = MigrationStatus.SUCCESS_WITH_WARNINGS if warnings else MigrationStatus.SUCCESS
return TemplateReport(
template_name=template_name,
source_id=source_id,
status=status,
docusign_template_id=docusign_template_id,
warnings=warnings,
dry_run=dry_run,
)
def build_blocked_report(
template_name: str,
source_id: str,
blockers: list[str],
warnings: list[str],
dry_run: bool = False,
) -> TemplateReport:
return TemplateReport(
template_name=template_name,
source_id=source_id,
status=MigrationStatus.BLOCKED,
blockers=blockers,
warnings=warnings,
dry_run=dry_run,
)
def build_error_report(
template_name: str,
source_id: str,
error: str,
dry_run: bool = False,
) -> TemplateReport:
return TemplateReport(
template_name=template_name,
source_id=source_id,
status=MigrationStatus.ERROR,
error=error,
dry_run=dry_run,
)
def build_skipped_report(template_name: str, source_id: str, reason: str) -> TemplateReport:
return TemplateReport(
template_name=template_name,
source_id=source_id,
status=MigrationStatus.SKIPPED,
warnings=[f"Skipped: {reason}"],
)

0
src/services/__init__.py Normal file
View File

View File

@ -0,0 +1,275 @@
"""
mapping_service.py
-------------------
Converts a downloaded Adobe Sign template folder into a NormalizedTemplate.
Extracted from compose_docusign_template.py so the normalization step is
decoupled from DocuSign-specific composition.
"""
from __future__ import annotations
import hashlib
import base64
import json
from pathlib import Path
from src.models.normalized_template import (
ActionType,
NormalizedDocument,
NormalizedField,
NormalizedRole,
NormalizedTemplate,
)
MIN_TEXT_WIDTH = 120
# ---------------------------------------------------------------------------
# Adobe Sign → Normalized
# ---------------------------------------------------------------------------
_ROLE_ACTION_MAP = {
"SIGNER": ActionType.SIGN,
"SIGN": ActionType.SIGN,
"APPROVER": ActionType.APPROVE,
"APPROVE": ActionType.APPROVE,
"CC": ActionType.CC,
"SHARE": ActionType.CC,
"ACKNOWLEDGE": ActionType.ACKNOWLEDGE,
}
_UNSUPPORTED_FEATURES = [
("conditionalAction", "action", "HIDE", "Conditional HIDE actions"),
("inputType", None, "INLINE_IMAGE", "INLINE_IMAGE fields (no DocuSign equivalent)"),
("inputType", None, "PARTICIPATION_STAMP", "PARTICIPATION_STAMP fields (no DocuSign equivalent)"),
]
_UNSUPPORTED_INPUT_TYPES = {"INLINE_IMAGE", "PARTICIPATION_STAMP"}
def _detect_unsupported(fields: list[dict], metadata: dict) -> list[str]:
"""Return human-readable strings for features that cannot be fully migrated."""
found: list[str] = []
seen: set[str] = set()
def _add(msg: str):
if msg not in seen:
seen.add(msg)
found.append(msg)
for f in fields:
input_type = f.get("inputType", "")
if input_type in _UNSUPPORTED_INPUT_TYPES:
_add(f"Unsupported field type: {input_type}")
ca = f.get("conditionalAction", {})
if ca.get("action") == "HIDE":
_add("Conditional HIDE action (not supported in DocuSign)")
preds = ca.get("predicates", [])
for p in preds:
if p.get("operator") not in ("EQUALS", None, ""):
_add(f"Non-EQUALS conditional operator: {p.get('operator')} (only EQUALS supported)")
if p.get("operator") == "EQUALS":
break # first EQUALS is handled, only note if there are more
if len(preds) > 1:
_add("Multi-predicate conditional logic (only first EQUALS predicate is mapped)")
if f.get("inputType") == "STAMP":
_add("STAMP fields (require stamp feature enabled on DocuSign account)")
# Check for webhook / workflow triggers in metadata
if metadata.get("workflowId") or metadata.get("externalId"):
_add("Workflow / webhook associations (require manual recreation)")
return found
def _derive_roles(fields: list[dict], participant_sets: list[dict] | None = None) -> list[NormalizedRole]:
"""
Build ordered NormalizedRole list from participant_sets if available,
otherwise derive from field assignees.
"""
if participant_sets:
roles = []
for ps in sorted(participant_sets, key=lambda p: p.get("order", 0)):
name = ps.get("name") or f"Role {ps.get('order', 1)}"
order = ps.get("order", 1)
action_raw = (ps.get("role") or "SIGN").upper()
action = _ROLE_ACTION_MAP.get(action_raw, ActionType.SIGN)
roles.append(NormalizedRole(name=name, order=order, action_type=action))
if roles:
return roles
# Fall back: derive from field assignees
seen: dict[str, int] = {}
for f in fields:
assignee = f.get("assignee") or f"recipient{max(f.get('signerIndex', 0), 0)}"
if assignee not in seen:
try:
idx = int(assignee.replace("recipient", ""))
except ValueError:
idx = len(seen)
seen[assignee] = idx
if not seen:
return [NormalizedRole(name="Signer 1", order=1)]
return [
NormalizedRole(name=f"Signer {v + 1}", order=v + 1)
for _, v in sorted(seen.items(), key=lambda kv: kv[1])
]
def _assignee_to_role(assignee: str | None, roles: list[NormalizedRole]) -> str:
"""Map an Adobe field assignee string (e.g. 'recipient0') to a role name."""
if not assignee:
return roles[0].name if roles else "Signer 1"
try:
idx = int(assignee.replace("recipient", ""))
except ValueError:
return roles[0].name if roles else "Signer 1"
# roles are ordered 1-based
match = next((r for r in roles if r.order == idx + 1), None)
return match.name if match else (roles[0].name if roles else "Signer 1")
def _normalize_field(f: dict, role_name: str, warnings: list[str]) -> NormalizedField | None:
"""Convert a single Adobe Sign field dict to NormalizedField."""
input_type = f.get("inputType", "")
label = f.get("name", "unnamed")
locations = f.get("locations", [])
if not locations:
return None
loc = locations[0]
x = float(loc.get("left", 0))
y = float(loc.get("top", 0))
width = float(max(loc.get("width", MIN_TEXT_WIDTH), MIN_TEXT_WIDTH))
height = float(loc.get("height", 24))
page = int(loc.get("pageNumber", 1))
content_type = f.get("contentType", "")
validation = f.get("validation", "")
# Map Adobe input type to normalized type
type_map = {
"SIGNATURE": "signature",
"CHECKBOX": "checkbox",
"DROP_DOWN": "dropdown",
"RADIO": "radio",
"FILE_CHOOSER": "attachment",
"STAMP": "stamp",
"INLINE_IMAGE": "inline_image",
"PARTICIPATION_STAMP": "participation_stamp",
}
if input_type == "BLOCK" and content_type == "SIGNATURE_BLOCK":
norm_type = "signature"
elif input_type == "TEXT_FIELD":
norm_type = "text"
else:
norm_type = type_map.get(input_type, input_type.lower())
# Conditional logic
parent_label = None
parent_value = None
ca = f.get("conditionalAction", {})
predicates = ca.get("predicates", [])
if predicates and ca.get("action") == "SHOW":
pred = next((p for p in predicates if p.get("operator") == "EQUALS"), None)
if pred:
parent_label = pred.get("fieldName")
parent_value = pred.get("value")
options: list[str] = []
if input_type in ("DROP_DOWN", "RADIO"):
options = (f.get("hiddenOptions") or f.get("visibleOptions") or [])
return NormalizedField(
type=norm_type,
label=label,
page=page,
x=x,
y=y,
width=width,
height=height,
required=bool(f.get("required", False)),
read_only=bool(f.get("readOnly", False)),
role_name=role_name,
options=options,
validation=validation,
content_type=content_type,
conditional_parent_label=parent_label,
conditional_parent_value=parent_value,
raw=f,
)
def adobe_folder_to_normalized(
template_dir: str,
include_documents: bool = True,
) -> tuple[NormalizedTemplate, list[str]]:
"""
Build a NormalizedTemplate from a downloaded Adobe Sign template folder.
Args:
template_dir: path to downloads/<template-name>/ with metadata.json,
form_fields.json, documents.json, and a PDF.
include_documents: whether to embed PDF bytes.
Returns:
(NormalizedTemplate, warnings_list)
"""
template_dir = Path(template_dir)
warnings: list[str] = []
metadata = json.loads((template_dir / "metadata.json").read_text())
fields_data = json.loads((template_dir / "form_fields.json").read_text())
documents_data = json.loads((template_dir / "documents.json").read_text())
fields: list[dict] = fields_data.get("fields", [])
participant_sets = metadata.get("participantSetsInfo", None)
roles = _derive_roles(fields, participant_sets)
# Build normalized fields
normalized_fields: list[NormalizedField] = []
for f in fields:
assignee = f.get("assignee") or f"recipient{max(f.get('signerIndex', 0), 0)}"
role_name = _assignee_to_role(assignee, roles)
nf = _normalize_field(f, role_name, warnings)
if nf:
normalized_fields.append(nf)
# Document
pdf_files = [f for f in template_dir.iterdir() if f.is_file() and "json" not in f.name]
doc_info = documents_data.get("documents", [{}])[0]
doc_name = doc_info.get("name", "")
normalized_docs: list[NormalizedDocument] = []
if pdf_files:
pdf_path = pdf_files[0]
if not doc_name.lower().endswith(".pdf"):
doc_name = Path(doc_name).stem + ".pdf" if doc_name else pdf_path.name
pdf_bytes = pdf_path.read_bytes()
checksum = hashlib.sha256(pdf_bytes).hexdigest()
content_b64 = base64.b64encode(pdf_bytes).decode() if include_documents else ""
normalized_docs.append(NormalizedDocument(
name=doc_name,
content_base64=content_b64,
checksum_sha256=checksum,
source_path=str(pdf_path),
))
unsupported = _detect_unsupported(fields, metadata)
return NormalizedTemplate(
name=metadata.get("name", template_dir.name),
description=f"Migrated from Adobe Sign — original owner: {metadata.get('ownerEmail', '')}",
email_subject=metadata.get("emailSubject") or f"Please sign: {metadata.get('name', '')}",
email_message=metadata.get("message", ""),
roles=roles,
documents=normalized_docs,
fields=normalized_fields,
source_id=metadata.get("id", ""),
unsupported_features=unsupported,
), warnings

View File

@ -0,0 +1,133 @@
"""
validation_service.py
---------------------
Pre/post migration checks. Returns a ValidationResult with blockers
(which halt migration) and warnings (which are logged but don't block).
"""
from __future__ import annotations
from dataclasses import dataclass, field
from src.models.normalized_template import NormalizedTemplate
@dataclass
class ValidationResult:
blockers: list[str] = field(default_factory=list)
warnings: list[str] = field(default_factory=list)
def has_blockers(self) -> bool:
return bool(self.blockers)
def is_ok(self) -> bool:
return not self.has_blockers()
def all_issues(self) -> list[str]:
return [f"BLOCKER: {b}" for b in self.blockers] + [f"WARNING: {w}" for w in self.warnings]
def validate_template(normalized: NormalizedTemplate) -> ValidationResult:
"""
Run all pre-migration checks on a NormalizedTemplate.
Returns a ValidationResult with blockers and warnings.
"""
result = ValidationResult()
_check_recipients(normalized, result)
_check_fields(normalized, result)
_check_role_assignments(normalized, result)
_check_documents(normalized, result)
_flag_unsupported(normalized, result)
return result
def _check_recipients(t: NormalizedTemplate, r: ValidationResult) -> None:
if not t.roles:
r.blockers.append("No recipients/roles defined — template cannot be migrated")
return
orders = [role.order for role in t.roles]
if len(orders) != len(set(orders)):
r.warnings.append("Duplicate routing orders detected in recipient roles")
expected = list(range(1, len(orders) + 1))
if sorted(orders) != expected:
r.warnings.append(
f"Non-sequential routing order: {sorted(orders)} — DocuSign expects {expected}"
)
def _check_fields(t: NormalizedTemplate, r: ValidationResult) -> None:
if not t.fields:
r.warnings.append("Template has 0 fields — the resulting DocuSign template will be empty")
return
sig_fields = [f for f in t.fields if f.type in ("signature", "initial")]
if not sig_fields:
r.warnings.append("No signature or initial fields found — signers will have nothing to sign")
def _check_role_assignments(t: NormalizedTemplate, r: ValidationResult) -> None:
role_names = {role.name for role in t.roles}
unassigned = [f.label for f in t.fields if f.role_name not in role_names]
if unassigned:
r.warnings.append(
f"{len(unassigned)} field(s) have role assignments that don't match any recipient: "
f"{unassigned[:5]}{'...' if len(unassigned) > 5 else ''}"
)
def _check_documents(t: NormalizedTemplate, r: ValidationResult) -> None:
if not t.documents:
r.blockers.append("No documents attached — at least one PDF is required")
return
for doc in t.documents:
if not doc.content_base64 and not doc.source_path:
r.warnings.append(f"Document '{doc.name}' has no content and no source path")
def _flag_unsupported(t: NormalizedTemplate, r: ValidationResult) -> None:
for feature in t.unsupported_features:
r.warnings.append(f"Unsupported feature (manual review needed): {feature}")
def compare_field_counts(
normalized: NormalizedTemplate,
docusign_template: dict,
) -> ValidationResult:
"""
Post-migration check: compare field count in NormalizedTemplate vs the
uploaded DocuSign template payload.
"""
result = ValidationResult()
expected = len(normalized.fields)
# Count tabs across all signers in the DS template payload
actual = 0
for signer in docusign_template.get("recipients", {}).get("signers", []):
tabs = signer.get("tabs", {})
for tab_list in tabs.values():
actual += len(tab_list)
if actual == 0 and expected > 0:
result.warnings.append(
f"DocuSign template has 0 tabs but {expected} fields were in the source"
)
elif abs(actual - expected) > 0:
result.warnings.append(
f"Field count mismatch: normalized={expected}, DocuSign tabs={actual} "
f"(some field types may expand or collapse during mapping)"
)
# Compare recipient counts
expected_roles = len(normalized.roles)
actual_signers = len(docusign_template.get("recipients", {}).get("signers", []))
if expected_roles != actual_signers:
result.warnings.append(
f"Recipient count mismatch: normalized={expected_roles}, DocuSign signers={actual_signers}"
)
return result

0
src/utils/__init__.py Normal file
View File

View File

@ -0,0 +1,98 @@
"""
log_sanitizer.py
----------------
Redacts secrets (tokens, keys, passwords) from log output so credentials
never appear in logs, stdout, or audit records.
"""
from __future__ import annotations
import logging
import re
from typing import Any
_REDACTED = "[REDACTED]"
# Patterns where group(1) is a safe label prefix and the rest is the secret.
# Result: group(1) + "[REDACTED]"
_LABEL_PATTERNS = [
# "Bearer <token>"
re.compile(r"(Bearer\s+)[A-Za-z0-9\-._~+/=]{8,}", re.IGNORECASE),
# key=value assignments for known secret keys
re.compile(
r"""((?:api[_\-]?key|access[_\-]?token|refresh[_\-]?token|client[_\-]?secret|password|private[_\-]?key|authorization)\s*[=:]\s*)["']?[A-Za-z0-9\-._~+/=!@#$%^&*]{6,}["']?""",
re.IGNORECASE,
),
]
# Patterns that fully match a secret — the entire match is replaced.
_FULL_SECRET_PATTERNS = [
# JWT-style tokens (three base64url segments separated by dots)
re.compile(r"\b[A-Za-z0-9\-_]{10,}\.[A-Za-z0-9\-_]{10,}\.[A-Za-z0-9\-_]{10,}\b"),
# Long base64 content (>500 chars) — PDF payloads, encoded keys, etc.
re.compile(r"[A-Za-z0-9+/]{500,}={0,2}"),
]
def redact(text: str) -> str:
"""Replace known secret patterns in *text* with [REDACTED]."""
for pattern in _LABEL_PATTERNS:
text = pattern.sub(lambda m: m.group(1) + _REDACTED, text)
for pattern in _FULL_SECRET_PATTERNS:
text = pattern.sub(_REDACTED, text)
return text
def redact_dict(data: dict, depth: int = 0) -> dict:
"""Recursively redact secret values in a dict (for logging structured data)."""
if depth > 10:
return data
_SECRET_KEYS = {
"access_token", "refresh_token", "token", "secret", "password",
"authorization", "api_key", "private_key", "client_secret",
"documentbase64",
}
result = {}
for k, v in data.items():
if k.lower().replace("-", "_") in _SECRET_KEYS:
result[k] = _REDACTED
elif isinstance(v, dict):
result[k] = redact_dict(v, depth + 1)
elif isinstance(v, list):
result[k] = [redact_dict(i, depth + 1) if isinstance(i, dict) else i for i in v]
elif isinstance(v, str) and len(v) > 100:
result[k] = redact(v)
else:
result[k] = v
return result
class SanitizingFilter(logging.Filter):
"""
A logging.Filter that runs redact() on every log record's message.
Attach to any logger or handler to ensure secrets never hit log output.
Usage:
logging.root.addFilter(SanitizingFilter())
"""
def filter(self, record: logging.LogRecord) -> bool:
try:
record.msg = redact(str(record.msg))
if record.args:
if isinstance(record.args, dict):
record.args = {k: redact(str(v)) for k, v in record.args.items()}
else:
record.args = tuple(redact(str(a)) for a in record.args)
except Exception:
pass
return True
def install_sanitizing_filter() -> None:
"""Install the SanitizingFilter on the root logger (idempotent)."""
root = logging.getLogger()
for existing in root.filters:
if isinstance(existing, SanitizingFilter):
return
root.addFilter(SanitizingFilter())

102
src/utils/retry.py Normal file
View File

@ -0,0 +1,102 @@
"""
retry.py
--------
Exponential backoff retry helpers for API calls that may hit rate limits
or transient server errors (429, 502, 503, 504).
"""
from __future__ import annotations
import asyncio
import functools
import logging
import time
from typing import Callable, TypeVar
logger = logging.getLogger(__name__)
T = TypeVar("T")
# HTTP status codes that are safe to retry
_RETRYABLE_STATUS = {429, 500, 502, 503, 504}
def retry_with_backoff(
max_retries: int = 3,
base_delay: float = 1.0,
max_delay: float = 30.0,
retryable_exceptions: tuple = (Exception,),
):
"""
Decorator for sync functions. Retries on exceptions with exponential backoff.
Usage:
@retry_with_backoff(max_retries=3, base_delay=1.0)
def my_api_call():
...
"""
def decorator(fn: Callable) -> Callable:
@functools.wraps(fn)
def wrapper(*args, **kwargs):
last_exc: Exception | None = None
for attempt in range(max_retries + 1):
try:
return fn(*args, **kwargs)
except retryable_exceptions as exc:
last_exc = exc
if attempt == max_retries:
break
delay = min(base_delay * (2 ** attempt), max_delay)
logger.warning(
"Retry %d/%d for %s after %.1fs — %s",
attempt + 1, max_retries, fn.__name__, delay, exc,
)
time.sleep(delay)
raise last_exc
return wrapper
return decorator
def async_retry_with_backoff(
max_retries: int = 3,
base_delay: float = 1.0,
max_delay: float = 30.0,
retryable_exceptions: tuple = (Exception,),
):
"""
Decorator for async functions. Retries on exceptions with exponential backoff.
Usage:
@async_retry_with_backoff(max_retries=3, base_delay=1.0)
async def my_api_call():
...
"""
def decorator(fn: Callable) -> Callable:
@functools.wraps(fn)
async def wrapper(*args, **kwargs):
last_exc: Exception | None = None
for attempt in range(max_retries + 1):
try:
return await fn(*args, **kwargs)
except retryable_exceptions as exc:
last_exc = exc
if attempt == max_retries:
break
delay = min(base_delay * (2 ** attempt), max_delay)
logger.warning(
"Async retry %d/%d for %s after %.1fs — %s",
attempt + 1, max_retries, fn.__name__, delay, exc,
)
await asyncio.sleep(delay)
raise last_exc
return wrapper
return decorator
class RateLimitError(Exception):
"""Raised when an API returns HTTP 429 Too Many Requests."""
def check_response_retryable(status_code: int) -> bool:
"""Return True if the HTTP status code warrants a retry."""
return status_code in _RETRYABLE_STATUS

125
tests/UI-SMOKE-TEST.md Normal file
View File

@ -0,0 +1,125 @@
# UI Smoke Test Checklist
Run these manual tests after any significant frontend change. Start the server with:
```bash
uvicorn web.app:app --reload --port 8000
```
Then open [http://localhost:8000](http://localhost:8000).
---
## 1. First Run — Project Switcher
- [ ] On first load (no `migrator_projects` in localStorage), the project switcher modal opens automatically
- [ ] Welcome copy is visible: "No projects yet. Create one below to get started."
- [ ] Cancel closes the modal (app loads with empty state)
- [ ] Type "Test Customer" in the name field → click Create Project
- [ ] Modal closes; nav footer shows "Test Customer" in the project button
- [ ] Nav footer "Current Project" label shows "Test Customer"
## 2. Project CRUD
- [ ] Click the project button in the nav → switcher modal opens
- [ ] "Test Customer" row shows with "● Active" badge
- [ ] Create a second project "Acme Corp"
- [ ] "Acme Corp" row appears; clicking it activates it and closes the modal
- [ ] Nav footer now shows "Acme Corp"
- [ ] Switch back to "Test Customer"
- [ ] Delete "Acme Corp" → confirmation dialog → confirm → row disappears
## 3. Authentication (requires .env credentials)
- [ ] Top bar shows two disconnected chips (red dot): "Adobe Sign" and "DocuSign"
- [ ] Click "Adobe Sign" chip → connects via `.env` refresh token → chip turns green
- [ ] Click "DocuSign" chip → connects via JWT grant → chip turns green
- [ ] Disconnecting either chip → chip turns red → templates clear
## 4. Templates View
- [ ] Navigate to Templates (default view or via nav)
- [ ] Templates load in a table with columns: Name, Readiness, Issues, Last Modified, DS Status, Actions
- [ ] Each template has a readiness badge (Ready / Caveats / Blocked / Migrated / Needs Update)
- [ ] Search bar filters by name in real time
- [ ] Status filter tabs (All / Not Migrated / Migrated / Needs Update) filter correctly
- [ ] "Blocked" and "Caveats" filter tabs show correct counts
- [ ] Clicking a column header sorts the table; clicking again reverses direction
- [ ] Checking a template checkbox shows the bulk bar: "1 template(s) selected"
- [ ] Selecting multiple templates updates the bulk bar count
- [ ] "Clear" button in bulk bar deselects all
## 5. Template Detail
- [ ] Click a template name → navigates to `#/templates/:id`
- [ ] Breadcrumb shows "← Templates" link
- [ ] Overview tab: shows Adobe ID, last modified date, migration status
- [ ] Issues tab: if template has blockers/warnings, shows them; otherwise shows "All ready" callout
- [ ] Migration History tab: shows past migrations for this template (or "No history" callout)
- [ ] "Migrate" button in detail header opens options modal
## 6. Dry Run Migration
- [ ] Select 13 templates → click "Migrate Selected →"
- [ ] Options modal opens with toggles (Dry Run off, Overwrite off, Include Documents on)
- [ ] Enable Dry Run toggle → click "Run Migration"
- [ ] Progress modal shows per-template rows with 🔍 icons
- [ ] "View Results →" button appears when complete
- [ ] Results view shows Dry Run count > 0, Created/Updated = 0
- [ ] Export CSV button downloads a CSV file
## 7. Real Migration
- [ ] Select templates that are "Not Migrated"
- [ ] Options modal → Dry Run off, Overwrite off → Run Migration
- [ ] Progress shows ✅ icons for created templates
- [ ] Results view shows Created count > 0
- [ ] Navigate back to Templates → readiness badges update to "Migrated"
## 8. Issues & Warnings View
- [ ] Navigate to Issues & Warnings via nav
- [ ] If any templates have blockers: Blockers section shows with red styling
- [ ] If any templates have warnings: Warnings section shows "Migrate Anyway" button
- [ ] "View Detail" links navigate to the correct template detail page
- [ ] Nav badge on "Issues & Warnings" shows correct blocked count (or hidden if 0)
## 9. Verification View (requires DocuSign credentials)
- [ ] Navigate to Verification via nav
- [ ] Migrated templates appear in the table with "Not Tested" status
- [ ] Click "Send Test" → dialog opens with pre-filled name/email from Settings
- [ ] Enter test recipient → Send Test → row status changes to "Sent" with spinner
- [ ] Status polls every 5s; updates to "Delivered" then "Completed" (or "Verified")
- [ ] "Void" button appears → clicking it confirms and voids the envelope → status → "Voided"
## 10. History & Audit View
- [ ] Navigate to History & Audit
- [ ] All migration records appear in a table, newest first
- [ ] Search by template name filters rows
- [ ] Status filter tabs work correctly
- [ ] Date range filter narrows results
- [ ] Clicking a row with warnings/blockers expands to show them
- [ ] Checksum column shows 8-char truncation; hover shows full hash
- [ ] "Export CSV" downloads a CSV with all filtered rows
## 11. Settings
- [ ] Navigate to Settings via nav
- [ ] Fill in test recipient name and email → Save → "✓ Saved" confirmation appears
- [ ] Refresh page → values persist in the form (read from localStorage)
- [ ] Toggle "Overwrite Existing by Default" → Save → open migration modal → toggle starts in correct state
- [ ] Connection info section shows correct Adobe Sign and DocuSign connection status
---
## Regression: Backend Test Suite
After any changes:
```bash
pytest tests/ -v
```
Expected: **≥ 118 tests passing**

View File

@ -142,7 +142,8 @@ def test_migrate_single_template_updates():
):
resp = client.post(
"/api/migrate",
json={"adobe_template_ids": [ADOBE_ID]},
# overwrite_if_exists=True so the existing template is updated, not skipped
json={"adobe_template_ids": [ADOBE_ID], "options": {"overwrite_if_exists": True}},
cookies={_COOKIE_NAME: _full_session()},
)

View File

@ -155,3 +155,77 @@ def test_status_needs_update():
resp = client.get("/api/templates/status", cookies={_COOKIE_NAME: _adobe_session()})
t = resp.json()["templates"][0]
assert t["status"] == "needs_update"
@respx.mock
def test_status_includes_blockers_and_warnings_fields():
"""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": [
{"id": "adobe1", "name": "NDA", "modifiedDate": "2026-04-10T00:00:00Z"},
]
})
)
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 "blockers" in t
assert "warnings" in t
assert isinstance(t["blockers"], list)
assert isinstance(t["warnings"], list)
@respx.mock
def test_status_empty_blockers_when_not_downloaded():
"""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": [
{"id": "adobe-unknown-id", "name": "Unknown 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()})
t = resp.json()["templates"][0]
assert t["blockers"] == []
assert t["warnings"] == []
@respx.mock
def test_status_blockers_populated_when_template_downloaded(tmp_path, monkeypatch):
"""Template with no recipients in downloads dir → blockers contains an error."""
import json
from pathlib import Path
import web.routers.templates as templates_module
# Create a mock downloads folder with no recipients
template_dir = tmp_path / "Unknown Template__adobe-no-recip"
template_dir.mkdir()
(template_dir / "metadata.json").write_text(json.dumps({"name": "Unknown Template", "id": "adobe-no-recip"}))
(template_dir / "form_fields.json").write_text(json.dumps({"fields": []}))
(template_dir / "documents.json").write_text(json.dumps({"documents": []}))
monkeypatch.setattr("web.routers.templates.Path", lambda p: tmp_path if p == getattr(__import__("web.config", fromlist=["settings"]).settings, "downloads_dir", "downloads") else Path(p))
respx.get(f"{ADOBE_BASE}/libraryDocuments").mock(
return_value=httpx.Response(200, json={
"libraryDocumentList": [
{"id": "adobe-no-recip", "name": "Unknown 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()})
t = resp.json()["templates"][0]
# 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)

162
tests/test_api_verify.py Normal file
View File

@ -0,0 +1,162 @@
"""
tests/test_api_verify.py
------------------------
Tests for /api/verify/* endpoints (send test envelope, status, void).
All DocuSign API calls are mocked with respx.
"""
import pytest
import respx
import httpx
from fastapi.testclient import TestClient
from web.app import app
from web.session import _serializer, _COOKIE_NAME
client = TestClient(app, raise_server_exceptions=True)
DS_BASE = "https://demo.docusign.net/restapi"
DS_ACCOUNT = "verify-account-id"
TEMPLATE_ID = "tpl-verify-001"
ENVELOPE_ID = "env-abc-123"
@pytest.fixture(autouse=True)
def patch_settings(monkeypatch):
import web.config as cfg
monkeypatch.setattr(cfg.settings, "docusign_account_id", DS_ACCOUNT)
monkeypatch.setattr(cfg.settings, "docusign_base_url", DS_BASE)
def _full_session():
return _serializer.dumps({
"adobe_access_token": "adobe-tok",
"docusign_access_token": "ds-tok",
})
def _ds_session():
return _serializer.dumps({"docusign_access_token": "ds-tok"})
class TestVerifySend:
def test_send_requires_auth(self):
"""No session → 401."""
resp = client.post(
"/api/verify/send",
json={"template_id": TEMPLATE_ID, "recipient_name": "Alice", "recipient_email": "alice@example.com"},
cookies={},
)
assert resp.status_code == 401
@respx.mock
def test_send_returns_envelope_id(self):
"""Authenticated + valid template → role names fetched, envelope_id returned."""
respx.get(
f"{DS_BASE}/v2.1/accounts/{DS_ACCOUNT}/templates/{TEMPLATE_ID}"
).mock(return_value=httpx.Response(200, json={
"recipients": {
"signers": [{"roleName": "Customer", "recipientId": "1"}],
}
}))
respx.post(
f"{DS_BASE}/v2.1/accounts/{DS_ACCOUNT}/envelopes"
).mock(return_value=httpx.Response(201, json={"envelopeId": ENVELOPE_ID}))
resp = client.post(
"/api/verify/send",
json={
"template_id": TEMPLATE_ID,
"recipient_name": "Alice Test",
"recipient_email": "alice@example.com",
},
cookies={_COOKIE_NAME: _ds_session()},
)
assert resp.status_code == 200
assert resp.json()["envelope_id"] == ENVELOPE_ID
assert resp.json()["roles"] == ["Customer"]
@respx.mock
def test_send_falls_back_to_signer_role_on_template_error(self):
"""Template fetch failure → falls back to 'Signer' role name."""
respx.get(
f"{DS_BASE}/v2.1/accounts/{DS_ACCOUNT}/templates/bad-id"
).mock(return_value=httpx.Response(404, json={"message": "Not found"}))
respx.post(
f"{DS_BASE}/v2.1/accounts/{DS_ACCOUNT}/envelopes"
).mock(return_value=httpx.Response(201, json={"envelopeId": ENVELOPE_ID}))
resp = client.post(
"/api/verify/send",
json={"template_id": "bad-id", "recipient_name": "X", "recipient_email": "x@x.com"},
cookies={_COOKIE_NAME: _ds_session()},
)
assert resp.status_code == 200
assert resp.json()["roles"] == ["Signer"]
@respx.mock
def test_send_propagates_docusign_error(self):
"""DocuSign 400 on envelope create → 502 with error detail."""
respx.get(
f"{DS_BASE}/v2.1/accounts/{DS_ACCOUNT}/templates/bad-id"
).mock(return_value=httpx.Response(200, json={"recipients": {}}))
respx.post(
f"{DS_BASE}/v2.1/accounts/{DS_ACCOUNT}/envelopes"
).mock(return_value=httpx.Response(400, json={"message": "Invalid templateId"}))
resp = client.post(
"/api/verify/send",
json={"template_id": "bad-id", "recipient_name": "X", "recipient_email": "x@x.com"},
cookies={_COOKIE_NAME: _ds_session()},
)
assert resp.status_code == 502
class TestVerifyStatus:
def test_status_requires_auth(self):
resp = client.get(f"/api/verify/status/{ENVELOPE_ID}", cookies={})
assert resp.status_code == 401
@respx.mock
def test_status_returns_envelope_state(self):
"""Authenticated → status and sent_at returned."""
respx.get(
f"{DS_BASE}/v2.1/accounts/{DS_ACCOUNT}/envelopes/{ENVELOPE_ID}"
).mock(return_value=httpx.Response(200, json={
"envelopeId": ENVELOPE_ID,
"status": "sent",
"sentDateTime": "2026-04-21T12:00:00Z",
"completedDateTime": None,
}))
resp = client.get(
f"/api/verify/status/{ENVELOPE_ID}",
cookies={_COOKIE_NAME: _ds_session()},
)
assert resp.status_code == 200
data = resp.json()
assert data["status"] == "sent"
assert data["envelope_id"] == ENVELOPE_ID
assert data["sent_at"] == "2026-04-21T12:00:00Z"
class TestVerifyVoid:
def test_void_requires_auth(self):
resp = client.post(f"/api/verify/void/{ENVELOPE_ID}", json={"reason": "test"}, cookies={})
assert resp.status_code == 401
@respx.mock
def test_void_calls_docusign(self):
"""Authenticated → PUT envelope status to voided → voided: true."""
respx.put(
f"{DS_BASE}/v2.1/accounts/{DS_ACCOUNT}/envelopes/{ENVELOPE_ID}"
).mock(return_value=httpx.Response(200, json={}))
resp = client.post(
f"/api/verify/void/{ENVELOPE_ID}",
json={"reason": "Verification complete"},
cookies={_COOKIE_NAME: _ds_session()},
)
assert resp.status_code == 200
assert resp.json()["voided"] is True
assert resp.json()["envelope_id"] == ENVELOPE_ID

View File

@ -0,0 +1,155 @@
"""
Tests for Phase 13: batch migration API.
"""
import asyncio
import json
import os
from unittest.mock import patch
import pytest
import respx
import httpx
from fastapi.testclient import TestClient
from web.app import app
from web.session import _serializer, _COOKIE_NAME
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"
TEMPLATE_NAME = "Batch Test Template"
DS_NEW_ID = "ds-batch-new-001"
def _full_session():
return _serializer.dumps({
"adobe_access_token": "adobe-tok",
"docusign_access_token": "ds-tok",
})
@pytest.fixture(autouse=True)
def patch_settings(monkeypatch):
import web.config as cfg
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)
@pytest.fixture(autouse=True)
def temp_history(tmp_path, monkeypatch):
history_path = str(tmp_path / ".history.json")
monkeypatch.setattr(migrate_module, "_HISTORY_FILE", history_path)
return history_path
@pytest.fixture(autouse=True)
def clear_batch_jobs():
"""Clear in-memory batch jobs between tests."""
migrate_module._batch_jobs.clear()
yield
migrate_module._batch_jobs.clear()
def _async_wrap(sync_fn):
async def wrapper(*args, **kwargs):
return sync_fn(*args, **kwargs)
return wrapper
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") as f:
json.dump({"name": f"Template {template_id}", "id": template_id}, f)
with open(os.path.join(output_dir, "form_fields.json"), "w") as f:
json.dump({"fields": []}, f)
with open(os.path.join(output_dir, "documents.json"), "w") as f:
json.dump({"documents": []}, f)
return True
def _mock_compose(template_dir, output_path):
with open(output_path, "w") as f:
json.dump({"name": TEMPLATE_NAME}, f)
def _mock_validation_ok(download_dir):
return {"blockers": [], "warnings": [], "has_blockers": False}
class TestBatchMigrationPost:
def test_batch_requires_auth(self):
resp = client.post("/api/migrate/batch", json={"source_template_ids": ["id1"]}, cookies={})
assert resp.status_code == 401
def test_batch_no_ids_returns_400(self):
resp = client.post(
"/api/migrate/batch",
json={},
cookies={_COOKIE_NAME: _full_session()},
)
assert resp.status_code == 400
@respx.mock
def test_batch_returns_job_id(self):
"""POST /api/migrate/batch returns a job_id immediately."""
with (
patch.object(migrate_module, "_download_adobe_template", new=_async_wrap(_mock_download)),
patch.object(migrate_module, "_load_compose", return_value=_mock_compose),
patch.object(migrate_module, "_run_validation", side_effect=_mock_validation_ok),
):
resp = client.post(
"/api/migrate/batch",
json={"source_template_ids": ["id1", "id2"]},
cookies={_COOKIE_NAME: _full_session()},
)
assert resp.status_code == 200
body = resp.json()
assert "job_id" in body
assert body["total"] == 2
assert body["status"] == "queued"
@respx.mock
def test_batch_job_status_endpoint(self):
"""GET /api/migrate/batch/{id} returns job state."""
with (
patch.object(migrate_module, "_download_adobe_template", new=_async_wrap(_mock_download)),
patch.object(migrate_module, "_load_compose", return_value=_mock_compose),
patch.object(migrate_module, "_run_validation", side_effect=_mock_validation_ok),
):
post_resp = client.post(
"/api/migrate/batch",
json={"source_template_ids": ["id1"]},
cookies={_COOKIE_NAME: _full_session()},
)
job_id = post_resp.json()["job_id"]
get_resp = client.get(f"/api/migrate/batch/{job_id}")
assert get_resp.status_code == 200
assert get_resp.json()["job_id"] == job_id
def test_batch_unknown_job_returns_404(self):
resp = client.get("/api/migrate/batch/nonexistent-job-id")
assert resp.status_code == 404
@respx.mock
def test_batch_dry_run_option(self):
"""Dry run in batch: no uploads, all results are dry_run."""
with (
patch.object(migrate_module, "_download_adobe_template", new=_async_wrap(_mock_download)),
patch.object(migrate_module, "_load_compose", return_value=_mock_compose),
patch.object(migrate_module, "_run_validation", side_effect=_mock_validation_ok),
):
resp = client.post(
"/api/migrate/batch",
json={"source_template_ids": ["id1"], "options": {"dry_run": True}},
cookies={_COOKIE_NAME: _full_session()},
)
assert resp.status_code == 200
assert resp.json()["status"] == "queued"

View File

@ -175,7 +175,8 @@ def test_full_migration_flow(temp_history):
):
migrate_resp2 = test_client.post(
"/api/migrate",
json={"adobe_template_ids": [ADOBE_ID]},
# overwrite_if_exists=True so the second run updates the existing template
json={"adobe_template_ids": [ADOBE_ID], "options": {"overwrite_if_exists": True}},
cookies={_COOKIE_NAME: session_cookie},
)

View File

@ -0,0 +1,234 @@
"""
Tests for Phase 10: migration options (dryRun, overwriteIfExists, includeDocuments).
"""
import json
import os
from unittest.mock import patch
import pytest
import respx
import httpx
from fastapi.testclient import TestClient
from web.app import app
from web.session import _serializer, _COOKIE_NAME
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"
TEMPLATE_NAME = "Options Test Template"
ADOBE_ID = "opt-adobe-001"
DS_EXISTING_ID = "ds-existing-opt-001"
DS_NEW_ID = "ds-new-opt-001"
def _full_session():
return _serializer.dumps({
"adobe_access_token": "adobe-tok",
"docusign_access_token": "ds-tok",
})
@pytest.fixture(autouse=True)
def patch_settings(monkeypatch):
import web.config as cfg
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)
@pytest.fixture(autouse=True)
def temp_history(tmp_path, monkeypatch):
history_path = str(tmp_path / ".history.json")
monkeypatch.setattr(migrate_module, "_HISTORY_FILE", history_path)
return history_path
def _async_wrap(sync_fn):
async def wrapper(*args, **kwargs):
return sync_fn(*args, **kwargs)
return wrapper
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") as f:
json.dump({"name": TEMPLATE_NAME, "id": template_id}, f)
with open(os.path.join(output_dir, "form_fields.json"), "w") as f:
json.dump({"fields": []}, f)
with open(os.path.join(output_dir, "documents.json"), "w") as f:
json.dump({"documents": []}, f)
return True
def _mock_compose(template_dir: str, output_path: str):
with open(output_path, "w") as f:
json.dump({"name": TEMPLATE_NAME, "description": "mocked"}, f)
def _mock_validation_ok(download_dir):
return {"blockers": [], "warnings": [], "has_blockers": False}
class TestDryRun:
@respx.mock
def test_dry_run_does_not_upload(self):
"""dry_run=True: compose succeeds but no POST/PUT to DocuSign."""
with (
patch.object(migrate_module, "_download_adobe_template", new=_async_wrap(_mock_download)),
patch.object(migrate_module, "_load_compose", return_value=_mock_compose),
patch.object(migrate_module, "_run_validation", side_effect=_mock_validation_ok),
):
resp = client.post(
"/api/migrate",
json={
"source_template_ids": [ADOBE_ID],
"options": {"dry_run": True},
},
cookies={_COOKIE_NAME: _full_session()},
)
assert resp.status_code == 200
results = resp.json()["results"]
assert results[0]["status"] == "dry_run"
assert results[0]["action"] == "dry_run"
assert results[0]["docusign_template_id"] is None
assert results[0]["dry_run"] is True
@respx.mock
def test_dry_run_false_does_upload(self):
"""dry_run=False (default): upload proceeds."""
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})
)
with (
patch.object(migrate_module, "_download_adobe_template", new=_async_wrap(_mock_download)),
patch.object(migrate_module, "_load_compose", return_value=_mock_compose),
patch.object(migrate_module, "_run_validation", side_effect=_mock_validation_ok),
):
resp = client.post(
"/api/migrate",
json={"source_template_ids": [ADOBE_ID], "options": {"dry_run": False}},
cookies={_COOKIE_NAME: _full_session()},
)
assert resp.status_code == 200
assert resp.json()["results"][0]["status"] == "success"
class TestOverwriteIfExists:
@respx.mock
def test_skip_when_overwrite_false(self):
"""overwrite_if_exists=False + existing template → skipped."""
respx.get(f"{DS_BASE}/v2.1/accounts/{DS_ACCOUNT}/templates").mock(
return_value=httpx.Response(200, json={
"envelopeTemplates": [
{"templateId": DS_EXISTING_ID, "name": TEMPLATE_NAME, "lastModified": "2026-04-10T00:00:00Z"}
]
})
)
with (
patch.object(migrate_module, "_download_adobe_template", new=_async_wrap(_mock_download)),
patch.object(migrate_module, "_load_compose", return_value=_mock_compose),
patch.object(migrate_module, "_run_validation", side_effect=_mock_validation_ok),
):
resp = client.post(
"/api/migrate",
json={"source_template_ids": [ADOBE_ID], "options": {"overwrite_if_exists": False}},
cookies={_COOKIE_NAME: _full_session()},
)
results = resp.json()["results"]
assert results[0]["status"] == "skipped"
assert results[0]["docusign_template_id"] == DS_EXISTING_ID
@respx.mock
def test_overwrite_when_true(self):
"""overwrite_if_exists=True + existing template → PUT update."""
respx.get(f"{DS_BASE}/v2.1/accounts/{DS_ACCOUNT}/templates").mock(
return_value=httpx.Response(200, json={
"envelopeTemplates": [
{"templateId": DS_EXISTING_ID, "name": TEMPLATE_NAME, "lastModified": "2026-04-10T00:00:00Z"}
]
})
)
respx.put(f"{DS_BASE}/v2.1/accounts/{DS_ACCOUNT}/templates/{DS_EXISTING_ID}").mock(
return_value=httpx.Response(200, json={})
)
with (
patch.object(migrate_module, "_download_adobe_template", new=_async_wrap(_mock_download)),
patch.object(migrate_module, "_load_compose", return_value=_mock_compose),
patch.object(migrate_module, "_run_validation", side_effect=_mock_validation_ok),
):
resp = client.post(
"/api/migrate",
json={"source_template_ids": [ADOBE_ID], "options": {"overwrite_if_exists": True}},
cookies={_COOKIE_NAME: _full_session()},
)
assert resp.json()["results"][0]["action"] == "updated"
class TestSourceTemplateIds:
@respx.mock
def test_source_template_ids_field(self):
"""source_template_ids (new field) works correctly."""
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})
)
with (
patch.object(migrate_module, "_download_adobe_template", new=_async_wrap(_mock_download)),
patch.object(migrate_module, "_load_compose", return_value=_mock_compose),
patch.object(migrate_module, "_run_validation", side_effect=_mock_validation_ok),
):
resp = client.post(
"/api/migrate",
json={"source_template_ids": [ADOBE_ID]},
cookies={_COOKIE_NAME: _full_session()},
)
assert resp.status_code == 200
assert resp.json()["results"][0]["status"] == "success"
def test_no_ids_returns_400(self):
resp = client.post(
"/api/migrate",
json={},
cookies={_COOKIE_NAME: _full_session()},
)
assert resp.status_code == 400
class TestValidationBlocking:
def test_blocked_template_not_uploaded(self):
"""Template with validation blockers → status=blocked, no upload."""
def _mock_validation_blocked(download_dir):
return {
"blockers": ["No documents attached"],
"warnings": [],
"has_blockers": True,
}
with (
patch.object(migrate_module, "_download_adobe_template", new=_async_wrap(_mock_download)),
patch.object(migrate_module, "_run_validation", side_effect=_mock_validation_blocked),
):
resp = client.post(
"/api/migrate",
json={"source_template_ids": [ADOBE_ID]},
cookies={_COOKIE_NAME: _full_session()},
)
assert resp.status_code == 200
result = resp.json()["results"][0]
assert result["status"] == "blocked"
assert "No documents" in result["error"]

View File

@ -0,0 +1,139 @@
"""
Tests for Phase 8: normalized intermediate schema and mapping service.
"""
import json
from pathlib import Path
import pytest
from src.models.normalized_template import (
ActionType,
NormalizedDocument,
NormalizedField,
NormalizedRole,
NormalizedTemplate,
)
from src.services.mapping_service import adobe_folder_to_normalized
DOWNLOADS = Path(__file__).parent.parent / "downloads"
DAVID_DIR = DOWNLOADS / "David Tag Demo Form__CBJCHBCA"
NDA_DIR = DOWNLOADS / "_DEMO USE ONLY_ NDA__CBJCHBCA"
ROB_DIR = DOWNLOADS / "Rob Test__CBJCHBCA"
# ---------------------------------------------------------------------------
# Model construction
# ---------------------------------------------------------------------------
class TestNormalizedModels:
def test_normalized_role_defaults(self):
r = NormalizedRole(name="Customer", order=1)
assert r.action_type == ActionType.SIGN
assert r.order == 1
def test_normalized_field_defaults(self):
f = NormalizedField(type="text", label="Name", page=1, x=10, y=20, width=120, height=24)
assert f.required is False
assert f.read_only is False
assert f.options == []
assert f.conditional_parent_label is None
def test_normalized_template_construction(self):
t = NormalizedTemplate(
name="My Template",
roles=[NormalizedRole(name="Signer 1", order=1)],
fields=[
NormalizedField(type="signature", label="sig1", page=1, x=0, y=0, width=140, height=28)
],
)
assert t.name == "My Template"
assert len(t.roles) == 1
assert len(t.fields) == 1
def test_role_names(self):
t = NormalizedTemplate(
name="T",
roles=[
NormalizedRole(name="Customer", order=1),
NormalizedRole(name="Company", order=2),
],
)
assert t.role_names() == ["Customer", "Company"]
def test_fields_for_role(self):
t = NormalizedTemplate(
name="T",
roles=[NormalizedRole(name="Signer 1", order=1)],
fields=[
NormalizedField(type="signature", label="s1", page=1, x=0, y=0, width=140, height=28, role_name="Signer 1"),
NormalizedField(type="text", label="name", page=1, x=0, y=50, width=120, height=24, role_name="Signer 2"),
],
)
assert len(t.fields_for_role("Signer 1")) == 1
assert len(t.fields_for_role("Signer 2")) == 1
assert len(t.fields_for_role("Nobody")) == 0
def test_normalized_document_checksum(self):
doc = NormalizedDocument(
name="test.pdf",
content_base64="dGVzdA==",
checksum_sha256="9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
)
assert doc.checksum_sha256 != ""
def test_serialization_roundtrip(self):
t = NormalizedTemplate(
name="Round Trip",
roles=[NormalizedRole(name="Signer 1", order=1)],
)
dumped = t.model_dump()
restored = NormalizedTemplate(**dumped)
assert restored.name == t.name
assert len(restored.roles) == 1
# ---------------------------------------------------------------------------
# Mapping service — requires real download fixtures
# ---------------------------------------------------------------------------
@pytest.mark.skipif(not DAVID_DIR.exists(), reason="Downloads fixtures not present")
class TestMappingService:
def test_david_template_normalizes(self):
norm, warnings = adobe_folder_to_normalized(str(DAVID_DIR))
assert isinstance(norm, NormalizedTemplate)
assert norm.name != ""
assert len(norm.roles) >= 1
assert len(norm.fields) > 0
def test_david_fields_have_roles(self):
norm, _ = adobe_folder_to_normalized(str(DAVID_DIR))
role_names = norm.role_names()
for f in norm.fields:
assert f.role_name in role_names, f"Field '{f.label}' has unresolved role '{f.role_name}'"
def test_david_documents_have_checksum(self):
norm, _ = adobe_folder_to_normalized(str(DAVID_DIR))
assert len(norm.documents) >= 1
for doc in norm.documents:
assert doc.checksum_sha256 != "", f"Document '{doc.name}' missing checksum"
assert len(doc.checksum_sha256) == 64 # SHA-256 hex
def test_exclude_documents_option(self):
norm, _ = adobe_folder_to_normalized(str(DAVID_DIR), include_documents=False)
for doc in norm.documents:
assert doc.content_base64 == ""
# checksum still computed even when content excluded
assert doc.checksum_sha256 != ""
@pytest.mark.skipif(not NDA_DIR.exists(), reason="NDA fixture not present")
def test_nda_template_normalizes(self):
norm, _ = adobe_folder_to_normalized(str(NDA_DIR))
assert norm.name != ""
assert len(norm.fields) > 0
@pytest.mark.skipif(not ROB_DIR.exists(), reason="Rob fixture not present")
def test_rob_template_normalizes(self):
norm, _ = adobe_folder_to_normalized(str(ROB_DIR))
assert norm.name != ""

View File

@ -55,7 +55,7 @@ def test_compose_regression(template_name, update_snapshots):
output_path = tf.name
try:
result, warnings = compose_template(template_dir, output_path)
result, warnings, _ = compose_template(template_dir, output_path)
if update_snapshots:
os.makedirs(FIXTURES_DIR, exist_ok=True)
@ -121,7 +121,7 @@ def test_no_tabs_lost_on_recompose():
with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as tf:
output_path = tf.name
try:
result, _ = compose_template(template_dir, output_path)
result, _, _issues = compose_template(template_dir, output_path)
total_tabs = sum(_count_tabs(result).values())
assert total_tabs > 0, f"No tabs produced for {template_name}"
finally:

152
tests/test_retry.py Normal file
View File

@ -0,0 +1,152 @@
"""
Tests for Phase 11: retry with backoff utility.
"""
import asyncio
import time
from unittest.mock import MagicMock, patch
import pytest
from src.utils.retry import (
RateLimitError,
async_retry_with_backoff,
check_response_retryable,
retry_with_backoff,
)
class TestRetryWithBackoff:
def test_success_on_first_try(self):
call_count = {"n": 0}
@retry_with_backoff(max_retries=3, base_delay=0.01)
def fn():
call_count["n"] += 1
return "ok"
result = fn()
assert result == "ok"
assert call_count["n"] == 1
def test_retries_on_exception(self):
call_count = {"n": 0}
@retry_with_backoff(max_retries=2, base_delay=0.01)
def fn():
call_count["n"] += 1
if call_count["n"] < 3:
raise ConnectionError("transient")
return "ok"
with patch("src.utils.retry.time.sleep"):
result = fn()
assert result == "ok"
assert call_count["n"] == 3
def test_raises_after_max_retries(self):
@retry_with_backoff(max_retries=2, base_delay=0.01)
def fn():
raise ConnectionError("always fails")
with patch("src.utils.retry.time.sleep"):
with pytest.raises(ConnectionError):
fn()
def test_exponential_delay(self):
sleeps = []
@retry_with_backoff(max_retries=3, base_delay=1.0)
def fn():
raise ValueError("fail")
with patch("src.utils.retry.time.sleep", side_effect=lambda d: sleeps.append(d)):
with pytest.raises(ValueError):
fn()
assert len(sleeps) == 3
assert sleeps[0] == 1.0
assert sleeps[1] == 2.0
assert sleeps[2] == 4.0
def test_max_delay_capped(self):
sleeps = []
@retry_with_backoff(max_retries=5, base_delay=10.0, max_delay=15.0)
def fn():
raise ValueError("fail")
with patch("src.utils.retry.time.sleep", side_effect=lambda d: sleeps.append(d)):
with pytest.raises(ValueError):
fn()
assert all(d <= 15.0 for d in sleeps)
def test_only_retries_specified_exceptions(self):
call_count = {"n": 0}
@retry_with_backoff(max_retries=3, base_delay=0.01, retryable_exceptions=(ConnectionError,))
def fn():
call_count["n"] += 1
raise ValueError("not retryable")
with pytest.raises(ValueError):
fn()
assert call_count["n"] == 1 # no retries for ValueError
class TestAsyncRetryWithBackoff:
def test_async_success_on_first_try(self):
call_count = {"n": 0}
@async_retry_with_backoff(max_retries=3, base_delay=0.01)
async def fn():
call_count["n"] += 1
return "ok"
result = asyncio.get_event_loop().run_until_complete(fn())
assert result == "ok"
assert call_count["n"] == 1
def test_async_retries_on_exception(self):
call_count = {"n": 0}
@async_retry_with_backoff(max_retries=2, base_delay=0.01)
async def fn():
call_count["n"] += 1
if call_count["n"] < 3:
raise ConnectionError("transient")
return "ok"
with patch("src.utils.retry.asyncio.sleep", new=asyncio.coroutine(lambda d: None)):
result = asyncio.get_event_loop().run_until_complete(fn())
assert result == "ok"
def test_async_raises_after_max_retries(self):
@async_retry_with_backoff(max_retries=1, base_delay=0.01)
async def fn():
raise ConnectionError("always fails")
with patch("src.utils.retry.asyncio.sleep", new=asyncio.coroutine(lambda d: None)):
with pytest.raises(ConnectionError):
asyncio.get_event_loop().run_until_complete(fn())
class TestCheckResponseRetryable:
def test_429_is_retryable(self):
assert check_response_retryable(429) is True
def test_503_is_retryable(self):
assert check_response_retryable(503) is True
def test_200_not_retryable(self):
assert check_response_retryable(200) is False
def test_400_not_retryable(self):
assert check_response_retryable(400) is False
def test_404_not_retryable(self):
assert check_response_retryable(404) is False

138
tests/test_security.py Normal file
View File

@ -0,0 +1,138 @@
"""
Tests for Phase 12: security log sanitization and audit trail.
"""
import hashlib
import json
import logging
import pytest
from src.utils.log_sanitizer import (
SanitizingFilter,
install_sanitizing_filter,
redact,
redact_dict,
)
class TestRedact:
def test_bearer_token_redacted(self):
text = "Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.abc.def"
result = redact(text)
assert "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9" not in result
assert "[REDACTED]" in result
def test_access_token_assignment_redacted(self):
text = 'access_token: "super_secret_value_12345"'
result = redact(text)
assert "super_secret_value_12345" not in result
assert "[REDACTED]" in result
def test_password_redacted(self):
text = "password=hunter2supersecure"
result = redact(text)
assert "hunter2supersecure" not in result
def test_safe_text_unchanged(self):
text = "Template migrated successfully: NDA v2"
result = redact(text)
assert result == text
def test_long_base64_redacted(self):
# Simulate a long PDF base64 payload being logged
b64 = "A" * 600
result = redact(b64)
assert "A" * 100 not in result
assert "[REDACTED]" in result
def test_short_base64_not_redacted(self):
# Short base64 (e.g. an ID) should not be redacted
short_b64 = "dGVzdA==" # "test" base64
result = redact(short_b64)
assert "dGVzdA" in result
class TestRedactDict:
def test_token_key_redacted(self):
d = {"access_token": "secret123", "name": "My Template"}
result = redact_dict(d)
assert result["access_token"] == "[REDACTED]"
assert result["name"] == "My Template"
def test_nested_dict_redacted(self):
d = {"auth": {"token": "secret123", "user": "alice"}}
result = redact_dict(d)
assert result["auth"]["token"] == "[REDACTED]"
assert result["auth"]["user"] == "alice"
def test_document_base64_redacted(self):
d = {"documentBase64": "A" * 200}
result = redact_dict(d)
assert result["documentBase64"] == "[REDACTED]"
def test_list_of_dicts_redacted(self):
d = {"items": [{"token": "abc123xyz", "id": "1"}]}
result = redact_dict(d)
assert result["items"][0]["token"] == "[REDACTED]"
assert result["items"][0]["id"] == "1"
def test_safe_dict_unchanged(self):
d = {"template_name": "NDA", "status": "success", "count": 3}
result = redact_dict(d)
assert result == d
class TestSanitizingFilter:
def test_filter_redacts_log_message(self):
record = logging.LogRecord(
name="test", level=logging.INFO,
pathname="", lineno=0,
msg="Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.payload.signature",
args=(), exc_info=None,
)
f = SanitizingFilter()
f.filter(record)
assert "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9" not in record.msg
def test_filter_redacts_args(self):
record = logging.LogRecord(
name="test", level=logging.INFO,
pathname="", lineno=0,
msg="Token: %s",
args=("access_token=supersecretvalue123456",),
exc_info=None,
)
f = SanitizingFilter()
f.filter(record)
assert "supersecretvalue123456" not in str(record.args)
def test_install_sanitizing_filter_idempotent(self):
install_sanitizing_filter()
install_sanitizing_filter() # second call should not add duplicate
root = logging.getLogger()
sanitizing_filters = [f for f in root.filters if isinstance(f, SanitizingFilter)]
assert len(sanitizing_filters) == 1
# Clean up
for f in sanitizing_filters:
root.removeFilter(f)
class TestPdfChecksum:
def test_checksum_matches_content(self):
from src.services.mapping_service import adobe_folder_to_normalized
from pathlib import Path
downloads = Path(__file__).parent.parent / "downloads" / "David Tag Demo Form__CBJCHBCA"
if not downloads.exists():
pytest.skip("Downloads fixtures not present")
norm, _ = adobe_folder_to_normalized(str(downloads))
assert norm.documents, "Expected at least one document"
doc = norm.documents[0]
# Recompute checksum from source path to verify
import base64
pdf_bytes = Path(doc.source_path).read_bytes()
expected_checksum = hashlib.sha256(pdf_bytes).hexdigest()
assert doc.checksum_sha256 == expected_checksum

View File

@ -0,0 +1,181 @@
"""
Tests for Phase 9: validation service.
"""
import pytest
from src.models.normalized_template import (
NormalizedDocument,
NormalizedField,
NormalizedRole,
NormalizedTemplate,
)
from src.services.validation_service import (
ValidationResult,
compare_field_counts,
validate_template,
)
from src.reports.report_builder import (
MigrationReport,
MigrationStatus,
build_blocked_report,
build_error_report,
build_skipped_report,
build_success_report,
)
def _make_template(**kwargs) -> NormalizedTemplate:
defaults = dict(
name="Test Template",
roles=[NormalizedRole(name="Signer 1", order=1)],
fields=[
NormalizedField(
type="signature", label="sig1", page=1,
x=100, y=500, width=140, height=28,
role_name="Signer 1",
)
],
documents=[NormalizedDocument(name="test.pdf", checksum_sha256="abc", source_path="/fake.pdf")],
)
defaults.update(kwargs)
return NormalizedTemplate(**defaults)
class TestValidationService:
def test_valid_template_passes(self):
t = _make_template()
result = validate_template(t)
assert result.is_ok()
assert result.blockers == []
def test_no_recipients_is_blocker(self):
t = _make_template(roles=[])
result = validate_template(t)
assert result.has_blockers()
assert any("recipient" in b.lower() or "role" in b.lower() for b in result.blockers)
def test_no_documents_is_blocker(self):
t = _make_template(documents=[])
result = validate_template(t)
assert result.has_blockers()
assert any("document" in b.lower() for b in result.blockers)
def test_no_fields_is_warning(self):
t = _make_template(fields=[])
result = validate_template(t)
assert result.is_ok() # not a blocker
assert any("0 field" in w or "empty" in w.lower() for w in result.warnings)
def test_no_signature_field_is_warning(self):
t = _make_template(fields=[
NormalizedField(type="text", label="name", page=1, x=0, y=0, width=120, height=24, role_name="Signer 1")
])
result = validate_template(t)
assert result.is_ok()
assert any("signature" in w.lower() for w in result.warnings)
def test_field_with_unknown_role_is_warning(self):
t = _make_template(fields=[
NormalizedField(
type="signature", label="sig1", page=1, x=0, y=0,
width=140, height=28, role_name="NonExistentRole"
)
])
result = validate_template(t)
assert result.is_ok()
assert any("role" in w.lower() or "assign" in w.lower() for w in result.warnings)
def test_unsupported_features_become_warnings(self):
t = _make_template(unsupported_features=["Conditional HIDE action", "Webhook associations"])
result = validate_template(t)
assert result.is_ok()
assert len([w for w in result.warnings if "Unsupported" in w or "manual" in w.lower()]) >= 2
def test_validation_result_all_issues(self):
r = ValidationResult(blockers=["blocker1"], warnings=["warn1"])
issues = r.all_issues()
assert any("BLOCKER" in i for i in issues)
assert any("WARNING" in i for i in issues)
class TestCompareFieldCounts:
def test_matching_counts_no_warnings(self):
t = _make_template(fields=[
NormalizedField(type="signature", label="sig1", page=1, x=0, y=0, width=140, height=28, role_name="Signer 1")
])
ds = {
"recipients": {
"signers": [{"tabs": {"signHereTabs": [{"tabLabel": "sig1"}]}}]
}
}
result = compare_field_counts(t, ds)
assert result.is_ok()
def test_mismatched_counts_warns(self):
t = _make_template(fields=[
NormalizedField(type="signature", label="s1", page=1, x=0, y=0, width=140, height=28, role_name="Signer 1"),
NormalizedField(type="text", label="t1", page=1, x=0, y=50, width=120, height=24, role_name="Signer 1"),
])
ds = {"recipients": {"signers": [{"tabs": {"signHereTabs": [{}]}}]}}
result = compare_field_counts(t, ds)
assert any("mismatch" in w.lower() or "count" in w.lower() for w in result.warnings)
def test_zero_tabs_with_fields_warns(self):
t = _make_template()
ds = {"recipients": {"signers": []}}
result = compare_field_counts(t, ds)
assert result.warnings # should warn about 0 tabs
class TestReportBuilder:
def test_success_report(self):
r = build_success_report("My Template", "src_001", "ds_001", warnings=[])
assert r.status == MigrationStatus.SUCCESS
assert r.docusign_template_id == "ds_001"
def test_success_with_warnings(self):
r = build_success_report("My Template", "src_001", "ds_001", warnings=["some warning"])
assert r.status == MigrationStatus.SUCCESS_WITH_WARNINGS
def test_blocked_report(self):
r = build_blocked_report("T", "id1", blockers=["no docs"], warnings=[])
assert r.status == MigrationStatus.BLOCKED
assert r.blockers == ["no docs"]
def test_error_report(self):
r = build_error_report("T", "id1", error="Connection refused")
assert r.status == MigrationStatus.ERROR
assert "Connection" in r.error
def test_skipped_report(self):
r = build_skipped_report("T", "id1", reason="already migrated")
assert r.status == MigrationStatus.SKIPPED
def test_migration_report_summary(self):
report = MigrationReport()
report.add(build_success_report("T1", "1", "ds1", []))
report.add(build_success_report("T2", "2", "ds2", ["warn"]))
report.add(build_error_report("T3", "3", "fail"))
summary = report.summary()
assert summary["total"] == 3
assert summary.get("success", 0) == 1
assert summary.get("error", 0) == 1
def test_report_to_dict(self):
report = MigrationReport()
report.add(build_success_report("T1", "1", "ds1", []))
d = report.to_dict()
assert "summary" in d
assert "templates" in d
assert d["templates"][0]["template_name"] == "T1"
def test_report_has_errors(self):
report = MigrationReport()
report.add(build_error_report("T", "1", "err"))
assert report.has_errors()
def test_report_no_errors(self):
report = MigrationReport()
report.add(build_success_report("T", "1", "ds1", []))
assert not report.has_errors()

View File

@ -15,7 +15,7 @@ from fastapi.responses import FileResponse
import os
from web.config import settings
from web.routers import auth, templates, migrate
from web.routers import auth, templates, migrate, verify
app = FastAPI(
title="Adobe Sign → DocuSign Migrator",
@ -24,9 +24,10 @@ app = FastAPI(
)
# Routers
app.include_router(auth.router, prefix="/api/auth", tags=["auth"])
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(migrate.router, prefix="/api/migrate", tags=["migrate"])
app.include_router(verify.router, prefix="/api/verify", tags=["verify"])
# Static files (frontend)
_static_dir = os.path.join(os.path.dirname(__file__), "static")

View File

@ -3,8 +3,10 @@ web/routers/migrate.py
----------------------
Migration trigger and history endpoints.
POST /api/migrate run the pipeline for one or more Adobe template IDs
GET /api/migrate/history return past migration records
POST /api/migrate run the pipeline for one or more Adobe template IDs
POST /api/migrate/batch batch migration with async progress tracking
GET /api/migrate/batch/{id} poll batch job status
GET /api/migrate/history return past migration records
"""
import asyncio
@ -12,8 +14,9 @@ import json
import os
import sys
import tempfile
import uuid
from datetime import datetime, timezone
from typing import List, Optional
from typing import Dict, List, Optional
import httpx
from fastapi import APIRouter, Request
@ -23,7 +26,6 @@ from pydantic import BaseModel
from web.config import settings
from web.session import get_session
# Ensure src/ is on path
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "src"))
router = APIRouter()
@ -32,9 +34,26 @@ _HISTORY_FILE = os.path.join(
os.path.dirname(__file__), "..", "..", "migration-output", ".history.json"
)
# In-memory batch job store (keyed by job_id)
_batch_jobs: Dict[str, dict] = {}
class MigrationOptions(BaseModel):
dry_run: bool = False
overwrite_if_exists: bool = False
include_documents: bool = True
class MigrateRequest(BaseModel):
adobe_template_ids: List[str]
# Primary API (blueprint-aligned)
source_template_ids: Optional[List[str]] = None
target_folder: Optional[str] = None
options: MigrationOptions = MigrationOptions()
# Legacy field kept for backward compatibility
adobe_template_ids: Optional[List[str]] = None
def resolved_ids(self) -> List[str]:
return self.source_template_ids or self.adobe_template_ids or []
def _load_history() -> list:
@ -51,10 +70,7 @@ def _save_history(records: list) -> None:
def _load_compose():
"""
Dynamically load and return the compose_template function from src/.
Isolated in its own function so tests can patch it without touching the file system.
"""
"""Dynamically load compose_template from src/."""
import importlib.util
spec = importlib.util.spec_from_file_location(
"compose_docusign_template",
@ -71,21 +87,17 @@ async def _download_adobe_template(template_id: str, access_token: str, output_d
base = settings.adobe_sign_base_url
async with httpx.AsyncClient() as client:
# Metadata
meta_resp = await client.get(f"{base}/libraryDocuments/{template_id}", headers=headers)
if not meta_resp.is_success:
return False
metadata = meta_resp.json()
# Form fields
fields_resp = await client.get(f"{base}/libraryDocuments/{template_id}/formFields", headers=headers)
form_fields = fields_resp.json() if fields_resp.is_success else {"fields": []}
# Documents list
docs_resp = await client.get(f"{base}/libraryDocuments/{template_id}/documents", headers=headers)
documents = docs_resp.json() if docs_resp.is_success else {"documents": []}
# Download first PDF
doc_list = documents.get("documents", [])
pdf_bytes = b""
if doc_list:
@ -111,10 +123,27 @@ async def _download_adobe_template(template_id: str, access_token: str, output_d
return True
def _run_validation(download_dir: str) -> dict:
"""Run validation service on downloaded template, return summary."""
try:
from src.services.mapping_service import adobe_folder_to_normalized
from src.services.validation_service import validate_template
norm, _ = adobe_folder_to_normalized(download_dir)
result = validate_template(norm)
return {
"blockers": result.blockers,
"warnings": result.warnings,
"has_blockers": result.has_blockers(),
}
except Exception as exc:
return {"blockers": [], "warnings": [f"Validation skipped: {exc}"], "has_blockers": False}
async def _migrate_one(
adobe_id: str,
adobe_access_token: str,
docusign_access_token: str,
options: MigrationOptions,
) -> dict:
"""Run the full pipeline for one Adobe template. Returns a result record."""
timestamp = datetime.now(timezone.utc).isoformat()
@ -134,18 +163,42 @@ async def _migrate_one(
"action": None,
"status": "failed",
"error": "Adobe Sign download failed",
"warnings": [],
"blockers": [],
"field_issues": [],
"dry_run": options.dry_run,
}
# Read template name from metadata
with open(os.path.join(download_dir, "metadata.json")) as f:
metadata = json.load(f)
template_name = metadata.get("name", adobe_id)
# 2. Compose DocuSign template JSON
# 2. Validate
validation = _run_validation(download_dir)
if validation["has_blockers"]:
return {
"timestamp": timestamp,
"adobe_template_id": adobe_id,
"adobe_template_name": template_name,
"docusign_template_id": None,
"action": "blocked",
"status": "blocked",
"error": f"Validation blockers: {'; '.join(validation['blockers'])}",
"warnings": validation["warnings"],
"blockers": validation["blockers"],
"field_issues": [],
"dry_run": options.dry_run,
}
# 3. Compose
composed_file = os.path.join(tmpdir, "docusign-template.json")
compose_issues: list = []
try:
compose_fn = _load_compose()
compose_fn(download_dir, composed_file)
compose_result = compose_fn(download_dir, composed_file)
# compose_template returns (template, warnings, issues)
if isinstance(compose_result, tuple) and len(compose_result) >= 3:
compose_issues = compose_result[2] or []
except Exception as exc:
return {
"timestamp": timestamp,
@ -155,6 +208,10 @@ async def _migrate_one(
"action": None,
"status": "failed",
"error": f"Compose failed: {exc}",
"warnings": validation["warnings"],
"blockers": [],
"field_issues": [],
"dry_run": options.dry_run,
}
if not os.path.exists(composed_file):
return {
@ -165,12 +222,36 @@ async def _migrate_one(
"action": None,
"status": "failed",
"error": "Compose produced no output file",
"warnings": validation["warnings"],
"blockers": [],
"field_issues": [],
"dry_run": options.dry_run,
}
# 3. Upload (upsert) to DocuSign using web session token
# 4. Dry run — stop here, do not upload
if options.dry_run:
return {
"timestamp": timestamp,
"adobe_template_id": adobe_id,
"adobe_template_name": template_name,
"docusign_template_id": None,
"action": "dry_run",
"status": "dry_run",
"error": None,
"warnings": validation["warnings"],
"blockers": [],
"field_issues": compose_issues,
"dry_run": True,
}
# 5. Upload (upsert) to DocuSign
with open(composed_file) as f:
template_json = json.load(f)
if not options.include_documents:
for doc in template_json.get("documents", []):
doc.pop("documentBase64", None)
ds_headers = {
"Authorization": f"Bearer {docusign_access_token}",
"Content-Type": "application/json",
@ -179,7 +260,7 @@ async def _migrate_one(
list_url = f"{settings.docusign_base_url}/v2.1/accounts/{settings.docusign_account_id}/templates"
async with httpx.AsyncClient() as client:
# Find existing
# Duplicate detection
list_resp = await client.get(
list_url, headers=ds_headers, params={"search_text": template_name, "count": 100}
)
@ -191,6 +272,22 @@ async def _migrate_one(
exact.sort(key=lambda t: t.get("lastModified", ""), reverse=True)
existing_id = exact[0]["templateId"]
# Skip if already exists and overwrite is disabled
if existing_id and not options.overwrite_if_exists:
return {
"timestamp": timestamp,
"adobe_template_id": adobe_id,
"adobe_template_name": template_name,
"docusign_template_id": existing_id,
"action": "skipped",
"status": "skipped",
"error": None,
"warnings": validation["warnings"] + ["Skipped: template already exists (overwrite_if_exists=false)"],
"blockers": [],
"field_issues": compose_issues,
"dry_run": False,
}
if existing_id:
up_resp = await client.put(
f"{list_url}/{existing_id}", headers=ds_headers, json=template_json
@ -211,6 +308,10 @@ async def _migrate_one(
"action": None,
"status": "failed",
"error": f"DocuSign upload failed ({up_resp.status_code}): {up_resp.text[:200]}",
"warnings": validation["warnings"],
"blockers": [],
"field_issues": compose_issues,
"dry_run": False,
}
return {
@ -221,6 +322,10 @@ async def _migrate_one(
"action": action,
"status": "success",
"error": None,
"warnings": validation["warnings"],
"blockers": [],
"field_issues": compose_issues,
"dry_run": False,
}
@ -233,17 +338,21 @@ async def run_migration(body: MigrateRequest, request: Request):
if not session.get("docusign_access_token"):
return JSONResponse({"error": "not authenticated to DocuSign"}, status_code=401)
ids = body.resolved_ids()
if not ids:
return JSONResponse({"error": "no template IDs provided"}, status_code=400)
tasks = [
_migrate_one(
aid,
session["adobe_access_token"],
session["docusign_access_token"],
body.options,
)
for aid in body.adobe_template_ids
for aid in ids
]
results = await asyncio.gather(*tasks)
# Append to history
history = _load_history()
history.extend(results)
_save_history(history)
@ -255,3 +364,101 @@ async def run_migration(body: MigrateRequest, request: Request):
def migration_history():
"""Return all past migration records."""
return {"history": _load_history()}
# ---------------------------------------------------------------------------
# Batch migration
# ---------------------------------------------------------------------------
async def _run_batch_job(
job_id: str,
ids: List[str],
adobe_token: str,
ds_token: str,
options: MigrationOptions,
) -> None:
"""Background coroutine that processes a batch job and updates _batch_jobs."""
job = _batch_jobs[job_id]
job["status"] = "running"
results = []
for i, adobe_id in enumerate(ids):
job["progress"] = {"completed": i, "total": len(ids), "current_id": adobe_id}
result = await _migrate_one(adobe_id, adobe_token, ds_token, options)
# Retry once on transient failures (network errors, not validation blockers)
if result["status"] == "failed" and "upload failed" in (result.get("error") or ""):
result = await _migrate_one(adobe_id, adobe_token, ds_token, options)
if result["status"] != "failed":
result["retried"] = True
results.append(result)
job["results"] = results
# Persist to history
history = _load_history()
history.extend(results)
_save_history(history)
success = sum(1 for r in results if r["status"] == "success")
failed = sum(1 for r in results if r["status"] in ("failed", "blocked"))
skipped = sum(1 for r in results if r["status"] == "skipped")
dry_runs = sum(1 for r in results if r["status"] == "dry_run")
job["status"] = "completed"
job["progress"] = {"completed": len(ids), "total": len(ids), "current_id": None}
job["summary"] = {
"total": len(ids),
"success": success,
"failed": failed,
"skipped": skipped,
"dry_run": dry_runs,
}
@router.post("/batch")
async def run_batch_migration(body: MigrateRequest, request: Request):
"""
Start an async batch migration job. Returns a job_id immediately.
Poll GET /api/migrate/batch/{job_id} for status.
"""
session = get_session(request)
if not session.get("adobe_access_token"):
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)
ids = body.resolved_ids()
if not ids:
return JSONResponse({"error": "no template IDs provided"}, status_code=400)
job_id = str(uuid.uuid4())
_batch_jobs[job_id] = {
"job_id": job_id,
"status": "queued",
"total": len(ids),
"results": [],
"progress": {"completed": 0, "total": len(ids), "current_id": None},
"summary": None,
"created_at": datetime.now(timezone.utc).isoformat(),
}
asyncio.create_task(
_run_batch_job(
job_id, ids,
session["adobe_access_token"],
session["docusign_access_token"],
body.options,
)
)
return {"job_id": job_id, "total": len(ids), "status": "queued"}
@router.get("/batch/{job_id}")
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)
return job

View File

@ -6,6 +6,7 @@ Computes per-template migration status for the side-by-side UI.
"""
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
import httpx
@ -151,6 +152,8 @@ async def template_status(request: Request):
# needs_update if Adobe was modified after the DS template
status = "needs_update" if adobe_modified > ds_modified else "migrated"
blockers, warnings = _get_validation(t.get("id", ""), name)
results.append({
"adobe_id": t.get("id"),
"name": name,
@ -158,10 +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": blockers,
"warnings": warnings,
})
return {"templates": results}
def _get_validation(template_id: str, template_name: str) -> tuple[list, list]:
"""Return (blockers, warnings) if the template has been downloaded; else ([], [])."""
try:
from src.services.mapping_service import adobe_folder_to_normalized
from src.services.validation_service import validate_template
downloads_dir = Path(settings.downloads_dir) if hasattr(settings, "downloads_dir") else Path("downloads")
# Match folder by name__id or name pattern
candidates = list(downloads_dir.glob(f"*__{template_id}"))
if not candidates:
# Try matching by sanitised name prefix
safe = template_name.replace("/", "_").replace("\\", "_")
candidates = list(downloads_dir.glob(f"{safe}*"))
if not candidates or not candidates[0].is_dir():
return [], []
normalized = adobe_folder_to_normalized(str(candidates[0]))
result = validate_template(normalized)
return result.blockers, result.warnings
except Exception:
return [], []
# asyncio needed for gather — import at top of module
import asyncio

146
web/routers/verify.py Normal file
View File

@ -0,0 +1,146 @@
"""
web/routers/verify.py
---------------------
Verification endpoints: send test envelopes, poll status, void.
Uses DocuSign Envelopes API to confirm migrated templates work end-to-end.
"""
from typing import Optional
import httpx
from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from web.config import settings
from web.session import get_session
router = APIRouter()
class SendRequest(BaseModel):
template_id: str
recipient_name: str
recipient_email: str
class VoidRequest(BaseModel):
reason: str = "Test envelope — voided after verification"
def _require_docusign(session: dict) -> Optional[JSONResponse]:
if not session.get("docusign_access_token"):
return JSONResponse({"error": "not authenticated to DocuSign"}, status_code=401)
return None
@router.post("/send")
async def send_test_envelope(body: SendRequest, request: Request):
"""Send a test envelope using a migrated DocuSign template."""
session = get_session(request)
err = _require_docusign(session)
if err:
return err
headers = {
"Authorization": f"Bearer {session['docusign_access_token']}",
"Content-Type": "application/json",
}
base = f"{settings.docusign_base_url}/v2.1/accounts/{settings.docusign_account_id}"
async with httpx.AsyncClient() as client:
# Fetch template to discover actual role names
tpl_resp = await client.get(f"{base}/templates/{body.template_id}", headers=headers)
role_names = []
if tpl_resp.is_success:
tpl = tpl_resp.json()
recipients = tpl.get("recipients", {})
for group in recipients.values():
if isinstance(group, list):
for r in group:
rn = r.get("roleName")
if rn and rn not in role_names:
role_names.append(rn)
# Fall back to generic role name if template fetch failed
if not role_names:
role_names = ["Signer"]
template_roles = [
{"email": body.recipient_email, "name": body.recipient_name, "roleName": rn}
for rn in role_names
]
payload = {
"templateId": body.template_id,
"status": "sent",
"templateRoles": template_roles,
"emailSubject": "[Verification Test] Please sign this document",
}
resp = await client.post(f"{base}/envelopes", headers=headers, json=payload)
if not resp.is_success:
return JSONResponse(
{"error": "DocuSign API error", "detail": resp.text},
status_code=502,
)
data = resp.json()
return {"envelope_id": data.get("envelopeId"), "roles": role_names}
@router.get("/status/{envelope_id}")
async def envelope_status(envelope_id: str, request: Request):
"""Get the current status of a test envelope."""
session = get_session(request)
err = _require_docusign(session)
if err:
return err
async with httpx.AsyncClient() as client:
resp = await client.get(
f"{settings.docusign_base_url}/v2.1/accounts/{settings.docusign_account_id}/envelopes/{envelope_id}",
headers={"Authorization": f"Bearer {session['docusign_access_token']}"},
)
if not resp.is_success:
return JSONResponse(
{"error": "DocuSign API error", "detail": resp.text},
status_code=502,
)
data = resp.json()
return {
"envelope_id": envelope_id,
"status": data.get("status"),
"completed_at": data.get("completedDateTime"),
"sent_at": data.get("sentDateTime"),
}
@router.post("/void/{envelope_id}")
async def void_envelope(envelope_id: str, body: VoidRequest, request: Request):
"""Void a test envelope after verification is complete."""
session = get_session(request)
err = _require_docusign(session)
if err:
return err
async with httpx.AsyncClient() as client:
resp = await client.put(
f"{settings.docusign_base_url}/v2.1/accounts/{settings.docusign_account_id}/envelopes/{envelope_id}",
headers={
"Authorization": f"Bearer {session['docusign_access_token']}",
"Content-Type": "application/json",
},
json={"status": "voided", "voidedReason": body.reason},
)
if not resp.is_success:
return JSONResponse(
{"error": "DocuSign API error", "detail": resp.text},
status_code=502,
)
return {"voided": True, "envelope_id": envelope_id}

View File

@ -1,343 +0,0 @@
// Adobe Sign → DocuSign Migrator — frontend app
// Vanilla JS, no build step.
const $ = id => document.getElementById(id);
let statusTemplates = []; // [{adobe_id, name, status, docusign_id, ...}]
let dsTemplates = []; // [{id, name, lastModified}]
let authState = { adobe: false, docusign: false };
// ── Init ────────────────────────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', async () => {
await refreshAuth();
await refreshTemplates();
await refreshHistory();
$('btn-migrate').addEventListener('click', onMigrate);
$('btn-refresh').addEventListener('click', async () => {
await refreshTemplates();
await refreshHistory();
});
});
// ── Auth ─────────────────────────────────────────────────────────────────────
async function refreshAuth() {
const resp = await fetch('/api/auth/status');
authState = await resp.json();
renderAuthBar();
}
function renderAuthBar() {
// Adobe: use .env credentials (primary), OAuth dialog (secondary)
const adobeEl = $('badge-adobe');
adobeEl.textContent = authState.adobe ? '✓ Adobe Sign' : 'Connect Adobe Sign';
adobeEl.className = 'auth-badge' + (authState.adobe ? ' connected' : '');
adobeEl.onclick = authState.adobe
? () => disconnectPlatform('adobe')
: () => connectAdobeEnv();
// DocuSign: JWT grant from .env — no browser sign-in needed
const dsEl = $('badge-docusign');
dsEl.textContent = authState.docusign ? '✓ DocuSign' : 'Connect DocuSign';
dsEl.className = 'auth-badge' + (authState.docusign ? ' connected' : '');
dsEl.onclick = authState.docusign
? () => disconnectPlatform('docusign')
: () => connectDocusign();
}
async function disconnectPlatform(platform) {
await fetch(`/api/auth/${platform}/disconnect`);
authState[platform] = false;
renderAuthBar();
await refreshTemplates();
}
async function connectAdobeEnv() {
const el = $('badge-adobe');
el.textContent = 'Connecting…';
const resp = await fetch('/api/auth/adobe/connect');
const data = await resp.json();
if (data.connected) {
authState.adobe = true;
renderAuthBar();
await refreshTemplates();
} else {
el.textContent = 'Connect Adobe Sign';
// If .env has no credentials, fall back to the OAuth dialog
if (data.error && data.error.includes('No Adobe Sign credentials')) {
startAdobeAuth();
} else {
setStatus('Adobe Sign error: ' + (data.error || 'unknown'));
}
}
}
async function connectDocusign() {
const dsEl = $('badge-docusign');
dsEl.textContent = 'Connecting…';
const resp = await fetch('/api/auth/docusign/connect');
const data = await resp.json();
if (data.connected) {
authState.docusign = true;
renderAuthBar();
await refreshTemplates();
} else {
dsEl.textContent = 'Connect DocuSign';
setStatus('DocuSign error: ' + (data.error || 'unknown'));
}
}
// Adobe Sign uses the same manual-paste flow as the CLI:
// 1. Open auth URL in new tab
// 2. User authorizes → lands on failed https://localhost:8080/callback page
// 3. User copies that URL, pastes it into the dialog here
// 4. We POST it to /api/auth/adobe/exchange
async function startAdobeAuth() {
const resp = await fetch('/api/auth/adobe/url');
const { url } = await resp.json();
showAdobeDialog(url);
}
function showAdobeDialog(authUrl) {
// Remove any existing dialog
const existing = $('adobe-auth-dialog');
if (existing) existing.remove();
const dialog = document.createElement('div');
dialog.id = 'adobe-auth-dialog';
dialog.innerHTML = `
<div class="dialog-backdrop"></div>
<div class="dialog-box">
<h2>Connect Adobe Sign</h2>
<ol>
<li><a href="${escHtml(authUrl)}" target="_blank" rel="noopener" id="adobe-auth-link">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" placeholder="https://localhost:8080/callback?code=…" />
<div class="dialog-error" id="dialog-error"></div>
<div class="dialog-actions">
<button id="btn-submit-code">Connect</button>
<button id="btn-cancel-dialog" class="btn-secondary">Cancel</button>
</div>
</div>
`;
document.body.appendChild(dialog);
$('btn-cancel-dialog').onclick = () => dialog.remove();
$('btn-submit-code').onclick = () => submitAdobeCode(dialog);
// Also handle Enter key
$('adobe-redirect-input').addEventListener('keydown', e => {
if (e.key === 'Enter') submitAdobeCode(dialog);
});
}
async function submitAdobeCode(dialog) {
const url = $('adobe-redirect-input').value.trim();
if (!url) return;
$('btn-submit-code').disabled = true;
$('btn-submit-code').textContent = 'Connecting…';
$('dialog-error').textContent = '';
try {
const resp = await fetch('/api/auth/adobe/exchange', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ redirect_url: url }),
});
const data = await resp.json();
if (!resp.ok || data.error) {
$('dialog-error').textContent = data.error || 'Connection failed.';
$('btn-submit-code').disabled = false;
$('btn-submit-code').textContent = 'Connect';
return;
}
dialog.remove();
authState.adobe = true;
renderAuthBar();
await refreshTemplates();
} catch (e) {
$('dialog-error').textContent = 'Error: ' + e.message;
$('btn-submit-code').disabled = false;
$('btn-submit-code').textContent = 'Connect';
}
}
// ── Templates ────────────────────────────────────────────────────────────────
async function refreshTemplates() {
renderAdobeList([]);
renderDsList([]);
if (!authState.adobe || !authState.docusign) {
setStatus(authState.adobe || authState.docusign
? 'Connect both platforms to see migration status.'
: 'Connect Adobe Sign and DocuSign to get started.');
$('btn-migrate').disabled = true;
return;
}
setStatus('Loading templates…');
try {
const [statusResp, dsResp] = await Promise.all([
fetch('/api/templates/status'),
fetch('/api/templates/docusign'),
]);
statusTemplates = (await statusResp.json()).templates || [];
dsTemplates = (await dsResp.json()).templates || [];
renderAdobeList(statusTemplates);
renderDsList(dsTemplates);
setStatus(`${statusTemplates.length} Adobe template(s) loaded.`);
} catch (e) {
setStatus('Error loading templates: ' + e.message);
}
}
function renderAdobeList(items) {
const ul = $('adobe-list');
if (!items.length) {
ul.innerHTML = '<li class="empty-msg">No templates found.</li>';
return;
}
ul.innerHTML = items.map(t => `
<li class="template-item" data-id="${t.adobe_id}">
<input type="checkbox" data-id="${t.adobe_id}" />
<span class="template-name">${escHtml(t.name)}</span>
<span class="badge badge-${t.status}">${statusLabel(t.status)}</span>
<span class="template-spinner" id="spin-${t.adobe_id}"></span>
</li>
`).join('');
ul.querySelectorAll('.template-item').forEach(li => {
li.addEventListener('click', e => {
if (e.target.type === 'checkbox') return;
const cb = li.querySelector('input[type=checkbox]');
cb.checked = !cb.checked;
li.classList.toggle('selected', cb.checked);
updateMigrateButton();
});
li.querySelector('input').addEventListener('change', () => {
li.classList.toggle('selected', li.querySelector('input').checked);
updateMigrateButton();
});
});
}
function renderDsList(items) {
const ul = $('ds-list');
if (!items.length) {
ul.innerHTML = '<li class="empty-msg">No templates found.</li>';
return;
}
ul.innerHTML = items.map(t => `
<li class="template-item">
<span class="template-name">${escHtml(t.name)}</span>
<span style="font-size:11px;color:#999">${(t.lastModified || '').slice(0, 10)}</span>
</li>
`).join('');
}
function updateMigrateButton() {
const checked = document.querySelectorAll('#adobe-list input[type=checkbox]:checked');
$('btn-migrate').disabled = checked.length === 0;
}
// ── Migration ─────────────────────────────────────────────────────────────────
async function onMigrate() {
const checked = [...document.querySelectorAll('#adobe-list input[type=checkbox]:checked')];
const ids = checked.map(cb => cb.dataset.id);
if (!ids.length) return;
$('btn-migrate').disabled = true;
setStatus(`Migrating ${ids.length} template(s)…`);
ids.forEach(id => {
const spin = $('spin-' + id);
if (spin) spin.textContent = '⏳';
});
try {
const resp = await fetch('/api/migrate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ adobe_template_ids: ids }),
});
const data = await resp.json();
let successCount = 0;
(data.results || []).forEach(r => {
const spin = $('spin-' + r.adobe_template_id);
if (r.status === 'success') {
successCount++;
if (spin) spin.textContent = r.action === 'updated' ? '✏️' : '✅';
} else {
if (spin) spin.textContent = '❌';
}
});
setStatus(`Done: ${successCount}/${ids.length} succeeded.`);
await refreshTemplates();
await refreshHistory();
} catch (e) {
setStatus('Migration error: ' + e.message);
}
}
// ── History ───────────────────────────────────────────────────────────────────
async function refreshHistory() {
try {
const resp = await fetch('/api/migrate/history');
const { history } = await resp.json();
renderHistory(history || []);
} catch {
renderHistory([]);
}
}
function renderHistory(records) {
const tbody = $('history-tbody');
if (!records.length) {
tbody.innerHTML = '<tr><td colspan="5" class="empty-msg">No migrations yet.</td></tr>';
return;
}
tbody.innerHTML = [...records].reverse().slice(0, 50).map(r => `
<tr>
<td>${(r.timestamp || '').replace('T', ' ').slice(0, 19)}</td>
<td>${escHtml(r.adobe_template_name || r.adobe_template_id || '')}</td>
<td>${escHtml(r.docusign_template_id || '—')}</td>
<td>${escHtml(r.action || '—')}</td>
<td>
<span class="badge ${r.status === 'success' ? 'badge-migrated' : 'badge-not_migrated'}">
${r.status}
</span>
</td>
</tr>
`).join('');
}
// ── Utilities ─────────────────────────────────────────────────────────────────
function setStatus(msg) { $('status-msg').textContent = msg; }
function statusLabel(s) {
return { not_migrated: 'Not Migrated', migrated: 'Migrated', needs_update: 'Needs Update' }[s] || s;
}
function escHtml(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}

279
web/static/css/base.css Normal file
View File

@ -0,0 +1,279 @@
/* Base reset, typography, and utility classes */
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap');
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
height: 100%;
font-family: var(--font);
font-size: var(--font-size-base);
color: var(--text);
background: var(--page-bg);
-webkit-font-smoothing: antialiased;
}
body {
display: flex;
height: 100vh;
overflow: hidden;
}
/* ── Scrollbar ── */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
/* ── Buttons ── */
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
border-radius: var(--radius-sm);
font-size: var(--font-size-base);
font-family: var(--font);
font-weight: 600;
cursor: pointer;
border: none;
transition: all 0.15s;
user-select: none;
white-space: nowrap;
line-height: 1;
}
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-primary { background: var(--cobalt); color: #fff; }
.btn-primary:not(:disabled):hover { background: var(--cobalt-hover); }
.btn-secondary { background: var(--card-bg); color: var(--text); border: 1px solid var(--border); }
.btn-secondary:hover { background: var(--ecru); }
.btn-ghost { background: transparent; color: var(--cobalt); padding: 6px 10px; }
.btn-ghost:hover { background: var(--cobalt-light); }
.btn-danger { background: var(--poppy); color: #fff; }
.btn-danger:hover { background: #e04040; }
.btn-sm { padding: 5px 10px; font-size: var(--font-size-sm); }
.btn-xs { padding: 3px 8px; font-size: var(--font-size-xs); }
.btn-icon { padding: 6px; border-radius: var(--radius-sm); }
/* ── Badges ── */
.badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 12px;
font-size: var(--font-size-xs);
font-weight: 600;
white-space: nowrap;
}
.badge-dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
.badge-green { background: var(--success-bg); color: var(--success); }
.badge-amber { background: var(--warning-bg); color: var(--warning); }
.badge-red { background: var(--error-bg); color: var(--error); }
.badge-blue { background: var(--cobalt-light); color: var(--cobalt); }
.badge-gray { background: #EDF0F4; color: var(--slate); }
/* ── Cards ── */
.card {
background: var(--card-bg);
border-radius: var(--radius-md);
border: 1px solid var(--border);
box-shadow: var(--shadow-sm);
margin-bottom: var(--space-md);
}
.card-header {
padding: 14px 20px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
}
.card-title { font-size: var(--font-size-md); font-weight: 700; }
.card-body { padding: var(--space-md) 20px; }
/* ── Tables ── */
.table-wrap { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; }
th {
text-align: left;
padding: 10px 14px;
font-size: var(--font-size-xs);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted);
border-bottom: 1px solid var(--border);
background: #FAFBFC;
white-space: nowrap;
}
td {
padding: 11px 14px;
border-bottom: 1px solid var(--border);
font-size: var(--font-size-base);
vertical-align: middle;
}
tr:last-child td { border-bottom: none; }
tr:hover td { background: #FAFBFC; }
/* ── Page layout ── */
.page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 20px;
}
.page-title { font-size: var(--font-size-xl); font-weight: 700; color: var(--text); }
.page-subtitle { font-size: var(--font-size-base); color: var(--text-muted); margin-top: 2px; }
.page-actions { display: flex; gap: var(--space-sm); align-items: center; }
/* ── Callouts ── */
.callout {
padding: 12px 16px;
border-radius: var(--radius-sm);
font-size: var(--font-size-base);
margin-bottom: var(--space-md);
display: flex;
gap: 10px;
align-items: flex-start;
}
.callout-icon { font-size: 16px; flex-shrink: 0; }
.callout.info { background: var(--cobalt-light); border: 1px solid #B3D4FF; color: #0052A3; }
.callout.warn { background: var(--warning-bg); border: 1px solid #FFD280; color: #7A3E00; }
.callout.success { background: var(--success-bg); border: 1px solid #B3E8D5; color: #006644; }
.callout.error { background: var(--error-bg); border: 1px solid #FFB3B3; color: #8B0000; }
/* ── Tabs ── */
.tabs {
display: flex;
border-bottom: 2px solid var(--border);
margin-bottom: 20px;
gap: 0;
}
.tab {
padding: 10px 18px;
font-size: var(--font-size-base);
font-weight: 600;
cursor: pointer;
color: var(--text-muted);
border-bottom: 2px solid transparent;
margin-bottom: -2px;
transition: all 0.1s;
user-select: none;
}
.tab:hover { color: var(--text); }
.tab.active { color: var(--cobalt); border-bottom-color: var(--cobalt); }
/* ── Divider ── */
.divider { height: 1px; background: var(--border); margin: var(--space-md) 0; }
/* ── Misc utilities ── */
.mono { font-family: var(--font-mono); font-size: var(--font-size-sm); background: var(--ecru); padding: 1px 6px; border-radius: 3px; }
.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; }
/* ── Empty state ── */
.empty-state {
text-align: center;
padding: 48px 24px;
color: var(--text-muted);
}
.empty-state-icon { font-size: 40px; margin-bottom: 12px; opacity: 0.5; }
.empty-state-title { font-size: var(--font-size-lg); font-weight: 700; margin-bottom: 6px; color: var(--text); }
.empty-state-sub { font-size: var(--font-size-base); }
/* ── Spinner ── */
@keyframes spin { to { transform: rotate(360deg); } }
.spinner {
display: inline-block;
width: 16px;
height: 16px;
border: 2px solid var(--border);
border-top-color: var(--cobalt);
border-radius: 50%;
animation: spin 0.7s linear infinite;
flex-shrink: 0;
}
.spinner-sm { width: 12px; height: 12px; border-width: 1.5px; }
/* ── Progress bar ── */
.progress-wrap { margin-bottom: var(--space-lg); }
.progress-label { display: flex; justify-content: space-between; margin-bottom: 6px; font-size: var(--font-size-sm); color: var(--text-muted); }
.progress-bar { height: 8px; border-radius: 4px; background: var(--border); overflow: hidden; }
.progress-fill { height: 100%; background: var(--cobalt); border-radius: 4px; transition: width 0.4s ease; }
.progress-fill.green { background: var(--success); }
.progress-fill.amber { background: var(--warning-amber); }
/* ── Toggle switch ── */
.toggle {
width: 36px;
height: 20px;
background: var(--border);
border-radius: 10px;
cursor: pointer;
position: relative;
flex-shrink: 0;
transition: background 0.2s;
border: none;
}
.toggle.on { background: var(--cobalt); }
.toggle::after {
content: '';
position: absolute;
width: 14px;
height: 14px;
background: #fff;
border-radius: 50%;
top: 3px;
left: 3px;
transition: left 0.2s;
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
}
.toggle.on::after { left: 19px; }
/* ── Stat cards grid ── */
.stat-grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 14px; margin-bottom: 24px; }
.stat-card {
background: var(--card-bg);
border-radius: var(--radius-md);
padding: 16px 18px;
border: 1px solid var(--border);
box-shadow: var(--shadow-sm);
cursor: pointer;
transition: box-shadow 0.15s;
}
.stat-card:hover { box-shadow: var(--shadow-md); }
.stat-label { font-size: var(--font-size-xs); font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-muted); margin-bottom: 8px; }
.stat-value { font-size: 28px; font-weight: 800; line-height: 1; margin-bottom: 4px; }
.stat-sub { font-size: var(--font-size-xs); color: var(--text-muted); }
.stat-card.blue .stat-value { color: var(--cobalt); }
.stat-card.green .stat-value { color: var(--success); }
.stat-card.amber .stat-value { color: var(--warning); }
.stat-card.red .stat-value { color: var(--error); }
.stat-card.gray .stat-value { color: var(--slate); }
/* ── Two/three-col layouts ── */
.two-col { display: grid; grid-template-columns: 1fr 320px; gap: var(--space-md); align-items: start; }
.three-col { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; margin-bottom: var(--space-md); }
/* ── Avatar ── */
.avatar {
width: 30px;
height: 30px;
border-radius: 50%;
background: var(--cobalt);
color: #fff;
font-size: var(--font-size-sm);
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
/* ── Responsive ── */
@media (max-width: 900px) {
.stat-grid { grid-template-columns: repeat(3, 1fr); }
.two-col { grid-template-columns: 1fr; }
}
@media (max-width: 600px) {
.stat-grid { grid-template-columns: repeat(2, 1fr); }
}

271
web/static/css/cards.css Normal file
View File

@ -0,0 +1,271 @@
/* Template cards, readiness badges, filter bar, bulk toolbar */
/* ── Readiness badges (extend base .badge) ── */
.badge-ready { background: var(--success-bg); color: var(--success); }
.badge-caveats { background: var(--warning-bg); color: var(--warning); }
.badge-blocked { background: var(--error-bg); color: var(--error); }
.badge-migrated { background: var(--cobalt-light); color: var(--cobalt); }
.badge-needs-update { background: var(--warning-bg); color: var(--warning); }
.badge-verified { background: var(--success-bg); color: var(--success); }
.badge-not-migrated { background: #EDF0F4; color: var(--slate); }
.badge-dry-run { background: #EDF0F4; color: var(--slate); }
.badge-skipped { background: #EDF0F4; color: var(--slate); }
.badge-error { background: var(--error-bg); color: var(--error); }
/* ── Table name cell ── */
.table-name {
font-weight: 600;
color: var(--text);
cursor: pointer;
}
.table-name:hover { color: var(--cobalt); }
.table-sub {
font-size: var(--font-size-xs);
color: var(--text-muted);
margin-top: 2px;
}
/* ── Issue count cell ── */
.issue-count {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: var(--font-size-sm);
font-weight: 600;
}
.issue-count.has-issues { color: var(--warning); cursor: pointer; }
.issue-count.no-issues { color: var(--success); }
.issue-count.blocked { color: var(--error); }
/* ── Filter bar ── */
.filter-bar {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: var(--space-md);
flex-wrap: wrap;
}
.search-input {
flex: 1;
min-width: 200px;
max-width: 320px;
padding: 7px 12px 7px 32px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
font-size: var(--font-size-base);
font-family: var(--font);
background: var(--card-bg) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 24 24' fill='none' stroke='%236B5F8A' stroke-width='2'%3E%3Ccircle cx='11' cy='11' r='8'/%3E%3Cpath d='m21 21-4.35-4.35'/%3E%3C/svg%3E") no-repeat 10px center;
outline: none;
color: var(--text);
}
.search-input:focus { border-color: var(--cobalt); }
.search-input::placeholder { color: var(--text-muted); }
/* ── Filter tabs ── */
.filter-tabs {
display: flex;
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
overflow: hidden;
}
.filter-tab {
padding: 7px 14px;
font-size: var(--font-size-sm);
font-weight: 600;
cursor: pointer;
color: var(--text-muted);
border-right: 1px solid var(--border);
white-space: nowrap;
transition: all 0.1s;
user-select: none;
background: transparent;
border-top: none;
border-bottom: none;
}
.filter-tab:last-child { border-right: none; }
.filter-tab:hover { background: var(--ecru); }
.filter-tab.active { background: var(--cobalt); color: #fff; border-color: var(--cobalt); }
.tab-count { font-size: 10px; margin-left: 4px; opacity: 0.8; }
/* ── Bulk action toolbar ── */
.bulk-bar {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 16px;
background: var(--cobalt-light);
border: 1px solid var(--cobalt);
border-radius: var(--radius-sm);
margin-bottom: 12px;
}
.bulk-bar-text {
font-size: var(--font-size-base);
font-weight: 600;
color: var(--cobalt);
flex: 1;
}
.bulk-bar.hidden { display: none; }
/* ── Template row action buttons ── */
.row-actions { display: flex; gap: 6px; align-items: center; }
/* ── Stat progress bar (dashboard) ── */
.migration-progress-bar {
height: 6px;
border-radius: 3px;
background: var(--border);
overflow: hidden;
margin-top: 6px;
}
.migration-progress-fill {
height: 100%;
background: var(--cobalt);
border-radius: 3px;
transition: width 0.4s;
}
/* ── Attention items (issues view) ── */
.attention-list { display: flex; flex-direction: column; gap: 8px; }
.attention-item {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 12px 16px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
}
.attention-item.blocker { border-left: 3px solid var(--error); background: var(--error-bg); }
.attention-item.warning { border-left: 3px solid var(--warning-amber); background: var(--warning-bg); }
.attention-icon { font-size: 16px; flex-shrink: 0; }
.attention-name { font-weight: 600; font-size: var(--font-size-base); }
.attention-detail { font-size: var(--font-size-sm); color: var(--text-muted); margin-top: 2px; }
.attention-action { margin-left: auto; flex-shrink: 0; }
/* ── Issue rows (template detail) ── */
.issue-row {
display: flex;
gap: 14px;
padding: 12px 0;
border-bottom: 1px solid var(--border);
align-items: flex-start;
}
.issue-row:last-child { border-bottom: none; }
.issue-severity {
font-size: var(--font-size-xs);
font-weight: 700;
padding: 2px 8px;
border-radius: 12px;
flex-shrink: 0;
margin-top: 1px;
}
.issue-severity.blocker { background: var(--error-bg); color: var(--error); }
.issue-severity.warn { background: var(--warning-bg); color: var(--warning); }
.issue-severity.info { background: var(--cobalt-light); color: var(--cobalt); }
.issue-body { flex: 1; }
.issue-title { font-weight: 600; font-size: var(--font-size-base); }
.issue-desc { font-size: var(--font-size-sm); color: var(--text-muted); margin-top: 3px; line-height: 1.5; }
.issue-fix { font-size: var(--font-size-xs); margin-top: 6px; padding: 4px 10px; background: var(--ecru); border-radius: var(--radius-sm); color: var(--text); display: inline-block; }
/* ── Result rows (migration results view) ── */
.result-row {
border: 1px solid var(--border);
border-radius: var(--radius-sm);
margin-bottom: 8px;
overflow: hidden;
}
.result-header {
display: flex;
align-items: center;
gap: 14px;
padding: 12px 16px;
cursor: pointer;
background: var(--card-bg);
transition: background 0.1s;
}
.result-header:hover { background: #FAFBFC; }
.result-icon { font-size: 16px; flex-shrink: 0; }
.result-name { font-weight: 600; flex: 1; }
.result-meta { font-size: var(--font-size-xs); color: var(--text-muted); }
.result-body { padding: 12px 16px; border-top: 1px solid var(--border); background: #FAFBFC; display: none; }
.result-row.open .result-body { display: block; }
/* ── Field issues block (structured dropped-feature list) ── */
.field-issues-block {
margin-top: 10px;
border-top: 1px solid var(--border);
padding-top: 8px;
}
.field-issue-group {
margin-bottom: 6px;
}
.field-issue-group-header {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 600;
color: var(--warning);
margin-bottom: 4px;
cursor: pointer;
user-select: none;
}
.field-issue-group-body {
display: none;
padding-left: 12px;
}
.field-issue-group.open .field-issue-group-body { display: block; }
.field-issue-row {
display: flex;
gap: 8px;
font-size: 11px;
padding: 3px 0;
border-bottom: 1px solid var(--border);
align-items: baseline;
}
.field-issue-row:last-child { border-bottom: none; }
.field-issue-field {
font-weight: 600;
color: var(--text);
white-space: nowrap;
flex-shrink: 0;
min-width: 120px;
}
.field-issue-msg { color: var(--text-muted); line-height: 1.4; }
/* ── DS template link pill ── */
.ds-pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
background: #EAF2FF;
border: 1px solid #B3D4FF;
border-radius: var(--radius-pill);
font-size: var(--font-size-xs);
font-weight: 600;
color: var(--cobalt);
}
/* ── Activity list (dashboard) ── */
.activity-item {
display: flex;
gap: 12px;
padding: 10px 0;
border-bottom: 1px solid var(--border);
align-items: flex-start;
}
.activity-item:last-child { border-bottom: none; }
.activity-dot {
width: 8px;
height: 8px;
border-radius: 50%;
margin-top: 5px;
flex-shrink: 0;
}
.activity-dot.green { background: var(--success); }
.activity-dot.amber { background: var(--warning-amber); }
.activity-dot.red { background: var(--error); }
.activity-dot.blue { background: var(--cobalt); }
.activity-text { font-size: var(--font-size-base); flex: 1; }
.activity-time { font-size: var(--font-size-xs); color: var(--text-muted); flex-shrink: 0; }

107
web/static/css/forms.css Normal file
View File

@ -0,0 +1,107 @@
/* Form input styles — used in settings and modals */
.form-group {
margin-bottom: var(--space-md);
}
.form-label {
display: block;
font-size: var(--font-size-sm);
font-weight: 600;
color: var(--text);
margin-bottom: 6px;
}
.form-label-sub {
font-size: var(--font-size-xs);
color: var(--text-muted);
font-weight: 400;
margin-left: 4px;
}
.form-input {
width: 100%;
padding: 8px 12px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
font-size: var(--font-size-base);
font-family: var(--font);
color: var(--text);
background: var(--card-bg);
outline: none;
transition: border-color 0.15s;
}
.form-input:focus { border-color: var(--cobalt); box-shadow: 0 0 0 3px rgba(76,0,255,0.08); }
.form-input:disabled { background: var(--ecru); color: var(--text-muted); cursor: not-allowed; }
.form-input.error { border-color: var(--error); }
.form-input-mono { font-family: var(--font-mono); font-size: var(--font-size-sm); }
.form-hint {
font-size: var(--font-size-xs);
color: var(--text-muted);
margin-top: 4px;
}
.form-error {
font-size: var(--font-size-xs);
color: var(--error);
margin-top: 4px;
min-height: 16px;
}
/* ── Toggle setting row ── */
.setting-row {
display: flex;
align-items: center;
gap: 14px;
padding: 14px 0;
border-bottom: 1px solid var(--border);
}
.setting-row:last-child { border-bottom: none; }
.setting-body { flex: 1; }
.setting-label { font-weight: 600; font-size: var(--font-size-base); }
.setting-desc { font-size: var(--font-size-sm); color: var(--text-muted); margin-top: 2px; line-height: 1.5; }
.setting-control { flex-shrink: 0; }
/* ── Settings section ── */
.settings-section {
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: var(--radius-md);
margin-bottom: var(--space-md);
overflow: hidden;
}
.settings-section-header {
padding: 14px 20px;
border-bottom: 1px solid var(--border);
background: #FAFBFC;
}
.settings-section-title {
font-size: var(--font-size-md);
font-weight: 700;
}
.settings-section-sub {
font-size: var(--font-size-sm);
color: var(--text-muted);
margin-top: 2px;
}
.settings-section-body { padding: 6px 20px; }
/* ── Connection info card ── */
.conn-info-row {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 0;
border-bottom: 1px solid var(--border);
font-size: var(--font-size-base);
}
.conn-info-row:last-child { border-bottom: none; }
.conn-info-label { width: 160px; color: var(--text-muted); font-size: var(--font-size-sm); flex-shrink: 0; }
.conn-info-value { flex: 1; font-family: var(--font-mono); font-size: var(--font-size-sm); }
.conn-info-status { flex-shrink: 0; }
/* ── Number input ── */
input[type="number"].form-input {
-moz-appearance: textfield;
}
input[type="number"].form-input::-webkit-outer-spin-button,
input[type="number"].form-input::-webkit-inner-spin-button {
-webkit-appearance: none;
}

192
web/static/css/modals.css Normal file
View File

@ -0,0 +1,192 @@
/* Modal and dialog styles */
/* ── Overlay ── */
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(19, 0, 50, 0.5);
z-index: 200;
animation: backdropIn 0.15s ease;
}
@keyframes backdropIn { from { opacity: 0; } to { opacity: 1; } }
/* ── Modal box ── */
.modal-box {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: var(--card-bg);
border-radius: var(--radius-md);
width: min(520px, 94vw);
max-height: 90vh;
display: flex;
flex-direction: column;
z-index: 201;
box-shadow: var(--shadow-md);
animation: modalIn 0.18s ease;
}
@keyframes modalIn {
from { opacity: 0; transform: translate(-50%, -52%); }
to { opacity: 1; transform: translate(-50%, -50%); }
}
.modal-box.modal-lg { width: min(720px, 94vw); }
.modal-box.modal-sm { width: min(380px, 94vw); }
/* ── Modal sections ── */
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.modal-title {
font-size: var(--font-size-md);
font-weight: 700;
color: var(--text);
}
.modal-body {
padding: 20px;
overflow-y: auto;
flex: 1;
}
.modal-footer {
padding: 14px 20px;
border-top: 1px solid var(--border);
display: flex;
justify-content: flex-end;
gap: var(--space-sm);
flex-shrink: 0;
background: var(--ecru);
border-radius: 0 0 var(--radius-md) var(--radius-md);
}
/* ── Close button ── */
.modal-close {
background: transparent;
border: none;
cursor: pointer;
color: var(--text-muted);
font-size: 18px;
padding: 2px 6px;
border-radius: var(--radius-sm);
line-height: 1;
transition: background 0.1s;
}
.modal-close:hover { background: var(--ecru); color: var(--text); }
/* ── Options panel inside modal ── */
.options-panel {
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: 18px;
margin-bottom: var(--space-md);
}
.options-title {
font-weight: 700;
font-size: var(--font-size-md);
margin-bottom: 14px;
}
.option-row {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 10px 0;
border-bottom: 1px solid var(--border);
}
.option-row:last-child { border-bottom: none; }
.option-label { font-weight: 600; font-size: var(--font-size-base); }
.option-desc { font-size: var(--font-size-sm); color: var(--text-muted); margin-top: 2px; }
.option-body { flex: 1; }
/* ── Progress inside modal ── */
.migration-progress {
padding: 8px 0;
}
.progress-template-list {
display: flex;
flex-direction: column;
gap: 6px;
max-height: 280px;
overflow-y: auto;
margin-top: 12px;
}
.progress-template-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px;
padding: 8px 12px;
background: var(--ecru);
border-radius: var(--radius-sm);
font-size: var(--font-size-base);
}
.progress-template-name { flex: 1; font-weight: 500; }
.progress-template-status { font-size: 16px; flex-shrink: 0; }
.progress-template-error {
flex-basis: 100%;
font-size: 11px;
color: var(--error, #c0392b);
margin-top: -4px;
padding-left: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* ── Project switcher modal ── */
.project-list {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 16px;
max-height: 280px;
overflow-y: auto;
}
.project-row {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 14px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
cursor: pointer;
transition: background 0.1s;
}
.project-row:hover { background: var(--ecru); }
.project-row.active {
border-color: var(--cobalt);
background: var(--cobalt-light);
}
.project-row-icon {
width: 32px;
height: 32px;
border-radius: var(--radius-sm);
background: var(--cobalt);
color: #fff;
font-size: var(--font-size-sm);
font-weight: 800;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.project-row-name { font-weight: 600; font-size: var(--font-size-base); flex: 1; }
.project-row-sub { font-size: var(--font-size-xs); color: var(--text-muted); }
.project-row-active-badge { font-size: var(--font-size-xs); color: var(--cobalt); font-weight: 700; }
/* ── New project form inside modal ── */
.new-project-form {
border-top: 1px solid var(--border);
padding-top: 16px;
margin-top: 8px;
}
.new-project-form h4 {
font-size: var(--font-size-base);
font-weight: 700;
margin-bottom: 10px;
}

236
web/static/css/nav.css Normal file
View File

@ -0,0 +1,236 @@
/* Left sidebar navigation and top bar */
/* ── App layout shell ── */
#app-nav {
width: var(--nav-width);
background: var(--nav-bg);
display: flex;
flex-direction: column;
flex-shrink: 0;
height: 100vh;
position: fixed;
left: 0;
top: 0;
z-index: 50;
}
#app-body {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
margin-left: var(--nav-width);
height: 100vh;
}
/* ── Logo area ── */
#nav-logo {
padding: 14px 20px 12px;
border-bottom: 1px solid rgba(255,255,255,0.08);
}
#nav-logo svg { display: block; }
.nav-logo-sub {
font-size: var(--font-size-xs);
color: var(--nav-text);
font-weight: 500;
letter-spacing: 0.02em;
margin-top: 6px;
}
/* ── Project switcher ── */
#nav-project-switcher {
background: rgba(255,255,255,0.06);
border: 1px solid rgba(255,255,255,0.12);
border-radius: 6px;
padding: 8px 10px;
cursor: pointer;
transition: background 0.15s;
display: flex;
align-items: center;
gap: 8px;
margin-top: 8px;
width: 100%;
}
#nav-project-switcher:hover { background: rgba(255,255,255,0.10); }
.project-icon {
width: 24px;
height: 24px;
border-radius: 4px;
background: var(--cobalt);
display: flex;
align-items: center;
justify-content: center;
font-size: var(--font-size-xs);
font-weight: 800;
color: #fff;
flex-shrink: 0;
}
.project-name {
font-size: var(--font-size-sm);
font-weight: 600;
color: #fff;
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.project-arrow {
font-size: 10px;
color: var(--nav-text);
flex-shrink: 0;
}
.project-name.no-project { color: var(--warning-amber); }
/* ── Nav sections ── */
.nav-section-label {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: rgba(189,201,217,0.5);
padding: 16px 20px 6px;
}
#nav-links { list-style: none; flex: 1; overflow-y: auto; }
.nav-item {
display: flex;
align-items: center;
gap: 10px;
padding: 9px 20px;
color: var(--nav-text);
cursor: pointer;
border-left: 3px solid transparent;
transition: all 0.15s;
font-size: var(--font-size-base);
user-select: none;
text-decoration: none;
}
.nav-item:hover {
background: var(--nav-hover);
color: var(--nav-text-active);
}
.nav-item.active {
background: var(--nav-active-bg);
color: var(--nav-text-active);
border-left-color: var(--nav-active-border);
}
.nav-icon {
font-size: 16px;
width: 20px;
text-align: center;
flex-shrink: 0;
}
.nav-label { flex: 1; }
.nav-badge {
margin-left: auto;
background: var(--poppy);
color: #fff;
border-radius: 10px;
font-size: 10px;
font-weight: 700;
padding: 1px 6px;
min-width: 18px;
text-align: center;
}
.nav-badge.amber { background: var(--warning-amber); }
.nav-badge[data-count="0"] { display: none; }
/* ── Nav bottom (customer context) ── */
#nav-bottom {
margin-top: auto;
padding: 12px 0;
border-top: 1px solid rgba(255,255,255,0.08);
}
.nav-customer { padding: 10px 20px; }
.nav-customer-label {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: rgba(189,201,217,0.5);
margin-bottom: 4px;
}
.nav-customer-name { font-size: var(--font-size-sm); font-weight: 600; color: var(--nav-text-active); }
.nav-customer-sub { font-size: var(--font-size-xs); color: var(--nav-text); }
/* ── Top bar ── */
#top-bar {
height: var(--topbar-h);
background: var(--card-bg);
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
padding: 0 var(--space-lg);
gap: var(--space-md);
flex-shrink: 0;
box-shadow: var(--shadow-sm);
z-index: 10;
}
.breadcrumb {
display: flex;
align-items: center;
gap: 6px;
font-size: var(--font-size-base);
color: var(--text-muted);
}
.breadcrumb .sep { color: var(--border); }
.breadcrumb .current { color: var(--text); font-weight: 600; }
#topbar-right {
margin-left: auto;
display: flex;
align-items: center;
gap: 12px;
}
.conn-pill {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: var(--radius-pill);
font-size: var(--font-size-sm);
font-weight: 500;
border: 1px solid var(--border);
cursor: pointer;
transition: background 0.15s;
background: var(--card-bg);
}
.conn-pill:hover { background: var(--ecru); }
.conn-dot {
width: 7px;
height: 7px;
border-radius: 50%;
flex-shrink: 0;
}
.conn-pill.connected .conn-dot { background: var(--success); }
.conn-pill.disconnected .conn-dot { background: var(--error); }
.conn-pill.connecting .conn-dot { background: var(--warning-amber); }
/* ── Router outlet ── */
#router-outlet {
flex: 1;
overflow-y: auto;
padding: var(--space-lg);
}
/* ── View transitions ── */
.view-enter {
animation: fadeIn 0.18s ease;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
/* ── Mobile nav toggle ── */
@media (max-width: 768px) {
#app-nav {
transform: translateX(-100%);
transition: transform 0.2s;
}
#app-nav.open { transform: translateX(0); }
#app-body { margin-left: 0; }
}

82
web/static/css/tables.css Normal file
View File

@ -0,0 +1,82 @@
/* History and audit table styles */
/* ── Sortable column headers ── */
th.sortable {
cursor: pointer;
user-select: none;
}
th.sortable:hover { background: #F0F1F5; }
th.sortable::after {
content: ' ⇅';
font-size: 10px;
opacity: 0.5;
}
th.sort-asc::after { content: ' ↑'; opacity: 1; color: var(--cobalt); }
th.sort-desc::after { content: ' ↓'; opacity: 1; color: var(--cobalt); }
/* ── Expandable row ── */
.row-expandable { cursor: pointer; }
.row-expanded-content {
background: #FAFBFC;
}
.row-expand-body {
padding: 12px 14px 14px;
font-size: var(--font-size-sm);
color: var(--text-muted);
border-top: 1px solid var(--border);
}
.expand-icon { font-size: 10px; color: var(--text-muted); transition: transform 0.15s; }
tr.open .expand-icon { transform: rotate(90deg); }
/* ── Checksum display ── */
.checksum {
font-family: var(--font-mono);
font-size: var(--font-size-xs);
background: var(--ecru);
padding: 2px 6px;
border-radius: 3px;
cursor: help;
}
/* ── Date range filter ── */
.date-filter {
display: flex;
align-items: center;
gap: 8px;
font-size: var(--font-size-sm);
}
.date-input {
padding: 6px 10px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
font-size: var(--font-size-sm);
font-family: var(--font);
color: var(--text);
background: var(--card-bg);
outline: none;
}
.date-input:focus { border-color: var(--cobalt); }
/* ── Pagination ── */
.pagination {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 14px;
border-top: 1px solid var(--border);
font-size: var(--font-size-sm);
color: var(--text-muted);
}
.pagination-pages { display: flex; gap: 4px; }
.page-btn {
padding: 4px 10px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: var(--card-bg);
cursor: pointer;
font-size: var(--font-size-sm);
transition: background 0.1s;
}
.page-btn:hover { background: var(--ecru); }
.page-btn.active { background: var(--cobalt); color: #fff; border-color: var(--cobalt); }
.page-btn:disabled { opacity: 0.4; cursor: not-allowed; }

70
web/static/css/tokens.css Normal file
View File

@ -0,0 +1,70 @@
/* Docusign 2024 brand design tokens
Source: brand.docusign.com (April 2024 rebrand)
Inkwell #130032 replaces old navy; Cobalt #4C00FF is new primary.
*/
:root {
/* ── Brand palette ── */
--cobalt: #4C00FF;
--cobalt-hover: #3A00CC;
--cobalt-light: #EDE8FF;
--inkwell: #130032;
--deep-violet: #26065D;
--mist: #CBC2FF;
--ecru: #F8F3F0;
--poppy: #FF5252;
--poppy-bg: #FFF0F0;
--slate: #6B5F8A;
/* ── Semantic colours ── */
--success: #027A48;
--success-bg: #ECFDF3;
--warning: #92400E;
--warning-bg: #FFFBEB;
--warning-amber:#FFAB00;
--error: var(--poppy);
--error-bg: var(--poppy-bg);
/* ── Nav ── */
--nav-bg: var(--inkwell);
--nav-hover: var(--deep-violet);
--nav-active-bg: rgba(76,0,255,0.22);
--nav-active-border:var(--cobalt);
--nav-text: #A899CC;
--nav-text-active: #FFFFFF;
--nav-width: 228px;
/* ── Layout ── */
--topbar-h: 56px;
--page-bg: var(--ecru);
--card-bg: #FFFFFF;
--border: #E2DDF0;
--text: var(--inkwell);
--text-muted: var(--slate);
/* ── Shadows ── */
--shadow-sm: 0 1px 3px rgba(19,0,50,0.08), 0 1px 2px rgba(19,0,50,0.04);
--shadow-md: 0 4px 16px rgba(19,0,50,0.14);
/* ── Spacing ── */
--space-xs: 4px;
--space-sm: 8px;
--space-md: 16px;
--space-lg: 24px;
--space-xl: 32px;
/* ── Border radius ── */
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-pill: 20px;
/* ── Typography ── */
--font: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
--font-mono: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace;
--font-size-xs: 11px;
--font-size-sm: 12px;
--font-size-base:13px;
--font-size-md: 14px;
--font-size-lg: 16px;
--font-size-xl: 20px;
}

View File

@ -3,77 +3,164 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Adobe Sign → DocuSign Migrator</title>
<link rel="stylesheet" href="/static/style.css" />
<title>docusign — Template Migration Console</title>
<link rel="stylesheet" href="/static/css/tokens.css" />
<link rel="stylesheet" href="/static/css/base.css" />
<link rel="stylesheet" href="/static/css/nav.css" />
<link rel="stylesheet" href="/static/css/cards.css" />
<link rel="stylesheet" href="/static/css/modals.css" />
<link rel="stylesheet" href="/static/css/tables.css" />
<link rel="stylesheet" href="/static/css/forms.css" />
</head>
<body>
<header>
<h1>Adobe Sign → DocuSign Migrator</h1>
<div id="auth-bar">
<span id="badge-adobe" class="auth-badge">Connect Adobe Sign</span>
<span id="badge-docusign" class="auth-badge">Connect DocuSign</span>
</div>
</header>
<!-- ═══════════════════════════════════════════════════════════════
LEFT NAVIGATION
═══════════════════════════════════════════════════════════════ -->
<nav id="app-nav">
<main>
<!-- Logo + project switcher -->
<div id="nav-logo">
<svg viewBox="0 0 1200 241.4" xmlns="http://www.w3.org/2000/svg"
style="width:148px;height:auto;display:block;">
<style>.st0{fill:#4C00FF;}.st1{fill:#FF5252;}</style>
<g fill="#FFFFFF">
<g>
<path d="M1169.2,109.7v78.7h-28.9v-73.5c0-17.9-7.7-27.9-22.7-27.9s-24.9,10.5-27.7,28.1c-0.8,4.2-1,10.7-1,24.4v48.8H1060v-125h25.6c0.1,1.1,0.7,12.3,0.7,13c0,0.9,1.1,1.4,1.8,0.8c10.6-8.4,22.3-16.2,38.6-16.2C1153.5,60.9,1169.2,79,1169.2,109.7z"/>
<path d="M1013.4,63.4l-0.9,14.3c-0.1,0.9-1.2,1.4-1.8,0.8c-3.5-3.3-16.4-17.5-38.3-17.5c-31.4,0-54.5,27.1-54.5,63.9l0,0c0,37.3,22.9,64.5,54.5,64.5c21.1,0,34-13.7,36.4-16.7c0.7-0.8,2-0.3,2,0.7c-0.3,3.8-0.8,13.3-4,21.4c-4,10.2-13,19.7-31.1,19.7c-14.9,0-28.1-5.7-40.6-17.9L920,217.3c13.7,15.5,35.3,24.2,58.8,24.2c37.8,0,60.5-25.9,60.5-68.2V63.4H1013.4z M978.6,163.2c-18.7,0-31.9-16.2-31.9-38.3S959.9,87,978.6,87c18.7,0,31.9,15.7,31.9,37.9C1010.4,147.1,997.2,163.2,978.6,163.2z"/>
<path d="M857.5,151.3c0,23.7-19.9,39.6-49.1,39.6c-22.9,0-43.3-8.9-55.5-21.6l0,0l0,0l9.5-22.6c9.2,8.3,24,20.2,45.1,20.2c14.7,0,23.2-6.5,23.2-14.7c0-9.5-11.7-12-25.7-14.7c-19.9-4.2-46.3-11-46.3-38.1c0-22.7,18.4-38.3,45.6-38.3c20.9,0,38.9,8,51.3,18.4l-14.2,19.9c-12-9.5-24.6-14.2-37.1-14.2s-18.7,5.2-18.7,12.7c0,10.5,13.5,13.2,23.4,15.2C833.9,117.9,857.5,125.4,857.5,151.3z"/>
<path d="M434.9,60.9c-35.3,0-60.7,27.4-60.7,65s25.4,65,60.7,65s60.8-27.4,60.8-65S470.3,60.9,434.9,60.9z M434.9,164.7c-18.7,0-31.9-15.9-31.9-38.9c0-22.9,12.9-38.9,31.9-38.9c18.9,0,31.9,15.9,31.9,38.9S453.6,164.7,434.9,164.7z"/>
<path d="M505.9,125.9c0-37.1,25.4-65,59.3-65c26.9,0,46.6,13.5,55.8,38.9l-25.6,9.7c-7-15.7-16.2-22.4-30.1-22.4c-17.4,0-30.4,16.4-30.4,38.9c0,22.4,12.9,38.9,30.4,38.9c14,0,23.1-6.7,30.1-22.4l25.6,9.7c-9.2,25.4-28.9,38.9-55.8,38.9C531.3,190.9,505.9,163,505.9,125.9z"/>
<path d="M351.4,5.3c-0.5,0-1.1,0.1-1.6,0.4l-18.8,10c-0.4,0.2-0.6,0.6-0.6,1v59.5c0,1-1.2,1.4-1.9,0.8c-2.8-2.4-9.3-8.5-18.3-12.7c-4.7-2.2-11.6-3.4-17.9-3.4c-31.6,0-54.5,27.4-54.5,65s22.9,65,54.5,65c16.6,0,29.1-8.7,36.7-16.5c0.5-0.5,0.8-0.8,1.3-1.3c0.7-0.7,1.9-0.3,1.9,0.7l1,14.6h26.1V6.1c0-0.4-0.3-0.8-0.8-0.8C358.5,5.3,351.4,5.3,351.4,5.3z M298.5,164.7c-18.9,0-31.9-15.9-31.9-38.9S279.9,87,298.5,87c18.7,0,31.9,15.9,31.9,38.9C330.4,148.8,317.5,164.7,298.5,164.7z"/>
<path d="M891.5,63.8l-18.1,9.6c-0.4,0.2-0.6,0.6-0.6,1v114h28.9V64.1c0-0.4-0.3-0.8-0.8-0.8h-7.8C892.5,63.4,892,63.5,891.5,63.8z"/>
<path d="M887.2,43.1c9.6,0,17.4-7.8,17.4-17.4s-7.8-17.4-17.4-17.4c-9.6,0-17.4,7.8-17.4,17.4S877.6,43.1,887.2,43.1z"/>
<path d="M742.5,63.3v67.9c0,51.5-28.8,59.6-54.5,59.6s-54.5-8.2-54.5-59.6V63.3h28.8v75.1c0,7.3,1.8,26.3,25.7,26.3s25.7-18.9,25.7-26.3V63.3H742.5z"/>
</g>
<g fill="#FFFFFF">
<path d="M1185.7,175.6v1.8h-4.1v10.9h-2v-10.9h-4.1v-1.8H1185.7z M1200,188.3h-2v-10l-3.9,7.5h-1.1l-3.9-7.4v9.9h-2v-12.7h2.6l3.8,7.3l3.8-7.3h2.6L1200,188.3z"/>
</g>
</g>
<path class="st0" d="M139.5,139.5V189c0,2.6-2.1,4.7-4.7,4.7H4.7c-2.6,0-4.7-2.1-4.7-4.7V59c0-2.6,2.1-4.7,4.7-4.7h49.4v80.5c0,2.6,2.1,4.7,4.7,4.7H139.5z"/>
<path class="st1" d="M193.7,69.7c0,41.6-24.3,69.7-54.2,69.8V87.1c0-1.5-0.6-3-1.7-4l-27.2-27.2c-1.1-1.1-2.5-1.7-4-1.7H54.2V4.8c0-2.6,2.1-4.7,4.7-4.7h73.3C167,0,193.7,28,193.7,69.7z"/>
<path fill="#FFFFFF" d="M137.8,83c1.1,1.1,1.7,2.5,1.7,4v52.4H58.9c-2.6,0-4.7-2.1-4.7-4.7V54.2h52.4c1.5,0,3,0.6,4,1.7L137.8,83z"/>
</svg>
<div class="nav-logo-sub">Template Migration Console</div>
<!-- Action bar -->
<div class="action-bar">
<button id="btn-migrate" disabled>Migrate Selected</button>
<button id="btn-refresh">↻ Refresh</button>
<span id="status-msg">Loading…</span>
<!-- Project switcher button -->
<button id="nav-project-switcher" aria-label="Switch project">
<div class="project-icon" id="nav-project-icon">?</div>
<div class="project-name no-project" id="nav-project-name">New Project</div>
<div class="project-arrow"></div>
</button>
</div>
<!-- Side-by-side panels -->
<div class="panel-row">
<!-- Nav links -->
<ul id="nav-links">
<li class="nav-section-label">Migration</li>
<li>
<a class="nav-item" data-route="#/templates" href="#/templates">
<span class="nav-icon"></span>
<span class="nav-label">Templates</span>
<span class="nav-badge amber" id="nav-badge-caveats" data-count="0">0</span>
</a>
</li>
<li>
<a class="nav-item" data-route="#/results" href="#/results">
<span class="nav-icon"></span>
<span class="nav-label">Migration Results</span>
</a>
</li>
<li>
<a class="nav-item" data-route="#/issues" href="#/issues">
<span class="nav-icon"></span>
<span class="nav-label">Issues &amp; Warnings</span>
<span class="nav-badge" id="nav-badge-issues" data-count="0">0</span>
</a>
</li>
<div class="panel">
<div class="panel-header">
<span>Adobe Sign Templates</span>
<span style="font-weight:400;font-size:12px;color:#888">Select to migrate →</span>
</div>
<div class="panel-body">
<ul class="template-list" id="adobe-list">
<li class="empty-msg">Loading…</li>
</ul>
</div>
<li class="nav-section-label">Post-Migration</li>
<li>
<a class="nav-item" data-route="#/verify" href="#/verify">
<span class="nav-icon"></span>
<span class="nav-label">Verification</span>
</a>
</li>
<li>
<a class="nav-item" data-route="#/history" href="#/history">
<span class="nav-icon"></span>
<span class="nav-label">History &amp; Audit</span>
</a>
</li>
<li class="nav-section-label">Admin</li>
<li>
<a class="nav-item" data-route="#/settings" href="#/settings">
<span class="nav-icon"></span>
<span class="nav-label">Settings</span>
</a>
</li>
</ul>
<!-- Bottom: customer context -->
<div id="nav-bottom">
<div class="nav-customer">
<div class="nav-customer-label">Current Project</div>
<div class="nav-customer-name" id="nav-customer-name"></div>
<div class="nav-customer-sub" id="nav-customer-sub"></div>
</div>
</div>
<div class="panel">
<div class="panel-header">
<span>DocuSign Templates</span>
</div>
<div class="panel-body">
<ul class="template-list" id="ds-list">
<li class="empty-msg">Loading…</li>
</ul>
</div>
</nav>
<!-- ═══════════════════════════════════════════════════════════════
MAIN CONTENT AREA
═══════════════════════════════════════════════════════════════ -->
<div id="app-body">
<!-- Top bar -->
<header id="top-bar">
<nav class="breadcrumb" aria-label="breadcrumb">
<span>Migration Console</span>
<span class="sep"></span>
<span class="current" id="breadcrumb-current">Templates</span>
</nav>
<div id="topbar-right">
<!-- Auth connection chips -->
<button id="chip-adobe" class="conn-pill disconnected" aria-label="Adobe Sign connection">
<span class="conn-dot"></span>Adobe Sign
</button>
<button id="chip-docusign" class="conn-pill disconnected" aria-label="Docusign connection">
<span class="conn-dot"></span>Docusign
</button>
<!-- User avatar -->
<div class="avatar" title="Logged in" aria-label="User">M</div>
</div>
</header>
</div>
<!-- Router outlet — views injected here -->
<main id="router-outlet">
<div class="empty-state">
<div class="empty-state-icon"></div>
<div class="empty-state-title">Loading…</div>
</div>
</main>
<!-- Migration history -->
<div class="history-section">
<div class="panel-header">Migration History</div>
<table class="history-table">
<thead>
<tr>
<th>Time</th>
<th>Adobe Template</th>
<th>DocuSign Template ID</th>
<th>Action</th>
<th>Status</th>
</tr>
</thead>
<tbody id="history-tbody">
<tr><td colspan="5" class="empty-msg">No migrations yet.</td></tr>
</tbody>
</table>
</div>
</div>
</main>
<!-- ═══════════════════════════════════════════════════════════════
MODAL OVERLAY (shared, managed by modals.js)
═══════════════════════════════════════════════════════════════ -->
<div id="modal-root"></div>
<!-- ═══════════════════════════════════════════════════════════════
TOAST CONTAINER (managed by auth.js)
═══════════════════════════════════════════════════════════════ -->
<div id="toast-container"></div>
<!-- ═══════════════════════════════════════════════════════════════
APP ENTRY POINT
═══════════════════════════════════════════════════════════════ -->
<script type="module" src="/static/js/app.js"></script>
<script src="/static/app.js"></script>
</body>
</html>

92
web/static/js/api.js Normal file
View File

@ -0,0 +1,92 @@
// Thin fetch wrappers for all backend endpoints
async function request(method, path, body) {
const opts = {
method,
headers: { 'Content-Type': 'application/json' },
};
if (body !== undefined) opts.body = JSON.stringify(body);
const resp = await fetch(path, opts);
const data = await resp.json().catch(() => ({}));
if (!resp.ok) {
const msg = data.detail || data.error || `HTTP ${resp.status}`;
throw Object.assign(new Error(msg), { status: resp.status, data });
}
return data;
}
const GET = path => request('GET', path);
const POST = (path, body) => request('POST', path, body);
const PUT = (path, body) => request('PUT', path, body);
export const api = {
// ── Auth ──────────────────────────────────────────────────────────────────
auth: {
status() {
return GET('/api/auth/status');
},
connectAdobe() {
return GET('/api/auth/adobe/connect');
},
adobeUrl() {
return GET('/api/auth/adobe/url');
},
exchangeAdobe(redirectUrl) {
return POST('/api/auth/adobe/exchange', { redirect_url: redirectUrl });
},
connectDocusign() {
return GET('/api/auth/docusign/connect');
},
disconnect(platform) {
return GET(`/api/auth/${platform}/disconnect`);
},
},
// ── Templates ─────────────────────────────────────────────────────────────
templates: {
status() {
return GET('/api/templates/status');
},
adobe() {
return GET('/api/templates/adobe');
},
docusign() {
return GET('/api/templates/docusign');
},
},
// ── Migration ─────────────────────────────────────────────────────────────
migrate: {
run(body) {
return POST('/api/migrate', body);
},
batch(body) {
return POST('/api/migrate/batch', body);
},
batchStatus(jobId) {
return GET(`/api/migrate/batch/${jobId}`);
},
history() {
return GET('/api/migrate/history');
},
},
// ── Verification ──────────────────────────────────────────────────────────
verify: {
send(templateId, recipientName, recipientEmail) {
return POST('/api/verify/send', {
template_id: templateId,
recipient_name: recipientName,
recipient_email: recipientEmail,
});
},
status(envelopeId) {
return GET(`/api/verify/status/${envelopeId}`);
},
void(envelopeId, reason = 'Test envelope — voided after verification') {
return POST(`/api/verify/void/${envelopeId}`, { reason });
},
},
};

109
web/static/js/app.js Normal file
View File

@ -0,0 +1,109 @@
// Main app entry point — wires together router, auth, state, and nav badges
import * as router from './router.js';
import { refreshAuth, renderAuthChips } from './auth.js';
import { state, subscribe } from './state.js';
import { getActive, initProject } from './project.js';
// ── Route registrations (lazy-loaded views) ───────────────────────────────
router.register('#/templates', async (param) => {
const { renderTemplates, renderTemplateDetail } = await import('./templates.js');
if (param) {
await renderTemplateDetail(param);
} else {
await renderTemplates();
}
});
router.register('#/results', async () => {
const { renderResults } = await import('./migration.js');
renderResults();
});
router.register('#/issues', async () => {
const { renderIssues } = await import('./issues.js');
renderIssues();
});
router.register('#/verify', async () => {
const { renderVerification } = await import('./verification.js');
await renderVerification();
});
router.register('#/history', async () => {
const { renderHistory } = await import('./history.js');
await renderHistory();
});
router.register('#/settings', async () => {
const { renderSettings } = await import('./settings.js');
renderSettings();
});
// ── Nav badge subscriptions ───────────────────────────────────────────────
subscribe('issueCount', count => {
const badge = document.getElementById('nav-badge-issues');
if (badge) {
badge.dataset.count = count;
badge.textContent = count;
}
});
subscribe('templates', templates => {
const caveats = (templates || []).filter(t =>
(!t.blockers || t.blockers.length === 0) &&
t.warnings && t.warnings.length > 0
).length;
const badge = document.getElementById('nav-badge-caveats');
if (badge) {
badge.dataset.count = caveats;
badge.textContent = caveats;
}
});
// ── Project switcher wiring ───────────────────────────────────────────────
function syncProjectDisplay() {
const project = getActive();
const iconEl = document.getElementById('nav-project-icon');
const nameEl = document.getElementById('nav-project-name');
const custName = document.getElementById('nav-customer-name');
const custSub = document.getElementById('nav-customer-sub');
if (project) {
const initials = project.name.slice(0, 2).toUpperCase();
if (iconEl) { iconEl.textContent = initials; }
if (nameEl) { nameEl.textContent = project.name; nameEl.classList.remove('no-project'); }
if (custName) { custName.textContent = project.name; }
if (custSub) { custSub.textContent = `Created ${new Date(project.createdAt).toLocaleDateString()}`; }
} else {
if (iconEl) { iconEl.textContent = '?'; }
if (nameEl) { nameEl.textContent = 'New Project'; nameEl.classList.add('no-project'); }
if (custName) { custName.textContent = '—'; }
if (custSub) { custSub.textContent = ''; }
}
}
// ── Init ─────────────────────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', async () => {
// Init project context
initProject(syncProjectDisplay);
// Wire project switcher button
const switcher = document.getElementById('nav-project-switcher');
if (switcher) {
switcher.addEventListener('click', async () => {
const { showProjectModal } = await import('./project.js');
showProjectModal(syncProjectDisplay);
});
}
// Auth chips
await refreshAuth();
// Start router
router.init();
});

208
web/static/js/auth.js Normal file
View File

@ -0,0 +1,208 @@
// Auth: connect/disconnect Adobe Sign and Docusign, auth status chips
import { api } from './api.js';
import { state, setState } from './state.js';
import { escHtml } from './utils.js';
// ── Refresh auth state and update chips ────────────────────────────────────
export async function refreshAuth() {
try {
const data = await api.auth.status();
setState('auth', {
adobe: !!data.adobe,
docusign: !!data.docusign,
adobeLabel: data.adobe_label || 'Adobe Sign',
docusignLabel: data.docusign_label || 'Docusign',
});
} catch (e) {
console.warn('Auth status failed:', e.message);
}
renderAuthChips();
}
// ── Render connection pills in top bar ─────────────────────────────────────
export function renderAuthChips() {
renderChip('chip-adobe', state.auth.adobe, 'Adobe Sign', onClickAdobe);
renderChip('chip-docusign', state.auth.docusign, 'Docusign', onClickDocusign);
}
function renderChip(id, connected, label, onClick) {
const el = document.getElementById(id);
if (!el) return;
el.className = 'conn-pill ' + (connected ? 'connected' : 'disconnected');
el.innerHTML = `<span class="conn-dot"></span>${escHtml(label)}`;
el.onclick = onClick;
}
// ── Click handlers ─────────────────────────────────────────────────────────
async function onClickAdobe() {
if (state.auth.adobe) {
await disconnect('adobe');
} else {
await connectAdobeEnv();
}
}
async function onClickDocusign() {
if (state.auth.docusign) {
await disconnect('docusign');
} else {
await connectDocusign();
}
}
async function disconnect(platform) {
setChipConnecting(platform === 'adobe' ? 'chip-adobe' : 'chip-docusign');
try {
await api.auth.disconnect(platform);
setState('auth', { ...state.auth, [platform]: false });
renderAuthChips();
// Reload templates (they'll be empty without auth)
const { refreshTemplates } = await import('./templates.js');
refreshTemplates();
} catch (e) {
console.error('Disconnect failed:', e.message);
renderAuthChips();
}
}
async function connectAdobeEnv() {
setChipConnecting('chip-adobe');
try {
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.error && data.error.includes('No Adobe Sign credentials')) {
renderAuthChips();
showAdobeOAuthDialog();
} else {
renderAuthChips();
showToast('Adobe Sign error: ' + (data.error || 'unknown'), 'error');
}
} catch (e) {
renderAuthChips();
showAdobeOAuthDialog();
}
}
async function connectDocusign() {
setChipConnecting('chip-docusign');
try {
const data = await api.auth.connectDocusign();
if (data.connected) {
setState('auth', { ...state.auth, docusign: true });
renderAuthChips();
const { refreshTemplates } = await import('./templates.js');
refreshTemplates();
} else {
renderAuthChips();
showToast('Docusign error: ' + (data.error || 'unknown'), 'error');
}
} catch (e) {
renderAuthChips();
showToast('Docusign connection failed: ' + e.message, 'error');
}
}
function setChipConnecting(id) {
const el = document.getElementById(id);
if (!el) return;
el.className = 'conn-pill connecting';
el.innerHTML = `<span class="conn-dot"></span><span class="spinner spinner-sm"></span>`;
}
// ── Adobe OAuth dialog (manual redirect URL paste) ─────────────────────────
async function showAdobeOAuthDialog() {
const { url } = await api.auth.adobeUrl().catch(() => ({ url: '#' }));
const existing = document.getElementById('adobe-auth-dialog');
if (existing) existing.remove();
const dialog = document.createElement('div');
dialog.id = 'adobe-auth-dialog';
dialog.innerHTML = `
<div class="modal-backdrop"></div>
<div class="modal-box">
<div class="modal-header">
<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">
<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="adobe-dialog-cancel">Cancel</button>
<button class="btn btn-primary" id="adobe-dialog-submit">Connect</button>
</div>
</div>
`;
document.body.appendChild(dialog);
document.getElementById('adobe-dialog-close').onclick = () => dialog.remove();
document.getElementById('adobe-dialog-cancel').onclick = () => dialog.remove();
document.getElementById('adobe-dialog-submit').onclick = () => submitAdobeCode(dialog);
document.getElementById('adobe-redirect-input').addEventListener('keydown', e => {
if (e.key === 'Enter') submitAdobeCode(dialog);
});
}
async function submitAdobeCode(dialog) {
const url = document.getElementById('adobe-redirect-input').value.trim();
if (!url) return;
const submitBtn = document.getElementById('adobe-dialog-submit');
const errorEl = document.getElementById('adobe-dialog-error');
submitBtn.disabled = true;
submitBtn.textContent = 'Connecting…';
errorEl.textContent = '';
try {
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) {
errorEl.textContent = e.data?.error || e.message || 'Connection failed.';
submitBtn.disabled = false;
submitBtn.textContent = 'Connect';
}
}
// ── Toast notification ─────────────────────────────────────────────────────
export function showToast(message, type = 'info') {
let container = document.getElementById('toast-container');
if (!container) {
container = document.createElement('div');
container.id = 'toast-container';
container.style.cssText = 'position:fixed;bottom:24px;right:24px;z-index:9999;display:flex;flex-direction:column;gap:8px;';
document.body.appendChild(container);
}
const toast = document.createElement('div');
const colors = { info: 'var(--cobalt-light)', error: 'var(--error-bg)', success: 'var(--success-bg)' };
const borders = { info: 'var(--cobalt)', error: 'var(--error)', success: 'var(--success)' };
toast.style.cssText = `
padding:10px 16px;border-radius:6px;font-size:13px;font-weight:500;
background:${colors[type]||colors.info};border:1px solid ${borders[type]||borders.info};
box-shadow:var(--shadow-md);max-width:360px;animation:fadeIn 0.2s ease;
`;
toast.textContent = message;
container.appendChild(toast);
setTimeout(() => toast.remove(), 4000);
}

225
web/static/js/history.js Normal file
View File

@ -0,0 +1,225 @@
// History & Audit view — filterable, exportable migration history
import { api } from './api.js';
import { escHtml, formatDateTime, shortHash, downloadCsv, debounce, renderFieldIssues, bindFieldIssueToggles } from './utils.js';
let _allRecords = [];
let _filter = { search: '', status: 'all', from: '', to: '' };
let _sort = { col: 'timestamp', dir: 'desc' };
const PAGE_SIZE = 50;
let _page = 0;
export async function renderHistory() {
const outlet = document.getElementById('router-outlet');
outlet.innerHTML = `<div class="empty-state"><div class="spinner"></div></div>`;
try {
const data = await api.migrate.history();
_allRecords = (data.history || []).reverse(); // newest first
} catch (e) {
outlet.innerHTML = `<div class="callout error"><span class="callout-icon">❌</span>Failed to load history: ${escHtml(e.message)}</div>`;
return;
}
_page = 0;
_render();
}
function _render() {
const outlet = document.getElementById('router-outlet');
const filtered = _applyFilter(_allRecords);
const page = filtered.slice(_page * PAGE_SIZE, (_page + 1) * PAGE_SIZE);
const totalPages = Math.ceil(filtered.length / PAGE_SIZE);
outlet.innerHTML = `
<div class="page-header">
<div>
<div class="page-title">History &amp; Audit</div>
<div class="page-subtitle">${_allRecords.length} total migration records</div>
</div>
<div class="page-actions">
<button class="btn btn-secondary btn-sm" id="btn-export-history"> Export CSV</button>
</div>
</div>
<!-- Filter bar -->
<div class="filter-bar">
<input type="search" class="search-input" id="hist-search"
placeholder="Search by template name…" value="${escHtml(_filter.search)}" />
<div class="filter-tabs">
<button class="filter-tab ${_filter.status === 'all' ? 'active' : ''}" data-status="all">All</button>
<button class="filter-tab ${_filter.status === 'success' ? 'active' : ''}" data-status="success">Success</button>
<button class="filter-tab ${_filter.status === 'error' ? 'active' : ''}" data-status="error">Errors</button>
<button class="filter-tab ${_filter.status === 'dry_run' ? 'active' : ''}" data-status="dry_run">Dry Run</button>
<button class="filter-tab ${_filter.status === 'skipped' ? 'active' : ''}" data-status="skipped">Skipped</button>
</div>
<div class="date-filter">
<label style="font-size:11px;color:var(--text-muted)">From:</label>
<input type="date" class="date-input" id="hist-from" value="${_filter.from}" />
<label style="font-size:11px;color:var(--text-muted)">To:</label>
<input type="date" class="date-input" id="hist-to" value="${_filter.to}" />
</div>
</div>
${filtered.length === 0 ? `
<div class="empty-state">
<div class="empty-state-icon">📋</div>
<div class="empty-state-title">${_allRecords.length ? 'No records match your filter' : 'No migration history yet'}</div>
<div class="empty-state-sub">${_allRecords.length ? 'Try clearing the search or filters.' : 'Run a migration to see history here.'}</div>
</div>
` : `
<div class="card">
<div class="table-wrap">
<table>
<thead>
<tr>
${_th('timestamp', 'Time')}
${_th('adobe_template_name', 'Template')}
${_th('action', 'Action')}
${_th('status', 'Status')}
<th>Docusign ID</th>
<th>Checksum</th>
</tr>
</thead>
<tbody>
${page.map(r => _historyRow(r)).join('')}
</tbody>
</table>
</div>
${totalPages > 1 ? `
<div class="pagination">
<span>Showing ${_page * PAGE_SIZE + 1}${Math.min((_page + 1) * PAGE_SIZE, filtered.length)} of ${filtered.length}</span>
<div class="pagination-pages">
<button class="page-btn" id="pg-prev" ${_page === 0 ? 'disabled' : ''}> Prev</button>
<button class="page-btn" id="pg-next" ${_page >= totalPages - 1 ? 'disabled' : ''}>Next </button>
</div>
</div>` : ''}
</div>
`}
`;
_bindEvents(filtered);
}
function _th(col, label) {
const dir = _sort.col === col ? (_sort.dir === 'asc' ? 'sort-asc' : 'sort-desc') : '';
return `<th class="sortable ${dir}" data-col="${col}">${label}</th>`;
}
function _historyRow(r) {
const hasIssues = (r.field_issues || []).length > 0;
const statusBadge = r.status === 'success'
? `<span class="badge badge-green">${escHtml(r.action || 'success')}</span>${hasIssues ? '<span class="badge badge-amber">partial</span>' : ''}`
: `<span class="badge badge-${r.status === 'skipped' ? 'gray' : r.status === 'dry_run' ? 'gray' : 'red'}">${escHtml(r.status || '—')}</span>`;
const checksum = r.checksum_sha256 || r.checksum || '';
return `
<tr class="row-expandable" data-expanded="false">
<td style="white-space:nowrap;font-size:12px">${(r.timestamp||'').slice(0,19).replace('T',' ')}</td>
<td>
<div class="table-name">${escHtml(r.adobe_template_name || r.adobe_template_id || '—')}</div>
<div class="table-sub">${escHtml(r.adobe_template_id || '')}</div>
</td>
<td>${escHtml(r.action || '—')}</td>
<td>${statusBadge}</td>
<td class="mono" style="font-size:11px">${r.docusign_template_id ? escHtml(r.docusign_template_id.slice(0,12)) + '…' : '—'}</td>
<td>
${checksum
? `<span class="checksum" title="${escHtml(checksum)}">${escHtml(shortHash(checksum))}</span>`
: '<span style="color:var(--text-muted)">—</span>'}
</td>
</tr>
${(r.blockers || r.warnings || r.error || (r.field_issues||[]).length) ? `
<tr class="row-expanded-content" style="display:none">
<td colspan="6">
<div class="row-expand-body">
${(r.blockers||[]).map(b => `<div style="color:var(--error);font-size:12px">🚫 ${escHtml(b)}</div>`).join('')}
${(r.warnings||[]).map(w => `<div style="color:var(--warning);font-size:12px">⚠ ${escHtml(w)}</div>`).join('')}
${r.error ? `<div style="color:var(--error);font-size:12px">❌ ${escHtml(r.error)}</div>` : ''}
${renderFieldIssues(r.field_issues)}
</div>
</td>
</tr>` : ''}
`;
}
function _applyFilter(records) {
let list = [...records];
if (_filter.search) {
const q = _filter.search.toLowerCase();
list = list.filter(r =>
(r.adobe_template_name || '').toLowerCase().includes(q) ||
(r.adobe_template_id || '').toLowerCase().includes(q)
);
}
if (_filter.status !== 'all') {
list = list.filter(r => r.status === _filter.status);
}
if (_filter.from) {
list = list.filter(r => r.timestamp >= _filter.from);
}
if (_filter.to) {
list = list.filter(r => r.timestamp <= _filter.to + 'T23:59:59');
}
list.sort((a, b) => {
const va = a[_sort.col] || '';
const vb = b[_sort.col] || '';
return _sort.dir === 'asc' ? String(va).localeCompare(String(vb)) : String(vb).localeCompare(String(va));
});
return list;
}
function _bindEvents(filtered) {
document.getElementById('hist-search')?.addEventListener('input', debounce(e => {
_filter.search = e.target.value; _page = 0; _render();
}, 250));
document.querySelectorAll('.filter-tab[data-status]').forEach(btn => {
btn.addEventListener('click', () => { _filter.status = btn.dataset.status; _page = 0; _render(); });
});
document.getElementById('hist-from')?.addEventListener('change', e => { _filter.from = e.target.value; _page = 0; _render(); });
document.getElementById('hist-to')?.addEventListener('change', e => { _filter.to = e.target.value; _page = 0; _render(); });
document.querySelectorAll('th.sortable').forEach(th => {
th.addEventListener('click', () => {
const col = th.dataset.col;
_sort.dir = _sort.col === col && _sort.dir === 'asc' ? 'desc' : 'asc';
_sort.col = col; _page = 0; _render();
});
});
document.getElementById('pg-prev')?.addEventListener('click', () => { if (_page > 0) { _page--; _render(); } });
document.getElementById('pg-next')?.addEventListener('click', () => { _page++; _render(); });
// Expand rows
document.querySelectorAll('.row-expandable').forEach(row => {
row.addEventListener('click', () => {
const next = row.nextElementSibling;
if (next && next.classList.contains('row-expanded-content')) {
const open = next.style.display !== 'none';
next.style.display = open ? 'none' : 'table-row';
}
});
});
bindFieldIssueToggles();
document.getElementById('btn-export-history')?.addEventListener('click', () => {
downloadCsv('migration-history.csv', filtered.map(r => ({
timestamp: r.timestamp || '',
template: r.adobe_template_name || r.adobe_template_id || '',
adobe_id: r.adobe_template_id || '',
docusign_id: r.docusign_template_id || '',
action: r.action || '',
status: r.status || '',
checksum: r.checksum_sha256 || '',
warnings: (r.warnings || []).join('; '),
})));
});
}

124
web/static/js/issues.js Normal file
View File

@ -0,0 +1,124 @@
// Issues & Warnings view — surfaces all validation problems before migration
import { state } from './state.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 => t.blockers && t.blockers.length > 0);
const warnings = templates.filter(t =>
(!t.blockers || t.blockers.length === 0) && t.warnings && t.warnings.length > 0
);
if (!state.auth.adobe || !state.auth.docusign) {
outlet.innerHTML = `
<div class="page-header"><div><div class="page-title">Issues &amp; Warnings</div></div></div>
<div class="callout info"><span class="callout-icon"></span>Connect both platforms to see validation results.</div>`;
return;
}
if (!blocked.length && !warnings.length) {
outlet.innerHTML = `
<div class="page-header">
<div>
<div class="page-title">Issues &amp; Warnings</div>
<div class="page-subtitle">${templates.length} templates analyzed</div>
</div>
</div>
<div class="callout success" style="font-size:14px">
<span class="callout-icon">🎉</span>
<div>
<strong>All templates are ready!</strong>
<div style="margin-top:4px">No blockers or warnings found across ${templates.length} template${templates.length !== 1 ? 's' : ''}.</div>
</div>
</div>`;
return;
}
outlet.innerHTML = `
<div class="page-header">
<div>
<div class="page-title">Issues &amp; Warnings</div>
<div class="page-subtitle">${templates.length} templates analyzed
${blocked.length ? `<span style="color:var(--error);font-weight:600">${blocked.length} blocked</span>` : ''}
${blocked.length && warnings.length ? ', ' : ''}
${warnings.length ? `<span style="color:var(--warning);font-weight:600">${warnings.length} with warnings</span>` : ''}
</div>
</div>
<div class="page-actions">
<a href="#/templates" class="btn btn-secondary btn-sm"> All Templates</a>
</div>
</div>
${blocked.length ? `
<div style="margin-bottom:24px">
<div style="font-size:14px;font-weight:700;color:var(--error);margin-bottom:10px">
🚫 Blockers ${blocked.length} template${blocked.length > 1 ? 's' : ''} will fail migration
</div>
<div class="attention-list">
${blocked.map(t => _blockerItem(t)).join('')}
</div>
</div>` : ''}
${warnings.length ? `
<div>
<div style="font-size:14px;font-weight:700;color:var(--warning);margin-bottom:10px">
Warnings ${warnings.length} template${warnings.length > 1 ? 's' : ''} will migrate with caveats
</div>
<div class="attention-list">
${warnings.map(t => _warningItem(t)).join('')}
</div>
</div>` : ''}
`;
// Migrate Anyway buttons
document.querySelectorAll('.btn-migrate-anyway').forEach(btn => {
btn.addEventListener('click', () => {
import('./migration.js').then(m => m.showOptionsModal([btn.dataset.id]));
});
});
// View Template links
document.querySelectorAll('.btn-view-template').forEach(btn => {
btn.addEventListener('click', () => navigate(`#/templates/${btn.dataset.id}`));
});
}
function _blockerItem(t) {
const blockers = t.blockers || [];
return `
<div class="attention-item blocker">
<span class="attention-icon">🚫</span>
<div style="flex:1">
<div class="attention-name">${escHtml(t.name)}</div>
${blockers.map(b => `<div class="attention-detail">• ${escHtml(b)}</div>`).join('')}
<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">
<button class="btn btn-secondary btn-xs btn-view-template" data-id="${escHtml(t.adobe_id)}">View Detail</button>
</div>
</div>
`;
}
function _warningItem(t) {
const warnings = t.warnings || [];
return `
<div class="attention-item warning">
<span class="attention-icon"></span>
<div style="flex:1">
<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>` : ''}
<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">
<button class="btn btn-primary btn-xs btn-migrate-anyway" data-id="${escHtml(t.adobe_id)}">Migrate Anyway</button>
<button class="btn btn-secondary btn-xs btn-view-template" data-id="${escHtml(t.adobe_id)}">View Detail</button>
</div>
</div>
`;
}

392
web/static/js/migration.js Normal file
View File

@ -0,0 +1,392 @@
// Migration workflow: options modal → progress → results view
import { api } from './api.js';
import { state, setState } from './state.js';
import { escHtml, formatDateTime, downloadCsv, renderFieldIssues, bindFieldIssueToggles } from './utils.js';
import { navigate } from './router.js';
import { refreshTemplates } from './templates.js';
// ── Helpers ────────────────────────────────────────────────────────────────
function getSettings() {
try { return JSON.parse(localStorage.getItem('migrator_settings')) || {}; }
catch { return {}; }
}
// ── Options modal ──────────────────────────────────────────────────────────
export function showOptionsModal(ids) {
if (!ids || !ids.length) return;
const settings = getSettings();
const names = ids.map(id => {
const t = state.templates.find(t => t.adobe_id === id);
return t ? t.name : id;
});
const existing = document.getElementById('migration-modal');
if (existing) existing.remove();
const wrapper = document.createElement('div');
wrapper.id = 'migration-modal';
wrapper.innerHTML = `
<div class="modal-backdrop"></div>
<div class="modal-box">
<div class="modal-header">
<span class="modal-title">Migration Options</span>
<button class="modal-close" id="mm-close"></button>
</div>
<div class="modal-body">
<div class="callout info" style="margin-bottom:16px">
<span class="callout-icon">📋</span>
<span>Migrating <strong>${ids.length}</strong> template${ids.length > 1 ? 's' : ''}:
${names.slice(0, 3).map(n => `<em>${escHtml(n)}</em>`).join(', ')}${names.length > 3 ? `… +${names.length - 3} more` : ''}</span>
</div>
<div class="options-panel">
<div class="options-title">Options</div>
<div class="option-row">
<button class="toggle" id="opt-dry-run" role="switch" aria-checked="false"></button>
<div class="option-body">
<div class="option-label">Dry Run</div>
<div class="option-desc">Validate and preview without creating Docusign templates</div>
</div>
</div>
<div class="option-row">
<button class="toggle" id="opt-overwrite" role="switch"
aria-checked="${settings.defaultOverwrite ? 'true' : 'false'}"
class="toggle ${settings.defaultOverwrite ? 'on' : ''}"></button>
<div class="option-body">
<div class="option-label">Overwrite Existing</div>
<div class="option-desc">Update Docusign templates that already exist (default: skip)</div>
</div>
</div>
<div class="option-row">
<button class="toggle on" id="opt-include-docs" role="switch" aria-checked="true"></button>
<div class="option-body">
<div class="option-label">Include Documents</div>
<div class="option-desc">Embed PDFs in the Docusign template payload</div>
</div>
</div>
<div class="option-row" style="align-items:center">
<div class="option-body">
<div class="option-label">Target Folder <span class="form-label-sub">(optional)</span></div>
<input type="text" class="form-input" id="opt-folder"
placeholder="e.g. Migrated Templates" style="margin-top:6px" />
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" id="mm-cancel">Cancel</button>
<button class="btn btn-primary" id="mm-run">Run Migration </button>
</div>
</div>
`;
document.body.appendChild(wrapper);
// Wire toggles
wrapper.querySelectorAll('.toggle').forEach(btn => {
if (settings.defaultOverwrite && btn.id === 'opt-overwrite') btn.classList.add('on');
btn.addEventListener('click', () => {
btn.classList.toggle('on');
btn.setAttribute('aria-checked', btn.classList.contains('on'));
});
});
document.getElementById('mm-close').onclick = () => wrapper.remove();
document.getElementById('mm-cancel').onclick = () => wrapper.remove();
document.getElementById('mm-run').onclick = () => _startMigration(ids, wrapper);
}
// ── Start migration ────────────────────────────────────────────────────────
async function _startMigration(ids, wrapper) {
const dryRun = document.getElementById('opt-dry-run')?.classList.contains('on') || false;
const overwrite = document.getElementById('opt-overwrite')?.classList.contains('on') || false;
const includeDocs = !(document.getElementById('opt-include-docs')?.classList.contains('on') === false);
const folder = document.getElementById('opt-folder')?.value.trim() || undefined;
// Replace modal body with progress view
const body = wrapper.querySelector('.modal-body');
const footer = wrapper.querySelector('.modal-footer');
wrapper.querySelector('.modal-title').textContent = dryRun ? 'Dry Run' : 'Migrating…';
footer.innerHTML = ''; // hide footer during migration
const names = ids.map(id => {
const t = state.templates.find(t => t.adobe_id === id);
return { id, name: t ? t.name : id };
});
body.innerHTML = `
<div class="migration-progress">
<div class="progress-wrap">
<div class="progress-label">
<span id="prog-label">Starting</span>
<span id="prog-count">0 / ${ids.length}</span>
</div>
<div class="progress-bar"><div class="progress-fill" id="prog-bar" style="width:0%"></div></div>
</div>
<div class="progress-template-list" id="prog-list">
${names.map(n => `
<div class="progress-template-row" id="prog-row-${escHtml(n.id)}">
<div class="progress-template-name">${escHtml(n.name)}</div>
<span class="progress-template-status spinner spinner-sm"></span>
</div>`).join('')}
</div>
</div>
`;
try {
const jobData = await api.migrate.batch({
source_template_ids: ids,
target_folder: folder,
options: { dry_run: dryRun, overwrite_if_exists: overwrite, include_documents: includeDocs },
});
const jobId = jobData.job_id;
await pollJob(jobId, (progress) => {
const { completed, total } = progress.progress || { completed: 0, total: ids.length };
const pct = total > 0 ? Math.round(completed / total * 100) : 0;
document.getElementById('prog-bar') && (document.getElementById('prog-bar').style.width = pct + '%');
document.getElementById('prog-count')&& (document.getElementById('prog-count').textContent = `${completed} / ${total}`);
document.getElementById('prog-label')&& (document.getElementById('prog-label').textContent = `Migrating… ${pct}%`);
// Update per-template icons as results come in
(progress.results || []).forEach(r => {
const row = document.getElementById(`prog-row-${r.adobe_template_id}`);
if (!row) return;
const statusEl = row.querySelector('.progress-template-status');
if (!statusEl) return;
statusEl.className = 'progress-template-status';
if (r.status === 'success') statusEl.textContent = r.action === 'created' ? '✅' : r.action === 'dry_run' ? '🔍' : '✏️';
else if (r.status === 'skipped') statusEl.textContent = '⏭';
else if (r.status === 'blocked') statusEl.textContent = '🚫';
else statusEl.textContent = '❌';
if (r.status === 'blocked' || r.status === 'error' || r.status === 'failed') {
const msg = r.error || (r.blockers||[])[0] || 'Migration failed';
let hint = row.querySelector('.progress-template-error');
if (!hint) {
hint = document.createElement('div');
hint.className = 'progress-template-error';
row.appendChild(hint);
}
hint.textContent = msg;
}
});
});
// Migration done — show "View Results" button
const allResults = (jobData.results || []);
const failCount = allResults.filter(r => r.status === 'blocked' || r.status === 'error' || r.status === 'failed').length;
document.getElementById('prog-label') && (document.getElementById('prog-label').textContent = 'Done!');
if (failCount > 0) {
const hint = document.createElement('div');
hint.style.cssText = 'font-size:12px;color:var(--text-muted);margin-top:10px;text-align:center';
hint.textContent = `${failCount} template${failCount > 1 ? 's' : ''} had issues — select View Results for details.`;
body.appendChild(hint);
}
footer.innerHTML = `
<button class="btn btn-secondary" id="mm-close-done">Close</button>
<button class="btn btn-primary" id="mm-view-results">View Results </button>
`;
document.getElementById('mm-close-done').onclick = () => wrapper.remove();
document.getElementById('mm-view-results').onclick = () => {
wrapper.remove();
navigate('#/results');
};
// Refresh template list
await refreshTemplates();
state.selectedIds.clear();
} catch (err) {
body.innerHTML += `<div class="callout error" style="margin-top:12px">
<span class="callout-icon"></span> Migration failed: ${escHtml(err.message)}
</div>`;
footer.innerHTML = `<button class="btn btn-secondary" id="mm-err-close">Close</button>`;
document.getElementById('mm-err-close').onclick = () => wrapper.remove();
}
}
// ── Poll batch job ─────────────────────────────────────────────────────────
export async function pollJob(jobId, onProgress) {
const POLL_MS = 2000;
const MAX_WAIT = 300000; // 5 minutes
const started = Date.now();
return new Promise((resolve, reject) => {
const tick = async () => {
try {
const data = await api.migrate.batchStatus(jobId);
if (onProgress) onProgress(data);
if (data.status === 'done' || data.status === 'complete' || data.status === 'completed') {
setState('lastMigrationResults', data);
resolve(data);
} else if (data.status === 'failed') {
reject(new Error('Migration job failed'));
} else if (Date.now() - started > MAX_WAIT) {
reject(new Error('Migration timed out'));
} else {
setTimeout(tick, POLL_MS);
}
} catch (e) {
reject(e);
}
};
tick();
});
}
// ── Results view ───────────────────────────────────────────────────────────
export function renderResults() {
const outlet = document.getElementById('router-outlet');
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.</div>
</div>`;
return;
}
const templateResults = results.results || [];
const summary = {
created: templateResults.filter(r => r.action === 'created').length,
updated: templateResults.filter(r => r.action === 'updated').length,
skipped: templateResults.filter(r => r.status === 'skipped').length,
blocked: templateResults.filter(r => r.status === 'blocked').length,
errors: templateResults.filter(r => r.status === 'error').length,
dry_run: templateResults.filter(r => r.status === 'dry_run').length,
};
const migratedIds = templateResults
.filter(r => r.status === 'success')
.map(r => r.adobe_template_id);
outlet.innerHTML = `
<div class="page-header">
<div>
<div class="page-title">Migration Results</div>
<div class="page-subtitle">${formatDateTime(results.completed_at || new Date().toISOString())}</div>
</div>
<div class="page-actions">
<button class="btn btn-secondary btn-sm" id="btn-export-results"> Export CSV</button>
${migratedIds.length ? `<button class="btn btn-primary btn-sm" id="btn-verify-results">Verify Templates →</button>` : ''}
</div>
</div>
<!-- Summary stat cards -->
<div class="stat-grid" style="grid-template-columns:repeat(${summary.dry_run ? 6 : 5},1fr)">
<div class="stat-card green">
<div class="stat-label">Created</div>
<div class="stat-value">${summary.created}</div>
</div>
<div class="stat-card blue">
<div class="stat-label">Updated</div>
<div class="stat-value">${summary.updated}</div>
</div>
<div class="stat-card gray">
<div class="stat-label">Skipped</div>
<div class="stat-value">${summary.skipped}</div>
</div>
<div class="stat-card red">
<div class="stat-label">Blocked</div>
<div class="stat-value">${summary.blocked}</div>
</div>
<div class="stat-card red">
<div class="stat-label">Errors</div>
<div class="stat-value">${summary.errors}</div>
</div>
${summary.dry_run ? `<div class="stat-card gray"><div class="stat-label">Dry Run</div><div class="stat-value">${summary.dry_run}</div></div>` : ''}
</div>
<!-- Per-template results -->
<div class="card">
<div class="card-header">
<span class="card-title">Per-Template Results</span>
</div>
<div id="results-list" style="padding:8px 16px">
${templateResults.map(r => _resultRow(r)).join('')}
</div>
</div>
<div style="display:flex;gap:8px;margin-top:8px">
<a href="#/templates" class="btn btn-secondary btn-sm"> Back to Templates</a>
</div>
`;
// Expand/collapse result rows
document.querySelectorAll('.result-header').forEach(hdr => {
hdr.addEventListener('click', () => {
hdr.parentElement.classList.toggle('open');
});
});
bindFieldIssueToggles();
// Export CSV
document.getElementById('btn-export-results')?.addEventListener('click', () => {
downloadCsv('migration-results.csv', templateResults.map(r => ({
name: r.adobe_template_name || r.adobe_template_id,
adobe_id: r.adobe_template_id,
docusign_id: r.docusign_template_id || '',
status: r.status,
action: r.action || '',
warnings: (r.warnings || []).join('; '),
})));
});
// Verify button
document.getElementById('btn-verify-results')?.addEventListener('click', () => {
import('./verification.js').then(m => {
setState('verifyIds', migratedIds);
navigate('#/verify');
});
});
}
function _resultRow(r) {
const issues = r.field_issues || [];
const warnings = r.warnings || [];
const hasDetail = warnings.length || r.error || issues.length;
const icon = r.status === 'success'
? (issues.length ? '⚠️' : r.action === 'created' ? '✅' : r.action === 'dry_run' ? '🔍' : '✏️')
: (r.status === 'skipped' ? '⏭' : r.status === 'blocked' ? '🚫' : '❌');
const statusBadge = r.status === 'success'
? `<span class="badge badge-green">${r.action || 'success'}</span>${issues.length ? '<span class="badge badge-amber">partial</span>' : ''}`
: `<span class="badge badge-${r.status === 'skipped' ? 'gray' : 'red'}">${r.status}</span>`;
return `
<div class="result-row">
<div class="result-header">
<span class="result-icon">${icon}</span>
<span class="result-name">${escHtml(r.adobe_template_name || r.adobe_template_id)}</span>
${statusBadge}
${r.docusign_template_id ? `<span class="ds-pill" title="${escHtml(r.docusign_template_id)}">DS: ${escHtml(r.docusign_template_id.slice(0,8))}…</span>` : ''}
${issues.length ? `<span class="result-meta">⚠ ${issues.length} field issue${issues.length > 1 ? 's' : ''}</span>` : ''}
${warnings.length ? `<span class="result-meta">${warnings.length} warning${warnings.length > 1 ? 's' : ''}</span>` : ''}
</div>
${hasDetail ? `
<div class="result-body">
${warnings.map(w => `<div class="result-warn-item"><span class="ri">⚠</span>${escHtml(w)}</div>`).join('')}
${r.error ? `<div class="result-warn-item" style="color:var(--error)"><span class="ri">❌</span>${escHtml(r.error)}</div>` : ''}
${renderFieldIssues(issues)}
</div>` : ''}
</div>
`;
}

197
web/static/js/project.js Normal file
View File

@ -0,0 +1,197 @@
// Project / customer context — localStorage CRUD + switcher modal
import { escHtml, uuid, formatDate } from './utils.js';
const STORAGE_KEY = 'migrator_projects';
// ── Data model ─────────────────────────────────────────────────────────────
// localStorage schema:
// { active: string|null, projects: Array<{ id, name, createdAt }> }
function load() {
try {
return JSON.parse(localStorage.getItem(STORAGE_KEY)) || { active: null, projects: [] };
} catch {
return { active: null, projects: [] };
}
}
function save(data) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
}
export function listProjects() {
return load().projects;
}
export function getActive() {
const data = load();
return data.projects.find(p => p.id === data.active) || null;
}
export function createProject(name) {
const data = load();
const project = { id: uuid(), name: name.trim(), createdAt: new Date().toISOString() };
data.projects.push(project);
if (!data.active) data.active = project.id;
save(data);
return project;
}
export function deleteProject(id) {
const data = load();
data.projects = data.projects.filter(p => p.id !== id);
if (data.active === id) {
data.active = data.projects[0]?.id || null;
}
save(data);
}
export function setActive(id) {
const data = load();
if (data.projects.find(p => p.id === id)) {
data.active = id;
save(data);
}
}
// ── Init: called on app startup ─────────────────────────────────────────────
// onUpdate callback is called whenever the active project changes
let _onUpdate = null;
export function initProject(onUpdate) {
_onUpdate = onUpdate;
onUpdate();
// Show project modal on first run if no projects exist
if (listProjects().length === 0) {
showProjectModal(onUpdate);
}
}
// ── Project switcher modal ─────────────────────────────────────────────────
export function showProjectModal(onUpdate) {
if (onUpdate) _onUpdate = onUpdate;
const existing = document.getElementById('project-modal');
if (existing) existing.remove();
const wrapper = document.createElement('div');
wrapper.id = 'project-modal';
wrapper.innerHTML = `
<div class="modal-backdrop"></div>
<div class="modal-box modal-sm">
<div class="modal-header">
<span class="modal-title">Switch Project</span>
<button class="modal-close" id="pm-close"></button>
</div>
<div class="modal-body" style="padding-bottom:0">
<div class="project-list" id="pm-project-list"></div>
<div class="new-project-form">
<h4>New Project</h4>
<div class="form-group">
<input type="text" class="form-input" id="pm-new-name"
placeholder="Customer name (e.g. Acme Corp)" maxlength="60" />
<div class="form-error" id="pm-error"></div>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" id="pm-cancel">Cancel</button>
<button class="btn btn-primary" id="pm-create">Create Project</button>
</div>
</div>
`;
document.body.appendChild(wrapper);
renderProjectList();
document.getElementById('pm-close').onclick = () => wrapper.remove();
document.getElementById('pm-cancel').onclick = () => wrapper.remove();
document.getElementById('pm-create').onclick = handleCreate;
document.getElementById('pm-new-name').addEventListener('keydown', e => {
if (e.key === 'Enter') handleCreate();
});
// Focus name input
setTimeout(() => document.getElementById('pm-new-name')?.focus(), 50);
}
function renderProjectList() {
const list = document.getElementById('pm-project-list');
const active = getActive();
const projects = listProjects();
if (!projects.length) {
list.innerHTML = `<p style="font-size:13px;color:var(--text-muted);margin-bottom:8px">
No projects yet. Create one below to get started.
</p>`;
return;
}
list.innerHTML = projects.map(p => `
<div class="project-row ${p.id === active?.id ? 'active' : ''}" data-id="${escHtml(p.id)}">
<div class="project-row-icon">${escHtml(p.name.slice(0, 2).toUpperCase())}</div>
<div style="flex:1">
<div class="project-row-name">${escHtml(p.name)}</div>
<div class="project-row-sub">Created ${formatDate(p.createdAt)}</div>
</div>
${p.id === active?.id
? '<span class="project-row-active-badge">● Active</span>'
: `<button class="btn btn-secondary btn-xs pm-delete-btn" data-id="${escHtml(p.id)}" title="Delete project">✕</button>`
}
</div>
`).join('');
// Activate on row click
list.querySelectorAll('.project-row').forEach(row => {
row.addEventListener('click', e => {
if (e.target.classList.contains('pm-delete-btn')) return;
activateProject(row.dataset.id);
});
});
// Delete buttons
list.querySelectorAll('.pm-delete-btn').forEach(btn => {
btn.addEventListener('click', e => {
e.stopPropagation();
handleDelete(btn.dataset.id);
});
});
}
function activateProject(id) {
setActive(id);
if (_onUpdate) _onUpdate();
const modal = document.getElementById('project-modal');
if (modal) modal.remove();
}
function handleDelete(id) {
const project = listProjects().find(p => p.id === id);
if (!project) return;
if (!confirm(`Delete project "${project.name}"? This cannot be undone.`)) return;
deleteProject(id);
if (_onUpdate) _onUpdate();
renderProjectList();
}
function handleCreate() {
const input = document.getElementById('pm-new-name');
const errorEl = document.getElementById('pm-error');
const name = input?.value.trim();
if (!name) {
if (errorEl) errorEl.textContent = 'Project name is required.';
input?.focus();
return;
}
if (errorEl) errorEl.textContent = '';
const project = createProject(name);
setActive(project.id);
if (_onUpdate) _onUpdate();
const modal = document.getElementById('project-modal');
if (modal) modal.remove();
}

102
web/static/js/router.js Normal file
View File

@ -0,0 +1,102 @@
// Hash-based client-side router
// Usage: navigate('#/templates') or window.location.hash = '#/templates'
import { escHtml } from './utils.js';
const _routes = {};
let _current = null;
// Register a route: router.register('#/templates', loadFn)
export function register(hash, loadFn) {
_routes[hash] = loadFn;
}
// Navigate to a hash route
export function navigate(hash) {
window.location.hash = hash;
}
// Navigate and pass data to the view (stored temporarily)
let _routeData = null;
export function navigateWith(hash, data) {
_routeData = data;
navigate(hash);
}
export function getRouteData() {
const d = _routeData;
_routeData = null;
return d;
}
// Parse route: '#/templates/abc123' → { base: '#/templates', param: 'abc123' }
function parseHash(hash) {
const clean = hash || '#/templates';
const parts = clean.split('/');
if (parts.length >= 3) {
return { base: parts.slice(0, 2).join('/'), param: parts.slice(2).join('/') };
}
return { base: clean, param: null };
}
// Route to the current hash
async function route() {
const { base, param } = parseHash(window.location.hash);
const key = param ? base : (window.location.hash || '#/templates');
const baseKey = base;
const loader = _routes[key] || _routes[baseKey] || _routes['#/templates'];
if (!loader) return;
_current = key;
updateActiveNav(baseKey);
const outlet = document.getElementById('router-outlet');
if (outlet) outlet.classList.remove('view-enter');
try {
await loader(param);
} catch (err) {
console.error('Router error:', err);
const outlet = document.getElementById('router-outlet');
if (outlet) outlet.innerHTML = `
<div class="empty-state">
<div class="empty-state-icon"></div>
<div class="empty-state-title">Failed to load view</div>
<div class="empty-state-sub">${escHtml(err.message)}</div>
</div>`;
}
if (outlet) {
outlet.classList.add('view-enter');
// Remove class after animation to allow re-trigger
setTimeout(() => outlet.classList.remove('view-enter'), 200);
}
}
// Highlight active nav item
function updateActiveNav(hash) {
document.querySelectorAll('.nav-item').forEach(el => {
el.classList.toggle('active', el.dataset.route === hash);
});
// Update breadcrumb
const label = document.querySelector(`.nav-item[data-route="${hash}"] .nav-label`);
const breadcrumbCurrent = document.getElementById('breadcrumb-current');
if (breadcrumbCurrent && label) {
breadcrumbCurrent.textContent = label.textContent.trim();
}
}
// Init: listen for hash changes and route on load
export function init() {
window.addEventListener('hashchange', route);
// Route immediately
if (!window.location.hash || window.location.hash === '#') {
window.location.hash = '#/templates';
} else {
route();
}
}
export function current() { return _current; }

184
web/static/js/settings.js Normal file
View File

@ -0,0 +1,184 @@
// Settings view — verification defaults, migration defaults, connection info
import { api } from './api.js';
import { state } from './state.js';
import { escHtml } from './utils.js';
const SETTINGS_KEY = 'migrator_settings';
export function loadSettings() {
try { return JSON.parse(localStorage.getItem(SETTINGS_KEY)) || {}; }
catch { return {}; }
}
export function saveSettings(settings) {
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
}
export function renderSettings() {
const outlet = document.getElementById('router-outlet');
const s = loadSettings();
outlet.innerHTML = `
<div class="page-header">
<div>
<div class="page-title">Settings</div>
<div class="page-subtitle">Configure verification defaults and migration behavior</div>
</div>
</div>
<!-- Verification defaults -->
<div class="settings-section">
<div class="settings-section-header">
<div class="settings-section-title">Verification</div>
<div class="settings-section-sub">Default recipient for test envelopes</div>
</div>
<div class="settings-section-body">
<div class="setting-row">
<div class="setting-body">
<div class="setting-label">Test Recipient Name</div>
<div class="setting-desc">Pre-filled in the Send Test dialog on the Verification screen</div>
</div>
<div class="setting-control" style="min-width:240px">
<input type="text" class="form-input" id="set-recipient-name"
value="${escHtml(s.testRecipientName || '')}"
placeholder="e.g. Test User" />
</div>
</div>
<div class="setting-row">
<div class="setting-body">
<div class="setting-label">Test Recipient Email</div>
<div class="setting-desc">Pre-filled in the Send Test dialog</div>
</div>
<div class="setting-control" style="min-width:240px">
<input type="email" class="form-input" id="set-recipient-email"
value="${escHtml(s.testRecipientEmail || '')}"
placeholder="e.g. test@example.com" />
</div>
</div>
<div class="setting-row">
<div class="setting-body">
<div class="setting-label">Auto-Void Timer (hours)</div>
<div class="setting-desc">Reminder to void test envelopes after this many hours (display only no automatic action)</div>
</div>
<div class="setting-control">
<input type="number" class="form-input" id="set-auto-void"
value="${s.autoVoidHours ?? 24}" min="1" max="168" style="width:80px" />
</div>
</div>
</div>
</div>
<!-- Migration defaults -->
<div class="settings-section">
<div class="settings-section-header">
<div class="settings-section-title">Migration Defaults</div>
<div class="settings-section-sub">Pre-set options in the migration options modal</div>
</div>
<div class="settings-section-body">
<div class="setting-row">
<div class="setting-body">
<div class="setting-label">Overwrite Existing by Default</div>
<div class="setting-desc">When on, the Overwrite Existing toggle in the migration modal starts enabled</div>
</div>
<div class="setting-control">
<button class="toggle ${s.defaultOverwrite ? 'on' : ''}" id="set-overwrite"
role="switch" aria-checked="${!!s.defaultOverwrite}"></button>
</div>
</div>
<div class="setting-row">
<div class="setting-body">
<div class="setting-label">Include Documents by Default</div>
<div class="setting-desc">Embed PDFs in the Docusign template payload</div>
</div>
<div class="setting-control">
<button class="toggle ${s.defaultIncludeDocs !== false ? 'on' : ''}" id="set-include-docs"
role="switch" aria-checked="${s.defaultIncludeDocs !== false}"></button>
</div>
</div>
</div>
</div>
<!-- Connection info (read-only) -->
<div class="settings-section">
<div class="settings-section-header">
<div class="settings-section-title">Connections</div>
<div class="settings-section-sub">Current platform connection status (connect via top bar)</div>
</div>
<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>
<!-- Save -->
<div style="display:flex;gap:8px;align-items:center">
<button class="btn btn-primary" id="btn-save-settings">Save Settings</button>
<span id="save-confirm" style="font-size:13px;color:var(--success);display:none"> Saved</span>
</div>
`;
// Wire toggles
document.querySelectorAll('.toggle').forEach(btn => {
btn.addEventListener('click', () => {
btn.classList.toggle('on');
btn.setAttribute('aria-checked', btn.classList.contains('on'));
});
});
// Save
document.getElementById('btn-save-settings')?.addEventListener('click', () => {
const updated = {
testRecipientName: document.getElementById('set-recipient-name')?.value.trim() || '',
testRecipientEmail: document.getElementById('set-recipient-email')?.value.trim() || '',
autoVoidHours: parseInt(document.getElementById('set-auto-void')?.value) || 24,
defaultOverwrite: document.getElementById('set-overwrite')?.classList.contains('on') || false,
defaultIncludeDocs: document.getElementById('set-include-docs')?.classList.contains('on') !== false,
};
saveSettings(updated);
const confirm = document.getElementById('save-confirm');
if (confirm) {
confirm.style.display = 'inline';
setTimeout(() => { confirm.style.display = 'none'; }, 2500);
}
});
// Load connection info
_loadConnInfo();
}
async function _loadConnInfo() {
const connEl = document.getElementById('settings-conn-info');
if (!connEl) return;
try {
const data = await api.auth.status();
connEl.innerHTML = `
<div class="conn-info-row">
<span class="conn-info-label">Adobe Sign</span>
<span class="conn-info-value">${data.adobe ? 'Connected' : 'Not connected'}</span>
<span class="conn-info-status">
<span class="badge ${data.adobe ? 'badge-green' : 'badge-gray'}">${data.adobe ? '● Connected' : '○ Disconnected'}</span>
</span>
</div>
<div class="conn-info-row">
<span class="conn-info-label">Docusign</span>
<span class="conn-info-value">${data.docusign ? 'Connected' : 'Not connected'}</span>
<span class="conn-info-status">
<span class="badge ${data.docusign ? 'badge-green' : 'badge-gray'}">${data.docusign ? '● Connected' : '○ Disconnected'}</span>
</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>
<span class="conn-info-status"></span>
</div>
<div class="conn-info-row">
<span class="conn-info-label">API Environment</span>
<span class="conn-info-value mono">${escHtml(data.base_url || '—')}</span>
<span class="conn-info-status"></span>
</div>
`;
} catch (e) {
connEl.innerHTML = `<div class="callout error"><span class="callout-icon">❌</span>Failed to load connection info: ${escHtml(e.message)}</div>`;
}
}

43
web/static/js/state.js Normal file
View File

@ -0,0 +1,43 @@
// Global application state with simple pub/sub
const _listeners = {};
export const state = {
project: null, // { id, name, createdAt }
auth: {
adobe: false,
docusign: false,
adobeLabel: 'Adobe Sign',
docusignLabel: 'Docusign',
},
templates: [], // [{ adobe_id, name, status, blockers, warnings, ... }]
selectedIds: new Set(),
lastMigrationResults: null, // final batch job results
issueCount: 0, // blocked template count (drives nav badge)
};
// Subscribe to state key changes: fn is called with (newValue, oldValue)
export function subscribe(key, fn) {
if (!_listeners[key]) _listeners[key] = [];
_listeners[key].push(fn);
}
// Publish a state change
export function publish(key, newValue) {
const old = state[key];
state[key] = newValue;
(_listeners[key] || []).forEach(fn => {
try { fn(newValue, old); } catch (e) { console.error('state listener error', e); }
});
}
// Convenience setter that publishes
export function setState(key, value) {
publish(key, value);
}
// Recompute derived values after template list updates
export function updateDerivedState() {
const blocked = state.templates.filter(t => t.blockers && t.blockers.length > 0).length;
setState('issueCount', blocked);
}

502
web/static/js/templates.js Normal file
View File

@ -0,0 +1,502 @@
// Templates view — filterable table with readiness badges + template detail
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';
// ── Readiness badge ────────────────────────────────────────────────────────
function readiness(t) {
if (t.blockers && t.blockers.length > 0) {
return { key: 'blocked', label: 'Blocked', cls: 'badge-blocked' };
}
if (t.status === 'migrated') {
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 (t.warnings && t.warnings.length > 0) {
return { key: 'caveats', label: 'Caveats', cls: 'badge-caveats' };
}
return { key: 'ready', label: 'Ready', cls: 'badge-ready' };
}
// ── Refresh templates from API ─────────────────────────────────────────────
export async function refreshTemplates() {
if (!state.auth.adobe || !state.auth.docusign) {
setState('templates', []);
updateDerivedState();
return;
}
try {
const data = await api.templates.status();
setState('templates', data.templates || []);
updateDerivedState();
} catch (e) {
console.warn('refreshTemplates failed:', e.message);
}
}
// ── Templates list view ────────────────────────────────────────────────────
let _filter = { search: '', status: 'all' };
let _sort = { col: 'name', dir: 'asc' };
export async function renderTemplates() {
const outlet = document.getElementById('router-outlet');
// Fetch if not loaded
if (!state.templates.length && state.auth.adobe && state.auth.docusign) {
outlet.innerHTML = `<div class="empty-state"><div class="spinner"></div></div>`;
await refreshTemplates();
}
_render();
}
function _render() {
const outlet = document.getElementById('router-outlet');
const templates = _applyFilter(state.templates);
const counts = _statusCounts(state.templates);
const anySelected = state.selectedIds.size > 0;
outlet.innerHTML = `
<div class="page-header">
<div>
<div class="page-title">Templates</div>
<div class="page-subtitle">${state.templates.length} Adobe Sign templates</div>
</div>
<div class="page-actions">
<button class="btn btn-secondary btn-sm" id="btn-refresh-templates"> Refresh</button>
</div>
</div>
${!state.auth.adobe || !state.auth.docusign ? `
<div class="callout info">
<span class="callout-icon"></span>
Connect both Adobe Sign and Docusign in the top bar to load templates.
</div>
` : ''}
<!-- Filter bar -->
<div class="filter-bar">
<input type="search" class="search-input" id="template-search"
placeholder="Search templates…" value="${escHtml(_filter.search)}" />
<div class="filter-tabs">
${_filterTab('all', `All <span class="tab-count">${counts.total}</span>`)}
${_filterTab('not_migrated', `Not Migrated <span class="tab-count">${counts.not_migrated}</span>`)}
${_filterTab('migrated', `Migrated <span class="tab-count">${counts.migrated}</span>`)}
${_filterTab('needs_update', `Needs Update <span class="tab-count">${counts.needs_update}</span>`)}
</div>
<div class="filter-tabs">
${_filterTab2('blocked', `Blocked <span class="tab-count">${counts.blocked}</span>`)}
${_filterTab2('caveats', `Caveats <span class="tab-count">${counts.caveats}</span>`)}
</div>
</div>
<!-- Bulk action toolbar -->
<div class="bulk-bar ${anySelected ? '' : 'hidden'}" id="bulk-bar">
<span class="bulk-bar-text">${state.selectedIds.size} template(s) selected</span>
<button class="btn btn-primary btn-sm" id="btn-migrate-selected">Migrate Selected </button>
<button class="btn btn-secondary btn-sm" id="btn-clear-selection">Clear</button>
</div>
<!-- Templates table -->
<div class="card">
<div class="table-wrap">
<table id="templates-table">
<thead>
<tr>
<th style="width:34px">
<input type="checkbox" class="cb" id="select-all" title="Select all" />
</th>
${_th('name', 'Template Name')}
${_th('readiness', 'Readiness')}
${_th('warnings', 'Issues')}
${_th('adobe_modified','Last Modified')}
${_th('status', 'DS Status')}
<th>Actions</th>
</tr>
</thead>
<tbody>
${templates.length
? templates.map(t => _templateRow(t)).join('')
: `<tr><td colspan="7">
<div class="empty-state">
<div class="empty-state-icon">📄</div>
<div class="empty-state-title">${state.templates.length ? 'No templates match your filter' : 'No templates found'}</div>
<div class="empty-state-sub">${state.templates.length ? 'Try clearing the search or filter.' : 'Connect Adobe Sign to load templates.'}</div>
</div>
</td></tr>`
}
</tbody>
</table>
</div>
</div>
`;
_bindEvents();
}
function _filterTab(key, label) {
return `<button class="filter-tab ${_filter.status === key ? 'active' : ''}"
data-filter="${key}">${label}</button>`;
}
function _filterTab2(key, label) {
// readiness-based filters
return `<button class="filter-tab ${_filter.status === key ? 'active' : ''}"
data-filter="${key}">${label}</button>`;
}
function _th(col, label) {
const dir = _sort.col === col ? (_sort.dir === 'asc' ? 'sort-asc' : 'sort-desc') : '';
return `<th class="sortable ${dir}" data-col="${col}">${label}</th>`;
}
function _templateRow(t) {
const r = readiness(t);
const selected = state.selectedIds.has(t.adobe_id);
const warnCount = (t.warnings || []).length;
const blockCount = (t.blockers || []).length;
const issueClass = blockCount > 0 ? 'blocked' : (warnCount > 0 ? 'has-issues' : 'no-issues');
const issueLabel = blockCount > 0
? `🚫 ${blockCount} blocker${blockCount > 1 ? 's' : ''}`
: (warnCount > 0 ? `${warnCount} warning${warnCount > 1 ? 's' : ''}` : '✓ Clean');
return `
<tr class="${selected ? 'row-selected' : ''}" data-id="${escHtml(t.adobe_id)}">
<td><input type="checkbox" class="cb row-cb" data-id="${escHtml(t.adobe_id)}" ${selected ? 'checked' : ''} /></td>
<td>
<div class="table-name tpl-name-link" data-id="${escHtml(t.adobe_id)}">${escHtml(t.name)}</div>
<div class="table-sub">${escHtml(t.adobe_id)}</div>
</td>
<td><span class="badge ${r.cls}">${r.label}</span></td>
<td><span class="issue-count ${issueClass}">${issueLabel}</span></td>
<td>${formatRelative(t.adobe_modified)}</td>
<td>
${t.docusign_id
? `<span class="badge badge-blue" title="${escHtml(t.docusign_id)}">In Docusign</span>`
: `<span class="badge badge-gray">Not Migrated</span>`}
</td>
<td>
<div class="row-actions">
<button class="btn btn-ghost btn-xs btn-migrate-one" data-id="${escHtml(t.adobe_id)}" title="Migrate this template"> Migrate</button>
<button class="btn btn-ghost btn-xs btn-view-detail" data-id="${escHtml(t.adobe_id)}" title="View details">Detail</button>
</div>
</td>
</tr>
`;
}
function _statusCounts(templates) {
return {
total: templates.length,
not_migrated: templates.filter(t => t.status === 'not_migrated').length,
migrated: templates.filter(t => t.status === 'migrated').length,
needs_update: templates.filter(t => t.status === 'needs_update').length,
blocked: templates.filter(t => t.blockers && t.blockers.length > 0).length,
caveats: templates.filter(t => (!t.blockers || !t.blockers.length) && t.warnings && t.warnings.length > 0).length,
};
}
function _applyFilter(templates) {
let list = [...templates];
// Text search
if (_filter.search) {
const q = _filter.search.toLowerCase();
list = list.filter(t => t.name.toLowerCase().includes(q));
}
// Status / readiness filter
if (_filter.status !== 'all') {
if (_filter.status === 'blocked') {
list = list.filter(t => t.blockers && t.blockers.length > 0);
} else if (_filter.status === 'caveats') {
list = list.filter(t => (!t.blockers || !t.blockers.length) && t.warnings && t.warnings.length > 0);
} else {
list = list.filter(t => t.status === _filter.status);
}
}
// Sorting
list.sort((a, b) => {
let va = a[_sort.col] || '';
let vb = b[_sort.col] || '';
if (_sort.col === 'readiness') { va = readiness(a).key; vb = readiness(b).key; }
if (_sort.col === 'warnings') { va = (a.blockers||[]).length + (a.warnings||[]).length; vb = (b.blockers||[]).length + (b.warnings||[]).length; }
if (typeof va === 'number') return _sort.dir === 'asc' ? va - vb : vb - va;
return _sort.dir === 'asc' ? String(va).localeCompare(String(vb)) : String(vb).localeCompare(String(va));
});
return list;
}
// ── Event wiring ───────────────────────────────────────────────────────────
function _bindEvents() {
// Search
const searchEl = document.getElementById('template-search');
if (searchEl) {
searchEl.addEventListener('input', debounce(e => {
_filter.search = e.target.value;
_render();
}, 250));
}
// Filter tabs
document.querySelectorAll('.filter-tab').forEach(btn => {
btn.addEventListener('click', () => {
_filter.status = btn.dataset.filter;
_render();
});
});
// Sort headers
document.querySelectorAll('th.sortable').forEach(th => {
th.addEventListener('click', () => {
const col = th.dataset.col;
if (_sort.col === col) {
_sort.dir = _sort.dir === 'asc' ? 'desc' : 'asc';
} else {
_sort.col = col; _sort.dir = 'asc';
}
_render();
});
});
// Checkboxes
document.getElementById('select-all')?.addEventListener('change', e => {
const ids = _applyFilter(state.templates).map(t => t.adobe_id);
if (e.target.checked) { ids.forEach(id => state.selectedIds.add(id)); }
else { ids.forEach(id => state.selectedIds.delete(id)); }
_render();
});
document.querySelectorAll('.row-cb').forEach(cb => {
cb.addEventListener('change', e => {
const id = cb.dataset.id;
if (e.target.checked) state.selectedIds.add(id);
else state.selectedIds.delete(id);
_render();
});
});
// Migrate selected
document.getElementById('btn-migrate-selected')?.addEventListener('click', () => {
_launchMigration([...state.selectedIds]);
});
document.getElementById('btn-clear-selection')?.addEventListener('click', () => {
state.selectedIds.clear();
_render();
});
// Migrate individual
document.querySelectorAll('.btn-migrate-one').forEach(btn => {
btn.addEventListener('click', () => _launchMigration([btn.dataset.id]));
});
// View detail
document.querySelectorAll('.btn-view-detail, .tpl-name-link').forEach(el => {
el.addEventListener('click', () => navigate(`#/templates/${el.dataset.id}`));
});
// Refresh
document.getElementById('btn-refresh-templates')?.addEventListener('click', async () => {
await refreshTemplates();
_render();
});
}
async function _launchMigration(ids) {
if (!ids.length) return;
const { showOptionsModal } = await import('./migration.js');
showOptionsModal(ids);
}
// ── Template detail view ───────────────────────────────────────────────────
export async function renderTemplateDetail(adobeId) {
const outlet = document.getElementById('router-outlet');
const t = state.templates.find(t => t.adobe_id === adobeId);
if (!t) {
outlet.innerHTML = `
<div class="empty-state">
<div class="empty-state-icon">🔍</div>
<div class="empty-state-title">Template not found</div>
<div class="empty-state-sub"><a href="#/templates" class="btn btn-ghost btn-sm"> Back to Templates</a></div>
</div>`;
return;
}
const r = readiness(t);
outlet.innerHTML = `
<div class="page-header">
<div>
<a href="#/templates" style="font-size:12px;color:var(--cobalt);text-decoration:none"> Templates</a>
<div class="page-title" style="margin-top:4px">${escHtml(t.name)}</div>
<div class="page-subtitle">Adobe ID: <span class="mono">${escHtml(t.adobe_id)}</span></div>
</div>
<div class="page-actions">
<span class="badge ${r.cls}">${r.label}</span>
<button class="btn btn-primary btn-sm" id="detail-migrate-btn">Migrate</button>
</div>
</div>
<div class="tabs" id="detail-tabs">
<div class="tab active" data-tab="overview">Overview</div>
<div class="tab" data-tab="issues">Issues ${(t.blockers||[]).length + (t.warnings||[]).length > 0
? `<span class="nav-badge" style="position:static;display:inline">${(t.blockers||[]).length + (t.warnings||[]).length}</span>` : ''}</div>
<div class="tab" data-tab="history">Migration History</div>
</div>
<div id="detail-tab-content"></div>
`;
document.getElementById('detail-migrate-btn')?.addEventListener('click', () => {
import('./migration.js').then(m => m.showOptionsModal([adobeId]));
});
_bindDetailTabs(t);
_renderDetailTab(t, 'overview');
}
function _bindDetailTabs(t) {
document.querySelectorAll('#detail-tabs .tab').forEach(tab => {
tab.addEventListener('click', () => {
document.querySelectorAll('#detail-tabs .tab').forEach(x => x.classList.remove('active'));
tab.classList.add('active');
_renderDetailTab(t, tab.dataset.tab);
});
});
}
function _renderDetailTab(t, tabKey) {
const content = document.getElementById('detail-tab-content');
if (tabKey === 'overview') {
content.innerHTML = `
<div class="card">
<div class="card-header"><span class="card-title">Template Overview</span></div>
<div class="card-body">
<div class="conn-info-row">
<span class="conn-info-label">Adobe Sign ID</span>
<span class="conn-info-value">${escHtml(t.adobe_id)}</span>
</div>
<div class="conn-info-row">
<span class="conn-info-label">Last Modified</span>
<span class="conn-info-value">${formatDate(t.adobe_modified)}</span>
</div>
<div class="conn-info-row">
<span class="conn-info-label">Migration Status</span>
<span class="conn-info-value"><span class="badge badge-${t.status === 'migrated' ? 'migrated' : t.status === 'needs_update' ? 'amber' : 'gray'}">${t.status.replace('_', ' ')}</span></span>
</div>
${t.docusign_id ? `
<div class="conn-info-row">
<span class="conn-info-label">Docusign Template ID</span>
<span class="conn-info-value mono">${escHtml(t.docusign_id)}</span>
</div>` : ''}
</div>
</div>`;
} else if (tabKey === 'issues') {
const blockers = t.blockers || [];
const warnings = t.warnings || [];
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 = `
${blockers.length ? `
<div class="card">
<div class="card-header"><span class="card-title" style="color:var(--error)">🚫 Blockers (${blockers.length})</span></div>
<div class="card-body">
${blockers.map(b => `
<div class="issue-row">
<span class="issue-severity blocker">BLOCKER</span>
<div class="issue-body"><div class="issue-title">${escHtml(b)}</div></div>
</div>`).join('')}
</div>
</div>` : ''}
${warnings.length ? `
<div class="card">
<div class="card-header"><span class="card-title" style="color:var(--warning)"> Warnings (${warnings.length})</span></div>
<div class="card-body">
${warnings.map(w => `
<div class="issue-row">
<span class="issue-severity warn">WARNING</span>
<div class="issue-body"><div class="issue-title">${escHtml(w)}</div></div>
</div>`).join('')}
</div>
</div>` : ''}`;
}
} else if (tabKey === 'history') {
api.migrate.history().then(data => {
const records = (data.history || []).filter(r =>
r.adobe_template_id === t.adobe_id || r.adobe_template_name === t.name
);
if (!records.length) {
content.innerHTML = `<div class="callout info"><span class="callout-icon"></span>No migration history for this template yet.</div>`;
} else {
const rows = [...records].reverse().map(r => {
const fieldIssues = r.field_issues || [];
const hasIssues = fieldIssues.length > 0;
const hasDetail = r.error || (r.blockers||[]).length || (r.warnings||[]).length || hasIssues;
const detailHtml = hasDetail ? `
<tr class="row-expanded-content" style="display:none">
<td colspan="4">
<div class="row-expand-body">
${(r.blockers||[]).map(b => `<div style="color:var(--error);font-size:12px">🚫 ${escHtml(b)}</div>`).join('')}
${(r.warnings||[]).map(w => `<div style="color:var(--warning);font-size:12px">⚠ ${escHtml(w)}</div>`).join('')}
${r.error ? `<div style="color:var(--error);font-size:12px">❌ ${escHtml(r.error)}</div>` : ''}
${renderFieldIssues(fieldIssues)}
</div>
</td>
</tr>` : '';
return `
<tr class="${hasDetail ? 'row-expandable' : ''}" style="${hasDetail ? 'cursor:pointer' : ''}">
<td>${(r.timestamp||'').slice(0,19).replace('T',' ')}</td>
<td>${escHtml(r.action||'—')}</td>
<td>
<span class="badge ${r.status==='success'?'badge-green':'badge-red'}">${r.status}</span>
${hasIssues ? '<span class="badge badge-amber" style="font-size:10px">partial</span>' : ''}
${hasDetail ? '<span style="font-size:10px;color:var(--text-muted);margin-left:4px">▶ click for details</span>' : ''}
</td>
<td class="mono">${escHtml(r.docusign_template_id||'—')}</td>
</tr>${detailHtml}`;
}).join('');
content.innerHTML = `
<div class="card">
<div class="table-wrap">
<table>
<thead><tr><th>Time</th><th>Action</th><th>Status</th><th>Docusign ID</th></tr></thead>
<tbody>${rows}</tbody>
</table>
</div>
</div>`;
content.querySelectorAll('.row-expandable').forEach(row => {
row.addEventListener('click', () => {
const next = row.nextElementSibling;
if (next?.classList.contains('row-expanded-content')) {
const open = next.style.display !== 'none';
next.style.display = open ? 'none' : 'table-row';
const hint = row.querySelector('span[style*="text-muted"]');
if (hint) hint.textContent = open ? '▶ click for details' : '▼ hide details';
}
});
});
bindFieldIssueToggles(content);
}
}).catch(() => {
content.innerHTML = `<div class="callout error"><span class="callout-icon">❌</span>Failed to load history.</div>`;
});
}
}

154
web/static/js/utils.js Normal file
View File

@ -0,0 +1,154 @@
// Shared utility functions
export function escHtml(str) {
return String(str ?? '').replace(/[&<>"']/g, c => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
})[c]);
}
export function formatDate(iso) {
if (!iso) return '—';
try {
return new Date(iso).toLocaleDateString('en-US', {
year: 'numeric', month: 'short', day: 'numeric'
});
} catch { return iso.slice(0, 10); }
}
export function formatDateTime(iso) {
if (!iso) return '—';
try {
return new Date(iso).toLocaleString('en-US', {
year: 'numeric', month: 'short', day: 'numeric',
hour: '2-digit', minute: '2-digit'
});
} catch { return iso.slice(0, 19).replace('T', ' '); }
}
export function formatRelative(iso) {
if (!iso) return '—';
const diff = Date.now() - new Date(iso).getTime();
const m = Math.floor(diff / 60000);
if (m < 1) return 'just now';
if (m < 60) return `${m}m ago`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h ago`;
const d = Math.floor(h / 24);
if (d < 7) return `${d}d ago`;
return formatDate(iso);
}
export function debounce(fn, ms = 300) {
let timer;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), ms);
};
}
export function uuid() {
return crypto.randomUUID
? crypto.randomUUID()
: 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
const r = Math.random() * 16 | 0;
return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);
});
}
// Truncate a string to maxLen chars, appending ellipsis if needed
export function truncate(str, maxLen = 40) {
if (!str) return '';
return str.length > maxLen ? str.slice(0, maxLen - 1) + '…' : str;
}
// First letter of a name for avatar initials
export function initials(name) {
if (!name) return '?';
const parts = name.trim().split(/\s+/);
return parts.length >= 2
? (parts[0][0] + parts[parts.length - 1][0]).toUpperCase()
: name.slice(0, 2).toUpperCase();
}
// Download a string as a file
export function downloadText(filename, content, type = 'text/plain') {
const blob = new Blob([content], { type });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = filename;
document.body.appendChild(a); a.click();
document.body.removeChild(a); URL.revokeObjectURL(url);
}
// Convert array of objects to CSV and download
export function downloadCsv(filename, rows) {
if (!rows.length) return;
const headers = Object.keys(rows[0]);
const csv = [
headers.join(','),
...rows.map(r => headers.map(h => JSON.stringify(r[h] ?? '')).join(','))
].join('\n');
downloadText(filename, csv, 'text/csv');
}
// Shorten a SHA-256 hash for display
export function shortHash(hash, len = 8) {
return hash ? hash.slice(0, len) : '—';
}
// Human-readable labels for field issue codes (mirrors src/models/field_issue.py)
export const FIELD_ISSUE_LABELS = {
CROSS_RECIPIENT_CONDITIONAL: 'Cross-recipient conditional dropped',
UNSUPPORTED_OPERATOR: 'Unsupported condition operator dropped',
HIDE_ACTION: 'Hide condition dropped (no DocuSign equivalent)',
MULTI_PREDICATE: 'Multi-condition logic simplified to first match',
INVALID_PARENT_TAB: 'Conditional parent tab invalid or missing',
FIELD_TYPE_SKIPPED: 'Field type skipped (no DocuSign equivalent)',
PARTIAL_FIELD_TYPE: 'Field type approximated',
};
/**
* Wire click-to-expand on all .field-issue-group elements within root.
* Call this after injecting renderFieldIssues() HTML into the DOM.
*/
export function bindFieldIssueToggles(root = document) {
root.querySelectorAll('.field-issue-group-header').forEach(hdr => {
hdr.addEventListener('click', () => hdr.parentElement.classList.toggle('open'));
});
}
/**
* Render a grouped field-issues section as an HTML string.
* Groups issues by code, shows count + label, expands to field names + messages.
* Returns '' if no issues.
*/
export function renderFieldIssues(issues) {
if (!issues || !issues.length) return '';
// Group by code
const groups = {};
issues.forEach(i => {
if (!groups[i.code]) groups[i.code] = [];
groups[i.code].push(i);
});
const groupHtml = Object.entries(groups).map(([code, items]) => {
const label = FIELD_ISSUE_LABELS[code] || code;
const rows = items.map(i =>
`<div class="field-issue-row">
<span class="field-issue-field">${escHtml(i.field_name)}</span>
<span class="field-issue-msg">${escHtml(i.message)}</span>
</div>`
).join('');
return `
<div class="field-issue-group">
<div class="field-issue-group-header">
<span class="badge badge-amber" style="font-size:10px">${items.length}</span>
${escHtml(label)}
</div>
<div class="field-issue-group-body">${rows}</div>
</div>`;
}).join('');
return `<div class="field-issues-block">${groupHtml}</div>`;
}

View File

@ -0,0 +1,292 @@
// Verification view — send test envelopes to confirm migrated templates work
import { api } from './api.js';
import { state } from './state.js';
import { escHtml, formatDateTime } from './utils.js';
const POLL_MS = 30_000; // DocuSign rate-limit guidance: no more than once per 15 min in prod
const POLL_TIMEOUT = 300_000; // 5 minutes — treat as manual quick-test only; prod should use DS Connect
const _envelopes = {}; // { adobeId: { envelopeId, status, sentAt, completedAt, polling } }
function getSettings() {
try { return JSON.parse(localStorage.getItem('migrator_settings')) || {}; }
catch { return {}; }
}
export async function renderVerification(preloadedIds = null) {
const outlet = document.getElementById('router-outlet');
const ids = preloadedIds || state.verifyIds || null;
// Candidate templates: recently migrated (from state) or all migrated
const candidates = (state.templates || []).filter(t =>
t.status === 'migrated' || t.status === 'needs_update' || t.docusign_id
);
if (!state.auth.docusign) {
outlet.innerHTML = `
<div class="page-header"><div><div class="page-title">Verification</div></div></div>
<div class="callout info"><span class="callout-icon"></span>Connect Docusign to send verification envelopes.</div>`;
return;
}
if (!candidates.length) {
outlet.innerHTML = `
<div class="page-header"><div><div class="page-title">Verification</div></div></div>
<div class="callout info"><span class="callout-icon"></span>
No migrated templates yet. Run a migration first, then return here to verify.
</div>`;
return;
}
_renderVerifyView(candidates);
}
function _renderVerifyView(candidates) {
const outlet = document.getElementById('router-outlet');
const settings = getSettings();
outlet.innerHTML = `
<div class="page-header">
<div>
<div class="page-title">Verification</div>
<div class="page-subtitle">Send test envelopes to confirm templates work end-to-end</div>
</div>
</div>
<div class="callout info" style="margin-bottom:20px">
<span class="callout-icon"></span>
Verification sends a real Docusign envelope using each template. Test envelopes should be voided after use.
Configure default recipient in <a href="#/settings" style="color:var(--cobalt)">Settings</a>.
</div>
<div class="card">
<div class="card-header">
<span class="card-title">Migrated Templates</span>
<span style="font-size:12px;color:var(--text-muted)">${candidates.length} templates</span>
</div>
<div class="table-wrap">
<table id="verify-table">
<thead>
<tr>
<th>Template</th>
<th>Docusign ID</th>
<th>Verification Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
${candidates.map(t => _verifyRow(t, settings)).join('')}
</tbody>
</table>
</div>
</div>
`;
// Wire Send Test buttons
document.querySelectorAll('.btn-send-test').forEach(btn => {
btn.addEventListener('click', () => {
const adobeId = btn.dataset.id;
const t = candidates.find(t => t.adobe_id === adobeId);
if (t) _showSendDialog(t, settings);
});
});
// Wire Void buttons
document.querySelectorAll('.btn-void-envelope').forEach(btn => {
btn.addEventListener('click', () => {
_voidEnvelope(btn.dataset.id, btn.dataset.envelopeid);
});
});
}
function _verifyRow(t, settings) {
const env = _envelopes[t.adobe_id];
let statusCell = '<span class="badge badge-gray">Not Tested</span>';
let actionsCell = `<button class="btn btn-primary btn-sm btn-send-test" data-id="${escHtml(t.adobe_id)}">Send Test</button>`;
if (env) {
if (env.status === 'completed') {
statusCell = '<span class="badge badge-green">✓ Verified</span>';
actionsCell = `<button class="btn btn-secondary btn-xs btn-void-envelope"
data-id="${escHtml(t.adobe_id)}" data-envelopeid="${escHtml(env.envelopeId)}">Void</button>`;
} else if (env.status === 'sent' || env.status === 'delivered') {
statusCell = `<span class="badge badge-blue"><span class="spinner spinner-sm"></span> ${env.status}</span>`;
actionsCell = `<button class="btn btn-secondary btn-xs btn-void-envelope"
data-id="${escHtml(t.adobe_id)}" data-envelopeid="${escHtml(env.envelopeId)}">Void</button>`;
} else if (env.status === 'voided') {
statusCell = '<span class="badge badge-gray">Voided</span>';
actionsCell = `<button class="btn btn-primary btn-sm btn-send-test" data-id="${escHtml(t.adobe_id)}">Send Again</button>`;
} else if (env.status === 'timeout') {
statusCell = '<span class="badge badge-amber">Timed Out</span>';
actionsCell = `<button class="btn btn-primary btn-sm btn-send-test" data-id="${escHtml(t.adobe_id)}">Send Again</button>`;
} else {
statusCell = `<span class="badge badge-amber">${escHtml(env.status || 'pending')}</span>`;
}
}
return `
<tr id="verify-row-${escHtml(t.adobe_id)}">
<td>
<div class="table-name">${escHtml(t.name)}</div>
<div class="table-sub">${escHtml(t.adobe_id)}</div>
</td>
<td class="mono" style="font-size:11px">${t.docusign_id ? escHtml(t.docusign_id.slice(0,12)) + '…' : '—'}</td>
<td id="verify-status-${escHtml(t.adobe_id)}">${statusCell}</td>
<td id="verify-actions-${escHtml(t.adobe_id)}">${actionsCell}</td>
</tr>
`;
}
function _showSendDialog(t, settings) {
const existing = document.getElementById('send-dialog');
if (existing) existing.remove();
const wrapper = document.createElement('div');
wrapper.id = 'send-dialog';
wrapper.innerHTML = `
<div class="modal-backdrop"></div>
<div class="modal-box modal-sm">
<div class="modal-header">
<span class="modal-title">Send Test Envelope</span>
<button class="modal-close" id="sd-close"></button>
</div>
<div class="modal-body">
<div style="font-size:13px;color:var(--text-muted);margin-bottom:14px">
Template: <strong>${escHtml(t.name)}</strong>
</div>
<div class="form-group">
<label class="form-label" for="sd-name">Recipient Name</label>
<input type="text" class="form-input" id="sd-name"
value="${escHtml(settings.testRecipientName || '')}"
placeholder="Test Recipient" />
</div>
<div class="form-group">
<label class="form-label" for="sd-email">Recipient Email</label>
<input type="email" class="form-input" id="sd-email"
value="${escHtml(settings.testRecipientEmail || '')}"
placeholder="test@example.com" />
<div class="form-error" id="sd-error"></div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" id="sd-cancel">Cancel</button>
<button class="btn btn-primary" id="sd-send">Send Test </button>
</div>
</div>
`;
document.body.appendChild(wrapper);
document.getElementById('sd-close').onclick = () => wrapper.remove();
document.getElementById('sd-cancel').onclick = () => wrapper.remove();
document.getElementById('sd-send').onclick = () => _sendEnvelope(t, wrapper);
}
async function _sendEnvelope(t, wrapper) {
const name = document.getElementById('sd-name')?.value.trim();
const email = document.getElementById('sd-email')?.value.trim();
const errorEl = document.getElementById('sd-error');
if (!name || !email) {
if (errorEl) errorEl.textContent = 'Recipient name and email are required.';
return;
}
if (errorEl) errorEl.textContent = '';
const sendBtn = document.getElementById('sd-send');
sendBtn.disabled = true;
sendBtn.textContent = 'Sending…';
try {
const data = await api.verify.send(t.docusign_id || t.adobe_id, name, email);
wrapper.remove();
_envelopes[t.adobe_id] = { envelopeId: data.envelope_id, status: 'sent', sentAt: new Date().toISOString() };
_updateVerifyRow(t.adobe_id);
_startPolling(t.adobe_id, data.envelope_id);
} catch (e) {
if (errorEl) errorEl.textContent = 'Send failed: ' + e.message;
sendBtn.disabled = false;
sendBtn.textContent = 'Send Test →';
}
}
function _startPolling(adobeId, envelopeId) {
const env = _envelopes[adobeId];
if (!env || env.polling) return;
env.polling = true;
const deadline = Date.now() + POLL_TIMEOUT;
const poll = async () => {
try {
const data = await api.verify.status(envelopeId);
if (!_envelopes[adobeId]) return;
_envelopes[adobeId].status = data.status;
_envelopes[adobeId].completedAt = data.completed_at;
_updateVerifyRow(adobeId);
if (data.status === 'completed' || data.status === 'voided') {
_envelopes[adobeId].polling = false;
} else if (Date.now() >= deadline) {
_envelopes[adobeId].polling = false;
_envelopes[adobeId].status = 'timeout';
_updateVerifyRow(adobeId);
} else {
setTimeout(poll, POLL_MS);
}
} catch (e) {
console.warn('Polling error:', e.message);
}
};
setTimeout(poll, POLL_MS);
}
function _updateVerifyRow(adobeId) {
const t = (state.templates || []).find(t => t.adobe_id === adobeId);
const env = _envelopes[adobeId];
if (!t || !env) return;
const statusEl = document.getElementById(`verify-status-${adobeId}`);
const actionsEl = document.getElementById(`verify-actions-${adobeId}`);
if (!statusEl) return;
if (env.status === 'completed') {
statusEl.innerHTML = '<span class="badge badge-green">✓ Verified</span>';
actionsEl.innerHTML = `<button class="btn btn-secondary btn-xs btn-void-envelope"
data-id="${escHtml(adobeId)}" data-envelopeid="${escHtml(env.envelopeId)}">Void</button>`;
} else if (env.status === 'sent' || env.status === 'delivered') {
statusEl.innerHTML = `<span class="badge badge-blue"><span class="spinner spinner-sm"></span> ${env.status}</span>`;
actionsEl.innerHTML = `<button class="btn btn-secondary btn-xs btn-void-envelope"
data-id="${escHtml(adobeId)}" data-envelopeid="${escHtml(env.envelopeId)}">Void</button>`;
} else if (env.status === 'voided') {
statusEl.innerHTML = '<span class="badge badge-gray">Voided</span>';
actionsEl.innerHTML = `<button class="btn btn-primary btn-sm btn-send-test" data-id="${escHtml(adobeId)}">Send Again</button>`;
} else if (env.status === 'timeout') {
statusEl.innerHTML = '<span class="badge badge-amber">Timed Out</span>';
actionsEl.innerHTML = `<button class="btn btn-primary btn-sm btn-send-test" data-id="${escHtml(adobeId)}">Send Again</button>`;
}
// Re-wire newly injected buttons
actionsEl.querySelectorAll('.btn-void-envelope').forEach(btn => {
btn.onclick = () => _voidEnvelope(btn.dataset.id, btn.dataset.envelopeid);
});
actionsEl.querySelectorAll('.btn-send-test').forEach(btn => {
btn.onclick = () => {
const settings = getSettings();
_showSendDialog(t, settings);
};
});
}
async function _voidEnvelope(adobeId, envelopeId) {
if (!confirm('Void this test envelope?')) return;
try {
await api.verify.void(envelopeId);
if (_envelopes[adobeId]) {
_envelopes[adobeId].status = 'voided';
_envelopes[adobeId].polling = false;
}
_updateVerifyRow(adobeId);
} catch (e) {
alert('Failed to void envelope: ' + e.message);
}
}

View File

@ -1,186 +0,0 @@
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #f5f5f5;
color: #222;
font-size: 14px;
}
/* ── Header ── */
header {
background: #1a3c5e;
color: #fff;
padding: 14px 24px;
display: flex;
align-items: center;
justify-content: space-between;
}
header h1 { font-size: 18px; font-weight: 600; }
#auth-bar { display: flex; gap: 12px; align-items: center; font-size: 13px; }
.auth-badge {
padding: 4px 10px;
border-radius: 12px;
border: 1px solid rgba(255,255,255,0.4);
cursor: pointer;
transition: background 0.15s;
}
.auth-badge.connected { background: #28a745; border-color: #28a745; }
.auth-badge:not(.connected):hover { background: rgba(255,255,255,0.15); }
/* ── Main layout ── */
main { padding: 20px 24px; }
.panel-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-bottom: 20px;
}
.panel {
background: #fff;
border: 1px solid #ddd;
border-radius: 6px;
overflow: hidden;
}
.panel-header {
padding: 12px 16px;
background: #f8f9fa;
border-bottom: 1px solid #ddd;
display: flex;
justify-content: space-between;
align-items: center;
font-weight: 600;
font-size: 13px;
color: #555;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.panel-body { padding: 0; }
/* ── Template list ── */
.template-list { list-style: none; }
.template-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 16px;
border-bottom: 1px solid #f0f0f0;
cursor: pointer;
transition: background 0.1s;
}
.template-item:last-child { border-bottom: none; }
.template-item:hover { background: #f9f9f9; }
.template-item.selected { background: #eef4ff; }
.template-item input[type=checkbox] { flex-shrink: 0; }
.template-name { flex: 1; font-size: 13px; }
/* ── Status badges ── */
.badge {
font-size: 11px;
font-weight: 600;
padding: 2px 8px;
border-radius: 10px;
white-space: nowrap;
}
.badge-migrated { background: #d4edda; color: #155724; }
.badge-needs_update { background: #fff3cd; color: #856404; }
.badge-not_migrated { background: #f8d7da; color: #721c24; }
.template-spinner { font-size: 12px; color: #888; }
/* ── Action bar ── */
.action-bar {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 20px;
}
button {
padding: 8px 18px;
border: none;
border-radius: 5px;
cursor: pointer;
font-size: 13px;
font-weight: 600;
transition: opacity 0.15s;
}
button:disabled { opacity: 0.45; cursor: not-allowed; }
#btn-migrate { background: #1a3c5e; color: #fff; }
#btn-migrate:not(:disabled):hover { background: #235080; }
#btn-refresh { background: #e9ecef; color: #333; }
#btn-refresh:hover { background: #dee2e6; }
#status-msg { font-size: 13px; color: #555; }
/* ── History ── */
.history-section { background: #fff; border: 1px solid #ddd; border-radius: 6px; }
.history-section .panel-header { background: #f8f9fa; }
.history-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.history-table th {
text-align: left;
padding: 8px 14px;
background: #f8f9fa;
border-bottom: 1px solid #ddd;
font-weight: 600;
color: #555;
}
.history-table td {
padding: 8px 14px;
border-bottom: 1px solid #f0f0f0;
}
.history-table tr:last-child td { border-bottom: none; }
.empty-msg { padding: 20px; text-align: center; color: #999; font-size: 13px; }
/* ── Adobe auth dialog ── */
.dialog-backdrop {
position: fixed; inset: 0;
background: rgba(0,0,0,0.4);
z-index: 100;
}
.dialog-box {
position: fixed;
top: 50%; left: 50%;
transform: translate(-50%, -50%);
background: #fff;
border-radius: 8px;
padding: 28px 32px;
width: min(500px, 90vw);
z-index: 101;
box-shadow: 0 8px 32px rgba(0,0,0,0.18);
}
.dialog-box h2 { font-size: 16px; margin-bottom: 16px; }
.dialog-box ol { padding-left: 20px; margin-bottom: 16px; line-height: 1.7; }
.dialog-box ol a { color: #1a3c5e; }
.dialog-box input[type=text] {
width: 100%;
padding: 8px 10px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 13px;
margin-bottom: 8px;
font-family: monospace;
}
.dialog-error { color: #c00; font-size: 12px; min-height: 18px; margin-bottom: 10px; }
.dialog-actions { display: flex; gap: 8px; justify-content: flex-end; }
.btn-secondary { background: #e9ecef; color: #333; }
/* ── Responsive ── */
@media (max-width: 700px) {
.panel-row { grid-template-columns: 1fr; }
}