User details page

This commit is contained in:
Jonathon Leight
2026-06-30 21:01:36 -04:00
parent 6a6ec16825
commit a04d3bf2d0
20 changed files with 892 additions and 55 deletions
+151
View File
@@ -4,9 +4,13 @@ import (
"encoding/hex"
"errors"
"net/http"
"net/mail"
"strconv"
"strings"
"time"
meshcore "github.com/meshcore-go/meshcore-go"
"github.com/jleight/meshtender/internal/store"
"github.com/jleight/meshtender/internal/web"
)
@@ -60,9 +64,19 @@ func (s *Handlers) pageAccount(w http.ResponseWriter, r *http.Request) {
http.Error(w, "could not load account", http.StatusInternalServerError)
return
}
links, err := s.Store.ListUserLinks(r.Context(), uid)
if err != nil {
http.Error(w, "could not load profile links", http.StatusInternalServerError)
return
}
s.Render(w, r, "account.html", map[string]any{
"User": u,
"DisplayName": u.DisplayName, // nil when unset
"Bio": u.Bio,
"Location": u.Location,
"Callsign": u.Callsign,
"Links": links,
"Platforms": store.UserLinkPlatforms(),
"HasPassword": u.PasswordHash != nil,
"Passkeys": views,
"NextRename": nextRename, // nil when a rename is allowed now
@@ -110,6 +124,143 @@ func (s *Handlers) handleUpdateProfile(w http.ResponseWriter, r *http.Request) {
accountRedirect(w, r, "ok", "Profile updated.")
}
// handleSetProfileFields saves the user's public profile fields (bio, location,
// callsign) shown on their public page. Each field is trimmed and bounded; a
// blank field simply won't render.
func (s *Handlers) handleSetProfileFields(w http.ResponseWriter, r *http.Request) {
uid := s.Auth.CurrentUserID(r.Context())
bio := boundedText(r.FormValue("bio"), 500)
location := boundedText(r.FormValue("location"), 120)
callsign := boundedText(r.FormValue("callsign"), 32)
if err := s.Store.SetProfile(r.Context(), uid, bio, location, callsign); err != nil {
accountRedirect(w, r, "error", "Could not save your profile.")
return
}
accountRedirect(w, r, "ok", "Profile updated.")
}
// handleSetUserLinks replaces the current user's whole set of public profile
// links from the repeatable rows posted by the editor. Rows with a blank value
// are dropped. Most rows carry an http(s) URL; a "meshcore" row carries a
// MeshCore public key (validated as hex) and renders as a QR code. The optional
// primary-contact radio flags one non-MeshCore link as the preferred way to
// reach the user.
func (s *Handlers) handleSetUserLinks(w http.ResponseWriter, r *http.Request) {
uid := s.Auth.CurrentUserID(r.Context())
if err := r.ParseForm(); err != nil {
accountRedirect(w, r, "error", "Could not save links.")
return
}
// Index-aligned parallel arrays, one entry per row, in row order.
platforms := r.Form["link_platform"]
labels := r.Form["link_label"]
urls := r.Form["link_url"]
// The primary radio's value is the row index (renumbered to DOM order on
// submit), or absent when no primary is chosen.
primaryIdx := -1
if v := r.FormValue("link_primary"); v != "" {
if n, err := strconv.Atoi(v); err == nil {
primaryIdx = n
}
}
var links []store.UserLink
for i, raw := range urls {
val := strings.TrimSpace(raw)
if val == "" {
continue // empty row — skip it
}
platform := ""
if i < len(platforms) {
platform = platforms[i]
}
if !store.ValidUserLinkPlatform(platform) {
accountRedirect(w, r, "error", "Choose a type for each link.")
return
}
switch platform {
case store.MeshCorePlatform:
val = strings.ToLower(val)
if !validMeshCoreKey(val) {
accountRedirect(w, r, "error", "Enter a valid MeshCore public key (64-character hex).")
return
}
case store.EmailPlatform:
addr, err := mail.ParseAddress(val)
if err != nil || addr.Name != "" {
accountRedirect(w, r, "error", "Enter a valid email address.")
return
}
val = addr.Address
case store.SignalPlatform:
val = strings.TrimPrefix(val, "@")
if !validSignalUsername(val) {
accountRedirect(w, r, "error", "Enter a valid Signal username (332 characters: letters, digits, . and _).")
return
}
default:
if !store.ValidLinkURL(val) {
accountRedirect(w, r, "error", "Each link must be a valid http:// or https:// URL.")
return
}
}
label := ""
if i < len(labels) {
label = strings.TrimSpace(labels[i])
}
if len(val) > 300 {
val = val[:300]
}
if len(label) > 60 {
label = label[:60]
}
// A MeshCore key is an identity, not a way to reach someone, so it can't be
// the primary contact (mirrors excluding callsign/node info).
primary := i == primaryIdx && platform != store.MeshCorePlatform
links = append(links, store.UserLink{Platform: platform, Label: label, URL: val, IsPrimary: primary})
if len(links) >= store.MaxUserLinks {
break
}
}
if err := s.Store.ReplaceUserLinks(r.Context(), uid, links); err != nil {
accountRedirect(w, r, "error", "Could not save links.")
return
}
accountRedirect(w, r, "ok", "Links updated.")
}
// boundedText trims s and caps it at max bytes (empty means "unset").
func boundedText(s string, max int) string {
s = strings.TrimSpace(s)
if len(s) > max {
s = s[:max]
}
return s
}
// validMeshCoreKey reports whether s is a valid MeshCore public key (a 32-byte
// Ed25519 key, hex-encoded), using the MeshCore library's own parser.
func validMeshCoreKey(s string) bool {
_, err := meshcore.NewIdentityFromHex(s)
return err == nil
}
// validSignalUsername reports whether s is a plausible Signal username: 332
// characters of letters, digits, dot, or underscore (Signal usernames carry a
// dotted numeric discriminator, e.g. alice.42).
func validSignalUsername(s string) bool {
if len(s) < 3 || len(s) > 32 {
return false
}
for _, c := range s {
switch {
case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9', c == '.', c == '_':
default:
return false
}
}
return true
}
// handleChangePassword sets, changes, or removes the user's password.
func (s *Handlers) handleChangePassword(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
+115
View File
@@ -53,6 +53,121 @@
</div>
</div>
<div class="card mt-3">
<div class="card-header"><h3 class="card-title">Public profile</h3></div>
<div class="card-body">
<p class="text-secondary">These appear on your public page{{if .RootURL}} at <a href="{{.RootURL}}/u/{{.User.Username}}" target="_blank" rel="noopener">{{.RootURL}}/u/{{.User.Username}}</a>{{end}}, which anyone can view. Leave a field blank to keep it off your page.</p>
<form method="post" action="/account/profile-fields">
<div class="mb-3">
<label class="form-label" for="acct_bio">Bio <span class="form-label-description">optional</span></label>
<textarea class="form-control" id="acct_bio" name="bio" rows="3" maxlength="500" placeholder="A short blurb about you">{{.Bio}}</textarea>
</div>
<div class="row g-3">
<div class="col-md">
<label class="form-label" for="acct_location">Location <span class="form-label-description">optional</span></label>
<input type="text" class="form-control" id="acct_location" name="location" maxlength="120" value="{{.Location}}" placeholder="General area you operate in">
</div>
<div class="col-md">
<label class="form-label" for="acct_callsign">Callsign <span class="form-label-description">optional</span></label>
<input type="text" class="form-control" id="acct_callsign" name="callsign" maxlength="32" value="{{.Callsign}}" placeholder="e.g. KD2ABC">
</div>
</div>
<div class="mt-3">
<button type="submit" class="btn btn-primary">Save profile</button>
</div>
</form>
</div>
</div>
<div class="card mt-3">
<div class="card-header"><h3 class="card-title">Links <span class="text-secondary">(public)</span></h3></div>
<div class="card-body">
<p class="text-secondary">Ways to reach or find you — social media, a website, or your MeshCore public key (shown as a QR code). Mark one non-MeshCore link as your <strong>primary contact</strong> so people know the best way to reach you.</p>
<form method="post" action="/account/links" id="user-links-form">
<div id="user-links-rows">
{{range $i, $l := .Links}}
<div class="row g-2 mb-2 align-items-center link-row">
<div class="col-12 col-sm-3">
<select class="form-select" name="link_platform" aria-label="Link type">
{{$sel := $l.Platform}}
{{range $.Platforms}}<option value="{{.Key}}"{{if eq .Key $sel}} selected{{end}}>{{.Name}}</option>{{end}}
</select>
</div>
<div class="col-12 col-sm-3">
<input class="form-control" name="link_label" value="{{$l.Label}}" maxlength="60" placeholder="Label (optional)" aria-label="Link label">
</div>
<div class="col">
<input class="form-control" type="text" name="link_url" value="{{$l.URL}}" maxlength="300" placeholder="URL, email, Signal username, or MeshCore key" aria-label="Link value">
</div>
<div class="col-auto">
<label class="form-check form-check-inline m-0" title="Primary contact">
<input class="form-check-input" type="radio" name="link_primary" value="{{$i}}"{{if $l.IsPrimary}} checked{{end}}>
<span class="form-check-label">Primary</span>
</label>
</div>
<div class="col-auto">
<button type="button" class="btn btn-ghost-danger btn-icon remove-link" aria-label="Remove link">{{template "icon-trash" ""}}</button>
</div>
</div>
{{end}}
</div>
<div class="btn-list mt-2">
<button type="button" class="btn" id="add-user-link">{{template "icon-plus" "me-1"}}Add link</button>
<button type="submit" class="btn btn-primary">Save links</button>
</div>
</form>
<template id="user-link-row-tpl">
<div class="row g-2 mb-2 align-items-center link-row">
<div class="col-12 col-sm-3">
<select class="form-select" name="link_platform" aria-label="Link type">
{{range .Platforms}}<option value="{{.Key}}">{{.Name}}</option>{{end}}
</select>
</div>
<div class="col-12 col-sm-3">
<input class="form-control" name="link_label" maxlength="60" placeholder="Label (optional)" aria-label="Link label">
</div>
<div class="col">
<input class="form-control" type="text" name="link_url" maxlength="300" placeholder="URL, email, Signal username, or MeshCore key" aria-label="Link value">
</div>
<div class="col-auto">
<label class="form-check form-check-inline m-0" title="Primary contact">
<input class="form-check-input" type="radio" name="link_primary" value="">
<span class="form-check-label">Primary</span>
</label>
</div>
<div class="col-auto">
<button type="button" class="btn btn-ghost-danger btn-icon remove-link" aria-label="Remove link">{{template "icon-trash" ""}}</button>
</div>
</div>
</template>
<script>
(function(){
var form = document.getElementById('user-links-form');
var wrap = document.getElementById('user-links-rows');
var tpl = document.getElementById('user-link-row-tpl');
var add = document.getElementById('add-user-link');
if(!form || !wrap || !tpl || !add) return;
add.addEventListener('click', function(){
wrap.appendChild(tpl.content.cloneNode(true));
});
wrap.addEventListener('click', function(e){
var btn = e.target.closest('.remove-link');
if(btn){ btn.closest('.link-row').remove(); }
});
// Renumber the primary-contact radios to DOM order so the selected one's
// value matches its row's index in the submitted arrays (rows may have been
// added or removed).
form.addEventListener('submit', function(){
wrap.querySelectorAll('.link-row').forEach(function(row, i){
var radio = row.querySelector('input[name="link_primary"]');
if(radio) radio.value = i;
});
});
})();
</script>
</div>
</div>
<div class="card mt-3">
<div class="card-header"><h3 class="card-title">Passkeys</h3></div>
<div class="card-body">
+2
View File
@@ -53,6 +53,8 @@ func (s *Handlers) Routes() chi.Router {
r.Get("/account", s.pageAccount)
r.Post("/account/username", s.handleChangeUsername)
r.Post("/account/profile", s.handleUpdateProfile)
r.Post("/account/profile-fields", s.handleSetProfileFields)
r.Post("/account/links", s.handleSetUserLinks)
r.Post("/account/password", s.handleChangePassword)
r.Post("/account/passkeys/rename", s.handleRenamePasskey)
r.Post("/account/passkeys/delete", s.handleDeletePasskey)
+2 -13
View File
@@ -3,7 +3,6 @@ package core
import (
"errors"
"net/http"
"net/url"
"strconv"
"strings"
@@ -148,7 +147,7 @@ func (s *Handlers) pageOrg(w http.ResponseWriter, r *http.Request) {
// ?view=public) sees a "Back to member view" link. The data shape matches the
// marketing surface's anonymous rendering of the same template.
func (s *Handlers) renderOrgPublic(w http.ResponseWriter, r *http.Request, org *store.Org, isMember, isAdmin bool) {
admins, err := s.Store.ListOrgAdminNames(r.Context(), org.ID)
admins, err := s.Store.ListOrgAdmins(r.Context(), org.ID)
if err != nil {
http.Error(w, "could not load org", http.StatusInternalServerError)
return
@@ -321,7 +320,7 @@ func (s *Handlers) handleSetOrgLinks(w http.ResponseWriter, r *http.Request) {
orgErr(w, r, "Choose a type for each link.")
return
}
if !validLinkURL(u) {
if !store.ValidLinkURL(u) {
orgErr(w, r, "Each link must be a valid http:// or https:// URL.")
return
}
@@ -347,16 +346,6 @@ func (s *Handlers) handleSetOrgLinks(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/orgs/"+orgParam(r), http.StatusSeeOther) //nolint:gosec // G710: local path or config-pinned origin
}
// validLinkURL reports whether s is an absolute http(s) URL with a host. Limiting
// the scheme keeps javascript:/data: URLs out of rendered hrefs.
func validLinkURL(s string) bool {
u, err := url.Parse(s)
if err != nil {
return false
}
return (u.Scheme == "http" || u.Scheme == "https") && u.Host != ""
}
// requireOrgAdmin resolves {id} and verifies the current user is an org admin.
func (s *Handlers) requireOrgAdmin(w http.ResponseWriter, r *http.Request) (int64, bool) {
uid := s.Auth.CurrentUserID(r.Context())
+6 -23
View File
@@ -1,20 +1,16 @@
package core
import (
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"html/template"
"image/color"
"math"
"net/http"
"net/url"
"strconv"
"strings"
meshcore "github.com/meshcore-go/meshcore-go"
qrcode "github.com/skip2/go-qrcode"
"github.com/jleight/meshtender/internal/config"
"github.com/jleight/meshtender/internal/store"
@@ -120,24 +116,15 @@ func (s *Handlers) pageRepeater(w http.ResponseWriter, r *http.Request) {
if isOwner && rep.ExposePublicPage {
publicURL := s.Origin(r, s.rootHost()) + "/r/" + rep.PublicID
data["PublicPageURL"] = publicURL
if qr, err := qrcode.New(publicURL, qrcode.Medium); err == nil {
qr.BackgroundColor = color.Transparent
qr.ForegroundColor = color.RGBA{R: 0x8a, G: 0x97, B: 0xa8, A: 0xff}
if png, err := qr.PNG(256); err == nil {
data["PublicPageQR"] = template.URL("data:image/png;base64," + base64.StdEncoding.EncodeToString(png)) //nolint:gosec // G203: fixed data: URI over base64 PNG, no user input
}
if qr, ok := web.QRDataURI(publicURL); ok {
data["PublicPageQR"] = qr
}
}
// QR code that adds the repeater as a MeshCore contact. Embedded as a data
// URI so it needs no extra route or asset; if encoding fails the page just
// renders without it. Light modules on a transparent quiet zone so it sits on
// the dark card instead of a stark white block (scanners decode inverted QR).
if qr, err := qrcode.New(contactURI, qrcode.Medium); err == nil {
qr.BackgroundColor = color.Transparent
qr.ForegroundColor = color.RGBA{R: 0x8a, G: 0x97, B: 0xa8, A: 0xff}
if png, err := qr.PNG(256); err == nil {
data["ContactQR"] = template.URL("data:image/png;base64," + base64.StdEncoding.EncodeToString(png)) //nolint:gosec // G203: fixed data: URI over base64 PNG, no user input
}
// renders without it.
if qr, ok := web.QRDataURI(contactURI); ok {
data["ContactQR"] = qr
}
if isOwner {
orgs, err := s.Store.ListRepeaterOrgs(r.Context(), id)
@@ -153,11 +140,7 @@ func (s *Handlers) pageRepeater(w http.ResponseWriter, r *http.Request) {
// repeaterContactURI builds the meshcore:// deep link that adds the repeater as
// a contact in the MeshCore app. type=2 is MeshCore's repeater contact type.
func repeaterContactURI(rep *store.Repeater) string {
q := url.Values{}
q.Set("name", rep.Name)
q.Set("public_key", rep.PublicKeyHex)
q.Set("type", "2")
return "meshcore://contact/add?" + q.Encode()
return web.MeshCoreContactURI(rep.Name, rep.PublicKeyHex, int(meshcore.AdvertTypeRepeater))
}
func addErr(w http.ResponseWriter, r *http.Request, msg string) {
+22
View File
@@ -299,6 +299,28 @@ func (s *Handlers) pageDashboard(w http.ResponseWriter, r *http.Request) {
Done: len(orgs) > 0,
},
}
// People listed publicly (org admins, public repeater owners/stewards) should
// give visitors a way to reach them. Only nudge those users, and only until
// they set a primary contact link.
publicRole, err := s.Store.UserHasPublicRole(ctx, uid)
if err != nil {
http.Error(w, "could not load account", http.StatusInternalServerError)
return
}
if publicRole {
links, err := s.Store.ListUserLinks(ctx, uid)
if err != nil {
http.Error(w, "could not load account", http.StatusInternalServerError)
return
}
steps = append(steps, onboardingStep{
Title: "Add a contact link",
Desc: "You're listed publicly as an admin or steward — add a way for people to reach you.",
Action: "Edit profile",
Href: s.Origin(r, s.Cfg.AuthHost) + "/account",
Done: store.PrimaryUserLink(links) != nil,
})
}
total := len(steps)
doneCount := 0
for _, st := range steps {
+1
View File
@@ -53,6 +53,7 @@ func (s *Handlers) Routes() chi.Router {
r.Get("/orgs/{id}/repeaters", s.pageOrgRepeaters) // public repeater list + map
r.Get("/orgs/{id}/config", s.pageOrgConfig) // public recommended config
r.Get("/r/{id}", s.pageRepeaterPublic) // public repeater page (NFC/QR target)
r.Get("/u/{username}", s.pageUserPublic) // public user profile
return r
}
+1 -1
View File
@@ -65,7 +65,7 @@ func (s *Handlers) pageOrgs(w http.ResponseWriter, r *http.Request) {
// renderOrgPublic renders the public-facing org page (name, description, admins,
// counts, and a map of repeaters opted into public display).
func (s *Handlers) renderOrgPublic(w http.ResponseWriter, r *http.Request, org *store.Org, isMember, isAdmin bool) {
admins, err := s.Store.ListOrgAdminNames(r.Context(), org.ID)
admins, err := s.Store.ListOrgAdmins(r.Context(), org.ID)
if err != nil {
http.Error(w, "could not load org", http.StatusInternalServerError)
return
@@ -81,13 +81,13 @@
<div class="list-group list-group-flush">
<div class="list-group-item d-flex align-items-center gap-2">
<span class="text-secondary">{{template "icon-user" ""}}</span>
<div class="flex-fill"><span class="fw-bold">{{.Repeater.OwnerName}}</span></div>
<div class="flex-fill"><a class="fw-bold" href="{{$.RootURL}}/u/{{.Repeater.OwnerUsername}}">{{.Repeater.OwnerName}}</a></div>
<span class="badge bg-success-lt">Primary</span>
</div>
{{range .Stewards}}
<div class="list-group-item d-flex align-items-center gap-2">
<span class="text-secondary">{{template "icon-user" ""}}</span>
<div class="flex-fill"><span class="fw-bold">{{.Name}}</span></div>
<div class="flex-fill"><a class="fw-bold" href="{{$.RootURL}}/u/{{.Username}}">{{.Name}}</a></div>
<span class="badge bg-azure-lt">Backup</span>
</div>
{{end}}
@@ -0,0 +1,58 @@
{{define "title"}}{{.ProfileUser.Name}} · MeshTender{{end}}
{{define "header"}}
<div class="row g-2 align-items-center">
<div class="col">
<div class="page-pretitle">Profile</div>
<h2 class="page-title">{{.ProfileUser.Name}}</h2>
<div class="text-secondary">@{{.ProfileUser.Username}}</div>
</div>
</div>
{{end}}
{{define "content"}}
<div class="row row-cards">
<div class="col-lg-8">
<div class="card">
<div class="card-header"><h3 class="card-title">About</h3></div>
<div class="card-body">
{{if .Location}}<div class="d-flex align-items-center text-secondary mb-2">{{template "icon-map-pin" "me-1"}}{{.Location}}</div>{{end}}
{{if .Callsign}}<div class="mb-2"><span class="text-secondary">Callsign:</span> <span class="font-monospace">{{.Callsign}}</span></div>{{end}}
{{if .Bio}}<p class="mb-0">{{.Bio}}</p>{{end}}
{{if not .HasDetails}}<p class="text-secondary mb-0">This person hasn't added any profile details yet.</p>{{end}}
{{if .Links}}
<div class="hr-text">Links</div>
<div class="btn-list">
{{range .Links}}
{{if .Href}}
<a class="btn" href="{{.Href}}" target="_blank" rel="noopener noreferrer nofollow">{{template "link-icon" .Platform}}<span class="ms-1">{{.Display}}</span>{{if .IsPrimary}} <span class="badge bg-primary-lt ms-1">Primary</span>{{end}}</a>
{{else}}
<span class="btn disabled">{{template "link-icon" .Platform}}<span class="ms-1">{{.URL}}</span>{{if .IsPrimary}} <span class="badge bg-primary-lt ms-1">Primary</span>{{end}}</span>
{{end}}
{{end}}
</div>
{{end}}
</div>
</div>
</div>
{{if .MeshKeys}}
<div class="col-lg-4">
<div class="card">
<div class="card-header"><h3 class="card-title">MeshCore</h3></div>
<div class="card-body">
{{range .MeshKeys}}
<div class="mb-3">
{{if .QR}}<div class="text-center mb-2"><img src="{{.QR}}" width="200" height="200" alt="MeshCore contact QR for {{.Label}}"></div>{{end}}
<div class="form-label">{{.Label}}</div>
<code class="pk d-block">{{.Key}}</code>
</div>
{{end}}
<p class="text-secondary small mb-0">Scan to add this contact in the MeshCore app.</p>
</div>
</div>
</div>
{{end}}
</div>
<a class="back-link mt-3" href="{{.RootURL}}/orgs">{{template "icon-arrow-left" "me-1"}}All organizations</a>
{{end}}
+68
View File
@@ -0,0 +1,68 @@
package marketing
import (
"errors"
"html/template"
"net/http"
"github.com/go-chi/chi/v5"
meshcore "github.com/meshcore-go/meshcore-go"
"github.com/jleight/meshtender/internal/auth"
"github.com/jleight/meshtender/internal/store"
"github.com/jleight/meshtender/internal/web"
)
// meshKeyView is a MeshCore public key rendered for the public profile: the
// label, the key text, and a QR code that adds the person as a MeshCore contact.
type meshKeyView struct {
Label string
Key string
QR template.URL
}
// pageUserPublic renders a user's public profile (/u/{username}) for anyone. All
// profile fields are optional; an unfilled profile just shows the display name.
// MeshCore-key links render as scannable QR codes; other links render as buttons.
func (s *Handlers) pageUserPublic(w http.ResponseWriter, r *http.Request) {
username := auth.NormalizeUsername(chi.URLParam(r, "username"))
u, err := s.Store.GetUserByUsername(r.Context(), username)
if errors.Is(err, store.ErrNotFound) {
http.NotFound(w, r)
return
}
if err != nil {
http.Error(w, "could not load profile", http.StatusInternalServerError)
return
}
links, err := s.Store.ListUserLinks(r.Context(), u.ID)
if err != nil {
http.Error(w, "could not load profile", http.StatusInternalServerError)
return
}
// Ordinary links render as buttons; MeshCore keys render as QR codes.
var webLinks []store.UserLink
var meshKeys []meshKeyView
for _, l := range links {
if l.IsMeshCore() {
mk := meshKeyView{Label: l.Display(), Key: l.URL}
if qr, ok := web.QRDataURI(web.MeshCoreContactURI(u.Name(), l.URL, int(meshcore.AdvertTypeChat))); ok {
mk.QR = qr
}
meshKeys = append(meshKeys, mk)
continue
}
webLinks = append(webLinks, l)
}
// Whether there's anything beyond the name to show — drives an empty-state hint.
hasDetails := u.Bio != "" || u.Location != "" || u.Callsign != "" || len(webLinks) > 0 || len(meshKeys) > 0
s.Render(w, r, "user_public.html", map[string]any{
"ProfileUser": u,
"Bio": u.Bio,
"Location": u.Location,
"Callsign": u.Callsign,
"Links": webLinks,
"MeshKeys": meshKeys,
"HasDetails": hasDetails,
})
}
@@ -0,0 +1,28 @@
-- +goose Up
-- Public profile fields for a user's page (/u/{username}). All optional — a blank
-- field simply doesn't render, which is how users keep information private.
ALTER TABLE users ADD COLUMN bio TEXT NOT NULL DEFAULT '';
ALTER TABLE users ADD COLUMN location TEXT NOT NULL DEFAULT '';
ALTER TABLE users ADD COLUMN callsign TEXT NOT NULL DEFAULT '';
-- Contact/social links shown on a user's public page, mirroring org_links. One
-- link may be flagged is_primary as the preferred way to reach the user (drives
-- the "add a contact link" nudge for people listed publicly). The meshcore
-- platform's url column holds a MeshCore public key rather than an http URL and
-- renders as a QR code.
CREATE TABLE user_links (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
platform TEXT NOT NULL,
label TEXT NOT NULL DEFAULT '',
url TEXT NOT NULL,
position INT NOT NULL DEFAULT 0,
is_primary BOOLEAN NOT NULL DEFAULT FALSE
);
CREATE INDEX user_links_user_idx ON user_links(user_id, position);
-- +goose Down
DROP TABLE user_links;
ALTER TABLE users DROP COLUMN callsign;
ALTER TABLE users DROP COLUMN location;
ALTER TABLE users DROP COLUMN bio;
+12
View File
@@ -3,6 +3,7 @@ package store
import (
"context"
"fmt"
"net/url"
"github.com/jackc/pgx/v5"
)
@@ -53,6 +54,17 @@ func ValidLinkPlatform(key string) bool {
return ok
}
// ValidLinkURL reports whether s is an absolute http(s) URL with a host. Limiting
// the scheme keeps javascript:/data: URLs out of rendered hrefs. Shared by the
// org- and user-link editors.
func ValidLinkURL(s string) bool {
u, err := url.Parse(s)
if err != nil {
return false
}
return (u.Scheme == "http" || u.Scheme == "https") && u.Host != ""
}
// linkPlatformName returns the display name for a platform key, or the key itself
// if it is unknown (defensive — stored rows should always be valid).
func linkPlatformName(key string) string {
+11 -10
View File
@@ -395,22 +395,23 @@ func (s *Store) ListOrgMembers(ctx context.Context, orgID int64) ([]OrgMemberInf
})
}
// ListOrgAdminNames returns just the display names of an org's admins, ordered
// for display. The public org page only needs admin names, so this avoids
// loading every member row via ListOrgMembers.
func (s *Store) ListOrgAdminNames(ctx context.Context, orgID int64) ([]string, error) {
// ListOrgAdmins returns an org's admins (id, username, display name) ordered for
// display. The public org page links each admin to their public profile, so it
// needs the username alongside the display name. It avoids loading every member
// row via ListOrgMembers.
func (s *Store) ListOrgAdmins(ctx context.Context, orgID int64) ([]OrgMemberInfo, error) {
rows, err := s.pool.Query(ctx, `
SELECT COALESCE(NULLIF(u.display_name, ''), u.username) AS name
SELECT u.id, u.username, u.display_name, m.role
FROM org_members m JOIN users u ON u.id = m.user_id
WHERE m.org_id = $1 AND m.role = 'admin'
ORDER BY name`, orgID)
ORDER BY COALESCE(NULLIF(u.display_name, ''), u.username)`, orgID)
if err != nil {
return nil, fmt.Errorf("list org admins: %w", err)
}
return collectRows(rows, func(r pgx.Row) (string, error) {
var name string
err := r.Scan(&name)
return name, err
return collectRows(rows, func(r pgx.Row) (OrgMemberInfo, error) {
var m OrgMemberInfo
err := r.Scan(&m.UserID, &m.Username, &m.DisplayName, &m.Role)
return m, err
})
}
+146
View File
@@ -0,0 +1,146 @@
package store
import (
"context"
"fmt"
"github.com/jackc/pgx/v5"
)
// MaxUserLinks caps how many contact/social links a user may list on their
// public profile. Same generous bound as orgs.
const MaxUserLinks = 20
// Platform keys whose values aren't plain http(s) URLs and so need their own
// validation and rendering: a MeshCore public key (hex, shown as a QR), an email
// address (a mailto: link), and a Signal username (shown as text — Signal has no
// stable link-from-username).
const (
MeshCorePlatform = "meshcore"
EmailPlatform = "email"
SignalPlatform = "signal"
)
// userLinkPlatforms is the curated set a user can choose from: direct contact
// channels first (email, Signal, MeshCore), then the shared social platforms.
var userLinkPlatforms = append([]LinkPlatform{
{EmailPlatform, "Email"},
{SignalPlatform, "Signal"},
{MeshCorePlatform, "MeshCore"},
}, linkPlatforms...)
var userLinkPlatformByKey = func() map[string]LinkPlatform {
m := make(map[string]LinkPlatform, len(userLinkPlatforms))
for _, p := range userLinkPlatforms {
m[p.Key] = p
}
return m
}()
// UserLinkPlatforms returns the platforms a user may pick for a profile link.
func UserLinkPlatforms() []LinkPlatform { return userLinkPlatforms }
// ValidUserLinkPlatform reports whether key is a platform a user link may use.
func ValidUserLinkPlatform(key string) bool {
_, ok := userLinkPlatformByKey[key]
return ok
}
func userLinkPlatformName(key string) string {
if p, ok := userLinkPlatformByKey[key]; ok {
return p.Name
}
return key
}
// UserLink is a single contact/social link on a user's public profile.
type UserLink struct {
ID int64
UserID int64
Platform string
Label string
// URL holds an http(s) URL for most platforms, or a MeshCore public key (hex)
// when Platform == MeshCorePlatform.
URL string
Position int
IsPrimary bool
}
// Display is the text to show for the link: the custom label if set, otherwise
// the platform's name (e.g. "Discord").
func (l UserLink) Display() string {
if l.Label != "" {
return l.Label
}
return userLinkPlatformName(l.Platform)
}
// IsMeshCore reports whether this link carries a MeshCore public key (rendered as
// a QR code) rather than an ordinary URL.
func (l UserLink) IsMeshCore() bool { return l.Platform == MeshCorePlatform }
// Href is the hyperlink target for this link, or "" when it isn't directly
// linkable (a MeshCore key renders as a QR; a Signal username as plain text). An
// email becomes a mailto: link; everything else uses the stored URL as-is.
func (l UserLink) Href() string {
switch l.Platform {
case MeshCorePlatform, SignalPlatform:
return ""
case EmailPlatform:
return "mailto:" + l.URL
default:
return l.URL
}
}
// PrimaryUserLink returns the link flagged as the primary contact, or nil if
// none is. Used to decide whether a publicly-listed user has a way to be reached.
func PrimaryUserLink(links []UserLink) *UserLink {
for i := range links {
if links[i].IsPrimary {
return &links[i]
}
}
return nil
}
// ListUserLinks returns a user's profile links in display order.
func (s *Store) ListUserLinks(ctx context.Context, userID int64) ([]UserLink, error) {
rows, err := s.pool.Query(ctx,
`SELECT id, user_id, platform, label, url, position, is_primary
FROM user_links WHERE user_id = $1 ORDER BY position, id`, userID)
if err != nil {
return nil, fmt.Errorf("list user links: %w", err)
}
return collectRows(rows, func(r pgx.Row) (UserLink, error) {
var l UserLink
err := r.Scan(&l.ID, &l.UserID, &l.Platform, &l.Label, &l.URL, &l.Position, &l.IsPrimary)
return l, err
})
}
// ReplaceUserLinks atomically replaces a user's entire link set with links, in
// the given order. An empty slice clears all links. At most one link is stored
// as primary (the first flagged wins). Platform/URL validation is the caller's
// responsibility.
func (s *Store) ReplaceUserLinks(ctx context.Context, userID int64, links []UserLink) error {
primarySeen := false
return s.inTx(ctx, func(tx pgx.Tx) error {
if _, err := tx.Exec(ctx, `DELETE FROM user_links WHERE user_id = $1`, userID); err != nil {
return fmt.Errorf("clear user links: %w", err)
}
for i, l := range links {
primary := l.IsPrimary && !primarySeen
if primary {
primarySeen = true
}
if _, err := tx.Exec(ctx,
`INSERT INTO user_links (user_id, platform, label, url, position, is_primary)
VALUES ($1, $2, $3, $4, $5, $6)`,
userID, l.Platform, l.Label, l.URL, i, primary); err != nil {
return fmt.Errorf("insert user link: %w", err)
}
}
return nil
})
}
+184
View File
@@ -0,0 +1,184 @@
package store
import (
"strings"
"testing"
)
func TestUserLinksReplaceAndList(t *testing.T) {
t.Parallel()
st, ctx := orgTestStore(t)
u, err := st.CreateUser(ctx, "linkuser", "")
if err != nil {
t.Fatalf("create user: %v", err)
}
// A fresh user has no links.
got, err := st.ListUserLinks(ctx, u.ID)
if err != nil {
t.Fatalf("list (empty): %v", err)
}
if len(got) != 0 {
t.Fatalf("new user links = %d, want 0", len(got))
}
// Replace with three links; order is preserved and the second is primary.
links := []UserLink{
{Platform: "discord", URL: "https://discord.gg/abc"},
{Platform: "website", Label: "Home", URL: "https://example.org", IsPrimary: true},
{Platform: MeshCorePlatform, URL: strings.Repeat("a", 64)},
}
if err := st.ReplaceUserLinks(ctx, u.ID, links); err != nil {
t.Fatalf("replace: %v", err)
}
got, err = st.ListUserLinks(ctx, u.ID)
if err != nil {
t.Fatalf("list: %v", err)
}
if len(got) != 3 {
t.Fatalf("links = %d, want 3", len(got))
}
if got[0].Position != 0 || got[1].Position != 1 || got[2].Position != 2 {
t.Errorf("positions = %d,%d,%d, want 0,1,2", got[0].Position, got[1].Position, got[2].Position)
}
if p := PrimaryUserLink(got); p == nil || p.URL != "https://example.org" {
t.Errorf("primary = %+v, want the website link", p)
}
if !got[2].IsMeshCore() {
t.Errorf("link[2] should be a MeshCore link")
}
if d := got[0].Display(); d != "Discord" {
t.Errorf("link[0].Display() = %q, want %q", d, "Discord")
}
}
func TestUserLinkHref(t *testing.T) {
t.Parallel()
cases := []struct {
link UserLink
want string
}{
{UserLink{Platform: EmailPlatform, URL: "a@b.com"}, "mailto:a@b.com"},
{UserLink{Platform: SignalPlatform, URL: "alice.42"}, ""},
{UserLink{Platform: MeshCorePlatform, URL: "abcd"}, ""},
{UserLink{Platform: "website", URL: "https://example.org"}, "https://example.org"},
}
for _, c := range cases {
if got := c.link.Href(); got != c.want {
t.Errorf("%s.Href() = %q, want %q", c.link.Platform, got, c.want)
}
}
}
func TestReplaceUserLinksSinglePrimary(t *testing.T) {
t.Parallel()
st, ctx := orgTestStore(t)
u, err := st.CreateUser(ctx, "multiprimary", "")
if err != nil {
t.Fatalf("create user: %v", err)
}
// Two links flagged primary — only the first flagged should win.
links := []UserLink{
{Platform: "website", URL: "https://one.example", IsPrimary: true},
{Platform: "website", URL: "https://two.example", IsPrimary: true},
}
if err := st.ReplaceUserLinks(ctx, u.ID, links); err != nil {
t.Fatalf("replace: %v", err)
}
got, err := st.ListUserLinks(ctx, u.ID)
if err != nil {
t.Fatalf("list: %v", err)
}
primaries := 0
for _, l := range got {
if l.IsPrimary {
primaries++
}
}
if primaries != 1 {
t.Fatalf("primary links = %d, want exactly 1", primaries)
}
if p := PrimaryUserLink(got); p == nil || p.URL != "https://one.example" {
t.Errorf("primary = %+v, want the first flagged link", p)
}
// Replacing with an empty set clears every link.
if err := st.ReplaceUserLinks(ctx, u.ID, nil); err != nil {
t.Fatalf("replace (clear): %v", err)
}
got, err = st.ListUserLinks(ctx, u.ID)
if err != nil {
t.Fatalf("list (after clear): %v", err)
}
if len(got) != 0 {
t.Errorf("links after clear = %d, want 0", len(got))
}
}
func TestUserHasPublicRole(t *testing.T) {
t.Parallel()
st, ctx := orgTestStore(t)
// A user with no public role.
nobody, err := st.CreateUser(ctx, "nobody", "")
if err != nil {
t.Fatalf("create user: %v", err)
}
if yes, err := st.UserHasPublicRole(ctx, nobody.ID); err != nil || yes {
t.Fatalf("nobody has public role = %v (err %v), want false", yes, err)
}
// An org admin is public.
admin, err := st.CreateUser(ctx, "orgadmin", "")
if err != nil {
t.Fatalf("create user: %v", err)
}
if _, err := st.CreateOrg(ctx, "Public Org", admin.ID); err != nil {
t.Fatalf("create org: %v", err)
}
if yes, err := st.UserHasPublicRole(ctx, admin.ID); err != nil || !yes {
t.Fatalf("org admin has public role = %v (err %v), want true", yes, err)
}
// A repeater owner is public only once the public page is exposed.
owner, err := st.CreateUser(ctx, "repowner", "")
if err != nil {
t.Fatalf("create user: %v", err)
}
rep, err := st.CreateRepeater(ctx, &Repeater{
OwnerID: owner.ID, Name: "R", PublicKeyHex: strings.Repeat("b", 64),
RadioFreqHz: 1, RadioBwHz: 1, RadioSF: 11, RadioCR: 5,
})
if err != nil {
t.Fatalf("create repeater: %v", err)
}
if yes, err := st.UserHasPublicRole(ctx, owner.ID); err != nil || yes {
t.Fatalf("owner of private repeater has public role = %v, want false", yes)
}
if err := st.UpdateRepeater(ctx, owner.ID, rep.ID, "R", 1, 1, 11, 5, false, true); err != nil {
t.Fatalf("expose public page: %v", err)
}
if yes, err := st.UserHasPublicRole(ctx, owner.ID); err != nil || !yes {
t.Fatalf("owner of public repeater has public role = %v (err %v), want true", yes, err)
}
// A steward of that public repeater is also public.
steward, err := st.CreateUser(ctx, "repsteward", "")
if err != nil {
t.Fatalf("create user: %v", err)
}
if _, err := st.AddShare(ctx, rep.ID, steward.ID); err != nil {
t.Fatalf("add share: %v", err)
}
if yes, err := st.UserHasPublicRole(ctx, steward.ID); err != nil || yes {
t.Fatalf("non-steward share has public role = %v, want false", yes)
}
if err := st.SetShareSteward(ctx, rep.ID, steward.ID, true); err != nil {
t.Fatalf("set steward: %v", err)
}
if yes, err := st.UserHasPublicRole(ctx, steward.ID); err != nil || !yes {
t.Fatalf("steward of public repeater has public role = %v (err %v), want true", yes, err)
}
}
+36 -2
View File
@@ -24,6 +24,11 @@ type User struct {
Username string
DisplayName *string
PasswordHash *string
// Public profile fields, shown on the user's public page (/u/{username}).
// Empty when unset; a blank field simply doesn't render.
Bio string
Location string
Callsign string
// Instance-level capability flags.
CapManageUsers bool
CapManageCatalog bool
@@ -37,12 +42,12 @@ func (u *User) Name() string {
return u.Username
}
const userCols = `id, username, display_name, password_hash, cap_manage_users, cap_manage_catalog`
const userCols = `id, username, display_name, password_hash, bio, location, callsign, cap_manage_users, cap_manage_catalog`
func scanUser(row pgx.Row) (*User, error) {
var u User
if err := row.Scan(&u.ID, &u.Username, &u.DisplayName, &u.PasswordHash,
&u.CapManageUsers, &u.CapManageCatalog); err != nil {
&u.Bio, &u.Location, &u.Callsign, &u.CapManageUsers, &u.CapManageCatalog); err != nil {
return nil, err
}
return &u, nil
@@ -173,6 +178,35 @@ func (s *Store) SetDisplayName(ctx context.Context, userID int64, displayName st
return nil
}
// SetProfile updates a user's public profile fields (bio, location, callsign).
// Empty strings clear a field. Length bounding is the caller's responsibility.
func (s *Store) SetProfile(ctx context.Context, userID int64, bio, location, callsign string) error {
_, err := s.pool.Exec(ctx,
`UPDATE users SET bio = $2, location = $3, callsign = $4 WHERE id = $1`,
userID, bio, location, callsign)
if err != nil {
return fmt.Errorf("set profile: %w", err)
}
return nil
}
// UserHasPublicRole reports whether the user is listed on any public page: as an
// org admin, or as the owner or steward of a repeater with a published public
// page. Such users are nudged to add a way to be reached.
func (s *Store) UserHasPublicRole(ctx context.Context, userID int64) (bool, error) {
var yes bool
err := s.pool.QueryRow(ctx, `SELECT
EXISTS(SELECT 1 FROM org_members WHERE user_id = $1 AND role = 'admin')
OR EXISTS(SELECT 1 FROM repeaters WHERE owner_id = $1 AND expose_public_page)
OR EXISTS(SELECT 1 FROM repeater_shares rs JOIN repeaters r ON r.id = rs.repeater_id
WHERE rs.user_id = $1 AND rs.steward AND r.expose_public_page)`,
userID).Scan(&yes)
if err != nil {
return false, fmt.Errorf("user has public role: %w", err)
}
return yes, nil
}
// SetPassword sets a user's bcrypt password hash.
func (s *Store) SetPassword(ctx context.Context, userID int64, hash string) error {
_, err := s.pool.Exec(ctx, `UPDATE users SET password_hash = $1 WHERE id = $2`, hash, userID)
+41
View File
@@ -0,0 +1,41 @@
package web
import (
"encoding/base64"
"html/template"
"image/color"
"net/url"
"strconv"
qrcode "github.com/skip2/go-qrcode"
)
// QRDataURI encodes content as a QR code and returns it as a base64 PNG data URI
// ready to drop into an <img src>. ok is false if encoding fails (the caller
// should then just render without the QR). Light modules on a transparent quiet
// zone so the code sits on a dark card rather than a stark white block (scanners
// decode inverted QR fine).
func QRDataURI(content string) (template.URL, bool) {
qr, err := qrcode.New(content, qrcode.Medium)
if err != nil {
return "", false
}
qr.BackgroundColor = color.Transparent
qr.ForegroundColor = color.RGBA{R: 0x8a, G: 0x97, B: 0xa8, A: 0xff}
png, err := qr.PNG(256)
if err != nil {
return "", false
}
return template.URL("data:image/png;base64," + base64.StdEncoding.EncodeToString(png)), true //nolint:gosec // G203: fixed data: URI over base64 PNG, no user input
}
// MeshCoreContactURI builds the meshcore:// deep link that adds a contact in the
// MeshCore app. advertType is the MeshCore advert/contact type (1 = chat/person,
// 2 = repeater).
func MeshCoreContactURI(name, publicKeyHex string, advertType int) string {
q := url.Values{}
q.Set("name", name)
q.Set("public_key", publicKeyHex)
q.Set("type", strconv.Itoa(advertType))
return "meshcore://contact/add?" + q.Encode()
}
+5 -3
View File
@@ -40,9 +40,11 @@
{{define "icon-brand-telegram"}}<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false" class="icon{{if .}} {{.}}{{end}}"><path d="M15 10l-4 4l6 6l4 -16l-18 7l4 2l2 6l3 -4"/></svg>{{end}}
{{define "icon-brand-reddit"}}<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false" class="icon{{if .}} {{.}}{{end}}"><path d="M12 8c2.648 0 5.028 .826 6.675 2.14a2.5 2.5 0 0 1 2.326 4.36c0 3.59 -4.03 6.5 -9 6.5c-4.97 0 -9 -2.91 -9 -6.5a2.5 2.5 0 0 1 2.326 -4.36c1.646 -1.313 4.026 -2.14 6.674 -2.14z"/><path d="M12 8l1 -5l6 1"/><path d="M19 4m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/><path d="M9 13l0 .01"/><path d="M15 13l0 .01"/><path d="M10 17a3.5 3.5 0 0 0 4 0"/></svg>{{end}}
{{define "icon-brand-linkedin"}}<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false" class="icon{{if .}} {{.}}{{end}}"><path d="M8 11l0 5"/><path d="M8 8l0 .01"/><path d="M12 16l0 -5"/><path d="M16 16v-3a2 2 0 0 0 -4 0"/><path d="M3 7a4 4 0 0 1 4 -4h10a4 4 0 0 1 4 4v10a4 4 0 0 1 -4 4h-10a4 4 0 0 1 -4 -4z"/></svg>{{end}}
{{/* link-icon renders the brand/site icon for an org link's platform key (.).
Falls back to a generic link glyph for "website" and anything unknown. */}}
{{define "link-icon"}}{{if eq . "discord"}}{{template "icon-brand-discord" ""}}{{else if eq . "facebook"}}{{template "icon-brand-facebook" ""}}{{else if eq . "instagram"}}{{template "icon-brand-instagram" ""}}{{else if eq . "x"}}{{template "icon-brand-x" ""}}{{else if eq . "youtube"}}{{template "icon-brand-youtube" ""}}{{else if eq . "github"}}{{template "icon-brand-github" ""}}{{else if eq . "telegram"}}{{template "icon-brand-telegram" ""}}{{else if eq . "reddit"}}{{template "icon-brand-reddit" ""}}{{else if eq . "linkedin"}}{{template "icon-brand-linkedin" ""}}{{else}}{{template "icon-link" ""}}{{end}}{{end}}
{{define "icon-mail"}}<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false" class="icon{{if .}} {{.}}{{end}}"><path d="M3 7a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2v10a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-10z"/><path d="M3 7l9 6l9 -6"/></svg>{{end}}
{{define "icon-brand-signal"}}<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false" class="icon{{if .}} {{.}}{{end}}"><path d="M3 20l1.3 -3.9a9 8 0 1 1 3.4 2.9l-4.7 1"/><path d="M12 12l0 .01"/><path d="M8 12l0 .01"/><path d="M16 12l0 .01"/></svg>{{end}}
{{/* link-icon renders the brand/site icon for a link's platform key (.). Falls
back to a generic link glyph for "website" and anything unknown. */}}
{{define "link-icon"}}{{if eq . "email"}}{{template "icon-mail" ""}}{{else if eq . "signal"}}{{template "icon-brand-signal" ""}}{{else if eq . "discord"}}{{template "icon-brand-discord" ""}}{{else if eq . "facebook"}}{{template "icon-brand-facebook" ""}}{{else if eq . "instagram"}}{{template "icon-brand-instagram" ""}}{{else if eq . "x"}}{{template "icon-brand-x" ""}}{{else if eq . "youtube"}}{{template "icon-brand-youtube" ""}}{{else if eq . "github"}}{{template "icon-brand-github" ""}}{{else if eq . "telegram"}}{{template "icon-brand-telegram" ""}}{{else if eq . "reddit"}}{{template "icon-brand-reddit" ""}}{{else if eq . "linkedin"}}{{template "icon-brand-linkedin" ""}}{{else}}{{template "icon-link" ""}}{{end}}{{end}}
{{/* link-list renders a wrapping row of org link buttons. . is a slice of links,
each exposing .Platform, .URL, and .Display. */}}
{{define "link-list"}}<div class="btn-list">{{range .}}<a class="btn" href="{{.URL}}" target="_blank" rel="noopener noreferrer nofollow">{{template "link-icon" .Platform}}<span class="ms-1">{{.Display}}</span></a>{{end}}</div>{{end}}
+1 -1
View File
@@ -76,7 +76,7 @@
<div class="hr-text">Admins</div>
{{if .Admins}}
<div class="d-flex flex-wrap gap-2">
{{range .Admins}}<span class="badge bg-success-lt">{{.}}</span>{{end}}
{{range .Admins}}<a class="badge bg-success-lt" href="{{$.RootURL}}/u/{{.Username}}">{{.Name}}</a>{{end}}
</div>
{{else}}<p class="text-secondary mb-0">No admins listed.</p>{{end}}
{{if .Links}}