Feat v0.6.15 user display audit #50

Merged
xcaliber merged 1 commits from feat/v0.6.15-user-display-audit into main 2026-04-01 14:19:48 +00:00
21 changed files with 542 additions and 35 deletions

View File

@@ -2,6 +2,41 @@
All notable changes to Armature are documented here.
## v0.6.15 — User Display Audit
Every user-facing identity surface now shows human-readable names instead of
UUIDs, with the canonical fallback chain: `display_name → username → "Unknown"`.
### Added
- **`GET /api/v1/users/resolve?ids=...`** — batch endpoint returns identity
records (username, display_name, handle, avatar_url) for up to 100 user IDs.
Response keyed by ID for O(1) client lookups.
- **`sw.users` SDK module** — `resolve(id)`, `resolveMany(ids)`,
`displayName(user)` with 60-second local cache. Surfaces use this instead
of ad-hoc lookups or stale snapshots.
- **5 handler tests** for the resolve endpoint (single, multiple, missing,
empty, no-param).
### Changed
- **Admin users list** — shows `display_name || username` as primary
identifier, with username shown as secondary when display_name is set.
- **Admin teams/groups member lists** — replaced `username || user_id`
with `display_name || username || 'Unknown'`.
- **Team-admin members** — dropdown and list now show display_name.
- **Chat participants** — resolved from users table via `sw.users.resolveMany()`
instead of relying on creation-time snapshot. Message sender names, typing
indicators, and participant sidebar all use resolved names.
- **Dashboard greeting** — added `|| 'Unknown'` terminal fallback.
- **Team activity log** — capitalized fallback to `'Unknown'`.
### Deprecated
- **`participants.display_name` column** in chat-core — column retained for
backward compatibility but UI no longer relies on snapshot values. Comments
added to `packages/chat-core/script.star` noting deprecation.
## v0.6.14 — Visual Polish
Systematic cleanup of stale values, self-hosted fonts, and rendering fixes.

View File

@@ -91,17 +91,13 @@ Shipped. Stale fallback colors purged (~65 instances), fonts self-hosted
regressions (half-step tokens), theme settings default, surface overflow,
and user menu zoom drift. See CHANGELOG.md.
### v0.6.15 — User Display Audit
### v0.6.15 — User Display Audit
Every place a user identity appears in the UI must show a human-readable
name, never a UUID. Fallback chain: `display_name → username → "Unknown"`.
| Step | Description |
|------|-------------|
| Participant display-name resolution | `chat-core` participants endpoint resolves `display_name` from the users table at query time instead of relying on a creation-time snapshot. If the stored `display_name` is empty, look up `users.display_name` or `users.username` by `participant_id`. |
| Audit all user-facing identity surfaces | Walk every surface that shows user identity: chat participant list, chat message sender names, chat typing indicators, workflow assignees, team member lists, admin user list, notification actor names. Ensure the fallback chain is `display_name → username → "Unknown"` everywhere. |
| SDK user-resolve utility | Add `sw.users.resolve(id)` or similar to the SDK — returns `{id, username, display_name, avatar}` with local caching. Surfaces use this instead of ad-hoc lookups. |
| Stale snapshot cleanup | Remove or deprecate the static `display_name` column in the participants table. All display names come from the users table via the resolve utility. |
Shipped. `GET /api/v1/users/resolve?ids=...` batch endpoint, `sw.users`
SDK module with `resolve()`, `resolveMany()`, `displayName()` + 60s cache.
Chat participants resolved from users table instead of snapshot. All admin
surfaces show `display_name || username || 'Unknown'`. Chat-core snapshot
column deprecated. 5 new handler tests. See CHANGELOG.md.
### v0.6.16 — Usability Survey Gate
@@ -133,7 +129,7 @@ v0.6.13 Responsive & Spacing ✅ SHIPPED
v0.6.14 Visual Polish ✅ SHIPPED
v0.6.15 User Display Audit ← No UUIDs in the UI, ever
v0.6.15 User Display Audit ✅ SHIPPED
v0.6.16 Usability Survey Gate ← Machine-audit the finished product
```

View File

@@ -1 +1 @@
0.6.14
0.6.15

View File

@@ -91,6 +91,9 @@ def create(title, type="group", participants=None, creator_id="", creator_displa
cid = conv["id"]
# Add creator as admin participant
# NOTE: display_name is a snapshot captured at creation time.
# DEPRECATED v0.6.15 — UI resolves display names from users table via sw.users.resolve().
# Column retained for backward compatibility; UI no longer relies on this value for display.
if creator_id:
db.insert("participants", {
"conversation_id": cid,
@@ -110,7 +113,7 @@ def create(title, type="group", participants=None, creator_id="", creator_displa
"conversation_id": cid,
"participant_id": pid,
"participant_type": _str(p.get("type", "user")),
"display_name": _str(p.get("display_name", "")),
"display_name": _str(p.get("display_name", "")), # DEPRECATED v0.6.15 — snapshot only
"role": _str(p.get("role", "member")),
"joined_at": "",
})
@@ -213,7 +216,7 @@ def add_participant(conversation_id, participant_id, participant_type="user", di
"conversation_id": cid,
"participant_id": pid,
"participant_type": _str(participant_type),
"display_name": _str(display_name),
"display_name": _str(display_name), # DEPRECATED v0.6.15 — snapshot only
"role": _str(role),
"joined_at": "",
})

View File

@@ -243,9 +243,9 @@
onMouseEnter=${() => setHover(true)}
onMouseLeave=${() => setHover(false)}>
${!isOwn && html`
<${Avatar} name=${msg._display_name || msg.participant_id} size="sm" />`}
<${Avatar} name=${msg._display_name || 'Unknown'} size="sm" />`}
<div class="ext-chat-msg__body">
${!isOwn && html`<span class="ext-chat-msg__name">${msg._display_name || msg.participant_id}</span>`}
${!isOwn && html`<span class="ext-chat-msg__name">${msg._display_name || 'Unknown'}</span>`}
${editing ? html`
<div class="ext-chat-msg__edit">
<textarea class="ext-chat-msg__edit-input"
@@ -285,20 +285,32 @@
var [hasMore, setHasMore] = useState(false);
var [nextCursor, setNextCursor] = useState('');
var [typingUsers, setTypingUsers] = useState({});
var [resolvedNames, setResolvedNames] = useState({});
var bottomRef = useRef(null);
var listRef = useRef(null);
var userId = currentUserId();
// Build participant lookup
// Resolve participant display names from users table (not snapshot)
useEffect(() => {
var ids = (participants || []).map(p => p.participant_id).filter(Boolean);
if (ids.length === 0) return;
sw.users.resolveMany(ids).then(map => {
var names = {};
map.forEach((user, id) => { names[id] = sw.users.displayName(user); });
setResolvedNames(names);
});
}, [participants]);
// Build participant lookup from resolved names
var partMap = useMemo(() => {
var m = {};
(participants || []).forEach(p => { m[p.participant_id] = p.display_name || p.participant_id; });
(participants || []).forEach(p => { m[p.participant_id] = resolvedNames[p.participant_id] || p.display_name || 'Unknown'; });
return m;
}, [participants]);
}, [participants, resolvedNames]);
// Enrich messages with display names
function enrichMessages(msgs) {
return msgs.map(m => ({ ...m, _display_name: partMap[m.participant_id] || m.participant_id }));
return msgs.map(m => ({ ...m, _display_name: partMap[m.participant_id] || 'Unknown' }));
}
// Load initial messages
@@ -361,7 +373,7 @@
var unsubs = [
sw.realtime.subscribe(channel, 'message', (payload) => {
var msg = { ...payload, _display_name: partMap[payload.participant_id] || payload.participant_id };
var msg = { ...payload, _display_name: partMap[payload.participant_id] || 'Unknown' };
setMessages(prev => [...prev, msg]);
setTimeout(() => scrollToBottom(), 50);
// Auto mark read if from someone else
@@ -386,7 +398,8 @@
unsubs.push(sw.realtime.subscribe(channel, 'typing', (payload) => {
var pid = payload.participant_id;
if (pid === userId) return;
setTypingUsers(prev => ({ ...prev, [pid]: payload.display_name || pid }));
var typingName = resolvedNames[pid] || payload.display_name || 'Someone';
setTypingUsers(prev => ({ ...prev, [pid]: typingName }));
clearTimeout(typingTimers[pid]);
typingTimers[pid] = setTimeout(() => {
setTypingUsers(prev => {
@@ -553,6 +566,18 @@
function ParticipantSidebar({ conversationId, participants, onRefresh, isAdmin }) {
var [addOpen, setAddOpen] = useState(false);
var [presence, setPresence] = useState({});
var [resolvedNames, setResolvedNames] = useState({});
// Resolve display names from users table
useEffect(() => {
var ids = (participants || []).map(p => p.participant_id).filter(Boolean);
if (ids.length === 0) return;
sw.users.resolveMany(ids).then(map => {
var names = {};
map.forEach((user, id) => { names[id] = sw.users.displayName(user); });
setResolvedNames(names);
});
}, [participants]);
// Query presence
useEffect(() => {
@@ -588,9 +613,9 @@
<div class="ext-chat-participants__list">
${(participants || []).map(p => html`
<div key=${p.participant_id} class="ext-chat-participants__item">
<${Avatar} name=${p.display_name || p.participant_id} size="sm" />
<${Avatar} name=${resolvedNames[p.participant_id] || p.display_name || 'Unknown'} size="sm" />
<span class="ext-chat-participants__name">
${p.display_name || p.participant_id}
${resolvedNames[p.participant_id] || p.display_name || 'Unknown'}
${p.role === 'admin' && html`<span class="ext-chat-participants__badge">admin</span>`}
</span>
<span class=${'chat-participants__status' + (presence[p.participant_id] ? ' ext-chat-participants__status--online' : '')} />

View File

@@ -156,7 +156,7 @@
const user = sw.user;
const greeting = document.createElement('div');
greeting.className = 'ext-dashboard-greeting';
greeting.textContent = 'Welcome back' + (user ? ', ' + (user.display_name || user.username) : '');
greeting.textContent = 'Welcome back' + (user ? ', ' + (user.display_name || user.username || 'Unknown') : '');
main.appendChild(greeting);
const subtitle = document.createElement('div');

View File

@@ -181,7 +181,7 @@
<div key=${e.id} class="ext-team-activity-log-entry">
<div class="ext-team-activity-log-entry__header">
<span class="ext-team-activity-log-entry__category badge">${e.category}</span>
<span class="ext-team-activity-log-entry__user">${esc(e.username || 'unknown')}</span>
<span class="ext-team-activity-log-entry__user">${esc(e.username || 'Unknown')}</span>
<span class="ext-team-activity-log-entry__time">${timeAgo(e.created_at)}</span>
<button class="ext-team-activity-log-entry__delete" onClick=${() => onDelete(e.id)}
title="Delete">×</button>

View File

@@ -81,3 +81,38 @@ func (h *PresenceHandler) SearchUsers(c *gin.Context) {
}
c.JSON(http.StatusOK, gin.H{"data": results})
}
// ResolveUsers returns identity records for a batch of user IDs.
// GET /api/v1/users/resolve?ids=uuid1,uuid2,...
func (h *PresenceHandler) ResolveUsers(c *gin.Context) {
raw := strings.TrimSpace(c.Query("ids"))
if raw == "" {
c.JSON(http.StatusOK, gin.H{"data": map[string]interface{}{}})
return
}
ids := make([]string, 0)
for _, id := range strings.Split(raw, ",") {
id = strings.TrimSpace(id)
if id != "" {
ids = append(ids, id)
}
}
if len(ids) == 0 {
c.JSON(http.StatusOK, gin.H{"data": map[string]interface{}{}})
return
}
results, err := h.stores.Users.ResolveByIDs(c.Request.Context(), ids)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "resolve failed"})
return
}
// Build map keyed by user ID for O(1) client-side lookups
m := make(map[string]interface{}, len(results))
for _, u := range results {
m[u.ID] = u
}
c.JSON(http.StatusOK, gin.H{"data": m})
}

View File

@@ -0,0 +1,179 @@
package handlers
import (
"encoding/json"
"net/http"
"testing"
"github.com/gin-gonic/gin"
"armature/config"
"armature/database"
"armature/middleware"
"armature/store"
postgres "armature/store/postgres"
sqlite "armature/store/sqlite"
)
// ── Presence/Resolve Test Harness ──────────────
type presenceHarness struct {
*testHarness
stores store.Stores
userToken string
userID string
}
func setupPresenceHarness(t *testing.T) *presenceHarness {
t.Helper()
database.RequireTestDB(t)
database.TruncateAll(t)
cfg := &config.Config{
JWTSecret: testJWTSecret,
BasePath: "",
}
var stores store.Stores
if database.IsSQLite() {
stores = sqlite.NewStores(database.TestDB)
} else {
stores = postgres.NewStores(database.TestDB)
}
userCache := middleware.NewUserStatusCache()
r := gin.New()
api := r.Group("/api/v1")
protected := api.Group("")
protected.Use(middleware.Auth(cfg, stores.Users, userCache))
presence := NewPresenceHandler(stores)
protected.GET("/users/resolve", presence.ResolveUsers)
protected.GET("/users/search", presence.SearchUsers)
// Seed a caller user
userID := database.SeedTestUser(t, "caller", "caller@test.com")
database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), userID)
userToken := makeToken(userID, "caller@test.com", "user")
return &presenceHarness{
testHarness: &testHarness{router: r, t: t},
stores: stores,
userToken: userToken,
userID: userID,
}
}
// ── Tests ──────────────────────────────────────
func TestResolveUsers_SingleID(t *testing.T) {
h := setupPresenceHarness(t)
// Seed a user with display_name
targetID := database.SeedTestUser(t, "alice", "alice@test.com")
database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true, display_name = $1 WHERE id = $2"), "Alice Smith", targetID)
w := h.request("GET", "/api/v1/users/resolve?ids="+targetID, h.userToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp struct {
Data map[string]store.UserSearchResult `json:"data"`
}
json.NewDecoder(w.Body).Decode(&resp)
u, ok := resp.Data[targetID]
if !ok {
t.Fatalf("expected user %s in response, got %v", targetID, resp.Data)
}
if u.Username != "alice" {
t.Errorf("expected username 'alice', got %q", u.Username)
}
if u.DisplayName != "Alice Smith" {
t.Errorf("expected display_name 'Alice Smith', got %q", u.DisplayName)
}
}
func TestResolveUsers_MultipleIDs(t *testing.T) {
h := setupPresenceHarness(t)
id1 := database.SeedTestUser(t, "bob", "bob@test.com")
id2 := database.SeedTestUser(t, "carol", "carol@test.com")
database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), id1)
database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true, display_name = $1 WHERE id = $2"), "Carol D", id2)
w := h.request("GET", "/api/v1/users/resolve?ids="+id1+","+id2, h.userToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var resp struct {
Data map[string]store.UserSearchResult `json:"data"`
}
json.NewDecoder(w.Body).Decode(&resp)
if len(resp.Data) != 2 {
t.Fatalf("expected 2 users, got %d", len(resp.Data))
}
if resp.Data[id1].Username != "bob" {
t.Errorf("expected bob, got %q", resp.Data[id1].Username)
}
if resp.Data[id2].DisplayName != "Carol D" {
t.Errorf("expected 'Carol D', got %q", resp.Data[id2].DisplayName)
}
}
func TestResolveUsers_MissingID(t *testing.T) {
h := setupPresenceHarness(t)
w := h.request("GET", "/api/v1/users/resolve?ids=00000000-0000-0000-0000-000000000099", h.userToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var resp struct {
Data map[string]store.UserSearchResult `json:"data"`
}
json.NewDecoder(w.Body).Decode(&resp)
if len(resp.Data) != 0 {
t.Errorf("expected empty map for missing ID, got %d entries", len(resp.Data))
}
}
func TestResolveUsers_EmptyParam(t *testing.T) {
h := setupPresenceHarness(t)
w := h.request("GET", "/api/v1/users/resolve?ids=", h.userToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var resp struct {
Data map[string]interface{} `json:"data"`
}
json.NewDecoder(w.Body).Decode(&resp)
if len(resp.Data) != 0 {
t.Errorf("expected empty map, got %d entries", len(resp.Data))
}
}
func TestResolveUsers_NoParam(t *testing.T) {
h := setupPresenceHarness(t)
w := h.request("GET", "/api/v1/users/resolve", h.userToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var resp struct {
Data map[string]interface{} `json:"data"`
}
json.NewDecoder(w.Body).Decode(&resp)
if len(resp.Data) != 0 {
t.Errorf("expected empty map, got %d entries", len(resp.Data))
}
}

View File

@@ -474,8 +474,9 @@ func main() {
protected.POST("/presence/heartbeat", presence.Heartbeat)
protected.GET("/presence", presence.Query)
// User search
// User search & resolve
protected.GET("/users/search", presence.SearchUsers)
protected.GET("/users/resolve", presence.ResolveUsers)
// Workflows
wfH := handlers.NewWorkflowHandler(stores)

View File

@@ -1355,6 +1355,42 @@ paths:
items:
type: object
/api/v1/users/resolve:
get:
summary: Resolve user identities by ID
operationId: resolveUsers
tags: [Presence]
parameters:
- name: ids
in: query
required: true
description: Comma-separated user IDs (max 100)
schema:
type: string
responses:
'200':
description: Map of user ID to identity record
content:
application/json:
schema:
type: object
properties:
data:
type: object
additionalProperties:
type: object
properties:
id:
type: string
username:
type: string
display_name:
type: string
handle:
type: string
avatar_url:
type: string
# ──────────────────────────────────────────────
# Notifications
# ──────────────────────────────────────────────

View File

@@ -103,6 +103,10 @@ type UserStore interface {
// Excludes the calling user. Max 20 results.
SearchActive(ctx context.Context, excludeUserID, query string) ([]UserSearchResult, error)
// ResolveByIDs returns lightweight identity records for the given user IDs.
// Missing IDs are silently omitted. Max 100 IDs.
ResolveByIDs(ctx context.Context, ids []string) ([]UserSearchResult, error)
// ── CS2 additions ──
// CountAll returns the total number of users.
@@ -132,6 +136,7 @@ type UserSearchResult struct {
Username string `json:"username"`
DisplayName string `json:"display_name"`
Handle string `json:"handle"`
AvatarURL string `json:"avatar_url,omitempty"`
}
// =========================================

View File

@@ -250,6 +250,47 @@ func (s *UserStore) SearchActive(ctx context.Context, excludeUserID, query strin
return results, rows.Err()
}
// ResolveByIDs returns lightweight identity records for the given user IDs.
func (s *UserStore) ResolveByIDs(ctx context.Context, ids []string) ([]store.UserSearchResult, error) {
if len(ids) == 0 {
return []store.UserSearchResult{}, nil
}
if len(ids) > 100 {
ids = ids[:100]
}
// Build $1,$2,... placeholders
placeholders := make([]string, len(ids))
args := make([]interface{}, len(ids))
for i, id := range ids {
placeholders[i] = fmt.Sprintf("$%d", i+1)
args[i] = id
}
q := `SELECT id, username, COALESCE(display_name, '') AS display_name,
COALESCE(handle, '') AS handle, COALESCE(avatar_url, '') AS avatar_url
FROM users WHERE id IN (` + strings.Join(placeholders, ",") + `)`
rows, err := DB.QueryContext(ctx, q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var results []store.UserSearchResult
for rows.Next() {
var u store.UserSearchResult
if err := rows.Scan(&u.ID, &u.Username, &u.DisplayName, &u.Handle, &u.AvatarURL); err != nil {
continue
}
results = append(results, u)
}
if results == nil {
results = []store.UserSearchResult{}
}
return results, rows.Err()
}
// ── CS2 additions ─────────────────────────────────────────────
func (s *UserStore) MergeSettings(ctx context.Context, userID string, patch []byte) error {

View File

@@ -262,6 +262,47 @@ func (s *UserStore) SearchActive(ctx context.Context, excludeUserID, query strin
return results, rows.Err()
}
// ResolveByIDs returns lightweight identity records for the given user IDs.
func (s *UserStore) ResolveByIDs(ctx context.Context, ids []string) ([]store.UserSearchResult, error) {
if len(ids) == 0 {
return []store.UserSearchResult{}, nil
}
if len(ids) > 100 {
ids = ids[:100]
}
// Build ?,?,... placeholders
placeholders := make([]string, len(ids))
args := make([]interface{}, len(ids))
for i, id := range ids {
placeholders[i] = "?"
args[i] = id
}
q := `SELECT id, username, COALESCE(display_name, '') AS display_name,
COALESCE(handle, '') AS handle, COALESCE(avatar_url, '') AS avatar_url
FROM users WHERE id IN (` + strings.Join(placeholders, ",") + `)`
rows, err := DB.QueryContext(ctx, q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var results []store.UserSearchResult
for rows.Next() {
var u store.UserSearchResult
if err := rows.Scan(&u.ID, &u.Username, &u.DisplayName, &u.Handle, &u.AvatarURL); err != nil {
continue
}
results = append(results, u)
}
if results == nil {
results = []store.UserSearchResult{}
}
return results, rows.Err()
}
// ── CS2 additions ─────────────────────────────────────────────
func (s *UserStore) MergeSettings(ctx context.Context, userID string, patch []byte) error {

View File

@@ -287,7 +287,8 @@ export function createDomains(restClient) {
// ── Users ──────────────────────────────
users: {
search: (q) => rc.get('/api/v1/users/search' + _qs({ q })),
search: (q) => rc.get('/api/v1/users/search' + _qs({ q })),
resolve: (ids) => rc.get('/api/v1/users/resolve' + _qs({ ids: ids.join(',') })),
},
presence: {

View File

@@ -24,6 +24,7 @@ import { createActions } from './actions.js';
import { createRealtime } from './realtime.js';
import { createRenderers } from './renderers.js';
import { createMarkdown } from './markdown.js';
import { createUsers } from './users.js';
import { confirm } from '../primitives/confirm.js';
import { prompt } from '../primitives/prompt.js';
@@ -117,6 +118,9 @@ export async function boot() {
// Markdown — unified renderer (marked + DOMPurify + sw.renderers pipeline)
sw.markdown = markdown;
// Users — identity resolution with local caching
sw.users = createUsers(restClient);
// Shell helpers — imperative confirm/prompt backed by primitives
sw.confirm = confirm;
sw.prompt = prompt;

104
src/js/sw/sdk/users.js Normal file
View File

@@ -0,0 +1,104 @@
// ==========================================
// Armature — SDK Users Module
// ==========================================
// Resolves user identity by ID with local caching.
//
// Usage:
// const user = await sw.users.resolve(id);
// const name = sw.users.displayName(user);
// const map = await sw.users.resolveMany([id1, id2]);
//
// Cache: 60-second TTL per entry. Batch-fetches missing
// IDs in a single GET /api/v1/users/resolve?ids=... call.
// ==========================================
const CACHE_TTL = 60_000; // 60 seconds
/**
* @param {object} restClient — SDK rest client (rc.get, rc.post, etc.)
* @returns {object} sw.users namespace
*/
export function createUsers(restClient) {
/** @type {Map<string, {data: object, fetchedAt: number}>} */
const _cache = new Map();
function _isFresh(entry) {
return entry && (Date.now() - entry.fetchedAt) < CACHE_TTL;
}
/**
* Resolve multiple user IDs to identity records.
* Deduplicates, checks cache, fetches remainder in one API call.
*
* @param {string[]} ids
* @returns {Promise<Map<string, {id, username, display_name, avatar_url}>>}
*/
async function resolveMany(ids) {
const unique = [...new Set(ids)].filter(Boolean);
const result = new Map();
const toFetch = [];
for (const id of unique) {
const cached = _cache.get(id);
if (_isFresh(cached)) {
result.set(id, cached.data);
} else {
toFetch.push(id);
}
}
if (toFetch.length > 0) {
try {
const resp = await restClient.get(
'/api/v1/users/resolve?ids=' + encodeURIComponent(toFetch.join(','))
);
// REST client does NOT auto-unwrap {data: Object} (only arrays).
const data = (resp && resp.data) || resp || {};
for (const id of toFetch) {
const user = data[id] || { id, username: '', display_name: '', avatar_url: '' };
_cache.set(id, { data: user, fetchedAt: Date.now() });
result.set(id, user);
}
} catch (err) {
console.warn('[sw.users] resolve failed:', err.message);
// Return empty placeholders for unfetched IDs
for (const id of toFetch) {
const placeholder = { id, username: '', display_name: '', avatar_url: '' };
result.set(id, placeholder);
}
}
}
return result;
}
/**
* Resolve a single user ID.
*
* @param {string} id
* @returns {Promise<{id, username, display_name, avatar_url}>}
*/
async function resolve(id) {
const m = await resolveMany([id]);
return m.get(id) || { id, username: '', display_name: '', avatar_url: '' };
}
/**
* Format a user object for display using the canonical fallback chain.
* Pure/synchronous — no network call.
*
* @param {object|null} user — object with display_name and/or username
* @returns {string}
*/
function displayName(user) {
if (!user) return 'Unknown';
return user.display_name || user.username || 'Unknown';
}
/** Flush the cache (useful after profile updates). */
function clearCache() {
_cache.clear();
}
return Object.freeze({ resolve, resolveMany, displayName, clearCache });
}

View File

@@ -143,7 +143,7 @@ export default function GroupsSection() {
<div class="admin-list">
${members.map(m => html`
<div class="admin-surface-row" key=${m.id || m.user_id}>
<span style="flex:1;">${m.username || m.user_id}</span>
<span style="flex:1;">${m.display_name || m.username || 'Unknown'}</span>
<button class="sw-btn sw-btn--danger sw-btn--sm" onClick=${() => removeMember(m.user_id || m.id)}>Remove</button>
</div>
`)}
@@ -155,7 +155,7 @@ export default function GroupsSection() {
<div class="form-group"><label>User</label>
<select name="userId">
<option value="">Select\u2026</option>
${allUsers.map(u => html`<option key=${u.id} value=${u.id}>${u.username}</option>`)}
${allUsers.map(u => html`<option key=${u.id} value=${u.id}>${u.display_name || u.username}</option>`)}
</select>
</div>
</div>

View File

@@ -106,7 +106,7 @@ export default function TeamsSection() {
<div class="admin-list">
${members.map(m => html`
<div class="admin-surface-row" key=${m.id}>
<span style="flex:1;">${m.username || m.user_id}</span>
<span style="flex:1;">${m.display_name || m.username || 'Unknown'}</span>
<select value=${m.role} onChange=${e => updateMemberRole(m.id, e.target.value)}
style="font-size:11px;padding:2px 4px;">
<option value="member">member</option>
@@ -124,7 +124,7 @@ export default function TeamsSection() {
<div class="form-group"><label>User</label>
<select name="userId">
<option value="">Select user\u2026</option>
${allUsers.map(u => html`<option key=${u.id} value=${u.id}>${u.username}</option>`)}
${allUsers.map(u => html`<option key=${u.id} value=${u.id}>${u.display_name || u.username}</option>`)}
</select>
</div>
<div class="form-group"><label>Role</label>

View File

@@ -128,7 +128,8 @@ export default function UsersSection() {
${filtered.map(u => html`
<div class="admin-surface-row" key=${u.id}>
<div style="flex:1;min-width:0;">
<strong>${u.username}</strong>
<strong>${u.display_name || u.username}</strong>
<span class="text-muted" style="margin-left:8px;font-size:12px;">${u.display_name ? u.username : ''}</span>
<span class="text-muted" style="margin-left:8px;font-size:12px;">${u.email || ''}</span>
${' '}${statusBadge(u)}
</div>

View File

@@ -88,7 +88,7 @@ export default function MembersSection({ teamId }) {
<div class="form-group"><label>User</label>
<select name="userId">
<option value="">Select\u2026</option>
${users.map(u => html`<option key=${u.id} value=${u.id}>${u.username || u.email}</option>`)}
${users.map(u => html`<option key=${u.id} value=${u.id}>${u.display_name || u.username || u.email}</option>`)}
</select>
</div>
<div class="form-group"><label>Role</label>
@@ -108,7 +108,7 @@ export default function MembersSection({ teamId }) {
${members.length === 0 && html`<div class="empty-hint">No members</div>`}
${members.map(m => html`
<div class="admin-surface-row" key=${m.id || m.user_id}>
<strong style="flex:1;">${m.username || m.user_id}</strong>
<strong style="flex:1;">${m.display_name || m.username || 'Unknown'}</strong>
<select value=${m.role || 'member'} style="width:100px;"
onChange=${e => updateRole(m.id || m.user_id, e.target.value)}>
${roles.map(r => html`<option key=${r} value=${r}>${r}</option>`)}