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:
parent
f662881170
commit
a62a33b4dc
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 = () => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue