Add Memby account management
This commit is contained in:
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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) =>
|
||||
'&':'&', '<':'<', '>':'>', '"':'"', "'":''',
|
||||
}[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 user’s 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' }) })));
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"},
|
||||
|
||||
@@ -161,6 +161,12 @@ func (s *Store) SetRecommendationOnboarding(
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) ClearRecommendationOnboarding(ctx context.Context, userID string) error {
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
DELETE FROM recommendation_onboarding WHERE emby_user_id = $1`, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
// UserItemExposures is deliberately item-scoped. A row impression with no item id is
|
||||
// useful for row ordering but cannot be used to claim a particular poster was ignored.
|
||||
func (s *Store) UserItemExposures(
|
||||
|
||||
@@ -4,6 +4,7 @@ package store
|
||||
import (
|
||||
"context"
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
@@ -49,6 +50,82 @@ type KnownClient struct {
|
||||
LastSeen time.Time `json:"lastSeen"`
|
||||
}
|
||||
|
||||
// MembyAccount is an account known to Memby, as opposed to an arbitrary user that
|
||||
// exists only in Emby. An account exists here once it has at least one gateway session.
|
||||
// Devices deliberately omit both gateway and upstream credentials.
|
||||
type MembyAccount struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
LastSeen time.Time `json:"lastSeen"`
|
||||
Devices []MembyDevice `json:"devices"`
|
||||
RecommendationPreferences json.RawMessage `json:"-"`
|
||||
}
|
||||
|
||||
type MembyDevice struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
Protocol string `json:"protocol"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
SignedInAt time.Time `json:"signedInAt"`
|
||||
LastSeen time.Time `json:"lastSeen"`
|
||||
}
|
||||
|
||||
// MembyAccounts returns only people who have signed in through Memby. It must not be
|
||||
// confused with an Emby user directory: users that exist solely in Emby are absent.
|
||||
func (s *Store) MembyAccounts(ctx context.Context) ([]MembyAccount, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT s.emby_user_id, s.username, s.device_id, s.device_name,
|
||||
s.client_version, s.client_protocol, s.client_capabilities,
|
||||
s.created_at, s.last_seen_at, COALESCE(o.preferences, '{}'::jsonb)
|
||||
FROM sessions s
|
||||
LEFT JOIN recommendation_onboarding o ON o.emby_user_id = s.emby_user_id
|
||||
ORDER BY s.last_seen_at DESC, s.emby_user_id, s.device_name`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: list Memby accounts: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
accounts := []MembyAccount{}
|
||||
byID := map[string]int{}
|
||||
for rows.Next() {
|
||||
var userID, username string
|
||||
var device MembyDevice
|
||||
var preferences []byte
|
||||
if err := rows.Scan(
|
||||
&userID, &username, &device.ID, &device.Name, &device.Version,
|
||||
&device.Protocol, &device.Capabilities, &device.SignedInAt,
|
||||
&device.LastSeen, &preferences,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("store: scan Memby account: %w", err)
|
||||
}
|
||||
index, ok := byID[userID]
|
||||
if !ok {
|
||||
index = len(accounts)
|
||||
byID[userID] = index
|
||||
accounts = append(accounts, MembyAccount{
|
||||
ID: userID, Username: username, CreatedAt: device.SignedInAt,
|
||||
LastSeen: device.LastSeen, Devices: []MembyDevice{},
|
||||
RecommendationPreferences: json.RawMessage(preferences),
|
||||
})
|
||||
}
|
||||
account := &accounts[index]
|
||||
if device.SignedInAt.Before(account.CreatedAt) {
|
||||
account.CreatedAt = device.SignedInAt
|
||||
}
|
||||
if device.LastSeen.After(account.LastSeen) {
|
||||
account.LastSeen = device.LastSeen
|
||||
account.Username = username
|
||||
}
|
||||
account.Devices = append(account.Devices, device)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("store: read Memby accounts: %w", err)
|
||||
}
|
||||
return accounts, nil
|
||||
}
|
||||
|
||||
// KnownClients gives the feature console compatibility evidence without exposing
|
||||
// gateway or Emby credentials. Stale sessions remain useful rollout information.
|
||||
func (s *Store) KnownClients(ctx context.Context) ([]KnownClient, error) {
|
||||
@@ -318,6 +395,32 @@ func (s *Store) DeleteUserDevice(ctx context.Context, userID, deviceID string) (
|
||||
return tokenHash, nil
|
||||
}
|
||||
|
||||
// DeleteUserSessions removes the Memby account's active gateway access while leaving
|
||||
// the upstream Emby user untouched.
|
||||
func (s *Store) DeleteUserSessions(ctx context.Context, userID string) ([][]byte, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
DELETE FROM sessions WHERE emby_user_id = $1 RETURNING token_hash`, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: delete user sessions: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
hashes := [][]byte{}
|
||||
for rows.Next() {
|
||||
var hash []byte
|
||||
if err := rows.Scan(&hash); err != nil {
|
||||
return nil, fmt.Errorf("store: scan deleted session: %w", err)
|
||||
}
|
||||
hashes = append(hashes, hash)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("store: read deleted sessions: %w", err)
|
||||
}
|
||||
if len(hashes) == 0 {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return hashes, nil
|
||||
}
|
||||
|
||||
// RenameUserDevice changes only the display name and keeps the session/token intact.
|
||||
func (s *Store) RenameUserDevice(ctx context.Context, userID, deviceID, deviceName string) error {
|
||||
tag, err := s.pool.Exec(ctx, `
|
||||
|
||||
Reference in New Issue
Block a user