Compare commits

..

10 Commits

Author SHA1 Message Date
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
32 changed files with 4993 additions and 601 deletions

View File

@ -102,7 +102,8 @@ If multiple templates share the same name, the most recently modified one is use
## Web UI ## 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:** **Additional `.env` keys required for the web UI:**
``` ```
@ -118,15 +119,36 @@ uvicorn web.app:app --reload --port 8000
``` ```
Then open [http://localhost:8000](http://localhost:8000) in your browser. Then open [http://localhost:8000](http://localhost:8000) in your browser.
**Using the UI:** ### Navigation
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. | Screen | Path | Purpose |
3. Your Adobe Sign templates appear on the left with status badges: |---|---|---|
- **Not Migrated** (red) — no matching DocuSign template yet | Templates | `#/templates` | Filterable table with readiness badges; bulk migration |
- **Migrated** (green) — a DocuSign template with the same name exists and is up to date | Migration Results | `#/results` | Summary + per-template results from last migration |
- **Needs Update** (yellow) — the Adobe template was modified after the last migration | Issues & Warnings | `#/issues` | All templates with blockers or warnings |
4. Check one or more templates and click **Migrate Selected**. | Verification | `#/verify` | Send test envelopes; confirm templates work end-to-end |
5. Migration results appear inline; the history table at the bottom logs all past runs. | 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.
6. **Verify** — on the Verification screen, send test envelopes and confirm receipt.
7. **Audit** — History & Audit logs every migration with checksums and export.
### 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) **API docs:** [http://localhost:8000/api/docs](http://localhost:8000/api/docs)
@ -135,7 +157,7 @@ Then open [http://localhost:8000](http://localhost:8000) in your browser.
## Running tests ## Running tests
```bash ```bash
pytest tests/ -v # full suite (108 tests) pytest tests/ -v # full suite (118 tests)
pytest tests/test_regression.py -v # compose regression only pytest tests/test_regression.py -v # compose regression only
pytest tests/test_regression.py --update-snapshots # regenerate snapshots after intentional changes pytest tests/test_regression.py --update-snapshots # regenerate snapshots after intentional changes
``` ```

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

@ -149,10 +149,78 @@
--- ---
## 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
- [ ] Push `ui-redesign` branch to Gitea
- [ ] Open PR to `master`
---
## Gitea ## Gitea
- [x] Committed and pushed all changes (2026-04-17) - [x] Committed and pushed all changes (2026-04-17)
- [ ] Commit and push Phase 813 work (ui-redesign branch) - [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)
- [ ] Push ui-redesign branch to Gitea
- [ ] Open PR to master
--- ---
@ -164,3 +232,6 @@
- (2026-04-17) v2 planning complete — idempotent upload + web UI implementation begins - (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) 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) 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

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

@ -155,3 +155,77 @@ def test_status_needs_update():
resp = client.get("/api/templates/status", cookies={_COOKIE_NAME: _adobe_session()}) resp = client.get("/api/templates/status", cookies={_COOKIE_NAME: _adobe_session()})
t = resp.json()["templates"][0] t = resp.json()["templates"][0]
assert t["status"] == "needs_update" 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)

133
tests/test_api_verify.py Normal file
View File

@ -0,0 +1,133 @@
"""
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 → envelope_id returned."""
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
@respx.mock
def test_send_propagates_docusign_error(self):
"""DocuSign 400 → 502 with error detail."""
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

@ -15,7 +15,7 @@ from fastapi.responses import FileResponse
import os import os
from web.config import settings from web.config import settings
from web.routers import auth, templates, migrate from web.routers import auth, templates, migrate, verify
app = FastAPI( app = FastAPI(
title="Adobe Sign → DocuSign Migrator", title="Adobe Sign → DocuSign Migrator",
@ -27,6 +27,7 @@ app = FastAPI(
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(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 files (frontend)
_static_dir = os.path.join(os.path.dirname(__file__), "static") _static_dir = os.path.join(os.path.dirname(__file__), "static")

View File

@ -6,6 +6,7 @@ Computes per-template migration status for the side-by-side UI.
""" """
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path
from typing import Optional from typing import Optional
import httpx import httpx
@ -151,6 +152,8 @@ async def template_status(request: Request):
# needs_update if Adobe was modified after the DS template # needs_update if Adobe was modified after the DS template
status = "needs_update" if adobe_modified > ds_modified else "migrated" status = "needs_update" if adobe_modified > ds_modified else "migrated"
blockers, warnings = _get_validation(t.get("id", ""), name)
results.append({ results.append({
"adobe_id": t.get("id"), "adobe_id": t.get("id"),
"name": name, "name": name,
@ -158,10 +161,36 @@ async def template_status(request: Request):
"docusign_id": ds_match.get("templateId") if ds_match else None, "docusign_id": ds_match.get("templateId") if ds_match else None,
"docusign_modified": ds_match.get("lastModified") if ds_match else None, "docusign_modified": ds_match.get("lastModified") if ds_match else None,
"status": status, "status": status,
"blockers": blockers,
"warnings": warnings,
}) })
return {"templates": results} 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 # asyncio needed for gather — import at top of module
import asyncio import asyncio

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

@ -0,0 +1,131 @@
"""
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
payload = {
"templateId": body.template_id,
"status": "sent",
"templateRoles": [
{
"email": body.recipient_email,
"name": body.recipient_name,
"roleName": "Signer",
}
],
"emailSubject": f"[Verification Test] Please sign this document",
}
async with httpx.AsyncClient() as client:
resp = await client.post(
f"{settings.docusign_base_url}/v2.1/accounts/{settings.docusign_account_id}/envelopes",
headers={
"Authorization": f"Bearer {session['docusign_access_token']}",
"Content-Type": "application/json",
},
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")}
@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); }
}

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

@ -0,0 +1,228 @@
/* 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; }
/* ── 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;
}

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

@ -0,0 +1,181 @@
/* 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;
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; }
/* ── 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> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Adobe Sign → DocuSign Migrator</title> <title>docusign — Template Migration Console</title>
<link rel="stylesheet" href="/static/style.css" /> <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> </head>
<body> <body>
<header> <!-- ═══════════════════════════════════════════════════════════════
<h1>Adobe Sign → DocuSign Migrator</h1> LEFT NAVIGATION
<div id="auth-bar"> ═══════════════════════════════════════════════════════════════ -->
<span id="badge-adobe" class="auth-badge">Connect Adobe Sign</span> <nav id="app-nav">
<span id="badge-docusign" class="auth-badge">Connect DocuSign</span>
</div>
</header>
<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 --> <!-- Project switcher button -->
<div class="action-bar"> <button id="nav-project-switcher" aria-label="Switch project">
<button id="btn-migrate" disabled>Migrate Selected</button> <div class="project-icon" id="nav-project-icon">?</div>
<button id="btn-refresh">↻ Refresh</button> <div class="project-name no-project" id="nav-project-name">New Project</div>
<span id="status-msg">Loading…</span> <div class="project-arrow"></div>
</button>
</div> </div>
<!-- Side-by-side panels --> <!-- Nav links -->
<div class="panel-row"> <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"> <li class="nav-section-label">Post-Migration</li>
<div class="panel-header"> <li>
<span>Adobe Sign Templates</span> <a class="nav-item" data-route="#/verify" href="#/verify">
<span style="font-weight:400;font-size:12px;color:#888">Select to migrate →</span> <span class="nav-icon"></span>
</div> <span class="nav-label">Verification</span>
<div class="panel-body"> </a>
<ul class="template-list" id="adobe-list"> </li>
<li class="empty-msg">Loading…</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> </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> </div>
<div class="panel"> </nav>
<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>
</div>
<!-- ═══════════════════════════════════════════════════════════════
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> </div>
</header>
<!-- Migration history --> <!-- Router outlet — views injected here -->
<div class="history-section"> <main id="router-outlet">
<div class="panel-header">Migration History</div> <div class="empty-state">
<table class="history-table"> <div class="empty-state-icon"></div>
<thead> <div class="empty-state-title">Loading…</div>
<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>
</main> </div>
<!-- ═══════════════════════════════════════════════════════════════
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> </body>
</html> </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 POST('/api/auth/adobe/connect');
},
adobeUrl() {
return GET('/api/auth/adobe/url');
},
exchangeAdobe(redirectUrl) {
return POST('/api/auth/adobe/exchange', { redirect_url: redirectUrl });
},
connectDocusign() {
return POST('/api/auth/docusign/connect');
},
disconnect(platform) {
return POST(`/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);
}

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

@ -0,0 +1,221 @@
// History & Audit view — filterable, exportable migration history
import { api } from './api.js';
import { escHtml, formatDateTime, shortHash, downloadCsv, debounce } 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 statusBadge = r.status === 'success'
? `<span class="badge badge-green">${escHtml(r.action || 'success')}</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) ? `
<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>` : ''}
</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';
}
});
});
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>
`;
}

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

@ -0,0 +1,365 @@
// Migration workflow: options modal → progress → results view
import { api } from './api.js';
import { state, setState } from './state.js';
import { escHtml, formatDateTime, downloadCsv } 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 = '❌';
});
});
// Migration done — show "View Results" button
document.getElementById('prog-label') && (document.getElementById('prog-label').textContent = 'Done!');
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') {
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');
});
});
// 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 icon = r.status === 'success'
? (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>`
: `<span class="badge badge-${r.status === 'skipped' ? 'gray' : 'red'}">${r.status}</span>`;
const warnings = r.warnings || [];
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>` : ''}
${warnings.length ? `<span class="result-meta">⚠ ${warnings.length} warning${warnings.length > 1 ? 's' : ''}</span>` : ''}
</div>
${warnings.length || r.error ? `
<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>` : ''}
</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, 3).join('/'), param: parts[3] || null };
}
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);
}

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

@ -0,0 +1,469 @@
// 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 } 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 {
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>
${[...records].reverse().map(r => `
<tr>
<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></td>
<td class="mono">${escHtml(r.docusign_template_id||'—')}</td>
</tr>`).join('')}
</tbody>
</table>
</div>
</div>`;
}
}).catch(() => {
content.innerHTML = `<div class="callout error"><span class="callout-icon">❌</span>Failed to load history.</div>`;
});
}
}

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

@ -0,0 +1,97 @@
// 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) : '—';
}

View File

@ -0,0 +1,281 @@
// 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 = 5000;
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 {
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 poll = async () => {
try {
const data = await api.verify.status(envelopeId);
if (_envelopes[adobeId]) {
_envelopes[adobeId].status = data.status;
_envelopes[adobeId].completedAt = data.completed_at;
_updateVerifyRow(adobeId);
if (data.status !== 'completed' && data.status !== 'voided') {
setTimeout(poll, POLL_MS);
} else {
_envelopes[adobeId].polling = false;
}
}
} 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>`;
}
// 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; }
}