feat: add wallet and spaces sections to EncryptID account page

- My Wallet: shows linked wallet address with rWallet link, plus any
  pending fund claims with "Claim Now" button
- My Spaces: lists communities the user belongs to with role and
  treasury wallet links
- New APIs: GET /api/user/spaces, GET /api/user/claims
- New DB function: listSpacesForUser()

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-06 23:57:55 -08:00
parent f662881170
commit a62a33b4dc
2 changed files with 152 additions and 3 deletions

View File

@ -274,6 +274,21 @@ export interface StoredSpaceMember {
grantedBy?: string;
}
export async function listSpacesForUser(userDID: string): Promise<StoredSpaceMember[]> {
const rows = await sql`
SELECT * FROM space_members
WHERE user_did = ${userDID}
ORDER BY joined_at DESC
`;
return rows.map((row) => ({
spaceSlug: row.space_slug,
userDID: row.user_did,
role: row.role,
joinedAt: new Date(row.joined_at).getTime(),
grantedBy: row.granted_by || undefined,
}));
}
export async function getSpaceMember(
spaceSlug: string,
userDID: string,

View File

@ -55,6 +55,7 @@ import {
markDeviceLinkUsed,
getSpaceMember,
listSpaceMembers,
listSpacesForUser,
upsertSpaceMember,
removeSpaceMember,
getUserProfile,
@ -753,6 +754,38 @@ app.get('/api/user/profile', async (c) => {
return c.json({ success: true, profile });
});
// GET /api/user/spaces — list spaces the user is a member of
app.get('/api/user/spaces', async (c) => {
const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
if (!claims) return c.json({ error: 'Unauthorized' }, 401);
const spaces = await listSpacesForUser(claims.sub);
return c.json({ spaces });
});
// GET /api/user/claims — list pending fund claims for the authenticated user
app.get('/api/user/claims', async (c) => {
const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
if (!claims) return c.json({ error: 'Unauthorized' }, 401);
const profile = await getUserProfile(claims.sub);
if (!profile?.profileEmail) return c.json({ claims: [] });
const emailHashed = await hashEmail(profile.profileEmail);
const pendingClaims = await getFundClaimsByEmailHash(emailHashed);
return c.json({
claims: pendingClaims.map(cl => ({
id: cl.id,
token: cl.token,
fiatAmount: cl.fiatAmount,
fiatCurrency: cl.fiatCurrency,
walletAddress: cl.walletAddress,
expiresAt: cl.expiresAt,
status: cl.status,
})),
});
});
// PUT /api/user/profile — update the authenticated user's profile
app.put('/api/user/profile', async (c) => {
const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
@ -3344,8 +3377,22 @@ app.get('/', (c) => {
.passkey-date { color: #64748b; }
/* Recovery email */
.recovery-section, .guardians-section, .devices-section, .vault-section { margin-top: 1.5rem; padding-top: 1.5rem; border-top: 1px solid rgba(255,255,255,0.08); }
.recovery-section h4, .guardians-section h4, .devices-section h4, .vault-section h4 { font-size: 0.9rem; margin-bottom: 0.75rem; color: #94a3b8; }
.recovery-section, .guardians-section, .devices-section, .vault-section, .wallet-section, .spaces-section { margin-top: 1.5rem; padding-top: 1.5rem; border-top: 1px solid rgba(255,255,255,0.08); }
.recovery-section h4, .guardians-section h4, .devices-section h4, .vault-section h4, .wallet-section h4, .spaces-section h4 { font-size: 0.9rem; margin-bottom: 0.75rem; color: #94a3b8; }
.wallet-card { padding: 0.75rem; border-radius: 0.5rem; background: rgba(255,255,255,0.04); margin-bottom: 0.5rem; }
.wallet-card .wallet-label { font-size: 0.75rem; color: #64748b; text-transform: uppercase; margin-bottom: 0.25rem; }
.wallet-card .wallet-addr { font-family: monospace; font-size: 0.85rem; color: #e2e8f0; word-break: break-all; }
.wallet-card .wallet-addr a { color: #60a5fa; text-decoration: none; }
.wallet-card .wallet-addr a:hover { text-decoration: underline; }
.wallet-card .wallet-balance { font-size: 0.8rem; color: #4ade80; margin-top: 0.25rem; }
.space-card { padding: 0.75rem; border-radius: 0.5rem; background: rgba(255,255,255,0.04); margin-bottom: 0.5rem; display: flex; justify-content: space-between; align-items: center; }
.space-card .space-name { font-weight: 600; color: #e2e8f0; }
.space-card .space-name a { color: #e2e8f0; text-decoration: none; }
.space-card .space-name a:hover { color: #60a5fa; }
.space-card .space-role { font-size: 0.75rem; color: #64748b; text-transform: uppercase; }
.space-wallet { font-size: 0.8rem; color: #94a3b8; margin-top: 0.25rem; font-family: monospace; }
.space-wallet a { color: #60a5fa; text-decoration: none; }
.space-wallet a:hover { text-decoration: underline; }
.recovery-row { display: flex; gap: 0.5rem; }
.recovery-row input { flex: 1; }
.recovery-row button { white-space: nowrap; }
@ -3538,6 +3585,19 @@ app.get('/', (c) => {
<div id="vault-msg" style="font-size:0.8rem;margin-top:0.5rem;display:none"></div>
</div>
<div class="wallet-section">
<h4>My Wallet</h4>
<p style="font-size:0.8rem;color:#64748b;margin-bottom:0.75rem">Your EncryptID-linked wallet for receiving and managing funds.</p>
<div id="wallet-info" style="font-size:0.85rem;padding:0.75rem;border-radius:0.5rem;background:rgba(255,255,255,0.04);color:#94a3b8">No wallet linked</div>
<div id="wallet-claims" style="margin-top:0.75rem"></div>
</div>
<div class="spaces-section">
<h4>My Spaces</h4>
<p style="font-size:0.8rem;color:#64748b;margin-bottom:0.75rem">Communities you belong to and their treasury wallets.</p>
<div id="spaces-list" style="font-size:0.85rem;color:#94a3b8">Loading...</div>
</div>
<div class="profile-actions">
<a href="/demo.html" class="btn-secondary">SDK Demo</a>
<button class="btn-secondary btn-danger" onclick="handleLogout()">Sign Out</button>
@ -3803,9 +3863,83 @@ app.get('/', (c) => {
}
} catch { /* ignore */ }
// Load guardians and check vault status
// Load guardians, vault status, wallet, and spaces
loadGuardians();
checkVaultStatus();
loadWalletInfo(token, username);
loadUserSpaces(token);
}
async function loadWalletInfo(token, username) {
var walletEl = document.getElementById('wallet-info');
var claimsEl = document.getElementById('wallet-claims');
try {
var res = await fetch('/api/user/profile', {
headers: { 'Authorization': 'Bearer ' + token },
});
var data = await res.json();
if (data.profile && data.profile.walletAddress) {
var addr = data.profile.walletAddress;
var short = addr.slice(0, 6) + '...' + addr.slice(-4);
var rwalletUrl = 'https://' + (username || 'demo') + '.rspace.online/rwallet?address=' + encodeURIComponent(addr);
walletEl.innerHTML =
'<div class="wallet-card">' +
'<div class="wallet-label">Linked Wallet</div>' +
'<div class="wallet-addr"><a href="' + rwalletUrl + '" target="_blank">' + short + '</a></div>' +
'</div>';
} else {
walletEl.innerHTML = '<div style="color:#64748b;font-size:0.85rem">No wallet linked yet. Complete a fund claim or add one manually.</div>';
}
// Check for pending fund claims
var claimRes = await fetch('/api/user/claims', {
headers: { 'Authorization': 'Bearer ' + token },
});
if (claimRes.ok) {
var claimData = await claimRes.json();
if (claimData.claims && claimData.claims.length > 0) {
claimsEl.innerHTML = claimData.claims.map(function(cl) {
return '<div class="wallet-card" style="border-left:3px solid #f59e0b">' +
'<div class="wallet-label">Pending Claim</div>' +
'<div class="wallet-balance">$' + (cl.fiatAmount || '0') + ' ' + (cl.fiatCurrency || 'USD') + '</div>' +
'<div style="font-size:0.75rem;color:#64748b;margin-top:0.25rem">Expires ' + new Date(cl.expiresAt).toLocaleDateString() + '</div>' +
'<a href="/claim?token=' + cl.token + '" class="btn-secondary" style="margin-top:0.5rem;display:inline-block;font-size:0.8rem;padding:0.35rem 0.75rem">Claim Now</a>' +
'</div>';
}).join('');
}
}
} catch (err) {
console.error('Failed to load wallet info:', err);
}
}
async function loadUserSpaces(token) {
var spacesEl = document.getElementById('spaces-list');
try {
var res = await fetch('/api/user/spaces', {
headers: { 'Authorization': 'Bearer ' + token },
});
var data = await res.json();
if (!data.spaces || data.spaces.length === 0) {
spacesEl.innerHTML = '<div style="color:#64748b;font-size:0.85rem">You are not a member of any spaces yet.</div>';
return;
}
spacesEl.innerHTML = data.spaces.map(function(s) {
var spaceUrl = 'https://' + s.spaceSlug + '.rspace.online';
var walletUrl = spaceUrl + '/rwallet';
return '<div class="space-card">' +
'<div>' +
'<div class="space-name"><a href="' + spaceUrl + '" target="_blank">' + s.spaceSlug + '</a></div>' +
'<div class="space-wallet"><a href="' + walletUrl + '" target="_blank">View Treasury</a></div>' +
'</div>' +
'<div class="space-role">' + s.role + '</div>' +
'</div>';
}).join('');
} catch (err) {
spacesEl.innerHTML = '<div style="color:#64748b;font-size:0.85rem">Could not load spaces.</div>';
console.error('Failed to load spaces:', err);
}
}
window.handleLogout = () => {