feat: add password management, reading persistence on feed switch, and spin animation

This commit is contained in:
2026-08-24 21:53:54 -07:00
parent d7ed8876dc
commit 5ea176d5ad
12 changed files with 424 additions and 76 deletions
+21
View File
@@ -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()
+1
View File
@@ -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
+23
View File
@@ -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(&currentHash)
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 {
+87
View File
@@ -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")
}
}
+9
View File
@@ -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 := `
+13
View File
@@ -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)
}
} }
+6
View File
@@ -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"`
}
+75 -30
View File
@@ -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]);
}, [entries, selectedEntryId]);
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,69 +332,90 @@ export const App: React.FC = () => {
case 'j': case 'j':
case 'J': // Next item case 'J': // Next item
e.preventDefault(); e.preventDefault();
if (entries.length > 0) {
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);
} }
return next; return next;
}); });
}
break; break;
case 'k': case 'k':
case 'K': // Previous item case 'K': // Previous item
e.preventDefault(); e.preventDefault();
if (entries.length > 0) {
setFocusedIndex((prev) => { setFocusedIndex((prev) => {
const next = Math.max(prev - 1, 0); const next = prev < 0 ? 0 : Math.max(prev - 1, 0);
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);
} }
return next; return next;
}); });
}
break; break;
case 'v': case 'v':
case 'V': // Open original link case 'V': // Open original link
if (entries[focusedIndex]?.url) { {
const targetUrl = activeArticle?.url || entries[focusedIndex]?.url;
if (targetUrl) {
e.preventDefault(); e.preventDefault();
window.open(entries[focusedIndex].url, '_blank'); window.open(targetUrl, '_blank');
}
} }
break; break;
case 's': case 's':
case 'S': // Toggle Star case 'S': // Toggle Star
if (entries[focusedIndex]) { {
const target = activeArticle || entries[focusedIndex];
if (target) {
e.preventDefault(); e.preventDefault();
handleToggleStar(entries[focusedIndex]); handleToggleStar(target);
}
} }
break; break;
case 'm': case 'm':
case 'M': // Toggle Read / Unread case 'M': // Toggle Read / Unread
if (entries[focusedIndex]) { {
const target = activeArticle || entries[focusedIndex];
if (target) {
e.preventDefault(); e.preventDefault();
handleToggleRead(entries[focusedIndex]); handleToggleRead(target);
}
} }
break; break;
case 'e': case 'e':
case 'E': // Archive / Mark read and advance case 'E': // Archive / Mark read and advance
if (entries[focusedIndex]) { {
const target = activeArticle || entries[focusedIndex];
if (target) {
e.preventDefault(); e.preventDefault();
if (!entries[focusedIndex].is_read) { if (!target.is_read) {
handleToggleRead(entries[focusedIndex]); handleToggleRead(target);
} }
if (entries.length > 0) {
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);
} }
return next; return next;
}); });
} }
}
}
break; break;
case 'a': case 'a':
@@ -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}
+120
View File
@@ -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' }}>
+3 -1
View File
@@ -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(() => {
+14
View File
@@ -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;
}
+7
View File
@@ -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');