feat: dashboard PIN change flow

- Backend: Add POST /api/auth/change-pin endpoint
  - Requires valid session; body: {old_pin, new_pin}
  - Verifies old PIN against bcrypt hash; returns 403 on mismatch
  - Hashes new PIN with bcrypt cost=12
  - Existing sessions remain valid after PIN change
  - Returns {ok:true} on success

- Dashboard: Security section in settings panel
  - Add "Security" section with Change PIN button
  - Modal form: old PIN → new PIN → confirm new PIN → Submit
  - Inline error display for incorrect current PIN (403)
  - Success toast notification on PIN change
  - Validation: 4-8 digits, numeric only, PINs must match, new ≠ old

- Tests: Add comprehensive tests for change PIN endpoint
  - Success case: old PIN verified, new PIN works
  - Wrong old PIN: returns 403, original PIN still works
  - Unauthenticated: returns 401
  - Invalid new PIN: validation for length, digits, etc.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-04-09 08:04:36 -04:00
parent 93d3faceee
commit 3da0c32ba3
6 changed files with 731 additions and 3 deletions

File diff suppressed because one or more lines are too long

View file

@ -1 +1 @@
68334a9b7894907da80e42e6ebdb3deb0d9fbee2
747940f9328660c49cb3add730005dd542b5ad4f

View file

@ -2198,3 +2198,166 @@
font-size: 10px;
flex-shrink: 0;
}
/* ============================================
Change PIN Modal Styles
============================================ */
.panel-modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
z-index: 2000;
display: flex;
align-items: center;
justify-content: center;
animation: panel-fade-in 0.2s ease-out;
}
@keyframes panel-fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.panel-modal {
background: #1e1e3a;
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
width: 400px;
max-width: 90vw;
max-height: 90vh;
display: flex;
flex-direction: column;
animation: panel-slide-up 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
@keyframes panel-slide-up {
from {
opacity: 0;
transform: scale(0.95) translateY(20px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
.panel-modal-header {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 24px 16px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.panel-modal-header h2 {
margin: 0;
font-size: 18px;
font-weight: 600;
color: #eee;
}
.panel-modal-close {
background: none;
border: none;
color: #888;
font-size: 24px;
line-height: 1;
cursor: pointer;
padding: 0;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
transition: background 0.2s, color 0.2s;
flex-shrink: 0;
}
.panel-modal-close:hover {
background: rgba(255, 255, 255, 0.1);
color: #eee;
}
.panel-modal-body {
flex: 1;
overflow-y: auto;
padding: 20px 24px;
}
.panel-input {
width: 100%;
padding: 10px 12px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 6px;
color: #eee;
font-size: 14px;
font-family: inherit;
transition: border-color 0.2s, box-shadow 0.2s;
box-sizing: border-box;
}
.panel-input:focus {
outline: none;
border-color: #4fc3f7;
box-shadow: 0 0 0 3px rgba(79, 195, 247, 0.15);
}
.panel-input::placeholder {
color: #555;
}
.panel-modal-actions {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 12px;
padding: 16px 24px 20px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
.panel-error {
background: rgba(239, 83, 80, 0.15);
border: 1px solid rgba(239, 83, 80, 0.3);
border-radius: 6px;
padding: 10px 16px;
font-size: 13px;
color: #ef5350;
margin-bottom: 16px;
}
/* Responsive adjustments for change PIN modal */
@media (max-width: 480px) {
.panel-modal {
width: 100%;
max-width: 100%;
border-radius: 12px 12px 0 0;
margin: 0 auto;
position: fixed;
bottom: 0;
transform: translateY(100%);
}
.panel-modal-overlay.visible .panel-modal {
transform: translateY(0);
}
.panel-modal-actions {
flex-direction: column;
}
.panel-modal-actions .panel-btn {
width: 100%;
}
}

View file

@ -110,6 +110,7 @@
content.innerHTML = `
${renderDetectionSettings(settings)}
${renderSecuritySettings(settings)}
${renderNotificationSettings(settings)}
${renderSystemInfo()}
`;
@ -199,6 +200,24 @@
`;
}
function renderSecuritySettings(settings) {
return `
<div class="panel-section">
<div class="panel-section-header">Security</div>
<div class="panel-info-card">
<div class="panel-info-card-title">Dashboard PIN</div>
<div class="panel-info-card-value">Configured</div>
<div class="panel-info-card-subtitle">Protects access to your dashboard</div>
</div>
<button class="panel-btn panel-btn-secondary panel-btn-full" id="change-pin-btn">
Change PIN
</button>
</div>
`;
}
function renderNotificationSettings(settings) {
const notificationChannels = settings.notification_channels || {};
const ntfyEnabled = notificationChannels.ntfy && notificationChannels.ntfy.enabled;
@ -395,6 +414,12 @@
if (logoutBtn) {
logoutBtn.addEventListener('click', handleLogout);
}
// Change PIN
const changePinBtn = document.getElementById('change-pin-btn');
if (changePinBtn) {
changePinBtn.addEventListener('click', openChangePINModal);
}
}
/**
@ -516,6 +541,224 @@
.replace(/"/g, '&quot;');
}
// ============================================
// Change PIN Modal
// ============================================
const changePINState = {
oldPin: '',
newPin: '',
confirmPin: '',
error: '',
isSubmitting: false
};
function openChangePINModal() {
changePINState.oldPin = '';
changePINState.newPin = '';
changePINState.confirmPin = '';
changePINState.error = '';
changePINState.isSubmitting = false;
const modal = document.createElement('div');
modal.id = 'change-pin-modal';
modal.className = 'panel-modal-overlay';
modal.innerHTML = renderChangePINModal();
document.body.appendChild(modal);
attachChangePINEvents(modal);
// Focus first input
setTimeout(function() {
const firstInput = modal.querySelector('#change-pin-old');
if (firstInput) firstInput.focus();
}, 10);
}
function closeChangePINModal() {
const modal = document.getElementById('change-pin-modal');
if (modal) {
modal.remove();
}
}
function renderChangePINModal() {
return `
<div class="panel-modal">
<div class="panel-modal-header">
<h2>Change PIN</h2>
<button class="panel-modal-close" id="change-pin-close">&times;</button>
</div>
<div class="panel-modal-body">
${changePINState.error ? `
<div class="panel-error" id="change-pin-error">${escapeHtml(changePINState.error)}</div>
` : ''}
<div class="panel-form-group">
<label for="change-pin-old">Current PIN</label>
<input type="password" id="change-pin-old" class="panel-input"
maxlength="8" placeholder="Enter current PIN" autocomplete="current-password">
</div>
<div class="panel-form-group">
<label for="change-pin-new">New PIN</label>
<input type="password" id="change-pin-new" class="panel-input"
maxlength="8" placeholder="Enter new PIN (4-8 digits)" autocomplete="new-password">
</div>
<div class="panel-form-group">
<label for="change-pin-confirm">Confirm New PIN</label>
<input type="password" id="change-pin-confirm" class="panel-input"
maxlength="8" placeholder="Confirm new PIN" autocomplete="new-password">
</div>
<div class="panel-modal-actions">
<button class="panel-btn panel-btn-secondary" id="change-pin-cancel" ${changePINState.isSubmitting ? 'disabled' : ''}>
Cancel
</button>
<button class="panel-btn panel-btn-primary" id="change-pin-submit" ${changePINState.isSubmitting ? 'disabled' : ''}>
${changePINState.isSubmitting ? 'Changing...' : 'Change PIN'}
</button>
</div>
</div>
</div>
`;
}
function attachChangePINEvents(modal) {
// Close button
const closeBtn = modal.querySelector('#change-pin-close');
if (closeBtn) {
closeBtn.addEventListener('click', closeChangePINModal);
}
// Cancel button
const cancelBtn = modal.querySelector('#change-pin-cancel');
if (cancelBtn) {
cancelBtn.addEventListener('click', closeChangePINModal);
}
// Submit button
const submitBtn = modal.querySelector('#change-pin-submit');
if (submitBtn) {
submitBtn.addEventListener('click', submitChangePIN);
}
// Close on overlay click
modal.addEventListener('click', function(e) {
if (e.target === modal) {
closeChangePINModal();
}
});
// Handle Enter key
const inputs = modal.querySelectorAll('.panel-input');
inputs.forEach(function(input) {
input.addEventListener('keydown', function(e) {
if (e.key === 'Enter') {
submitChangePIN();
}
});
// Only allow digits
input.addEventListener('input', function(e) {
const value = e.target.value;
if (!/^\d*$/.test(value)) {
e.target.value = value.replace(/\D/g, '');
}
});
});
}
function submitChangePIN() {
const oldPinInput = document.getElementById('change-pin-old');
const newPinInput = document.getElementById('change-pin-new');
const confirmPinInput = document.getElementById('change-pin-confirm');
if (!oldPinInput || !newPinInput || !confirmPinInput) {
return;
}
const oldPin = oldPinInput.value.trim();
const newPin = newPinInput.value.trim();
const confirmPin = confirmPinInput.value.trim();
// Validation
if (!oldPin || oldPin.length < 4) {
changePINState.error = 'Please enter your current PIN (4-8 digits)';
updateChangePINModal();
return;
}
if (!newPin || newPin.length < 4 || newPin.length > 8) {
changePINState.error = 'New PIN must be 4-8 digits';
updateChangePINModal();
return;
}
if (newPin !== confirmPin) {
changePINState.error = 'New PINs do not match';
updateChangePINModal();
return;
}
if (oldPin === newPin) {
changePINState.error = 'New PIN must be different from current PIN';
updateChangePINModal();
return;
}
changePINState.oldPin = oldPin;
changePINState.newPin = newPin;
changePINState.confirmPin = confirmPin;
changePINState.error = '';
changePINState.isSubmitting = true;
updateChangePINModal();
// Send change PIN request
fetch('/api/auth/change-pin', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
old_pin: oldPin,
new_pin: newPin
})
})
.then(function(res) {
if (res.status === 403) {
throw new Error('Incorrect current PIN');
}
if (!res.ok) {
return res.text().then(function(text) {
throw new Error(text || 'Failed to change PIN');
});
}
return res.json();
})
.then(function(data) {
closeChangePINModal();
SpaxelPanels.showSuccess('PIN changed successfully');
})
.catch(function(err) {
changePINState.error = err.message || 'Failed to change PIN';
changePINState.isSubmitting = false;
updateChangePINModal();
});
}
function updateChangePINModal() {
const modal = document.getElementById('change-pin-modal');
if (!modal) return;
const modalContent = modal.querySelector('.panel-modal');
if (modalContent) {
modalContent.innerHTML = renderChangePINModal().match(/<div class="panel-modal">([\s\S]*)<\/div>/)[1];
attachChangePINEvents(modal);
}
}
// ============================================
// Panel Registration
// ============================================

View file

@ -168,6 +168,7 @@ func (h *Handler) RegisterRoutes(mux interface{ HandleFunc(pattern string, handl
mux.HandleFunc("POST /api/auth/setup", h.handleSetup)
mux.HandleFunc("POST /api/auth/login", h.handleLogin)
mux.HandleFunc("POST /api/auth/logout", h.handleLogout)
mux.HandleFunc("POST /api/auth/change-pin", h.RequireAuth(h.handleChangePIN))
}
// handleStatus returns whether a PIN is configured.
@ -386,6 +387,89 @@ func (h *Handler) handleLogout(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]string{"ok": "true"})
}
// handleChangePIN changes the user's PIN.
// Requires valid session (authenticated).
func (h *Handler) handleChangePIN(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Parse request
var req struct {
OldPIN string `json:"old_pin"`
NewPIN string `json:"new_pin"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
// Get current PIN hash
var currentHash string
err := h.db.QueryRow("SELECT pin_bcrypt FROM auth WHERE id = 1").Scan(&currentHash)
if err != nil {
http.Error(w, "Database error", http.StatusInternalServerError)
log.Printf("[ERROR] Failed to get current PIN: %v", err)
return
}
if currentHash == "" {
http.Error(w, "PIN not configured", http.StatusNotFound)
return
}
// Verify old PIN matches current hash
if err := bcrypt.CompareHashAndPassword([]byte(currentHash), []byte(req.OldPIN)); err != nil {
// Old PIN doesn't match
http.Error(w, "Incorrect current PIN", http.StatusForbidden)
log.Printf("[WARN] Failed PIN change attempt from %s: incorrect old PIN", r.RemoteAddr)
return
}
// Validate new PIN
if len(req.NewPIN) < 4 || len(req.NewPIN) > 8 {
http.Error(w, "PIN must be 4-8 digits", http.StatusBadRequest)
return
}
// Ensure new PIN is numeric
for _, c := range req.NewPIN {
if c < '0' || c > '9' {
http.Error(w, "PIN must contain only digits", http.StatusBadRequest)
return
}
}
// Hash new PIN with bcrypt (cost 12)
newHash, err := bcrypt.GenerateFromPassword([]byte(req.NewPIN), 12)
if err != nil {
http.Error(w, "Failed to hash PIN", http.StatusInternalServerError)
log.Printf("[ERROR] Failed to hash new PIN: %v", err)
return
}
// Update PIN in database
_, err = h.db.Exec(`
UPDATE auth
SET pin_bcrypt = ?, updated_at = ?
WHERE id = 1
`, newHash, time.Now().UnixMilli())
if err != nil {
http.Error(w, "Failed to update PIN", http.StatusInternalServerError)
log.Printf("[ERROR] Failed to update PIN: %v", err)
return
}
log.Printf("[INFO] PIN changed successfully from %s", r.RemoteAddr)
// Note: Existing sessions remain valid after PIN change
// (session tokens are independent of PIN)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"ok": "true"})
}
// createSession creates a new session and returns the session ID.
func (h *Handler) createSession() (string, error) {
// Generate 32-byte random session ID (64 hex chars)

View file

@ -724,3 +724,241 @@ func TestHandleInstallSecret_AfterPINSet_Authorized(t *testing.T) {
t.Errorf("Expected 64-char hex secret, got %d chars", len(resp["install_secret"]))
}
}
func TestHandler_ChangePIN_Success(t *testing.T) {
db, err := sql.Open("sqlite", ":memory:")
if err != nil {
t.Fatal(err)
}
defer db.Close()
h, err := NewHandler(Config{DB: db})
if err != nil {
t.Fatal(err)
}
defer h.Close()
// Setup PIN first
reqBody := `{"pin": "1234"}`
req := httptest.NewRequest("POST", "/api/auth/setup", strings.NewReader(reqBody))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
h.handleSetup(w, req)
// Get session cookie
cookies := w.Result().Cookies()
var sessionCookie *http.Cookie
for _, c := range cookies {
if c.Name == "spaxel_session" {
sessionCookie = c
break
}
}
if sessionCookie == nil {
t.Fatal("Session cookie not set")
}
// Change PIN
changeReqBody := `{"old_pin": "1234", "new_pin": "5678"}`
req = httptest.NewRequest("POST", "/api/auth/change-pin", strings.NewReader(changeReqBody))
req.Header.Set("Content-Type", "application/json")
req.AddCookie(sessionCookie)
w = httptest.NewRecorder()
h.handleChangePIN(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]string
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatal(err)
}
if resp["ok"] != "true" {
t.Error("Expected ok: true response")
}
// Verify old PIN no longer works
loginReqBody := `{"pin": "1234"}`
req = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(loginReqBody))
req.Header.Set("Content-Type", "application/json")
w = httptest.NewRecorder()
h.handleLogin(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("Expected old PIN to be invalid (401), got %d", w.Code)
}
// Verify new PIN works
loginReqBody = `{"pin": "5678"}`
req = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(loginReqBody))
req.Header.Set("Content-Type", "application/json")
w = httptest.NewRecorder()
h.handleLogin(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected new PIN to work (200), got %d", w.Code)
}
// Verify original session still works
req = httptest.NewRequest("GET", "/api/test", nil)
req.AddCookie(sessionCookie)
if !h.IsAuthenticated(req) {
t.Error("Expected original session to remain valid after PIN change")
}
}
func TestHandler_ChangePIN_WrongOldPIN(t *testing.T) {
db, err := sql.Open("sqlite", ":memory:")
if err != nil {
t.Fatal(err)
}
defer db.Close()
h, err := NewHandler(Config{DB: db})
if err != nil {
t.Fatal(err)
}
defer h.Close()
// Setup PIN first
reqBody := `{"pin": "1234"}`
req := httptest.NewRequest("POST", "/api/auth/setup", strings.NewReader(reqBody))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
h.handleSetup(w, req)
// Get session cookie
cookies := w.Result().Cookies()
var sessionCookie *http.Cookie
for _, c := range cookies {
if c.Name == "spaxel_session" {
sessionCookie = c
break
}
}
if sessionCookie == nil {
t.Fatal("Session cookie not set")
}
// Try to change with wrong old PIN
changeReqBody := `{"old_pin": "9999", "new_pin": "5678"}`
req = httptest.NewRequest("POST", "/api/auth/change-pin", strings.NewReader(changeReqBody))
req.Header.Set("Content-Type", "application/json")
req.AddCookie(sessionCookie)
w = httptest.NewRecorder()
h.handleChangePIN(w, req)
if w.Code != http.StatusForbidden {
t.Errorf("Expected status 403 for wrong old PIN, got %d", w.Code)
}
// Verify original PIN still works
loginReqBody := `{"pin": "1234"}`
req = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(loginReqBody))
req.Header.Set("Content-Type", "application/json")
w = httptest.NewRecorder()
h.handleLogin(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected original PIN to still work after failed change, got %d", w.Code)
}
}
func TestHandler_ChangePIN_Unauthenticated(t *testing.T) {
db, err := sql.Open("sqlite", ":memory:")
if err != nil {
t.Fatal(err)
}
defer db.Close()
h, err := NewHandler(Config{DB: db})
if err != nil {
t.Fatal(err)
}
defer h.Close()
// Setup PIN first
reqBody := `{"pin": "1234"}`
req := httptest.NewRequest("POST", "/api/auth/setup", strings.NewReader(reqBody))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
h.handleSetup(w, req)
// Try to change PIN without authentication (no cookie)
changeReqBody := `{"old_pin": "1234", "new_pin": "5678"}`
req := httptest.NewRequest("POST", "/api/auth/change-pin", strings.NewReader(changeReqBody))
req.Header.Set("Content-Type", "application/json")
w = httptest.NewRecorder()
// Use RequireAuth wrapper
wrappedHandler := h.RequireAuth(h.handleChangePIN)
wrappedHandler(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("Expected status 401 for unauthenticated request, got %d", w.Code)
}
}
func TestHandler_ChangePIN_InvalidNewPIN(t *testing.T) {
tests := []struct {
name string
oldPIN string
newPIN string
wantStatus int
}{
{"too short", "1234", "123", http.StatusBadRequest},
{"too long", "1234", "123456789", http.StatusBadRequest},
{"non-numeric", "1234", "abcd", http.StatusBadRequest},
{"mixed", "1234", "12a4", http.StatusBadRequest},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
db, err := sql.Open("sqlite", ":memory:")
if err != nil {
t.Fatal(err)
}
defer db.Close()
h, err := NewHandler(Config{DB: db})
if err != nil {
t.Fatal(err)
}
defer h.Close()
// Setup PIN first
reqBody := `{"pin": "1234"}`
req := httptest.NewRequest("POST", "/api/auth/setup", strings.NewReader(reqBody))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
h.handleSetup(w, req)
// Get session cookie
cookies := w.Result().Cookies()
var sessionCookie *http.Cookie
for _, c := range cookies {
if c.Name == "spaxel_session" {
sessionCookie = c
break
}
}
if sessionCookie == nil {
t.Fatal("Session cookie not set")
}
// Try to change with invalid new PIN
changeReqBody := `{"old_pin": "` + tt.oldPIN + `", "new_pin": "` + tt.newPIN + `"}`
req = httptest.NewRequest("POST", "/api/auth/change-pin", strings.NewReader(changeReqBody))
req.Header.Set("Content-Type", "application/json")
req.AddCookie(sessionCookie)
w = httptest.NewRecorder()
h.handleChangePIN(w, req)
if w.Code != tt.wantStatus {
t.Errorf("Expected status %d, got %d", tt.wantStatus, w.Code)
}
})
}
}