# Web UI Implementation Plan: SSH CA, Transit, and User Engines

## Overview

Three engines (SSH CA, Transit, User) are fully implemented at the core,
gRPC, and REST layers but have no web UI. This plan adds browser-based
management for each, following the patterns established by the PKI engine UI.

## Architecture

The web UI is served by `metacrypt-web`, a separate binary that talks to the
API server over gRPC. All data access flows through the gRPC client — the web
server has no direct database or barrier access. Authorization is enforced by
the API server; the web UI only controls visibility (e.g. hiding admin-only
forms from non-admin users).

### Existing Patterns (from PKI reference)

| Concern | Pattern |
|---------|---------|
| Template composition | `layout.html` defines `"layout"` block; page templates define `"title"` and `"content"` blocks |
| Template rendering | `renderTemplate(w, "page.html", data)` — parses `layout.html` + page template, injects CSRF func |
| gRPC calls | `ws.vault.Method(ctx, token)` via `vaultBackend` interface; token from cookie |
| CSRF | Signed double-submit cookie; `{{csrfField}}` in every form |
| Error display | `{{if .Error}}<div class="error">{{.Error}}</div>{{end}}` at top of content |
| Success display | `<div class="success">...</div>` inline after action |
| Mount discovery | `findCAMount()` pattern — iterate `ListMounts()`, match on `.Type` |
| Tables | `.table-wrapper` > `<table>` with `<thead>`/`<tbody>` |
| Detail views | `.card` with `.card-title` + `.kv-table` for metadata |
| Admin actions | `{{if .IsAdmin}}` guards around admin-only cards |
| Forms | `.form-row` > `.form-group` > `<label>` + `<input>` |
| Navigation | Breadcrumb in `.page-meta`: `← Dashboard` |

---

## Shared Changes

### 1. Navigation (layout.html)

Add engine links to the top nav, conditionally displayed based on mounted
engines. The template data already includes `Username` and `IsAdmin`; extend
it with engine availability flags.

```html
<!-- After PKI link -->
{{if .HasSSHCA}}<a href="/sshca" class="btn btn-ghost btn-sm">SSH CA</a>{{end}}
{{if .HasTransit}}<a href="/transit" class="btn btn-ghost btn-sm">Transit</a>{{end}}
{{if .HasUser}}<a href="/user" class="btn btn-ghost btn-sm">User Crypto</a>{{end}}
```

To populate these flags without an extra gRPC call per page, cache mount
types in the web server after the first `ListMounts()` call per request (the
`requireAuth` middleware already validates the token — extend it to also
populate a `mountTypes` set on the request context).

### 2. Dashboard (dashboard.html)

Extend the mount table to link all engine types, not just CA:

```html
{{if eq (printf "%s" .Type) "ca"}}
<a href="/pki">{{.Name}}</a>
{{else if eq (printf "%s" .Type) "sshca"}}
<a href="/sshca">{{.Name}}</a>
{{else if eq (printf "%s" .Type) "transit"}}
<a href="/transit">{{.Name}}</a>
{{else if eq (printf "%s" .Type) "user"}}
<a href="/user">{{.Name}}</a>
{{else}}
{{.Name}}
{{end}}
```

Add mount forms for the three new engine types (admin only), following the
existing `<details><summary>Mount a CA engine</summary>` pattern.

### 3. gRPC Client (client.go)

Add gRPC service clients to `VaultClient`:

```go
type VaultClient struct {
    // ... existing fields ...
    sshca   pb.SSHCAServiceClient
    transit pb.TransitServiceClient
    user    pb.UserServiceClient
}
```

Add wrapper request/response types for each engine (see per-engine sections
below). Follow the existing pattern: thin structs that translate between
protobuf and template-friendly Go types.

### 4. vaultBackend Interface (server.go)

Add methods for each engine to the `vaultBackend` interface. Group by engine.

### 5. Mount Helpers

Add `findSSHCAMount()`, `findTransitMount()`, `findUserMount()` following
the `findCAMount()` pattern.

---

## SSH CA Engine Web UI

### Route: `/sshca`

File: `internal/webserver/routes.go` — register under `r.Route("/sshca", ...)`

| Method | Path | Handler | Auth |
|--------|------|---------|------|
| GET | `/sshca` | `handleSSHCA` | User |
| POST | `/sshca/sign-user` | `handleSSHCASignUser` | User |
| POST | `/sshca/sign-host` | `handleSSHCASignHost` | User |
| GET | `/sshca/cert/{serial}` | `handleSSHCACertDetail` | User |
| POST | `/sshca/cert/{serial}/revoke` | `handleSSHCACertRevoke` | Admin |
| POST | `/sshca/cert/{serial}/delete` | `handleSSHCACertDelete` | Admin |
| POST | `/sshca/profile/create` | `handleSSHCACreateProfile` | Admin |
| GET | `/sshca/profile/{name}` | `handleSSHCAProfileDetail` | User |
| POST | `/sshca/profile/{name}/update` | `handleSSHCAUpdateProfile` | Admin |
| POST | `/sshca/profile/{name}/delete` | `handleSSHCADeleteProfile` | Admin |

### Template: `sshca.html`

Main page for the SSH CA engine. Structure:

```
┌─────────────────────────────────────────┐
│ SSH CA: {mount}              ← Dashboard│
├─────────────────────────────────────────┤
│ CA Public Key                           │
│ ┌─────────────────────────────────────┐ │
│ │ Algorithm: ed25519                  │ │
│ │ Fingerprint: SHA256:xxxx            │ │
│ │ [Download Public Key]               │ │
│ │ ssh-ed25519 AAAA... (readonly area) │ │
│ └─────────────────────────────────────┘ │
├─────────────────────────────────────────┤
│ Sign User Certificate                   │
│ ┌─────────────────────────────────────┐ │
│ │ Profile: [select]                   │ │
│ │ Public Key: [textarea, ssh format]  │ │
│ │ Principals: [textarea, one/line]    │ │
│ │ TTL: [input, optional]              │ │
│ │ [Sign Certificate]                  │ │
│ └─────────────────────────────────────┘ │
├─────────────────────────────────────────┤
│ Sign Host Certificate                   │
│ ┌─────────────────────────────────────┐ │
│ │ Profile: [select]                   │ │
│ │ Public Key: [textarea, ssh format]  │ │
│ │ Hostnames: [textarea, one/line]     │ │
│ │ TTL: [input, optional]              │ │
│ │ [Sign Certificate]                  │ │
│ └─────────────────────────────────────┘ │
├─────────────────────────────────────────┤
│ Signing Profiles                        │
│ ┌─────────────────────────────────────┐ │
│ │ Name │ Type │ Max TTL │ Actions     │ │
│ │ ─────┼──────┼─────────┼──────────── │ │
│ │ eng  │ user │ 8760h   │ [View]      │ │
│ │ ops  │ host │ 720h    │ [View]      │ │
│ └─────────────────────────────────────┘ │
│ [Create Profile] (admin, <details>)     │
├─────────────────────────────────────────┤
│ Recent Certificates                     │
│ ┌─────────────────────────────────────┐ │
│ │ Serial │ Type │ Principals │ Issued │ │
│ │        │      │            │ By     │ │
│ │ ───────┼──────┼────────────┼─────── │ │
│ │ abc123 │ user │ kyle       │ kyle   │ │
│ └─────────────────────────────────────┘ │
├─────────────────────────────────────────┤
│ KRL (admin only)                        │
│ ┌─────────────────────────────────────┐ │
│ │ Version: 7                          │ │
│ │ [Download KRL]                      │ │
│ └─────────────────────────────────────┘ │
└─────────────────────────────────────────┘
```

### Template: `sshca_cert_detail.html`

Detail view for a single SSH certificate:

```
┌─────────────────────────────────────────┐
│ SSH Certificate: {serial}    ← SSH CA   │
├─────────────────────────────────────────┤
│ Details                                 │
│ ┌─────────────────────────────────────┐ │
│ │ Type:        user                   │ │
│ │ Serial:      abc123                 │ │
│ │ Key ID:      kyle@host              │ │
│ │ Principals:  kyle, root             │ │
│ │ Profile:     eng                    │ │
│ │ Valid After:  2026-03-16T...        │ │
│ │ Valid Before: 2026-03-17T...        │ │
│ │ Issued By:   kyle                   │ │
│ │ Issued At:   2026-03-16T...        │ │
│ │ Revoked:     No                     │ │
│ └─────────────────────────────────────┘ │
├─────────────────────────────────────────┤
│ Certificate (readonly textarea)         │
├─────────────────────────────────────────┤
│ Admin Actions (admin only)              │
│ [Revoke] [Delete]                       │
└─────────────────────────────────────────┘
```

### Template: `sshca_profile_detail.html`

Detail view for a signing profile:

```
┌─────────────────────────────────────────┐
│ Profile: {name}              ← SSH CA   │
├─────────────────────────────────────────┤
│ Configuration                           │
│ ┌─────────────────────────────────────┐ │
│ │ Name:              eng              │ │
│ │ Type:              user             │ │
│ │ Max TTL:           8760h            │ │
│ │ Default TTL:       24h              │ │
│ │ Allowed Principals: *               │ │
│ │ Force Command:     (none)           │ │
│ │ Source Addresses:  (none)           │ │
│ │ Allow Agent Fwd:   yes              │ │
│ │ Allow Port Fwd:    yes              │ │
│ │ Allow PTY:         yes              │ │
│ └─────────────────────────────────────┘ │
├─────────────────────────────────────────┤
│ Edit Profile (admin only, <details>)    │
│ ┌─────────────────────────────────────┐ │
│ │ (form with pre-populated fields)    │ │
│ │ [Update Profile]                    │ │
│ └─────────────────────────────────────┘ │
├─────────────────────────────────────────┤
│ Admin Actions (admin only)              │
│ [Delete Profile]                        │
└─────────────────────────────────────────┘
```

### Handler File: `internal/webserver/sshca.go`

Handlers for all SSH CA web routes. Follow `pki.go` patterns:

- `handleSSHCA` — main page: fetch CA pubkey, profiles, recent certs; render
- `handleSSHCASignUser` / `handleSSHCASignHost` — parse form, call gRPC,
  render success inline (show signed cert in readonly textarea) or re-render
  with error
- `handleSSHCACertDetail` — fetch cert by serial, render detail page
- `handleSSHCACertRevoke` / `handleSSHCACertDelete` — admin action, redirect
  back to main page
- `handleSSHCACreateProfile` — admin form, redirect to profile detail
- `handleSSHCAProfileDetail` — fetch profile, render detail page
- `handleSSHCAUpdateProfile` — admin form, redirect to profile detail
- `handleSSHCADeleteProfile` — admin action, redirect to main page

### gRPC Client Methods

```go
// client.go additions
GetSSHCAPublicKey(ctx, token, mount string) (*SSHCAPublicKey, error)
SSHCASignUser(ctx, token, mount string, req *SSHCASignRequest) (*SSHCACert, error)
SSHCASignHost(ctx, token, mount string, req *SSHCASignRequest) (*SSHCACert, error)
ListSSHCACerts(ctx, token, mount string) ([]SSHCACertSummary, error)
GetSSHCACert(ctx, token, mount, serial string) (*SSHCACertDetail, error)
RevokeSSHCACert(ctx, token, mount, serial string) error
DeleteSSHCACert(ctx, token, mount, serial string) error
CreateSSHCAProfile(ctx, token, mount string, req *SSHCAProfileRequest) error
GetSSHCAProfile(ctx, token, mount, name string) (*SSHCAProfile, error)
ListSSHCAProfiles(ctx, token, mount string) ([]SSHCAProfileSummary, error)
UpdateSSHCAProfile(ctx, token, mount, name string, req *SSHCAProfileRequest) error
DeleteSSHCAProfile(ctx, token, mount, name string) error
GetSSHCAKRL(ctx, token, mount string) ([]byte, uint64, error)
```

### Wrapper Types

```go
type SSHCAPublicKey struct {
    Algorithm   string
    PublicKey   string // authorized_keys format
    Fingerprint string
}

type SSHCASignRequest struct {
    PublicKey   string
    Principals []string
    TTL        string
    Profile    string
}

type SSHCACert struct {
    Serial      string
    Certificate string // SSH certificate text
}

type SSHCACertSummary struct {
    Serial     string
    Type       string // "user" or "host"
    Principals string // comma-joined
    IssuedBy   string
    IssuedAt   string
    ExpiresAt  string
    Revoked    bool
}

type SSHCACertDetail struct {
    SSHCACertSummary
    KeyID       string
    Profile     string
    Certificate string // full cert text
}

type SSHCAProfileSummary struct {
    Name       string
    Type       string
    MaxTTL     string
    DefaultTTL string
}

type SSHCAProfile struct {
    SSHCAProfileSummary
    AllowedPrincipals []string
    ForceCommand      string
    SourceAddresses   []string
    AllowAgentFwd     bool
    AllowPortFwd      bool
    AllowPTY          bool
}

type SSHCAProfileRequest struct {
    Name              string
    Type              string // "user" or "host"
    MaxTTL            string
    DefaultTTL        string
    AllowedPrincipals []string
    ForceCommand      string
    SourceAddresses   []string
    AllowAgentFwd     bool
    AllowPortFwd      bool
    AllowPTY          bool
}
```

---

## Transit Engine Web UI

### Route: `/transit`

| Method | Path | Handler | Auth |
|--------|------|---------|------|
| GET | `/transit` | `handleTransit` | User |
| GET | `/transit/key/{name}` | `handleTransitKeyDetail` | User |
| POST | `/transit/key/create` | `handleTransitCreateKey` | Admin |
| POST | `/transit/key/{name}/rotate` | `handleTransitRotateKey` | Admin |
| POST | `/transit/key/{name}/config` | `handleTransitUpdateConfig` | Admin |
| POST | `/transit/key/{name}/trim` | `handleTransitTrimKey` | Admin |
| POST | `/transit/key/{name}/delete` | `handleTransitDeleteKey` | Admin |
| POST | `/transit/encrypt` | `handleTransitEncrypt` | User |
| POST | `/transit/decrypt` | `handleTransitDecrypt` | User |
| POST | `/transit/rewrap` | `handleTransitRewrap` | User |
| POST | `/transit/sign` | `handleTransitSign` | User |
| POST | `/transit/verify` | `handleTransitVerify` | User |
| POST | `/transit/hmac` | `handleTransitHMAC` | User |

### Template: `transit.html`

Main page. Structure:

```
┌─────────────────────────────────────────┐
│ Transit: {mount}             ← Dashboard│
├─────────────────────────────────────────┤
│ Named Keys                              │
│ ┌─────────────────────────────────────┐ │
│ │ Name     │ Type       │ Versions   │ │
│ │ ─────────┼────────────┼─────────── │ │
│ │ payments │ aes256-gcm │ 3          │ │
│ │ signing  │ ed25519    │ 1          │ │
│ └─────────────────────────────────────┘ │
│ [Create Key] (admin, <details>)         │
│   Name: [input]                         │
│   Type: [select: aes256-gcm, etc.]     │
│   [Create]                              │
├─────────────────────────────────────────┤
│ Encrypt                                 │
│ ┌─────────────────────────────────────┐ │
│ │ Key: [select from named keys]       │ │
│ │ Plaintext (base64): [textarea]      │ │
│ │ Context (optional): [input]         │ │
│ │ [Encrypt]                           │ │
│ │                                     │ │
│ │ Result (if present):                │ │
│ │ metacrypt:v1:xxxxx (readonly area)  │ │
│ └─────────────────────────────────────┘ │
├─────────────────────────────────────────┤
│ Decrypt                                 │
│ ┌─────────────────────────────────────┐ │
│ │ Key: [select]                       │ │
│ │ Ciphertext: [textarea]              │ │
│ │ Context (optional): [input]         │ │
│ │ [Decrypt]                           │ │
│ │                                     │ │
│ │ Result (if present):                │ │
│ │ base64 plaintext (readonly area)    │ │
│ └─────────────────────────────────────┘ │
├─────────────────────────────────────────┤
│ Rewrap                                  │
│ ┌─────────────────────────────────────┐ │
│ │ Key: [select]                       │ │
│ │ Ciphertext: [textarea]              │ │
│ │ Context (optional): [input]         │ │
│ │ [Rewrap]                            │ │
│ │                                     │ │
│ │ Result: (new ciphertext)            │ │
│ └─────────────────────────────────────┘ │
├─────────────────────────────────────────┤
│ Sign                                    │
│ ┌─────────────────────────────────────┐ │
│ │ Key: [select, signing keys only]    │ │
│ │ Input (base64): [textarea]          │ │
│ │ [Sign]                              │ │
│ │                                     │ │
│ │ Result: metacrypt:v1:sig (readonly) │ │
│ └─────────────────────────────────────┘ │
├─────────────────────────────────────────┤
│ Verify                                  │
│ ┌─────────────────────────────────────┐ │
│ │ Key: [select, signing keys only]    │ │
│ │ Input (base64): [textarea]          │ │
│ │ Signature: [textarea]               │ │
│ │ [Verify]                            │ │
│ │                                     │ │
│ │ Result: Valid ✓ / Invalid ✗         │ │
│ └─────────────────────────────────────┘ │
├─────────────────────────────────────────┤
│ HMAC                                    │
│ ┌─────────────────────────────────────┐ │
│ │ Key: [select, HMAC keys only]       │ │
│ │ Input (base64): [textarea]          │ │
│ │ [Generate HMAC]                     │ │
│ │                                     │ │
│ │ Result: metacrypt:v1:hmac (ro)      │ │
│ └─────────────────────────────────────┘ │
└─────────────────────────────────────────┘
```

Note: Batch operations are API-only. The web UI does not expose batch
encrypt/decrypt/rewrap — those are designed for programmatic use.

### Template: `transit_key_detail.html`

```
┌─────────────────────────────────────────┐
│ Key: {name}                  ← Transit  │
├─────────────────────────────────────────┤
│ Key Details                             │
│ ┌─────────────────────────────────────┐ │
│ │ Name:                 payments      │ │
│ │ Type:                 aes256-gcm    │ │
│ │ Latest Version:       3             │ │
│ │ Min Decrypt Version:  1             │ │
│ │ Min Encrypt Version:  3             │ │
│ │ Allow Deletion:       no            │ │
│ │ Exportable:           no            │ │
│ │ Max Key Versions:     0 (unlimited) │ │
│ │ Created:              2026-03-16... │ │
│ └─────────────────────────────────────┘ │
├─────────────────────────────────────────┤
│ Key Versions                            │
│ ┌─────────────────────────────────────┐ │
│ │ Version │ Created                   │ │
│ │ ────────┼────────────────────────── │ │
│ │ 3       │ 2026-03-16T10:00:00Z     │ │
│ │ 2       │ 2026-03-10T08:00:00Z     │ │
│ │ 1       │ 2026-03-01T12:00:00Z     │ │
│ └─────────────────────────────────────┘ │
├─────────────────────────────────────────┤
│ Public Key (signing keys only)          │
│ [textarea, PEM, readonly]               │
├─────────────────────────────────────────┤
│ Admin Actions (admin only)              │
│ [Rotate Key]                            │
│                                         │
│ Update Config (<details>)               │
│   Min Decrypt Version: [input]          │
│   Allow Deletion: [checkbox]            │
│   [Update]                              │
│                                         │
│ Trim Versions (<details>)               │
│   Keep from version: [input]            │
│   [Trim]                                │
│                                         │
│ [Delete Key] (danger, requires confirm) │
└─────────────────────────────────────────┘
```

### Handler File: `internal/webserver/transit.go`

- `handleTransit` — list keys, render main page
- `handleTransitKeyDetail` — fetch key metadata + versions, render detail
- `handleTransitCreateKey` — parse form, call gRPC, redirect to key detail
- `handleTransitRotateKey` — admin, redirect to key detail
- `handleTransitUpdateConfig` — admin, redirect to key detail
- `handleTransitTrimKey` — admin, redirect to key detail
- `handleTransitDeleteKey` — admin, redirect to main page
- `handleTransitEncrypt` — parse form, call gRPC, re-render main page with
  result in `EncryptResult` field
- `handleTransitDecrypt` — same pattern, `DecryptResult` field
- `handleTransitRewrap` — same pattern, `RewrapResult` field
- `handleTransitSign` — same pattern, `SignResult` field
- `handleTransitVerify` — same pattern, `VerifyResult` field (boolean)
- `handleTransitHMAC` — same pattern, `HMACResult` field

### gRPC Client Methods

```go
ListTransitKeys(ctx, token, mount string) ([]TransitKeySummary, error)
GetTransitKey(ctx, token, mount, name string) (*TransitKeyDetail, error)
CreateTransitKey(ctx, token, mount string, req *CreateTransitKeyRequest) error
DeleteTransitKey(ctx, token, mount, name string) error
RotateTransitKey(ctx, token, mount, name string) error
UpdateTransitKeyConfig(ctx, token, mount, name string, req *UpdateTransitKeyConfigRequest) error
TrimTransitKey(ctx, token, mount, name string, minVersion int) error
TransitEncrypt(ctx, token, mount, key string, req *TransitEncryptRequest) (string, error)
TransitDecrypt(ctx, token, mount, key string, req *TransitDecryptRequest) (string, error)
TransitRewrap(ctx, token, mount, key string, req *TransitRewrapRequest) (string, error)
TransitSign(ctx, token, mount, key string, input string) (string, error)
TransitVerify(ctx, token, mount, key string, input, signature string) (bool, error)
TransitHMAC(ctx, token, mount, key string, input string) (string, error)
GetTransitPublicKey(ctx, token, mount, name string) (string, error)
```

### Wrapper Types

```go
type TransitKeySummary struct {
    Name           string
    Type           string
    LatestVersion  int
}

type TransitKeyVersion struct {
    Version   int
    CreatedAt string
}

type TransitKeyDetail struct {
    Name               string
    Type               string
    LatestVersion      int
    MinDecryptVersion  int
    MinEncryptVersion  int
    AllowDeletion      bool
    Exportable         bool
    MaxKeyVersions     int
    CreatedAt          string
    Versions           []TransitKeyVersion
    PublicKeyPEM       string // empty for symmetric keys
}

type CreateTransitKeyRequest struct {
    Name string
    Type string // aes256-gcm, chacha20-poly, ed25519, etc.
}

type UpdateTransitKeyConfigRequest struct {
    MinDecryptVersion int
    AllowDeletion     *bool // nil = no change
}

type TransitEncryptRequest struct {
    Plaintext string // base64
    Context   string // optional AAD
}

type TransitDecryptRequest struct {
    Ciphertext string // metacrypt:v1:...
    Context    string
}

type TransitRewrapRequest struct {
    Ciphertext string
    Context    string
}
```

---

## User Engine Web UI

### Route: `/user`

| Method | Path | Handler | Auth |
|--------|------|---------|------|
| GET | `/user` | `handleUser` | User |
| POST | `/user/register` | `handleUserRegister` | User |
| POST | `/user/provision` | `handleUserProvision` | Admin |
| GET | `/user/key/{username}` | `handleUserKeyDetail` | User |
| POST | `/user/encrypt` | `handleUserEncrypt` | User |
| POST | `/user/decrypt` | `handleUserDecrypt` | User |
| POST | `/user/re-encrypt` | `handleUserReEncrypt` | User |
| POST | `/user/rotate` | `handleUserRotateKey` | User |
| POST | `/user/delete/{username}` | `handleUserDeleteUser` | Admin |

### Template: `user.html`

Main page. Structure:

```
┌─────────────────────────────────────────┐
│ User Crypto: {mount}         ← Dashboard│
├─────────────────────────────────────────┤
│ Your Key                                │
│ ┌─────────────────────────────────────┐ │
│ │ (if registered)                     │ │
│ │ Algorithm: x25519                   │ │
│ │ Public Key: base64...               │ │
│ │                                     │ │
│ │ (if not registered)                 │ │
│ │ You have no keypair.                │ │
│ │ [Register]                          │ │
│ └─────────────────────────────────────┘ │
├─────────────────────────────────────────┤
│ Encrypt                                 │
│ ┌─────────────────────────────────────┐ │
│ │ Recipients: [textarea, one/line]    │ │
│ │ Plaintext (base64): [textarea]      │ │
│ │ Metadata (optional): [input]        │ │
│ │ [Encrypt]                           │ │
│ │                                     │ │
│ │ Result (if present):                │ │
│ │ JSON envelope (readonly textarea)   │ │
│ └─────────────────────────────────────┘ │
├─────────────────────────────────────────┤
│ Decrypt                                 │
│ ┌─────────────────────────────────────┐ │
│ │ Envelope (JSON): [textarea]         │ │
│ │ [Decrypt]                           │ │
│ │                                     │ │
│ │ Result (if present):                │ │
│ │ base64 plaintext (readonly)         │ │
│ │ Metadata: ... (readonly)            │ │
│ └─────────────────────────────────────┘ │
├─────────────────────────────────────────┤
│ Re-Encrypt                              │
│ ┌─────────────────────────────────────┐ │
│ │ Envelope (JSON): [textarea]         │ │
│ │ [Re-Encrypt]                        │ │
│ │                                     │ │
│ │ Result: updated envelope (readonly) │ │
│ └─────────────────────────────────────┘ │
├─────────────────────────────────────────┤
│ Key Rotation (danger zone)              │
│ ┌─────────────────────────────────────┐ │
│ │ ⚠ Rotating your key will make all   │ │
│ │ existing envelopes unreadable       │ │
│ │ unless re-encrypted first.          │ │
│ │ [Rotate Key] (confirm dialog)       │ │
│ └─────────────────────────────────────┘ │
├─────────────────────────────────────────┤
│ Registered Users                        │
│ ┌─────────────────────────────────────┐ │
│ │ Username   │ Algorithm │ Actions    │ │
│ │ ───────────┼───────────┼─────────── │ │
│ │ kyle       │ x25519    │ [View Key] │ │
│ │ alice      │ x25519    │ [View Key] │ │
│ └─────────────────────────────────────┘ │
│ [Provision User] (admin, <details>)     │
│   Username: [input]                     │
│   [Provision]                           │
├─────────────────────────────────────────┤
│ Admin: Delete User (admin only)         │
│ ┌─────────────────────────────────────┐ │
│ │ Username: [input]                   │ │
│ │ [Delete User] (danger, confirm)     │ │
│ └─────────────────────────────────────┘ │
└─────────────────────────────────────────┘
```

### Template: `user_key_detail.html`

```
┌─────────────────────────────────────────┐
│ User Key: {username}         ← User     │
├─────────────────────────────────────────┤
│ Public Key                              │
│ ┌─────────────────────────────────────┐ │
│ │ Username:   kyle                    │ │
│ │ Algorithm:  x25519                  │ │
│ │ Public Key: base64...               │ │
│ └─────────────────────────────────────┘ │
├─────────────────────────────────────────┤
│ Admin Actions (admin only)              │
│ [Delete User] (danger, confirm)         │
└─────────────────────────────────────────┘
```

### Handler File: `internal/webserver/user.go`

- `handleUser` — fetch own key (if registered), list users, render main page
- `handleUserRegister` — call gRPC, redirect to main page with success
- `handleUserProvision` — admin, call gRPC, redirect to main page
- `handleUserKeyDetail` — fetch public key for username, render detail
- `handleUserEncrypt` — parse form, call gRPC, re-render with envelope result
- `handleUserDecrypt` — parse form, call gRPC, re-render with plaintext result
- `handleUserReEncrypt` — parse form, call gRPC, re-render with new envelope
- `handleUserRotateKey` — confirm + call gRPC, redirect to main page
- `handleUserDeleteUser` — admin, call gRPC, redirect to main page

### gRPC Client Methods

```go
UserRegister(ctx, token, mount string) error
UserProvision(ctx, token, mount, username string) error
GetUserPublicKey(ctx, token, mount, username string) (*UserPublicKey, error)
ListUsers(ctx, token, mount string) ([]UserSummary, error)
UserEncrypt(ctx, token, mount string, req *UserEncryptRequest) (string, error)
UserDecrypt(ctx, token, mount string, envelope string) (*UserDecryptResult, error)
UserReEncrypt(ctx, token, mount string, envelope string) (string, error)
UserRotateKey(ctx, token, mount string) error
DeleteUser(ctx, token, mount, username string) error
```

### Wrapper Types

```go
type UserPublicKey struct {
    Username  string
    Algorithm string
    PublicKey string // base64
}

type UserSummary struct {
    Username  string
    Algorithm string
}

type UserEncryptRequest struct {
    Recipients []string
    Plaintext  string // base64
    Metadata   string // optional
}

type UserDecryptResult struct {
    Plaintext string // base64
    Metadata  string
    Sender    string
}
```

---

## Implementation Order

Implement in this order to build incrementally on shared infrastructure:

### Phase 1: Shared Infrastructure

1. Add gRPC service clients to `VaultClient` struct
2. Add `findSSHCAMount()`, `findTransitMount()`, `findUserMount()` helpers
3. Update `layout.html` navigation with conditional engine links
4. Update `dashboard.html` mount table links and mount forms
5. Extend `requireAuth` middleware to populate mount availability flags

### Phase 2: SSH CA (closest to existing PKI patterns)

6. Implement SSH CA wrapper types in `client.go`
7. Implement SSH CA gRPC client methods in `client.go`
8. Add SSH CA methods to `vaultBackend` interface
9. Create `sshca.html`, `sshca_cert_detail.html`, `sshca_profile_detail.html`
10. Implement `internal/webserver/sshca.go` handlers
11. Register SSH CA routes

### Phase 3: Transit

12. Implement Transit wrapper types and gRPC client methods
13. Add Transit methods to `vaultBackend` interface
14. Create `transit.html`, `transit_key_detail.html`
15. Implement `internal/webserver/transit.go` handlers
16. Register Transit routes

### Phase 4: User

17. Implement User wrapper types and gRPC client methods
18. Add User methods to `vaultBackend` interface
19. Create `user.html`, `user_key_detail.html`
20. Implement `internal/webserver/user.go` handlers
21. Register User routes

### Phase 5: Polish

22. Test all engine UIs end-to-end against a running instance
23. Update `ARCHITECTURE.md` web routes table
24. Update `CLAUDE.md` project structure (if template list changed)

---

## Design Decisions

### No batch operations in UI

Batch encrypt/decrypt/rewrap (transit) are for programmatic use. The web UI
exposes single-operation forms only. Users needing batch operations should use
the REST or gRPC API directly.

### Operation results inline, not separate pages

Encrypt/decrypt/sign/verify results are shown inline on the same page (in a
readonly textarea or success div), not on a separate result page. This
follows the PKI "Sign CSR" pattern where the signed cert appears in the same
card after submission. It avoids navigation complexity and keeps the workflow
tight.

### No JavaScript beyond htmx

All forms use standard POST submission. HTMX is used only where it already
is in the codebase (seal button). No client-side validation, key filtering,
or dynamic form behavior.

### Key selection via `<select>`

Transit and User operations that reference a named key use a `<select>`
dropdown populated server-side. The handler fetches the key list on every
page render. For mounts with many keys, this is acceptable — transit engines
typically have tens of keys, not thousands.

### Profile-gated signing

The SSH CA sign forms require selecting a profile. The profile list is
fetched from the backend — the user can only sign with profiles they have
policy access to. If the gRPC call returns a permission error, the web UI
shows it as a form error, not a 403 page.

### Danger zone pattern

Destructive operations (delete key, delete user, rotate user key) use the
existing pattern: red `.btn-danger` button with `onclick="return
confirm('...')"`. No additional confirmation pages.
