diff --git a/internal/domain/customer.go b/internal/domain/customer.go
index 9dbd88b..9843c62 100644
--- a/internal/domain/customer.go
+++ b/internal/domain/customer.go
@@ -28,9 +28,14 @@ type Ticketing struct {
CredentialsEnc string `bson:"credentialsEnc" json:"-"`
ProjectKey string `bson:"projectKey" json:"projectKey"`
PollIntervalSec int `bson:"pollIntervalSec" json:"pollIntervalSec"`
- LastSyncAt time.Time `bson:"lastSyncAt" json:"lastSyncAt"`
- LastSyncStatus string `bson:"lastSyncStatus" json:"lastSyncStatus"` // "", ok, error
- LastSyncError string `bson:"lastSyncError" json:"lastSyncError"`
+ // ConsultantIdentities maps a bounty-board consultant id to that
+ // consultant's username in THIS customer's ticketing system. It takes
+ // precedence over the consultant's global extra.ticketingIdentities so an
+ // admin can map accounts per project (§5.3).
+ ConsultantIdentities map[string]string `bson:"consultantIdentities,omitempty" json:"consultantIdentities,omitempty"`
+ LastSyncAt time.Time `bson:"lastSyncAt" json:"lastSyncAt"`
+ LastSyncStatus string `bson:"lastSyncStatus" json:"lastSyncStatus"` // "", ok, error
+ LastSyncError string `bson:"lastSyncError" json:"lastSyncError"`
}
// Customer == "project" in the UI (§4.2).
diff --git a/internal/domain/user.go b/internal/domain/user.go
index 0a01442..cc2a815 100644
--- a/internal/domain/user.go
+++ b/internal/domain/user.go
@@ -54,6 +54,7 @@ type NotificationPrefs struct {
type UserSettings struct {
Theme string `bson:"theme" json:"theme"`
+ NavLayout string `bson:"navLayout" json:"navLayout"` // "side" (default) | "top"
Notifications NotificationPrefs `bson:"notifications" json:"notifications"`
LeaderboardOptOut bool `bson:"leaderboardOptOut" json:"leaderboardOptOut"`
}
diff --git a/internal/http/admin_customers.go b/internal/http/admin_customers.go
index 1b7d712..6dd3116 100644
--- a/internal/http/admin_customers.go
+++ b/internal/http/admin_customers.go
@@ -19,16 +19,17 @@ import (
// customerPayload is the create/update body. Credentials are write-only:
// they are encrypted at rest and never returned.
type customerPayload struct {
- Name *string `json:"name"`
- Type *domain.TicketingType `json:"type"`
- BaseURL *string `json:"baseUrl"`
- ProjectKey *string `json:"projectKey"`
- Credentials *syncpkg.Credentials `json:"credentials"`
- PollInterval *int `json:"pollIntervalSec"`
- ConsultantIDs *[]string `json:"consultantIds"`
- DefaultBudget *float64 `json:"defaultBudget"`
- Archived *bool `json:"archived"`
- Version *int64 `json:"version"`
+ Name *string `json:"name"`
+ Type *domain.TicketingType `json:"type"`
+ BaseURL *string `json:"baseUrl"`
+ ProjectKey *string `json:"projectKey"`
+ Credentials *syncpkg.Credentials `json:"credentials"`
+ PollInterval *int `json:"pollIntervalSec"`
+ ConsultantIDs *[]string `json:"consultantIds"`
+ ConsultantIdentities *map[string]string `json:"consultantIdentities"`
+ DefaultBudget *float64 `json:"defaultBudget"`
+ Archived *bool `json:"archived"`
+ Version *int64 `json:"version"`
}
func (s *Server) sealCredentials(creds syncpkg.Credentials) (string, error) {
@@ -63,8 +64,9 @@ func customerJSON(c *domain.Customer) map[string]any {
"type": c.Ticketing.Type, "baseUrl": c.Ticketing.BaseURL,
"projectKey": c.Ticketing.ProjectKey, "pollIntervalSec": c.Ticketing.PollIntervalSec,
"lastSyncAt": c.Ticketing.LastSyncAt, "lastSyncStatus": c.Ticketing.LastSyncStatus,
- "lastSyncError": c.Ticketing.LastSyncError,
- "hasCredentials": c.Ticketing.CredentialsEnc != "",
+ "lastSyncError": c.Ticketing.LastSyncError,
+ "hasCredentials": c.Ticketing.CredentialsEnc != "",
+ "consultantIdentities": c.Ticketing.ConsultantIdentities,
},
"consultantIds": c.ConsultantIDs, "defaultBudget": c.DefaultBudget,
"archived": c.Archived, "createdAt": c.CreatedAt, "updatedAt": c.UpdatedAt,
@@ -99,6 +101,18 @@ func (s *Server) handleAdminGetCustomer(w http.ResponseWriter, r *http.Request)
writeJSON(w, http.StatusOK, map[string]any{"customer": customerJSON(c)})
}
+// cleanIdentities trims values and drops empty mappings so we don't persist
+// blank usernames for consultants the admin left unmapped.
+func cleanIdentities(in map[string]string) map[string]string {
+ out := map[string]string{}
+ for k, v := range in {
+ if t := strings.TrimSpace(v); t != "" {
+ out[k] = t
+ }
+ }
+ return out
+}
+
func (s *Server) validateConsultants(ctx context.Context, ids []string) (string, bool) {
for _, id := range ids {
u, err := s.store.UserByID(ctx, id)
@@ -156,6 +170,9 @@ func (s *Server) handleAdminCreateCustomer(w http.ResponseWriter, r *http.Reques
}
c.ConsultantIDs = *req.ConsultantIDs
}
+ if req.ConsultantIdentities != nil {
+ c.Ticketing.ConsultantIdentities = cleanIdentities(*req.ConsultantIdentities)
+ }
creds := syncpkg.Credentials{}
if req.Credentials != nil {
creds = *req.Credentials
@@ -236,6 +253,10 @@ func (s *Server) handleAdminPatchCustomer(w http.ResponseWriter, r *http.Request
set["consultantIds"] = *req.ConsultantIDs
diff["consultantIds"] = *req.ConsultantIDs
}
+ if req.ConsultantIdentities != nil {
+ set["ticketing.consultantIdentities"] = cleanIdentities(*req.ConsultantIdentities)
+ diff["consultantIdentities"] = "updated"
+ }
if req.DefaultBudget != nil {
set["defaultBudget"] = *req.DefaultBudget
diff["defaultBudget"] = *req.DefaultBudget
diff --git a/internal/http/messages.go b/internal/http/messages.go
index cdca030..9b376ab 100644
--- a/internal/http/messages.go
+++ b/internal/http/messages.go
@@ -24,6 +24,9 @@ func (s *Server) routesMessages(mux *http.ServeMux) {
mux.Handle("GET /api/v1/conversations/{id}/messages", s.requireAuth(http.HandlerFunc(s.handleListMessages)))
mux.Handle("POST /api/v1/conversations/{id}/messages", s.authed(s.handleSendMessage))
mux.Handle("POST /api/v1/conversations/{id}/read", s.authed(s.handleMarkRead))
+ mux.Handle("GET /api/v1/conversations/{id}/members", s.requireAuth(http.HandlerFunc(s.handleListMembers)))
+ mux.Handle("POST /api/v1/conversations/{id}/members", s.authed(s.handleAddMember))
+ mux.Handle("DELETE /api/v1/conversations/{id}/members/{userId}", s.authed(s.handleRemoveMember))
mux.Handle("POST /api/v1/files", s.authed(s.handleUploadFile))
mux.Handle("GET /api/v1/users", s.requireAuth(http.HandlerFunc(s.handleSearchUsers)))
}
@@ -81,6 +84,7 @@ func (s *Server) handleListConversations(w http.ResponseWriter, r *http.Request)
out[i] = map[string]any{
"id": c.ID, "kind": c.Kind, "title": title,
"customerId": c.CustomerID, "participantIds": c.ParticipantIDs,
+ "creatorId": c.CreatorID,
"lastMessageAt": c.LastMessageAt, "unread": unread[c.ID],
}
}
@@ -135,6 +139,7 @@ func (s *Server) handleCreateConversation(w http.ResponseWriter, r *http.Request
conv := &store.Conversation{
Kind: req.Kind,
Title: strings.TrimSpace(req.Title),
+ CreatorID: me.ID,
ParticipantIDs: participants,
}
if req.Kind == "project" {
@@ -256,6 +261,93 @@ func (s *Server) handleMarkRead(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"marked": n})
}
+// groupOwner loads a group/project conversation and authorizes the creator.
+func (s *Server) groupOwner(w http.ResponseWriter, r *http.Request, id string) (*store.Conversation, bool) {
+ conv, ok := s.conversation(w, r, id)
+ if !ok {
+ return nil, false
+ }
+ if conv.Kind == "dm" {
+ writeError(w, http.StatusBadRequest, "bad_request", "direct messages have no managed members")
+ return nil, false
+ }
+ if conv.CreatorID != CurrentUser(r.Context()).ID {
+ writeError(w, http.StatusForbidden, "forbidden", "only the group creator can manage members")
+ return nil, false
+ }
+ return conv, true
+}
+
+// handleListMembers returns the participants; any member may view.
+func (s *Server) handleListMembers(w http.ResponseWriter, r *http.Request) {
+ conv, ok := s.conversation(w, r, r.PathValue("id"))
+ if !ok {
+ return
+ }
+ me := CurrentUser(r.Context())
+ out := make([]map[string]any, 0, len(conv.ParticipantIDs))
+ for _, id := range conv.ParticipantIDs {
+ u, err := s.store.UserByID(r.Context(), id)
+ if err != nil {
+ continue
+ }
+ out = append(out, map[string]any{
+ "id": u.ID, "name": u.Name, "email": u.Email,
+ "avatarFileId": u.AvatarFileID, "isCreator": id == conv.CreatorID,
+ })
+ }
+ writeJSON(w, http.StatusOK, map[string]any{
+ "members": out,
+ "creatorId": conv.CreatorID,
+ "canManage": conv.Kind != "dm" && conv.CreatorID == me.ID,
+ })
+}
+
+// handleAddMember adds a participant to a group (creator only).
+func (s *Server) handleAddMember(w http.ResponseWriter, r *http.Request) {
+ conv, ok := s.groupOwner(w, r, r.PathValue("id"))
+ if !ok {
+ return
+ }
+ var req struct {
+ UserID string `json:"userId"`
+ }
+ if !decodeJSON(w, r, &req) {
+ return
+ }
+ if req.UserID == "" {
+ writeError(w, http.StatusBadRequest, "bad_request", "userId is required")
+ return
+ }
+ if _, err := s.store.UserByID(r.Context(), req.UserID); err != nil {
+ writeError(w, http.StatusBadRequest, "bad_user", "unknown user")
+ return
+ }
+ if err := s.store.AddParticipant(r.Context(), conv.ID, req.UserID); err != nil {
+ s.internalError(w, r, "add member", err)
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true})
+}
+
+// handleRemoveMember removes a participant from a group (creator only).
+func (s *Server) handleRemoveMember(w http.ResponseWriter, r *http.Request) {
+ conv, ok := s.groupOwner(w, r, r.PathValue("id"))
+ if !ok {
+ return
+ }
+ target := r.PathValue("userId")
+ if target == conv.CreatorID {
+ writeError(w, http.StatusBadRequest, "bad_request", "the creator cannot be removed")
+ return
+ }
+ if err := s.store.RemoveParticipant(r.Context(), conv.ID, target); err != nil {
+ s.internalError(w, r, "remove member", err)
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true})
+}
+
// handleUploadFile is the generic upload endpoint (POST /files, §6) used by
// chat; scope=chat unless the caller passes scope=task (consultants).
func (s *Server) handleUploadFile(w http.ResponseWriter, r *http.Request) {
diff --git a/internal/http/profile.go b/internal/http/profile.go
index 4a446a6..ee5045b 100644
--- a/internal/http/profile.go
+++ b/internal/http/profile.go
@@ -45,6 +45,7 @@ func (s *Server) handlePatchProfile(w http.ResponseWriter, r *http.Request) {
Extra *map[string]any `json:"extra"`
Settings *struct {
Theme *string `json:"theme"`
+ NavLayout *string `json:"navLayout"`
Notifications *domain.NotificationPrefs `json:"notifications"`
LeaderboardOptOut *bool `json:"leaderboardOptOut"`
} `json:"settings"`
@@ -83,6 +84,14 @@ func (s *Server) handlePatchProfile(w http.ResponseWriter, r *http.Request) {
}
set["settings.theme"] = theme
}
+ if req.Settings.NavLayout != nil {
+ nav := *req.Settings.NavLayout
+ if nav != "side" && nav != "top" {
+ writeError(w, http.StatusBadRequest, "invalid_nav", "navLayout must be side or top")
+ return
+ }
+ set["settings.navLayout"] = nav
+ }
if req.Settings.Notifications != nil {
set["settings.notifications"] = *req.Settings.Notifications
}
diff --git a/internal/http/web.go b/internal/http/web.go
index 3a43a83..aa4b0b7 100644
--- a/internal/http/web.go
+++ b/internal/http/web.go
@@ -22,6 +22,7 @@ type pageData struct {
Active string // nav highlight key
User *domain.User
DefaultTheme string
+ NavLayout string
Narrow bool
OIDCEnabled bool
Error string
@@ -81,6 +82,12 @@ func (s *Server) render(w http.ResponseWriter, r *http.Request, page string, dat
data.DefaultTheme = data.User.Settings.Theme
}
}
+ if data.NavLayout == "" {
+ data.NavLayout = "side" // sidebar by default
+ if data.User != nil && data.User.Settings.NavLayout == "top" {
+ data.NavLayout = "top"
+ }
+ }
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := t.ExecuteTemplate(w, "layout", data); err != nil {
s.log.Error("execute template", "page", page, "err", err)
diff --git a/internal/store/conversations.go b/internal/store/conversations.go
index 16dc1df..369751b 100644
--- a/internal/store/conversations.go
+++ b/internal/store/conversations.go
@@ -19,6 +19,7 @@ type Conversation struct {
Kind string `bson:"kind" json:"kind"` // dm | group | project
CustomerID string `bson:"customerId,omitempty" json:"customerId,omitempty"`
Title string `bson:"title,omitempty" json:"title,omitempty"`
+ CreatorID string `bson:"creatorId,omitempty" json:"creatorId,omitempty"`
ParticipantIDs []string `bson:"participantIds" json:"participantIds"`
LastMessageAt time.Time `bson:"lastMessageAt" json:"lastMessageAt"`
CreatedAt time.Time `bson:"createdAt" json:"createdAt"`
@@ -104,6 +105,28 @@ func (s *Store) ConversationByID(ctx context.Context, id string) (*Conversation,
return &c, nil
}
+// AddParticipant adds a user to a conversation (idempotent via $addToSet).
+func (s *Store) AddParticipant(ctx context.Context, convID, userID string) error {
+ _, err := s.DB.Collection("conversations").UpdateOne(ctx,
+ bson.M{"_id": convID},
+ bson.M{"$addToSet": bson.M{"participantIds": userID}})
+ if err != nil {
+ return fmt.Errorf("add participant: %w", err)
+ }
+ return nil
+}
+
+// RemoveParticipant removes a user from a conversation.
+func (s *Store) RemoveParticipant(ctx context.Context, convID, userID string) error {
+ _, err := s.DB.Collection("conversations").UpdateOne(ctx,
+ bson.M{"_id": convID},
+ bson.M{"$pull": bson.M{"participantIds": userID}})
+ if err != nil {
+ return fmt.Errorf("remove participant: %w", err)
+ }
+ return nil
+}
+
func (s *Store) ListConversations(ctx context.Context, userID string) ([]Conversation, error) {
cur, err := s.DB.Collection("conversations").Find(ctx,
bson.M{"participantIds": userID},
diff --git a/internal/sync/manager.go b/internal/sync/manager.go
index e672286..3c26bfc 100644
--- a/internal/sync/manager.go
+++ b/internal/sync/manager.go
@@ -257,7 +257,12 @@ func (m *Manager) SyncCustomer(ctx context.Context, c *domain.Customer) error {
if err != nil {
continue // consultant deleted; reconcile will catch up
}
- identity := ticketingIdentity(consultant, c.Ticketing.Type)
+ // Per-customer mapping (set by the admin on the customer) wins over
+ // the consultant's global identity; fall back to the global one.
+ identity := c.Ticketing.ConsultantIdentities[consultantID]
+ if identity == "" {
+ identity = ticketingIdentity(consultant, c.Ticketing.Type)
+ }
if identity == "" {
continue // §5.3: consultant has no linked account in this system
}
diff --git a/web/static/css/app.css b/web/static/css/app.css
index 130bef6..2502819 100644
--- a/web/static/css/app.css
+++ b/web/static/css/app.css
@@ -303,6 +303,52 @@ table, .badge, input[type=number] { font-variant-numeric: tabular-nums; }
}
.topnav .right { display: flex; align-items: center; gap: 8px; }
+/* ---- sidebar navigation (default; user can switch to top bar) ---- */
+body[data-nav=side] .topnav {
+ justify-content: flex-end; /* only the right controls remain in the flow */
+ padding-left: 232px;
+}
+body[data-nav=side] .topnav .brand {
+ position: fixed; top: 14px; left: 18px; z-index: 41;
+ font-size: 1.05rem;
+}
+body[data-nav=side] .topnav .links {
+ position: fixed; top: 0; left: 0; bottom: 0;
+ width: 214px;
+ flex: none; flex-direction: column; flex-wrap: nowrap; align-items: stretch;
+ gap: 4px;
+ background-color: var(--surface2);
+ background-image: var(--dither);
+ border-right: 2px solid var(--border);
+ padding: 58px 12px 18px;
+ overflow-y: auto;
+ z-index: 40;
+}
+:root[data-theme=light] body[data-nav=side] .topnav .links { background-image: none; }
+body[data-nav=side] .topnav .links a {
+ border-bottom: none;
+ border-left: 3px solid transparent;
+ border-radius: 0;
+ padding: 9px 12px;
+}
+body[data-nav=side] .topnav .links a[aria-current=page] {
+ border-left-color: var(--accent);
+ background: var(--surface);
+}
+body[data-nav=side] main.container { margin-left: 214px; }
+@media (max-width: 760px) {
+ /* collapse the sidebar back into a normal top bar on small screens */
+ body[data-nav=side] .topnav { padding-left: 20px; justify-content: flex-start; flex-wrap: wrap; }
+ body[data-nav=side] .topnav .brand { position: static; }
+ body[data-nav=side] .topnav .links {
+ position: static; width: auto; flex: 1; flex-direction: row; flex-wrap: wrap;
+ background: none; border-right: none; padding: 0; overflow: visible;
+ }
+ body[data-nav=side] .topnav .links a { border-left: none; border-bottom: 2px solid transparent; }
+ body[data-nav=side] .topnav .links a[aria-current=page] { border-bottom-color: var(--accent); background: none; }
+ body[data-nav=side] main.container { margin-left: auto; }
+}
+
/* ---- cards: printed stock with hard Y2K shadows ---- */
.card {
background: var(--surface);
diff --git a/web/static/js/admin.js b/web/static/js/admin.js
index a5ecbc4..2f5a0e9 100644
--- a/web/static/js/admin.js
+++ b/web/static/js/admin.js
@@ -57,6 +57,36 @@ async function loadConsultants() {
}));
}
+// Render one username input per selected consultant, prefilled from the
+// customer's saved per-consultant ticketing identity map.
+function renderIdentities() {
+ const wrap = document.getElementById('c-identities-wrap');
+ const host = document.getElementById('c-identities');
+ const selectedIds = [...document.getElementById('c-consultants').selectedOptions].map((o) => o.value);
+ const existing = (editingCustomer && editingCustomer.ticketing.consultantIdentities) || {};
+ if (!selectedIds.length) { wrap.hidden = true; host.replaceChildren(); return; }
+ wrap.hidden = false;
+ host.replaceChildren(...selectedIds.map((id) => {
+ const u = consultants.find((c) => c.id === id) || { name: id, email: '' };
+ const row = document.createElement('div');
+ row.className = 'spread';
+ row.style.marginTop = '6px';
+ const label = document.createElement('span');
+ label.className = 'muted';
+ label.style.flex = '1';
+ label.textContent = u.name;
+ const inp = document.createElement('input');
+ inp.type = 'text';
+ inp.dataset.identity = id;
+ inp.placeholder = 'username in ticketing system';
+ inp.value = existing[id] || '';
+ inp.style.flex = '1';
+ row.append(label, inp);
+ return row;
+ }));
+}
+document.getElementById('c-consultants').addEventListener('change', renderIdentities);
+
function openCustomerDialog(customer) {
editingCustomer = customer || null;
document.getElementById('customer-dialog-title').textContent =
@@ -73,6 +103,7 @@ function openCustomerDialog(customer) {
[...sel.options].forEach((o) => {
o.selected = customer ? customer.consultantIds.includes(o.value) : false;
});
+ renderIdentities();
document.getElementById('c-test-result').textContent =
customer && customer.ticketing.hasCredentials ? 'Stored credentials are kept unless you enter new ones.' : '';
dlg.showModal();
@@ -114,6 +145,11 @@ document.getElementById('customer-form').addEventListener('submit', async (e) =>
defaultBudget: parseFloat(val('c-budget')) || 0,
consultantIds: [...document.getElementById('c-consultants').selectedOptions].map((o) => o.value),
};
+ const identities = {};
+ document.querySelectorAll('#c-identities input[data-identity]').forEach((inp) => {
+ if (inp.value.trim()) identities[inp.dataset.identity] = inp.value.trim();
+ });
+ body.consultantIdentities = identities;
if (!editingCustomer || credsEntered(type)) body.credentials = credsFromForm(type);
try {
if (editingCustomer) {
diff --git a/web/static/js/messages.js b/web/static/js/messages.js
index 1c522cd..1cd8ddf 100644
--- a/web/static/js/messages.js
+++ b/web/static/js/messages.js
@@ -62,6 +62,8 @@ async function openConversation(c) {
current = c;
oldestCursor = '';
document.getElementById('chat-title').textContent = c.title;
+ const mbtn = document.getElementById('chat-members');
+ mbtn.hidden = !(c.kind !== 'dm' && c.creatorId && c.creatorId === meId);
form.hidden = false;
msgHost.replaceChildren();
renderConvList();
@@ -297,6 +299,73 @@ document.getElementById('newconv-form').addEventListener('submit', async (e) =>
} catch (err) { toast(err.message, 'err'); }
});
+// ---- manage members (group creator only) ----
+const membersDlg = document.getElementById('members-dialog');
+let mmMemberIds = new Set();
+
+async function renderMembers() {
+ const res = await api('GET', `/api/v1/conversations/${current.id}/members`);
+ mmMemberIds = new Set(res.members.map((u) => u.id));
+ const host = document.getElementById('mm-list');
+ host.replaceChildren(...res.members.map((u) => {
+ const li = document.createElement('li');
+ li.className = 'conv-item';
+ li.style.justifyContent = 'space-between';
+ li.innerHTML = `${esc(u.name)} ${u.isCreator ? 'creator' : ''}
+
${esc(u.email)}`;
+ if (res.canManage && !u.isCreator) {
+ const rm = document.createElement('button');
+ rm.className = 'btn small';
+ rm.textContent = 'Remove';
+ rm.addEventListener('click', async () => {
+ try {
+ await api('DELETE', `/api/v1/conversations/${current.id}/members/${u.id}`);
+ await renderMembers();
+ } catch (e) { toast(e.message, 'err'); }
+ });
+ li.appendChild(rm);
+ }
+ return li;
+ }));
+ document.getElementById('mm-add-wrap').hidden = !res.canManage;
+}
+
+let mmTimer = null;
+async function mmSearch() {
+ const q = document.getElementById('mm-search').value.trim();
+ const res = await api('GET', `/api/v1/users?q=${encodeURIComponent(q)}`);
+ const host = document.getElementById('mm-results');
+ const rows = res.users.filter((u) => !mmMemberIds.has(u.id)).map((u) => {
+ const b = document.createElement('button');
+ b.type = 'button';
+ b.className = 'nc-row';
+ b.textContent = `${u.name} — ${u.email}`;
+ b.addEventListener('click', async () => {
+ try {
+ await api('POST', `/api/v1/conversations/${current.id}/members`, { userId: u.id });
+ document.getElementById('mm-search').value = '';
+ host.replaceChildren();
+ await renderMembers();
+ } catch (e) { toast(e.message, 'err'); }
+ });
+ return b;
+ });
+ host.replaceChildren(...rows);
+}
+
+document.getElementById('chat-members').addEventListener('click', async () => {
+ if (!current) return;
+ document.getElementById('mm-search').value = '';
+ document.getElementById('mm-results').replaceChildren();
+ try { await renderMembers(); membersDlg.showModal(); }
+ catch (e) { toast(e.message, 'err'); }
+});
+document.getElementById('mm-close').addEventListener('click', () => membersDlg.close());
+document.getElementById('mm-search').addEventListener('input', () => {
+ clearTimeout(mmTimer);
+ mmTimer = setTimeout(mmSearch, 250);
+});
+
(async () => {
try {
const me = await api('GET', '/api/v1/auth/me');
diff --git a/web/static/js/profile.js b/web/static/js/profile.js
index 142c24f..9f67a97 100644
--- a/web/static/js/profile.js
+++ b/web/static/js/profile.js
@@ -56,12 +56,16 @@ form.addEventListener('submit', async (e) => {
links,
},
extra,
- settings: { theme: document.getElementById('p-theme').value },
+ settings: {
+ theme: document.getElementById('p-theme').value,
+ navLayout: document.getElementById('p-nav').value,
+ },
});
version = res.user.version;
const theme = res.user.settings.theme;
document.documentElement.dataset.theme = theme;
try { localStorage.setItem('theme', theme); } catch (err) { /* ignore */ }
+ document.body.dataset.nav = res.user.settings.navLayout || 'side';
okBox.textContent = 'Profile saved.';
} catch (err) {
if (err.status === 409) {
diff --git a/web/templates/admin.html b/web/templates/admin.html
index bf7384d..f97e08f 100644
--- a/web/templates/admin.html
+++ b/web/templates/admin.html
@@ -84,6 +84,11 @@
Hold Ctrl/Cmd to select multiple. At least one is needed for syncing.
+Username in this project's ticketing system for each assigned consultant. Overrides the consultant's global identity. Leave blank to use their global one.
+ +