diff --git a/internal/http/web.go b/internal/http/web.go index 0f720bd..b5edf45 100644 --- a/internal/http/web.go +++ b/internal/http/web.go @@ -263,6 +263,14 @@ func (s *Server) routesWeb(mux *http.ServeMux) { }) })) + mux.HandleFunc("GET /users/{id}", s.page(func(w http.ResponseWriter, r *http.Request, u *domain.User) { + s.render(w, r, "public-profile.html", &pageData{ + Title: "Profile", User: u, + Scripts: []string{"/static/js/public-profile.js"}, + Data: map[string]any{"UserID": r.PathValue("id")}, + }) + })) + rolePage("/messages", "messages.html", "Messages", "messages", "/static/js/messages.js", nil) rolePage("/metrics", "metrics.html", "Metrics", "metrics", "/static/js/metrics.js", nil) } diff --git a/web/static/js/public-profile.js b/web/static/js/public-profile.js new file mode 100644 index 0000000..5bdbf91 --- /dev/null +++ b/web/static/js/public-profile.js @@ -0,0 +1,57 @@ +// Public profile view (/users/{id}): full bio, contact, links and custom +// details from the user's profile card. +import { api } from '/static/js/api.js'; + +const root = document.getElementById('profile-root'); +const errorBox = document.getElementById('error'); +const uid = root.dataset.userId; + +const HIDDEN_EXTRA = new Set(['ticketingIdentities', 'savedBoardFilters']); + +const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ + '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', +}[c])); + +// only allow safe link schemes; bare domains get https://, others are blocked +export function safeUrl(u) { + const s = String(u ?? '').trim(); + if (/^(https?:\/\/|mailto:)/i.test(s)) return s; + if (/^[a-z][a-z0-9+.-]*:/i.test(s)) return '#'; + return s ? 'https://' + s : '#'; +} + +async function load() { + try { + const res = await api('GET', `/api/v1/users/${uid}/card`); + const c = res.card; + const roles = ['admin', 'consultant', 'developer'].filter((r) => c.roles && c.roles[r]); + const links = ((c.contact && c.contact.links) || []).filter(Boolean); + const extra = Object.entries(c.extra || {}) + .filter(([k, v]) => !HIDDEN_EXTRA.has(k) && v !== null && typeof v !== 'object'); + const hasContact = (c.contact && (c.contact.phone || c.contact.location)) || links.length; + root.innerHTML = ` +
${roles.map((r) => `${r}`).join(' ')}
+${esc(c.bio)}
` : ''} + ${hasContact ? '📍 ${esc(c.contact.location)}
` : ''} + ${c.contact && c.contact.phone ? `📞 ${esc(c.contact.phone)}
` : ''} + ${links.length ? `${esc(k)}: ${esc(String(v))}
`).join('')}` : ''} +${esc(c.bio.slice(0, 160))}
` : ''} - ${c.contact && c.contact.location ? `📍 ${esc(c.contact.location)}
` : ''}`; + ${c.bio ? `${esc(c.bio.slice(0, 220))}${c.bio.length > 220 ? '…' : ''}
` : ''} + ${c.contact && c.contact.location ? `📍 ${esc(c.contact.location)}
` : ''} + ${c.contact && c.contact.phone ? `📞 ${esc(c.contact.phone)}
` : ''} + ${links.length ? `${links.slice(0, 3).map((l) =>
+ `🔗 ${esc(l)}`).join('
')}