Add Memby account management

This commit is contained in:
ponzischeme89
2026-08-03 10:16:44 +12:00
parent 666da9c5d3
commit 2675e6d82b
7 changed files with 467 additions and 5 deletions
+6 -1
View File
@@ -32,6 +32,11 @@ func (s *Server) adminRoutes() http.Handler {
mux.HandleFunc("GET /admin/{$}", s.handleAdminRoot)
mux.HandleFunc("GET /admin/{page}", s.handleAdminPage)
mux.Handle("GET /admin/api/status", s.adminAuth(s.handleAdminStatus))
mux.Handle("GET /admin/api/accounts", s.adminAuth(s.handleAdminAccounts))
mux.Handle("PUT /admin/api/accounts/{userID}/devices/{deviceID}", s.adminAuth(s.handleAdminRenameDevice))
mux.Handle("DELETE /admin/api/accounts/{userID}/devices/{deviceID}", s.adminAuth(s.handleAdminDeleteDevice))
mux.Handle("DELETE /admin/api/accounts/{userID}/sessions", s.adminAuth(s.handleAdminDeleteAccount))
mux.Handle("DELETE /admin/api/accounts/{userID}/recommendations", s.adminAuth(s.handleAdminResetRecommendations))
mux.Handle("GET /admin/api/recommendations", s.adminAuth(s.handleAdminRecommendations))
mux.Handle("GET /admin/api/analytics", s.adminAuth(s.handleAdminAnalytics))
mux.Handle("GET /admin/api/events", s.adminAuth(s.handleAdminEvents))
@@ -50,7 +55,7 @@ func (s *Server) adminRoutes() http.Handler {
}
var adminPages = map[string]bool{
"library": true, "recommendations": true, "requests": true,
"accounts": true, "library": true, "recommendations": true, "requests": true,
"features": true, "playback": true, "maintenance": true, "updates": true, "engagement": true,
"ratings": true, "imports": true, "logs": true,
}
+147 -1
View File
@@ -138,6 +138,34 @@
.component.negative { color:#ffaaa3; background:#3a1d1c; }
.recommendation-meta { color:var(--muted); font-size:12px; margin-top:4px; }
.reason-text { margin-top:9px; color:#dce3e8; }
.account-summary {
display:grid; grid-template-columns:repeat(3,minmax(150px,1fr)); gap:12px; margin-bottom:16px;
}
.account-stat { padding:15px; border:1px solid var(--line); border-radius:10px; background:#10151a; }
.account-stat b { display:block; font-size:24px; }
.account-stat span { color:var(--muted); font-size:11px; text-transform:uppercase; letter-spacing:.06em; }
.account-list { display:grid; gap:12px; }
.account-card { border:1px solid var(--line); border-radius:11px; background:#10151a; overflow:hidden; }
.account-head { display:flex; align-items:flex-start; justify-content:space-between; gap:16px; padding:17px; }
.account-name { display:flex; align-items:center; gap:12px; }
.account-avatar { display:grid; place-items:center; width:42px; height:42px; border-radius:50%; background:var(--accent-soft); color:#91df94; font-size:17px; font-weight:800; }
.account-name h3 { margin:0; font-size:17px; }
.account-name p { margin:2px 0 0; color:var(--muted); font-size:12px; }
.account-actions { display:flex; gap:8px; flex-wrap:wrap; justify-content:flex-end; }
.account-actions button,.device-actions button { padding:7px 10px; font-size:12px; }
.account-body { display:grid; grid-template-columns:minmax(0,1.2fr) minmax(280px,.8fr); border-top:1px solid var(--line); }
.account-pane { padding:16px 17px; min-width:0; }
.account-pane + .account-pane { border-left:1px solid var(--line); }
.account-pane h4 { margin:0 0 10px; color:var(--muted); font-size:11px; text-transform:uppercase; letter-spacing:.07em; }
.device-row { display:grid; grid-template-columns:minmax(150px,1fr) auto; gap:12px; align-items:center; padding:10px 0; border-top:1px solid #202830; }
.device-row:first-of-type { border-top:0; padding-top:0; }
.device-title { font-weight:650; }
.device-meta { color:var(--muted); font-size:12px; margin-top:2px; }
.device-actions { display:flex; gap:6px; }
.choice-list { display:flex; gap:6px; flex-wrap:wrap; }
.choice { padding:4px 8px; border-radius:6px; background:#202830; color:#dce3e8; font-size:12px; }
.choice.rating { background:#302b16; color:#f2d575; }
.account-note { padding:12px 14px; border:1px solid #2b4830; border-radius:8px; background:rgba(82,181,75,.07); color:#b9c6bc; font-size:13px; margin-bottom:16px; }
.feature-hero {
display:grid; grid-template-columns:minmax(0,1fr) auto; gap:22px; align-items:center;
padding:20px; margin-bottom:16px; border:1px solid #2b4830; border-radius:11px;
@@ -176,6 +204,8 @@
.event-attrs { grid-column:1 / -1; }
.recommendation-controls { grid-template-columns:repeat(2,minmax(150px,1fr)); }
.feature-grid { grid-template-columns:1fr; }
.account-body { grid-template-columns:1fr; }
.account-pane + .account-pane { border-left:0; border-top:1px solid var(--line); }
}
@media (max-width: 560px) {
input[type=text], input[type=password], input[type=number], input[type=datetime-local],
@@ -184,6 +214,9 @@
.stats { gap: 18px; }
.recommendation-controls { grid-template-columns:1fr; }
.feature-hero { grid-template-columns:1fr; }
.account-summary { grid-template-columns:1fr; }
.account-head,.device-row { grid-template-columns:1fr; display:grid; }
.account-actions { justify-content:flex-start; }
}
@media (prefers-reduced-motion: reduce) {
html { scroll-behavior: auto; }
@@ -199,6 +232,9 @@
</a>
<div class="rail-label">Manage</div>
<nav class="rail-nav">
<a class="rail-link" href="/admin/accounts" data-section="accounts" title="Memby accounts">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M16 19v-1.5A3.5 3.5 0 0 0 12.5 14h-5A3.5 3.5 0 0 0 4 17.5V19M10 10a3 3 0 1 0 0-6 3 3 0 0 0 0 6Zm7-2h3m-1.5-1.5v3"/></svg><span>Memby accounts</span>
</a>
<a class="rail-link" href="/admin/features" data-section="features" title="Features">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h10M18 7h2M4 17h2m4 0h10M14 4v6M6 14v6"/></svg><span>Features</span>
</a>
@@ -253,6 +289,17 @@
<div id="error" class="banner"></div>
<section id="accounts" data-admin-page="accounts">
<h2>Memby accounts</h2>
<div class="account-note">
This is the Memby account list, not the Emby user directory. A person appears here only after
signing in to the Memby app. Removing access signs their Memby devices out and does not delete
or change their Emby account.
</div>
<div id="account-summary" class="account-summary"><span class="muted">Loading accounts…</span></div>
<div id="account-list" class="account-list"><span class="muted">Loading accounts…</span></div>
</section>
<section id="library" data-admin-page="library">
<h2>Library</h2>
<div class="stats" id="library-stats"><span class="muted">Loading…</span></div>
@@ -487,6 +534,7 @@
const railLinks = [...document.querySelectorAll('.rail-link[data-section]')];
const currentAdminPage = location.pathname.split('/').filter(Boolean).pop() || 'library';
const pageCopy = {
accounts: ['Memby accounts', 'See who uses Memby, where they are signed in and how they personalised recommendations.'],
library: ['Library', 'Import and inspect the catalogue Memby ranks.'],
recommendations: ['Recommendations', 'Pressure-test personalised rows and title scores per user.'],
requests: ['Media requests', 'Control who can request missing movies and shows.'],
@@ -550,6 +598,72 @@ const escapeHtml = (value) => String(value ?? '').replace(/[&<>"']/g, (char) =>
'&':'&amp;', '<':'&lt;', '>':'&gt;', '"':'&quot;', "'":'&#39;',
}[char]));
function initials(name) {
return String(name || '?').trim().split(/\s+/).slice(0, 2).map((part) => part[0] || '').join('').toUpperCase();
}
function recent(value) {
if (!value) return false;
return Date.now() - new Date(value).getTime() < 15 * 60 * 1000;
}
function renderAccounts(payload) {
const accounts = payload.accounts || [];
const devices = accounts.flatMap((account) => account.devices || []);
const online = devices.filter((device) => recent(device.lastSeen)).length;
const personalised = accounts.filter((account) => account.recommendations?.completed).length;
document.getElementById('account-summary').innerHTML =
'<div class="account-stat"><b>' + number(accounts.length) + '</b><span>Memby accounts</span></div>' +
'<div class="account-stat"><b>' + number(devices.length) + '</b><span>signed-in devices · ' + online + ' active now</span></div>' +
'<div class="account-stat"><b>' + number(personalised) + '</b><span>recommendation prompts completed</span></div>';
document.getElementById('account-list').innerHTML = accounts.length ? accounts.map((account) => {
const prefs = account.recommendations || {};
const ratings = prefs.ratings || [];
const dimensions = [
['Genres', prefs.genres], ['Studios', prefs.studios], ['Actors', prefs.actors],
['Directors', prefs.directors], ['Types', prefs.contentTypes],
].filter((entry) => (entry[1] || []).length);
const choices = ratings.map((rating) =>
'<span class="choice rating" title="' + escapeHtml(rating.itemId) + '">' +
escapeHtml(rating.title) + ' · ' + number(rating.rating) + ' ★</span>').join('') +
dimensions.flatMap(([label, values]) => (values || []).map((value) =>
'<span class="choice">' + escapeHtml(label) + ': ' + escapeHtml(value) + '</span>')).join('');
const deviceRows = (account.devices || []).map((device) => {
const state = recent(device.lastSeen);
return '<div class="device-row"><div><div class="device-title">' +
'<span class="pill ' + (state ? 'ok' : 'muted') + '">' + (state ? 'active' : 'idle') + '</span> ' +
escapeHtml(device.name || 'Memby TV') + '</div><div class="device-meta">' +
escapeHtml(device.version ? 'Memby ' + device.version : 'Legacy Memby client') +
(device.protocol ? ' · protocol ' + escapeHtml(device.protocol) : '') +
' · last seen ' + escapeHtml(when(device.lastSeen)) +
' · signed in ' + escapeHtml(when(device.signedInAt)) + '</div></div>' +
'<div class="device-actions"><button class="secondary" data-account-action="rename-device" data-user-id="' +
escapeHtml(account.id) + '" data-device-id="' + escapeHtml(device.id) + '" data-device-name="' +
escapeHtml(device.name) + '"' + (!device.id ? ' disabled' : '') + '>Rename</button>' +
'<button class="secondary" data-account-action="remove-device" data-user-id="' + escapeHtml(account.id) +
'" data-device-id="' + escapeHtml(device.id) + '"' + (!device.id ? ' disabled' : '') + '>Sign out</button></div></div>';
}).join('');
const promptState = prefs.completed
? '<span class="pill ok">completed</span>'
: '<span class="pill warn">not completed</span>';
return '<article class="account-card"><div class="account-head"><div class="account-name">' +
'<div class="account-avatar">' + escapeHtml(initials(account.username)) + '</div><div><h3>' +
escapeHtml(account.username || 'Unnamed user') + '</h3><p>Memby account · last seen ' +
escapeHtml(when(account.lastSeen)) + ' · ' + number((account.devices || []).length) + ' device' +
((account.devices || []).length === 1 ? '' : 's') + '</p></div></div>' +
'<div class="account-actions">' + (prefs.updated
? '<button class="secondary" data-account-action="reset-recommendations" data-user-id="' +
escapeHtml(account.id) + '">Clear stored choices</button>' : '') +
'<button class="danger" data-account-action="remove-account" data-user-id="' + escapeHtml(account.id) +
'" data-username="' + escapeHtml(account.username) + '">Remove Memby access</button></div></div>' +
'<div class="account-body"><div class="account-pane"><h4>Signed-in devices</h4>' + deviceRows + '</div>' +
'<div class="account-pane"><h4>Recommendation prompt · ' + promptState + '</h4>' +
(choices || '<span class="muted">No recommendation selections have been saved.</span>') +
'</div></div></article>';
}).join('') : '<div class="account-note">No one has signed in to Memby yet. Emby-only users are intentionally not listed here.</div>';
}
let featureRevision = 0;
function renderFeatures(payload, clients) {
@@ -896,14 +1010,18 @@ async function refresh() {
const live = document.getElementById('live');
const railLive = document.getElementById('rail-live');
try {
const [status, analytics] = await Promise.all([
const [status, analytics, accounts] = await Promise.all([
api('/admin/api/status'),
currentAdminPage === 'engagement'
? api('/admin/api/analytics?days=' + document.getElementById('days').value)
: Promise.resolve(null),
currentAdminPage === 'accounts'
? api('/admin/api/accounts')
: Promise.resolve(null),
]);
renderStatus(status);
if (analytics) renderAnalytics(analytics);
if (accounts) renderAccounts(accounts);
const updatedAt = new Date().toLocaleTimeString();
live.textContent = 'updated ' + updatedAt;
live.className = 'pill ok';
@@ -928,6 +1046,34 @@ async function act(fn) {
}
}
document.getElementById('account-list').addEventListener('click', (event) => {
const button = event.target.closest('[data-account-action]');
if (!button) return;
const action = button.dataset.accountAction;
const userId = button.dataset.userId;
const base = '/admin/api/accounts/' + encodeURIComponent(userId);
if (action === 'rename-device') {
const deviceName = prompt('Name this Memby device', button.dataset.deviceName || 'Memby TV');
if (deviceName === null || !deviceName.trim()) return;
act(() => api(base + '/devices/' + encodeURIComponent(button.dataset.deviceId), {
method: 'PUT', body: JSON.stringify({deviceName: deviceName.trim()}),
}));
}
if (action === 'remove-device') {
if (!confirm('Sign this device out of Memby? Its Emby account will not be changed.')) return;
act(() => api(base + '/devices/' + encodeURIComponent(button.dataset.deviceId), {method: 'DELETE'}));
}
if (action === 'remove-account') {
if (!confirm('Remove Memby access for ' + (button.dataset.username || 'this user') +
'? Every Memby device will be signed out. Their Emby account will not be changed.')) return;
act(() => api(base + '/sessions', {method: 'DELETE'}));
}
if (action === 'reset-recommendations') {
if (!confirm('Clear this users stored recommendation choices? Viewing history remains intact.')) return;
act(() => api(base + '/recommendations', {method: 'DELETE'}));
}
});
document.getElementById('sync-incremental').addEventListener('click', () =>
act(() => api('/admin/api/sync', { method: 'POST', body: JSON.stringify({ kind: 'incremental' }) })));
+180
View File
@@ -0,0 +1,180 @@
package api
import (
"encoding/json"
"net/http"
"sort"
"strings"
"time"
"github.com/ponzischeme89/memby/server/internal/cache"
"github.com/ponzischeme89/memby/server/internal/recommend"
"github.com/ponzischeme89/memby/server/internal/store"
)
type adminOnboardingRating struct {
ItemID string `json:"itemId"`
Title string `json:"title"`
Rating int `json:"rating"`
}
type adminOnboardingPreferences struct {
Completed bool `json:"completed"`
Updated bool `json:"updated"`
Ratings []adminOnboardingRating `json:"ratings"`
Genres []string `json:"genres"`
Studios []string `json:"studios"`
Actors []string `json:"actors"`
Directors []string `json:"directors"`
ContentTypes []string `json:"contentTypes"`
}
type adminMembyAccount struct {
ID string `json:"id"`
Username string `json:"username"`
CreatedAt time.Time `json:"createdAt"`
LastSeen time.Time `json:"lastSeen"`
Devices []store.MembyDevice `json:"devices"`
Recommendations adminOnboardingPreferences `json:"recommendations"`
}
func (s *Server) handleAdminAccounts(w http.ResponseWriter, r *http.Request) {
accounts, err := s.store.MembyAccounts(r.Context())
if err != nil {
s.log.Error("Memby account list failed", "error", err)
writeError(w, http.StatusInternalServerError, "could not load Memby accounts")
return
}
allRatingIDs := []string{}
preferences := make(map[string]recommend.OnboardingPreferences, len(accounts))
for _, account := range accounts {
var pref recommend.OnboardingPreferences
_ = json.Unmarshal(account.RecommendationPreferences, &pref)
preferences[account.ID] = pref
for id := range pref.Ratings {
allRatingIDs = append(allRatingIDs, id)
}
}
titles := map[string]string{}
if raws, loadErr := s.store.LibraryItemsByID(r.Context(), allRatingIDs); loadErr == nil {
for _, item := range recommend.Decode(raws) {
titles[item.ID] = item.Name
}
}
result := make([]adminMembyAccount, 0, len(accounts))
for _, account := range accounts {
pref := preferences[account.ID]
ratings := make([]adminOnboardingRating, 0, len(pref.Ratings))
for itemID, rating := range pref.Ratings {
title := titles[itemID]
if title == "" {
title = itemID
}
ratings = append(ratings, adminOnboardingRating{ItemID: itemID, Title: title, Rating: rating})
}
sort.Slice(ratings, func(i, j int) bool {
if ratings[i].Rating != ratings[j].Rating {
return ratings[i].Rating > ratings[j].Rating
}
return strings.ToLower(ratings[i].Title) < strings.ToLower(ratings[j].Title)
})
result = append(result, adminMembyAccount{
ID: account.ID, Username: account.Username, CreatedAt: account.CreatedAt,
LastSeen: account.LastSeen, Devices: account.Devices,
Recommendations: adminOnboardingPreferences{
Completed: pref.Completed, Updated: len(account.RecommendationPreferences) > 2,
Ratings: ratings, Genres: nonNilStrings(pref.Genres),
Studios: nonNilStrings(pref.Studios), Actors: nonNilStrings(pref.Actors),
Directors: nonNilStrings(pref.Directors), ContentTypes: nonNilStrings(pref.ContentTypes),
},
})
}
writeJSON(w, http.StatusOK, map[string]any{"accounts": result})
}
func (s *Server) handleAdminRenameDevice(w http.ResponseWriter, r *http.Request) {
userID, deviceID := strings.TrimSpace(r.PathValue("userID")), strings.TrimSpace(r.PathValue("deviceID"))
var req renameDeviceRequest
if userID == "" || deviceID == "" || json.NewDecoder(http.MaxBytesReader(w, r.Body, 2<<10)).Decode(&req) != nil {
writeError(w, http.StatusBadRequest, "user, device and name are required")
return
}
req.DeviceName = strings.TrimSpace(req.DeviceName)
if req.DeviceName == "" || len([]rune(req.DeviceName)) > 80 {
writeError(w, http.StatusBadRequest, "device name must be between 1 and 80 characters")
return
}
if err := s.store.RenameUserDevice(r.Context(), userID, deviceID, req.DeviceName); err == store.ErrNotFound {
writeError(w, http.StatusNotFound, "Memby device not found")
return
} else if err != nil {
writeError(w, http.StatusInternalServerError, "could not rename Memby device")
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handleAdminDeleteDevice(w http.ResponseWriter, r *http.Request) {
userID, deviceID := strings.TrimSpace(r.PathValue("userID")), strings.TrimSpace(r.PathValue("deviceID"))
if userID == "" || deviceID == "" {
writeError(w, http.StatusBadRequest, "user and device are required")
return
}
hash, err := s.store.DeleteUserDevice(r.Context(), userID, deviceID)
if err == store.ErrNotFound {
writeError(w, http.StatusNotFound, "Memby device not found")
return
}
if err != nil {
writeError(w, http.StatusInternalServerError, "could not sign out Memby device")
return
}
if s.cache != nil {
_ = s.cache.Delete(r.Context(), cache.SessionKey(hexHash(hash)))
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handleAdminDeleteAccount(w http.ResponseWriter, r *http.Request) {
userID := strings.TrimSpace(r.PathValue("userID"))
if userID == "" {
writeError(w, http.StatusBadRequest, "user is required")
return
}
hashes, err := s.store.DeleteUserSessions(r.Context(), userID)
if err == store.ErrNotFound {
writeError(w, http.StatusNotFound, "Memby account not found")
return
}
if err != nil {
writeError(w, http.StatusInternalServerError, "could not revoke Memby account access")
return
}
if s.cache != nil {
keys := make([]string, 0, len(hashes))
for _, hash := range hashes {
keys = append(keys, cache.SessionKey(hexHash(hash)))
}
_ = s.cache.Delete(r.Context(), keys...)
_ = s.cache.InvalidateUser(r.Context(), userID)
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handleAdminResetRecommendations(w http.ResponseWriter, r *http.Request) {
userID := strings.TrimSpace(r.PathValue("userID"))
if userID == "" {
writeError(w, http.StatusBadRequest, "user is required")
return
}
if err := s.store.ClearRecommendationOnboarding(r.Context(), userID); err != nil {
writeError(w, http.StatusInternalServerError, "could not reset recommendation choices")
return
}
if s.cache != nil {
_ = s.cache.Delete(r.Context(), cache.RecommendationsKey(userID))
}
w.WriteHeader(http.StatusNoContent)
}
+24 -2
View File
@@ -163,7 +163,7 @@ func TestServiceStatusAcceptsCurrentProtocol(t *testing.T) {
func TestAdminIsDisabledWithoutAToken(t *testing.T) {
server := testServer(config.Config{})
for _, path := range []string{"/admin/", "/admin/api/status"} {
for _, path := range []string{"/admin/", "/admin/api/status", "/admin/api/accounts"} {
rec := httptest.NewRecorder()
server.adminRoutes().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
if rec.Code != http.StatusNotFound {
@@ -323,7 +323,7 @@ func TestAdminPagesUseRealRoutes(t *testing.T) {
AdminToken: "secret", ReleasePublishToken: "release-secret",
})
for _, page := range []string{
"library", "recommendations", "requests", "ratings", "maintenance",
"accounts", "library", "recommendations", "requests", "ratings", "maintenance",
"updates", "engagement", "imports", "logs",
} {
req := httptest.NewRequest(http.MethodGet, "/admin/"+page, nil)
@@ -349,6 +349,28 @@ func TestAdminPagesUseRealRoutes(t *testing.T) {
}
}
func TestAccountsPageDistinguishesMembyFromEmbyAndProvidesManagement(t *testing.T) {
server := testServer(config.Config{
AdminToken: "secret", ReleasePublishToken: "release-secret",
})
req := httptest.NewRequest(http.MethodGet, "/admin/accounts", nil)
addInstallerSession(t, server, req)
rec := httptest.NewRecorder()
server.adminRoutes().ServeHTTP(rec, req)
body := rec.Body.String()
for _, wanted := range []string{
"This is the Memby account list, not the Emby user directory",
"Signed-in devices", "Recommendation prompt", "Remove Memby access",
"Clear stored choices",
} {
if !strings.Contains(body, wanted) {
t.Fatalf("accounts page does not contain %q", wanted)
}
}
}
func TestMDBListAdminStatusNeverExposesTheAPIKey(t *testing.T) {
view := publicMDBListSettings(store.MDBListSettings{
Enabled: true, APIKey: "super-secret", Sources: []string{"imdb"},