feat: add password management, reading persistence on feed switch, and spin animation
This commit is contained in:
@@ -101,6 +101,27 @@ func (h *Handler) GetMe(w http.ResponseWriter, r *http.Request) {
|
|||||||
h.writeJSON(w, http.StatusOK, user)
|
h.writeJSON(w, http.StatusOK, user)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) UpdatePassword(w http.ResponseWriter, r *http.Request) {
|
||||||
|
claims, ok := r.Context().Value(auth.UserContextKey).(*auth.Claims)
|
||||||
|
if !ok {
|
||||||
|
h.writeError(w, http.StatusUnauthorized, "unauthorized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req models.ChangePasswordRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
h.writeError(w, http.StatusBadRequest, "invalid request payload")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.auth.UpdatePassword(claims.UserID, req.OldPassword, req.NewPassword); err != nil {
|
||||||
|
h.writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.writeJSON(w, http.StatusOK, map[string]string{"message": "password updated successfully"})
|
||||||
|
}
|
||||||
|
|
||||||
// Stats Handler
|
// Stats Handler
|
||||||
func (h *Handler) GetStats(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) GetStats(w http.ResponseWriter, r *http.Request) {
|
||||||
stats, err := h.db.GetStats()
|
stats, err := h.db.GetStats()
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ func NewRouter(h *Handler, authService *auth.AuthService, staticDir string) http
|
|||||||
r.Use(authService.Middleware)
|
r.Use(authService.Middleware)
|
||||||
|
|
||||||
r.Get("/auth/me", h.GetMe)
|
r.Get("/auth/me", h.GetMe)
|
||||||
|
r.Put("/auth/password", h.UpdatePassword)
|
||||||
r.Get("/stats", h.GetStats)
|
r.Get("/stats", h.GetStats)
|
||||||
|
|
||||||
// Categories
|
// Categories
|
||||||
|
|||||||
@@ -84,6 +84,29 @@ func (s *AuthService) Authenticate(username, password string) (string, error) {
|
|||||||
return tokenString, nil
|
return tokenString, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *AuthService) UpdatePassword(userID, oldPassword, newPassword string) error {
|
||||||
|
var currentHash string
|
||||||
|
err := s.db.QueryRow("SELECT password_hash FROM users WHERE id = ?", userID).Scan(¤tHash)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("user not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := bcrypt.CompareHashAndPassword([]byte(currentHash), []byte(oldPassword)); err != nil {
|
||||||
|
return errors.New("incorrect current password")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(strings.TrimSpace(newPassword)) < 6 {
|
||||||
|
return errors.New("new password must be at least 6 characters long")
|
||||||
|
}
|
||||||
|
|
||||||
|
newHash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to hash new password: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.db.UpdateUserPassword(userID, string(newHash))
|
||||||
|
}
|
||||||
|
|
||||||
func (s *AuthService) ValidateToken(tokenString string) (*Claims, error) {
|
func (s *AuthService) ValidateToken(tokenString string) (*Claims, error) {
|
||||||
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
|
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
|
||||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.pengzhan.dev/haopengzhan/rsspipe/internal/config"
|
||||||
|
"git.pengzhan.dev/haopengzhan/rsspipe/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAuthService(t *testing.T) {
|
||||||
|
tempDir, err := os.MkdirTemp("", "rsspipe-auth-test-*")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create temp dir: %v", err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
_ = os.RemoveAll(tempDir)
|
||||||
|
}()
|
||||||
|
|
||||||
|
dbPath := filepath.Join(tempDir, "auth_test.db")
|
||||||
|
database, err := db.InitDB(dbPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("InitDB failed: %v", err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
_ = database.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
cfg := &config.Config{
|
||||||
|
AdminUsername: "admin",
|
||||||
|
AdminPassword: "initial_password_123",
|
||||||
|
JWTSecret: "super-secret-jwt-key-for-test",
|
||||||
|
}
|
||||||
|
|
||||||
|
authService := NewAuthService(database, cfg)
|
||||||
|
|
||||||
|
// 1. Bootstrap Admin
|
||||||
|
if err := authService.BootstrapAdmin(); err != nil {
|
||||||
|
t.Fatalf("BootstrapAdmin failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Authenticate with initial password
|
||||||
|
token, err := authService.Authenticate("admin", "initial_password_123")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Authenticate failed: %v", err)
|
||||||
|
}
|
||||||
|
if token == "" {
|
||||||
|
t.Fatalf("expected non-empty token")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Validate Token
|
||||||
|
claims, err := authService.ValidateToken(token)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ValidateToken failed: %v", err)
|
||||||
|
}
|
||||||
|
if claims.Username != "admin" {
|
||||||
|
t.Errorf("expected username admin, got %s", claims.Username)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Update Password
|
||||||
|
if err := authService.UpdatePassword(claims.UserID, "wrong_old_pwd", "new_secure_pwd_456"); err == nil {
|
||||||
|
t.Errorf("expected error with incorrect old password")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := authService.UpdatePassword(claims.UserID, "initial_password_123", "short"); err == nil {
|
||||||
|
t.Errorf("expected error with too short new password")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := authService.UpdatePassword(claims.UserID, "initial_password_123", "new_secure_pwd_456"); err != nil {
|
||||||
|
t.Fatalf("UpdatePassword failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Authenticate with new password
|
||||||
|
newToken, err := authService.Authenticate("admin", "new_secure_pwd_456")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Authenticate with new password failed: %v", err)
|
||||||
|
}
|
||||||
|
if newToken == "" {
|
||||||
|
t.Fatalf("expected non-empty token for new password")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Old password should now fail
|
||||||
|
if _, err := authService.Authenticate("admin", "initial_password_123"); err == nil {
|
||||||
|
t.Errorf("expected failure when authenticating with old password")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -173,6 +173,15 @@ func (d *DB) CreateUser(username, passwordHash string) (*models.User, error) {
|
|||||||
return u, nil
|
return u, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (d *DB) UpdateUserPassword(userID, passwordHash string) error {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
_, err := d.Exec(
|
||||||
|
"UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?",
|
||||||
|
passwordHash, now, userID,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// Category operations
|
// Category operations
|
||||||
func (d *DB) ListCategories() ([]models.Category, error) {
|
func (d *DB) ListCategories() ([]models.Category, error) {
|
||||||
query := `
|
query := `
|
||||||
|
|||||||
@@ -102,4 +102,17 @@ func TestDatabaseOperations(t *testing.T) {
|
|||||||
if stats.TotalFeeds != 1 || stats.TotalUnread != 1 || stats.TotalStarred != 1 || stats.TotalCategories != 1 {
|
if stats.TotalFeeds != 1 || stats.TotalUnread != 1 || stats.TotalStarred != 1 || stats.TotalCategories != 1 {
|
||||||
t.Errorf("unexpected stats: %+v", stats)
|
t.Errorf("unexpected stats: %+v", stats)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 8. Test UpdateUserPassword
|
||||||
|
newHash := "$2a$10$updatedhashvalue"
|
||||||
|
if err := database.UpdateUserPassword(u.ID, newHash); err != nil {
|
||||||
|
t.Fatalf("UpdateUserPassword failed: %v", err)
|
||||||
|
}
|
||||||
|
updatedUser, err := database.GetUserByUsername("admin")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetUserByUsername failed: %v", err)
|
||||||
|
}
|
||||||
|
if updatedUser.PasswordHash != newHash {
|
||||||
|
t.Errorf("expected hash %s, got %s", newHash, updatedUser.PasswordHash)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -116,3 +116,9 @@ type MarkAllReadRequest struct {
|
|||||||
CategoryID *string `json:"category_id,omitempty"`
|
CategoryID *string `json:"category_id,omitempty"`
|
||||||
Before *string `json:"before,omitempty"`
|
Before *string `json:"before,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ChangePasswordRequest is the body for PUT /api/auth/password
|
||||||
|
type ChangePasswordRequest struct {
|
||||||
|
OldPassword string `json:"old_password"`
|
||||||
|
NewPassword string `json:"new_password"`
|
||||||
|
}
|
||||||
|
|||||||
+120
-75
@@ -43,6 +43,8 @@ export const App: React.FC = () => {
|
|||||||
return localStorage.getItem('rsspipe_selected_entry_id');
|
return localStorage.getItem('rsspipe_selected_entry_id');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const [activeArticle, setActiveArticle] = useState<FeedEntry | null>(null);
|
||||||
|
|
||||||
const [categories, setCategories] = useState<Category[]>([]);
|
const [categories, setCategories] = useState<Category[]>([]);
|
||||||
const [feeds, setFeeds] = useState<Feed[]>([]);
|
const [feeds, setFeeds] = useState<Feed[]>([]);
|
||||||
const [entries, setEntries] = useState<FeedEntry[]>([]);
|
const [entries, setEntries] = useState<FeedEntry[]>([]);
|
||||||
@@ -121,19 +123,11 @@ export const App: React.FC = () => {
|
|||||||
search: filter.search,
|
search: filter.search,
|
||||||
limit: 100,
|
limit: 100,
|
||||||
});
|
});
|
||||||
setEntries((prev) => {
|
setEntries(res.entries);
|
||||||
if (filter.view === 'inbox' && selectedEntryId) {
|
|
||||||
const currentActive = prev.find((e) => e.id === selectedEntryId);
|
|
||||||
if (currentActive && !res.entries.some((e) => e.id === selectedEntryId)) {
|
|
||||||
return [currentActive, ...res.entries];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return res.entries;
|
|
||||||
});
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load entries:', err);
|
console.error('Failed to load entries:', err);
|
||||||
}
|
}
|
||||||
}, [currentUser, filter, selectedEntryId]);
|
}, [currentUser, filter]);
|
||||||
|
|
||||||
// Initial load
|
// Initial load
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -143,27 +137,41 @@ export const App: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, [currentUser, loadSidebarData, loadEntries]);
|
}, [currentUser, loadSidebarData, loadEntries]);
|
||||||
|
|
||||||
// Sync focused index with persisted selectedEntryId
|
// Sync focused index & active article when entries change without disrupting reading state
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (entries.length > 0) {
|
if (entries.length > 0) {
|
||||||
|
if (activeArticle) {
|
||||||
|
const found = entries.find((e) => e.id === activeArticle.id);
|
||||||
|
if (found) {
|
||||||
|
setActiveArticle(found);
|
||||||
|
setFocusedIndex(entries.findIndex((e) => e.id === activeArticle.id));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Active article remains in reader canvas from prior feed, but list is unselected
|
||||||
|
setFocusedIndex(-1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (selectedEntryId) {
|
if (selectedEntryId) {
|
||||||
const idx = entries.findIndex((e) => e.id === selectedEntryId);
|
const found = entries.find((e) => e.id === selectedEntryId);
|
||||||
if (idx >= 0) {
|
if (found) {
|
||||||
setFocusedIndex(idx);
|
setActiveArticle(found);
|
||||||
|
setFocusedIndex(entries.findIndex((e) => e.id === selectedEntryId));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setFocusedIndex(0);
|
setFocusedIndex(0);
|
||||||
if (entries[0]) {
|
setActiveArticle(entries[0]);
|
||||||
setSelectedEntryId(entries[0].id);
|
setSelectedEntryId(entries[0].id);
|
||||||
localStorage.setItem('rsspipe_selected_entry_id', entries[0].id);
|
localStorage.setItem('rsspipe_selected_entry_id', entries[0].id);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, [entries, selectedEntryId]);
|
}, [entries]);
|
||||||
|
|
||||||
const handleSelectEntry = (index: number) => {
|
const handleSelectEntry = (index: number) => {
|
||||||
setFocusedIndex(index);
|
setFocusedIndex(index);
|
||||||
if (entries[index]) {
|
if (entries[index]) {
|
||||||
|
setActiveArticle(entries[index]);
|
||||||
setSelectedEntryId(entries[index].id);
|
setSelectedEntryId(entries[index].id);
|
||||||
localStorage.setItem('rsspipe_selected_entry_id', entries[index].id);
|
localStorage.setItem('rsspipe_selected_entry_id', entries[index].id);
|
||||||
}
|
}
|
||||||
@@ -211,6 +219,9 @@ export const App: React.FC = () => {
|
|||||||
item.id === entry.id ? { ...item, is_read: newStatus } : item
|
item.id === entry.id ? { ...item, is_read: newStatus } : item
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
if (activeArticle && activeArticle.id === entry.id) {
|
||||||
|
setActiveArticle((prev) => (prev ? { ...prev, is_read: newStatus } : prev));
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await api.updateRead(entry.id, newStatus);
|
await api.updateRead(entry.id, newStatus);
|
||||||
@@ -223,6 +234,9 @@ export const App: React.FC = () => {
|
|||||||
item.id === entry.id ? { ...item, is_read: !newStatus } : item
|
item.id === entry.id ? { ...item, is_read: !newStatus } : item
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
if (activeArticle && activeArticle.id === entry.id) {
|
||||||
|
setActiveArticle((prev) => (prev ? { ...prev, is_read: !newStatus } : prev));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -237,6 +251,9 @@ export const App: React.FC = () => {
|
|||||||
item.id === entry.id ? { ...item, is_starred: newStatus } : item
|
item.id === entry.id ? { ...item, is_starred: newStatus } : item
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
if (activeArticle && activeArticle.id === entry.id) {
|
||||||
|
setActiveArticle((prev) => (prev ? { ...prev, is_starred: newStatus } : prev));
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await api.updateStar(entry.id, newStatus);
|
await api.updateStar(entry.id, newStatus);
|
||||||
@@ -248,6 +265,9 @@ export const App: React.FC = () => {
|
|||||||
item.id === entry.id ? { ...item, is_starred: !newStatus } : item
|
item.id === entry.id ? { ...item, is_starred: !newStatus } : item
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
if (activeArticle && activeArticle.id === entry.id) {
|
||||||
|
setActiveArticle((prev) => (prev ? { ...prev, is_starred: !newStatus } : prev));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -256,6 +276,9 @@ export const App: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
await api.markAllRead(filter.feedId, filter.categoryId);
|
await api.markAllRead(filter.feedId, filter.categoryId);
|
||||||
setEntries((prev) => prev.map((item) => ({ ...item, is_read: true })));
|
setEntries((prev) => prev.map((item) => ({ ...item, is_read: true })));
|
||||||
|
if (activeArticle) {
|
||||||
|
setActiveArticle((prev) => (prev ? { ...prev, is_read: true } : prev));
|
||||||
|
}
|
||||||
loadSidebarData();
|
loadSidebarData();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to mark all read:', err);
|
console.error('Failed to mark all read:', err);
|
||||||
@@ -309,63 +332,11 @@ export const App: React.FC = () => {
|
|||||||
case 'j':
|
case 'j':
|
||||||
case 'J': // Next item
|
case 'J': // Next item
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setFocusedIndex((prev) => {
|
if (entries.length > 0) {
|
||||||
const next = Math.min(prev + 1, Math.max(0, entries.length - 1));
|
|
||||||
if (entries[next]) {
|
|
||||||
setSelectedEntryId(entries[next].id);
|
|
||||||
localStorage.setItem('rsspipe_selected_entry_id', entries[next].id);
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'k':
|
|
||||||
case 'K': // Previous item
|
|
||||||
e.preventDefault();
|
|
||||||
setFocusedIndex((prev) => {
|
|
||||||
const next = Math.max(prev - 1, 0);
|
|
||||||
if (entries[next]) {
|
|
||||||
setSelectedEntryId(entries[next].id);
|
|
||||||
localStorage.setItem('rsspipe_selected_entry_id', entries[next].id);
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'v':
|
|
||||||
case 'V': // Open original link
|
|
||||||
if (entries[focusedIndex]?.url) {
|
|
||||||
e.preventDefault();
|
|
||||||
window.open(entries[focusedIndex].url, '_blank');
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 's':
|
|
||||||
case 'S': // Toggle Star
|
|
||||||
if (entries[focusedIndex]) {
|
|
||||||
e.preventDefault();
|
|
||||||
handleToggleStar(entries[focusedIndex]);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'm':
|
|
||||||
case 'M': // Toggle Read / Unread
|
|
||||||
if (entries[focusedIndex]) {
|
|
||||||
e.preventDefault();
|
|
||||||
handleToggleRead(entries[focusedIndex]);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'e':
|
|
||||||
case 'E': // Archive / Mark read and advance
|
|
||||||
if (entries[focusedIndex]) {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!entries[focusedIndex].is_read) {
|
|
||||||
handleToggleRead(entries[focusedIndex]);
|
|
||||||
}
|
|
||||||
setFocusedIndex((prev) => {
|
setFocusedIndex((prev) => {
|
||||||
const next = Math.min(prev + 1, Math.max(0, entries.length - 1));
|
const next = prev < 0 ? 0 : Math.min(prev + 1, entries.length - 1);
|
||||||
if (entries[next]) {
|
if (entries[next]) {
|
||||||
|
setActiveArticle(entries[next]);
|
||||||
setSelectedEntryId(entries[next].id);
|
setSelectedEntryId(entries[next].id);
|
||||||
localStorage.setItem('rsspipe_selected_entry_id', entries[next].id);
|
localStorage.setItem('rsspipe_selected_entry_id', entries[next].id);
|
||||||
}
|
}
|
||||||
@@ -374,6 +345,79 @@ export const App: React.FC = () => {
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'k':
|
||||||
|
case 'K': // Previous item
|
||||||
|
e.preventDefault();
|
||||||
|
if (entries.length > 0) {
|
||||||
|
setFocusedIndex((prev) => {
|
||||||
|
const next = prev < 0 ? 0 : Math.max(prev - 1, 0);
|
||||||
|
if (entries[next]) {
|
||||||
|
setActiveArticle(entries[next]);
|
||||||
|
setSelectedEntryId(entries[next].id);
|
||||||
|
localStorage.setItem('rsspipe_selected_entry_id', entries[next].id);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'v':
|
||||||
|
case 'V': // Open original link
|
||||||
|
{
|
||||||
|
const targetUrl = activeArticle?.url || entries[focusedIndex]?.url;
|
||||||
|
if (targetUrl) {
|
||||||
|
e.preventDefault();
|
||||||
|
window.open(targetUrl, '_blank');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 's':
|
||||||
|
case 'S': // Toggle Star
|
||||||
|
{
|
||||||
|
const target = activeArticle || entries[focusedIndex];
|
||||||
|
if (target) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleToggleStar(target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'm':
|
||||||
|
case 'M': // Toggle Read / Unread
|
||||||
|
{
|
||||||
|
const target = activeArticle || entries[focusedIndex];
|
||||||
|
if (target) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleToggleRead(target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'e':
|
||||||
|
case 'E': // Archive / Mark read and advance
|
||||||
|
{
|
||||||
|
const target = activeArticle || entries[focusedIndex];
|
||||||
|
if (target) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!target.is_read) {
|
||||||
|
handleToggleRead(target);
|
||||||
|
}
|
||||||
|
if (entries.length > 0) {
|
||||||
|
setFocusedIndex((prev) => {
|
||||||
|
const next = prev < 0 ? 0 : Math.min(prev + 1, entries.length - 1);
|
||||||
|
if (entries[next]) {
|
||||||
|
setActiveArticle(entries[next]);
|
||||||
|
setSelectedEntryId(entries[next].id);
|
||||||
|
localStorage.setItem('rsspipe_selected_entry_id', entries[next].id);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
case 'a':
|
case 'a':
|
||||||
case 'A': // Add feed modal
|
case 'A': // Add feed modal
|
||||||
if (!e.shiftKey && !e.metaKey && !e.ctrlKey) {
|
if (!e.shiftKey && !e.metaKey && !e.ctrlKey) {
|
||||||
@@ -421,7 +465,7 @@ export const App: React.FC = () => {
|
|||||||
|
|
||||||
window.addEventListener('keydown', handleKeyDown);
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
}, [entries, focusedIndex]);
|
}, [entries, focusedIndex, activeArticle]);
|
||||||
|
|
||||||
if (authLoading) {
|
if (authLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -467,6 +511,7 @@ export const App: React.FC = () => {
|
|||||||
stats={stats}
|
stats={stats}
|
||||||
entries={entries}
|
entries={entries}
|
||||||
selectedIndex={focusedIndex}
|
selectedIndex={focusedIndex}
|
||||||
|
activeArticle={activeArticle}
|
||||||
onSelectEntry={handleSelectEntry}
|
onSelectEntry={handleSelectEntry}
|
||||||
onToggleRead={handleToggleRead}
|
onToggleRead={handleToggleRead}
|
||||||
onToggleStar={handleToggleStar}
|
onToggleStar={handleToggleStar}
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ import {
|
|||||||
Clock,
|
Clock,
|
||||||
Timer,
|
Timer,
|
||||||
Eye,
|
Eye,
|
||||||
|
Lock,
|
||||||
|
Key,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
interface SettingsViewProps {
|
interface SettingsViewProps {
|
||||||
@@ -71,6 +73,43 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
|||||||
const [opmlLoading, setOpmlLoading] = useState(false);
|
const [opmlLoading, setOpmlLoading] = useState(false);
|
||||||
const [opmlResult, setOpmlResult] = useState<string | null>(null);
|
const [opmlResult, setOpmlResult] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Change Password state
|
||||||
|
const [oldPassword, setOldPassword] = useState('');
|
||||||
|
const [newPassword, setNewPassword] = useState('');
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
|
const [passwordLoading, setPasswordLoading] = useState(false);
|
||||||
|
const [passwordError, setPasswordError] = useState<string | null>(null);
|
||||||
|
const [passwordSuccess, setPasswordSuccess] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleChangePassword = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setPasswordError(null);
|
||||||
|
setPasswordSuccess(null);
|
||||||
|
|
||||||
|
if (newPassword.length < 6) {
|
||||||
|
setPasswordError('New password must be at least 6 characters long');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newPassword !== confirmPassword) {
|
||||||
|
setPasswordError('New passwords do not match');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPasswordLoading(true);
|
||||||
|
try {
|
||||||
|
await api.changePassword(oldPassword, newPassword);
|
||||||
|
setPasswordSuccess('Password updated successfully! Please use your new password next time you log in.');
|
||||||
|
setOldPassword('');
|
||||||
|
setNewPassword('');
|
||||||
|
setConfirmPassword('');
|
||||||
|
} catch (err: any) {
|
||||||
|
setPasswordError(err.message || 'Failed to update password');
|
||||||
|
} finally {
|
||||||
|
setPasswordLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Filter feeds
|
// Filter feeds
|
||||||
const filteredFeeds = feeds.filter(
|
const filteredFeeds = feeds.filter(
|
||||||
(f) =>
|
(f) =>
|
||||||
@@ -633,6 +672,87 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="settings-card">
|
||||||
|
<h3 className="settings-card-title" style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<Lock size={15} style={{ color: 'var(--accent)' }} />
|
||||||
|
<span>Change Password</span>
|
||||||
|
</h3>
|
||||||
|
<p className="settings-card-subtitle">
|
||||||
|
Update your single-user administrator password. Minimum 6 characters required.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{passwordSuccess && (
|
||||||
|
<div style={{ background: '#f0fdf4', border: '1px solid #bbf7d0', color: 'var(--success)', padding: '0.75rem 1rem', borderRadius: 6, marginBottom: '1rem', fontSize: '0.85rem', display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<CheckCircle2 size={16} />
|
||||||
|
<span>{passwordSuccess}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{passwordError && (
|
||||||
|
<div style={{ background: '#fef2f2', border: '1px solid #fecaca', color: 'var(--danger)', padding: '0.75rem 1rem', borderRadius: 6, marginBottom: '1rem', fontSize: '0.85rem', display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<AlertCircle size={16} />
|
||||||
|
<span>{passwordError}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleChangePassword} style={{ maxWidth: 420, marginTop: '0.75rem' }}>
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">Current Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
className="form-input"
|
||||||
|
placeholder="Enter current password"
|
||||||
|
value={oldPassword}
|
||||||
|
onChange={(e) => setOldPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">New Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
className="form-input"
|
||||||
|
placeholder="Enter new password (min. 6 characters)"
|
||||||
|
value={newPassword}
|
||||||
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">Confirm New Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
className="form-input"
|
||||||
|
placeholder="Re-enter new password"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn-primary"
|
||||||
|
disabled={passwordLoading}
|
||||||
|
style={{ marginTop: '0.5rem' }}
|
||||||
|
>
|
||||||
|
{passwordLoading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 size={14} className="animate-spin" />
|
||||||
|
<span>Updating Password...</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Key size={14} />
|
||||||
|
<span>Update Password</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="settings-card">
|
<div className="settings-card">
|
||||||
<h3 className="settings-card-title">Storage & Engine Stats</h3>
|
<h3 className="settings-card-title">Storage & Engine Stats</h3>
|
||||||
<div style={{ fontSize: '0.85rem', color: 'var(--text-secondary)', marginTop: '0.5rem' }}>
|
<div style={{ fontSize: '0.85rem', color: 'var(--text-secondary)', marginTop: '0.5rem' }}>
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ interface ThreePaneViewProps {
|
|||||||
stats: Stats | null;
|
stats: Stats | null;
|
||||||
entries: FeedEntry[];
|
entries: FeedEntry[];
|
||||||
selectedIndex: number;
|
selectedIndex: number;
|
||||||
|
activeArticle?: FeedEntry | null;
|
||||||
onSelectEntry: (index: number) => void;
|
onSelectEntry: (index: number) => void;
|
||||||
onToggleRead: (entry: FeedEntry, e?: React.MouseEvent) => void;
|
onToggleRead: (entry: FeedEntry, e?: React.MouseEvent) => void;
|
||||||
onToggleStar: (entry: FeedEntry, e?: React.MouseEvent) => void;
|
onToggleStar: (entry: FeedEntry, e?: React.MouseEvent) => void;
|
||||||
@@ -35,6 +36,7 @@ export const ThreePaneView: React.FC<ThreePaneViewProps> = ({
|
|||||||
stats,
|
stats,
|
||||||
entries,
|
entries,
|
||||||
selectedIndex,
|
selectedIndex,
|
||||||
|
activeArticle,
|
||||||
onSelectEntry,
|
onSelectEntry,
|
||||||
onToggleRead,
|
onToggleRead,
|
||||||
onToggleStar,
|
onToggleStar,
|
||||||
@@ -44,7 +46,7 @@ export const ThreePaneView: React.FC<ThreePaneViewProps> = ({
|
|||||||
const cardRefs = useRef<(HTMLDivElement | null)[]>([]);
|
const cardRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||||
const readerPaneRef = useRef<HTMLDivElement>(null);
|
const readerPaneRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const selectedEntry = entries[selectedIndex] || entries[0];
|
const selectedEntry = activeArticle || entries[selectedIndex] || entries[0];
|
||||||
|
|
||||||
// Auto-scroll list to active card
|
// Auto-scroll list to active card
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -1130,3 +1130,17 @@ html, body, #root {
|
|||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
margin-bottom: 1.1rem;
|
margin-bottom: 1.1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-spin {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -64,6 +64,13 @@ export const api = {
|
|||||||
return request<User>('/auth/me');
|
return request<User>('/auth/me');
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async changePassword(old_password: string, new_password: string): Promise<{ message: string }> {
|
||||||
|
return request<{ message: string }>('/auth/password', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ old_password, new_password }),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
// Stats
|
// Stats
|
||||||
async getStats(): Promise<Stats> {
|
async getStats(): Promise<Stats> {
|
||||||
return request<Stats>('/stats');
|
return request<Stats>('/stats');
|
||||||
|
|||||||
Reference in New Issue
Block a user