UI Polish: Conference-Ready Demo — Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Make the Pilot UI demo conference-ready with human-readable names instead of raw DIDs across login, transactions, and governance flows.
Architecture: Client-side name cache in app.js resolves DIDs to display names via the existing GET /v1/members/{coop_id}/{did} endpoint. A new PUT /v1/members/{coop_id}/{did}/profile backend endpoint enables name editing. Demo seed script creates 4 named members with transactions.
Tech Stack: Vanilla JS (app.js ~6,300 lines), Rust/actix-web (icn-gateway), bash (demo scripts)
Task 1: Backend — Add PUT profile endpoint
Files:
- Modify:
icn/crates/icn-gateway/src/api/members.rs(addupdate_member_profilehandler) - Modify:
icn/crates/icn-gateway/src/server.rs:995(register route) - Modify:
icn/crates/icn-gateway/src/commons_mgr.rs(addupdate_display_namemethod)
Step 1: Add update_display_name to CommonsManager
In icn/crates/icn-gateway/src/commons_mgr.rs, add after get_holder_by_did (line ~364):
/// Update the display name for a holder identified by DID
pub async fn update_display_name(&self, did: &Did, display_name: String) -> Result<()> {
let did_str = did.to_string();
let mut holder = self.store.get_holder_by_did(&did_str)?
.ok_or_else(|| anyhow::anyhow!("No holder found for DID: {did_str}"))?;
holder.set_display_name(display_name);
self.store.put_holder(&holder)?;
Ok(())
}
Step 2: Add PUT handler in members.rs
In icn/crates/icn-gateway/src/api/members.rs, add after the existing get_member_profile handler:
use actix_web::put;
/// Request body for updating member profile
#[derive(Debug, Deserialize, ToSchema)]
pub struct UpdateProfileRequest {
/// Display name to set
pub display_name: String,
}
/// PUT /v1/members/{coop_id}/{did}/profile - Update member profile
///
/// Allows a member to update their own display name.
/// JWT `sub` must match the DID being updated (self-service only).
#[put("/members/{coop_id}/{did}/profile")]
pub async fn update_member_profile(
http_req: HttpRequest,
path: web::Path<(String, String)>,
body: web::Json<UpdateProfileRequest>,
coop_manager: web::Data<Arc<CoopManager>>,
commons_manager: web::Data<Arc<CommonsManager>>,
) -> Result<HttpResponse> {
let (coop_id, did_str) = path.into_inner();
// Parse DID
let did = did_str
.parse::<Did>()
.map_err(|e| GatewayError::BadRequest(format!("Invalid DID: {e}")))?;
// Auth: JWT sub must match the DID being edited
let claims = get_claims(&http_req)
.ok_or_else(|| GatewayError::Unauthorized("Authentication required".to_string()))?;
let caller_did: Did = claims.sub.parse()
.map_err(|e| GatewayError::Unauthorized(format!("Invalid caller DID: {e}")))?;
if caller_did != did {
return Err(GatewayError::Forbidden("Can only update your own profile".to_string()));
}
// Verify member exists in coop
let coop = coop_manager.get_coop(&coop_id).await?;
if !coop.members.iter().any(|m| m.did == did) {
return Err(GatewayError::NotFound("Member not found in cooperative".to_string()));
}
// Validate display name
let name = body.display_name.trim().to_string();
if name.is_empty() || name.len() > 100 {
return Err(GatewayError::BadRequest(
"Display name must be 1-100 characters".to_string(),
));
}
// Update (creates holder record if needed via get_or_create pattern)
commons_manager.update_display_name(&did, name.clone()).await
.map_err(|e| GatewayError::Internal(format!("Failed to update display name: {e}")))?;
Ok(HttpResponse::Ok().json(serde_json::json!({
"did": did_str,
"display_name": name
})))
}
Step 3: Register the route in server.rs
In icn/crates/icn-gateway/src/server.rs, find line 995:
.service(api::members::get_member_profile)
Add the PUT route right after:
.service(api::members::get_member_profile)
.service(api::members::update_member_profile)
Step 4: Verify it compiles
Run: cd icn && cargo check -p icn-gateway
Expected: success (exit 0)
Step 5: Write test
Add to the #[cfg(test)] mod tests in members.rs:
#[actix_web::test]
async fn test_update_member_profile() {
let coop_manager = Arc::new(CoopManager::new());
let commons_manager = create_test_commons_manager();
let coop_id = "test-coop";
let keypair = icn_identity::KeyPair::generate().unwrap();
let did = keypair.did().clone();
let did_str = did.to_string();
let timestamp = icn_time::current_timestamp_secs();
coop_manager
.create_coop(coop_id.to_string(), "Test".to_string(), did.clone(), timestamp)
.await
.unwrap();
// Create a holder record so update_display_name can find it
commons_manager
.create_holder_from_anchor(&hex::encode([0u8; 32]), &did)
.await
.unwrap();
// Create JWT claims for auth
let jwt_secret = b"test-secret-32-characters-long!!";
let token = crate::middleware::create_test_token(&did_str, jwt_secret);
let app = test::init_service(
App::new()
.app_data(web::Data::new(coop_manager))
.app_data(web::Data::new(commons_manager))
.service(update_member_profile)
.wrap(crate::middleware::JwtAuth::new(jwt_secret.to_vec())),
)
.await;
let req = test::TestRequest::put()
.uri(&format!("/members/{coop_id}/{did_str}/profile"))
.set_json(serde_json::json!({"display_name": "Test User"}))
.insert_header(("Authorization", format!("Bearer {token}")))
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), 200);
}
Note: The test helper
create_test_tokenmay need to be added or adjusted to match actual JWT creation patterns in the gateway test suite. Checkicn-gateway/src/middleware.rsfor existing test helpers. If none exist, use the same JWT encoding the demo scripts use. The test can be simplified to justtest::TestRequest::put()without auth if the route is registered outside the auth middleware scope for testing.
Step 6: Run tests
Run: cd icn && cargo test -p icn-gateway -- members
Expected: all member tests pass
Step 7: Commit
git add icn/crates/icn-gateway/src/api/members.rs icn/crates/icn-gateway/src/server.rs icn/crates/icn-gateway/src/commons_mgr.rs
git commit -m "feat(gateway): add PUT /v1/members/{coop_id}/{did}/profile endpoint"
Task 2: Frontend — Name resolution layer
Files:
- Modify:
web/pilot-ui/app.js(add name cache + resolveName helper near top)
Step 1: Add name cache to state initialization
Find the state object initialization in app.js (near the top, around line 10-30). Add:
nameCache: new Map(), // Map<did, {name: string, fetchedAt: number}>
Step 2: Add resolveName and resolveNames helpers
Add after the truncateDid function (line ~334):
// Name resolution with caching (5-minute TTL)
const NAME_CACHE_TTL = 5 * 60 * 1000;
async function resolveName(did) {
if (!did) return 'Unknown';
// Check cache first
const cached = state.nameCache.get(did);
if (cached && (Date.now() - cached.fetchedAt) < NAME_CACHE_TTL) {
return cached.name;
}
// Current user shortcut
if (did === state.did && state.userName) {
return state.userName;
}
// Fetch from API
try {
const profile = await apiRequest('GET', `/members/${encodeURIComponent(state.coopId)}/${encodeURIComponent(did)}`);
const name = profile.name || truncateDid(did);
state.nameCache.set(did, { name, fetchedAt: Date.now() });
return name;
} catch (e) {
// Fallback to truncated DID
const fallback = truncateDid(did);
state.nameCache.set(did, { name: fallback, fetchedAt: Date.now() });
return fallback;
}
}
async function resolveNames(dids) {
const unique = [...new Set(dids.filter(Boolean))];
await Promise.all(unique.map(did => resolveName(did)));
// After this call, all names are cached; callers can use getNameSync
}
// Synchronous name access (returns cached or truncated)
function getNameSync(did) {
if (!did) return 'Unknown';
const cached = state.nameCache.get(did);
if (cached) return cached.name;
return truncateDid(did);
}
Step 3: Verify no syntax errors
Open http://<host>:3000 in browser (or check via console). The app should still load and function exactly as before since no call sites have changed yet.
Step 4: Commit
git add web/pilot-ui/app.js
git commit -m "feat(ui): add DID-to-name resolution layer with caching"
Task 3: Frontend — Wire name resolution into login flow
Files:
- Modify:
web/pilot-ui/app.js(login function + header display)
Step 1: Resolve current user's name on login
In the login() function (line ~530), after elements.userDid.textContent = truncateDid(state.did);, add:
// Resolve display name for current user
resolveName(state.did).then(name => {
state.userName = name;
elements.userDid.textContent = name;
});
Step 2: Add state.userName initialization
In the state object, add: userName: null,
Step 3: Commit
git add web/pilot-ui/app.js
git commit -m "feat(ui): resolve current user name on login"
Task 4: Frontend — Replace truncateDid in member dropdown
Files:
- Modify:
web/pilot-ui/app.js(lines 682-691, member dropdown population)
Step 1: Update member dropdown to show names
Find the loadMembers() function around line 682. Replace:
option.textContent = truncateDid(member.did);
With:
option.textContent = getNameSync(member.did) !== truncateDid(member.did)
? `${getNameSync(member.did)} (${truncateDid(member.did)})`
: truncateDid(member.did);
Step 2: Pre-resolve all member names before populating dropdown
Before the dropdown population loop (before elements.recipient.innerHTML = ...), add:
// Pre-resolve all member names
await resolveNames(members.map(m => m.did));
Note: If
loadMembers()is not already async, make it async.
Step 3: Commit
git add web/pilot-ui/app.js
git commit -m "feat(ui): show member names in recipient dropdown"
Task 5: Frontend — Replace truncateDid in transaction displays
Files:
- Modify:
web/pilot-ui/app.js(lines 1049, 1264, 3384-3386, 3235)
Step 1: Update activity feed (line ~1049)
Replace:
${isReceived ? 'From' : 'To'} ${truncateDid(other)}
With:
${isReceived ? 'From' : 'To'} ${getNameSync(other)}
Step 2: Update recent transactions (line ~1264)
Replace:
${truncateDid(tx.from)} → ${truncateDid(tx.to)}
With:
${getNameSync(tx.from)} → ${getNameSync(tx.to)}
Step 3: Update transaction list rows (lines ~3384-3386)
Replace:
<div class="transaction-from">${truncateDid(tx.from)}</div>
<div class="transaction-arrow">→</div>
<div class="transaction-to">${truncateDid(tx.to)}</div>
With:
<div class="transaction-from">${getNameSync(tx.from)}</div>
<div class="transaction-arrow">→</div>
<div class="transaction-to">${getNameSync(tx.to)}</div>
Step 4: Update transaction party display (line ~3235)
Replace:
<span class="did">${truncateDid(isIncoming ? tx.from : tx.to)}</span>
With:
<span class="did">${getNameSync(isIncoming ? tx.from : tx.to)}</span>
Step 5: Pre-resolve names before rendering transactions
In the function that loads/renders transactions, before the render loop, add:
// Pre-resolve all transaction party names
const allDids = transactions.flatMap(tx => [tx.from, tx.to]);
await resolveNames(allDids);
Step 6: Commit
git add web/pilot-ui/app.js
git commit -m "feat(ui): show member names in transaction displays"
Task 6: Frontend — Replace truncateDid in member lists and contributors
Files:
- Modify:
web/pilot-ui/app.js(lines 1242, 1298, 3280)
Step 1: Update top contributors (line ~1242)
Replace:
<span class="contributor-did">${truncateDid(did)}</span>
With:
<span class="contributor-did">${getNameSync(did)}</span>
Step 2: Update member list item (line ~1298)
Replace:
<div class="member-did" title="${escapeHtml(member.did)}">${truncateDid(member.did)}</div>
With:
<div class="member-did" title="${escapeHtml(member.did)}">${getNameSync(member.did)}</div>
Step 3: Update member card (line ~3280)
Replace:
<div class="member-did">${truncateDid(member.did)}</div>
With:
<div class="member-did">${getNameSync(member.did)}</div>
Step 4: Commit
git add web/pilot-ui/app.js
git commit -m "feat(ui): show member names in member lists and contributors"
Task 7: Frontend — Replace truncateDid in governance displays
Files:
- Modify:
web/pilot-ui/app.js(lines 777-859, proposal rendering)
Step 1: Add proposer name to proposal cards
In renderProposalList (line ~841), after the proposal-header div, add a proposer line:
${proposal.proposer ? `<div class="proposal-proposer">Proposed by ${getNameSync(proposal.proposer)}</div>` : ''}
Step 2: Pre-resolve proposer names
At the start of renderProposalList, before the map:
// Pre-resolve proposer names
await resolveNames(proposals.map(p => p.proposer).filter(Boolean));
Step 3: Commit
git add web/pilot-ui/app.js
git commit -m "feat(ui): show proposer names in governance proposals"
Task 8: Frontend — Replace remaining truncateDid calls
Files:
- Modify:
web/pilot-ui/app.js(lines 531, 2262, 2424, 2611, 4077-4078, 4289, 4304, 4319, 5143, 5146, 3705)
Step 1: Header user DID (line ~531)
Already handled by Task 3 (login flow resolves name).
Step 2: Profile page DID (line ~2262)
Replace:
document.getElementById('profile-did').textContent = truncateDid(state.did || 'did:icn:...');
With:
document.getElementById('profile-did').textContent = state.userName || truncateDid(state.did || 'did:icn:...');
Step 3: Service history peer (line ~2424)
Replace truncateDid(peer) with getNameSync(peer).
Step 4: Service listing poster (line ~2611)
Replace truncateDid(service.poster) with getNameSync(service.poster).
Step 5: Batch import preview (lines ~4077-4078)
Replace both truncateDid(row.from) and truncateDid(row.to) with getNameSync(row.from) and getNameSync(row.to).
Step 6: Toast notifications (lines ~4289, 4304, 4319)
Replace truncateDid(did) with getNameSync(did) in all three toasts.
Step 7: Filter labels (lines ~5143, 5146)
Replace truncateDid(filterState.fromDid) and truncateDid(filterState.toDid) with getNameSync(filterState.fromDid) and getNameSync(filterState.toDid).
Step 8: CSV import preview (line ~3705)
Replace truncateDid(row.did) with getNameSync(row.did).
Step 9: Commit
git add web/pilot-ui/app.js
git commit -m "feat(ui): replace all remaining truncateDid calls with name resolution"
Task 9: Frontend — Profile edit button in header
Files:
- Modify:
web/pilot-ui/app.js(header area near line 531) - Modify:
web/pilot-ui/index.html(add edit button element to header)
Step 1: Add edit button to HTML header
In index.html, find the user DID display element (likely <span id="user-did"> or similar). Add an edit button next to it:
<button id="edit-name-btn" class="btn-icon" title="Edit display name" style="display:none;">✏️</button>
Step 2: Add edit name handler in app.js
After the login name resolution code, add:
// Show edit button after login
const editNameBtn = document.getElementById('edit-name-btn');
if (editNameBtn) {
editNameBtn.style.display = 'inline-block';
editNameBtn.onclick = () => {
const currentName = state.userName || '';
const newName = prompt('Enter your display name:', currentName);
if (newName !== null && newName.trim() !== '') {
apiRequest('PUT', `/members/${encodeURIComponent(state.coopId)}/${encodeURIComponent(state.did)}/profile`, {
display_name: newName.trim()
}).then(() => {
state.userName = newName.trim();
state.nameCache.set(state.did, { name: newName.trim(), fetchedAt: Date.now() });
elements.userDid.textContent = newName.trim();
showToast('Display name updated', 'success');
}).catch(err => {
showToast('Failed to update name: ' + err.message, 'error');
});
}
};
}
Step 3: Commit
git add web/pilot-ui/app.js web/pilot-ui/index.html
git commit -m "feat(ui): add inline profile name editing"
Task 10: Demo seed — Create named members with transactions
Files:
- Modify:
demo/scripts/run-tool-library-demo.sh(Step 3b seed section, lines ~307-373)
Step 1: Expand seed section to create 4 named members
Replace the current single-recipient seed block (lines ~311-372) with a loop that creates 4 members, sets their display names, adds them to the coop, and creates transactions between them.
Pattern for each member:
# Create identity
MEMBER_DIR=$(mktemp -d /tmp/icn-demo-member-N.XXXXXX)
MEMBER_DID=$(cd "$ICN_DIR" && ICN_PASSPHRASE=demo123 $TARGET_DIR/release/icnctl \
-d "$MEMBER_DIR" id init 2>/dev/null \
| grep -oE 'did:icn:[A-Za-z0-9]+' | head -1 || true)
# Add to cooperative
curl -s -X POST "$GATEWAY/v1/coops/$COOP_ID/members" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "{\"did\":\"$MEMBER_DID\",\"role\":\"participant\"}" >/dev/null 2>&1
# Set display name (uses new PUT endpoint from Task 1)
# Note: The PUT endpoint requires JWT sub == DID. For demo seeding,
# we need a different approach. Option A: generate a token for each member.
# Option B: Add an admin override to the PUT endpoint for seeding.
# Simplest: Use the CommonsManager directly via a seed-specific endpoint
# or use the GET endpoint which already reads from CommonsHolderRecord.
#
# PRAGMATIC APPROACH: Since the demo script has admin tokens, add a
# query parameter ?admin=true or use a separate POST endpoint for seeding.
# OR: Seed names via icnctl (if it supports set-display-name).
#
# SIMPLEST: Add display_name to the AddMemberRequest so names are set
# when members are added to the coop. This is the least-effort path.
IMPORTANT DECISION: The PUT profile endpoint requires self-auth (JWT sub == DID). For demo seeding, the simplest approach is to add an optional
display_namefield toAddMemberRequestso names are set when the admin adds members. This avoids generating per-member JWTs during seeding.
Step 2: Add display_name to AddMemberRequest
In icn/crates/icn-gateway/src/models.rs:103:
pub struct AddMemberRequest {
pub did: String,
pub role: String,
#[serde(default)]
pub display_name: Option<String>,
}
Step 3: Wire display_name into add_member handler
In icn/crates/icn-gateway/src/api/coops.rs:175, after add_member_atomic succeeds, set display name if provided:
// Set display name if provided
if let Some(name) = &req.display_name {
if let Ok(commons) = commons_manager.as_ref() {
let _ = commons.update_display_name(&did, name.clone()).await;
}
}
Note: The
add_memberhandler needscommons_managerinjected. Add it as a parameter.
Step 4: Update seed script with 4 named members
# Member names for demo
declare -a MEMBER_NAMES=("Sarah Chen" "Marcus Rivera" "Priya Patel" "James Okafor")
declare -a MEMBER_DIDS=()
for i in "${!MEMBER_NAMES[@]}"; do
NAME="${MEMBER_NAMES[$i]}"
MEMBER_DIR=$(mktemp -d /tmp/icn-demo-member-$i.XXXXXX)
MEMBER_DID=$(cd "$ICN_DIR" && ICN_PASSPHRASE=demo123 $TARGET_DIR/release/icnctl \
-d "$MEMBER_DIR" id init 2>/dev/null \
| grep -oE 'did:icn:[A-Za-z0-9]+' | head -1 || true)
if [ -n "$MEMBER_DID" ]; then
curl -s -X POST "$GATEWAY/v1/coops/$COOP_ID/members" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "{\"did\":\"$MEMBER_DID\",\"role\":\"participant\",\"display_name\":\"$NAME\"}" \
>/dev/null 2>&1 && echo -e " ${GREEN}✓${NC} Member: $NAME ($MEMBER_DID)" || true
MEMBER_DIDS+=("$MEMBER_DID")
fi
rm -rf "$MEMBER_DIR"
done
# Set current user display name
curl -s -X PUT "$GATEWAY/v1/members/$COOP_ID/$CURRENT_DID/profile" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"display_name":"Demo User"}' \
>/dev/null 2>&1 && echo -e " ${GREEN}✓${NC} Set current user name: Demo User" || true
# Create transactions between members
if [ ${#MEMBER_DIDS[@]} -ge 2 ]; then
curl -s -X POST "$GATEWAY/v1/ledger/$COOP_ID/payment" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "{\"from\":\"$CURRENT_DID\",\"to\":\"${MEMBER_DIDS[0]}\",\"amount\":3,\"currency\":\"hours\",\"memo\":\"Garden bed construction — built 4x8 raised bed\"}" \
>/dev/null 2>&1 && echo -e " ${GREEN}✓${NC} Transaction: Demo User → Sarah Chen (3 hrs)" || true
curl -s -X POST "$GATEWAY/v1/ledger/$COOP_ID/payment" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "{\"from\":\"$CURRENT_DID\",\"to\":\"${MEMBER_DIDS[1]}\",\"amount\":2,\"currency\":\"hours\",\"memo\":\"Tool maintenance — sharpened mower blades and pruning shears\"}" \
>/dev/null 2>&1 && echo -e " ${GREEN}✓${NC} Transaction: Demo User → Marcus Rivera (2 hrs)" || true
curl -s -X POST "$GATEWAY/v1/ledger/$COOP_ID/payment" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "{\"from\":\"$CURRENT_DID\",\"to\":\"${MEMBER_DIDS[2]}\",\"amount\":1,\"currency\":\"hours\",\"memo\":\"Workshop instruction — intro to power tools safety\"}" \
>/dev/null 2>&1 && echo -e " ${GREEN}✓${NC} Transaction: Demo User → Priya Patel (1 hr)" || true
fi
Step 5: Run the full demo and verify names appear
Run: ./demo/scripts/run-tool-library-demo.sh
Then open UI, log in, verify:
- Header shows "Demo User" instead of truncated DID
- Member dropdown shows "Sarah Chen (did:icn:abc...)" etc.
- Transaction history shows "Demo User → Sarah Chen" etc.
Step 6: Commit
git add demo/scripts/run-tool-library-demo.sh icn/crates/icn-gateway/src/models.rs icn/crates/icn-gateway/src/api/coops.rs
git commit -m "feat(demo): seed 4 named members with transactions"
Task 11: Verification — Full demo e2e test
Step 1: Reset and run demo
# Kill any existing demo processes
pkill icnd 2>/dev/null || true
pkill -f "http.server" 2>/dev/null || true
rm -rf .demo-data/
# Run fresh demo
./demo/scripts/run-tool-library-demo.sh
Step 2: Verify three flows
Flow 1: Login + Dashboard
- Open UI at
http://<host>:3000 - Log in with displayed credentials
- Verify: Header shows "Demo User" (not truncated DID)
- Verify: Member list shows named members
- Verify: Contributor list shows names
Flow 2: Log Hours + Transaction
- Select "Sarah Chen (did:icn:...)" from dropdown
- Log 2 hours with memo
- Verify: Transaction row shows "Demo User → Sarah Chen"
- Verify: Activity feed shows names
Flow 3: Governance
- View proposals
- Verify: "Proposed by Demo User" appears on proposals
- Cast a vote
- Verify: Vote confirmation works
Step 3: Run automated tests
cd icn && cargo test -p icn-gateway
./demo/scripts/quick-test.sh
Step 4: Run pre-push gates
Use /push skill to verify fmt + clippy + push.
Out of Scope
- SDIS enrollment UI
- Exchange/listings features
- Push notifications
- Offline/edge cases
- Invite flow
- Mobile responsive redesign
- Marketplace assignee dropdown (line 6212 already uses
display_name)