Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 43 additions & 1 deletion backend/app/api/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,19 @@
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession

from app.core.security import get_current_user
from app.core.security import get_current_user, hash_password, verify_password
from app.database import get_db
from app.models.agent import Agent
from app.models.user import User

router = APIRouter(prefix="/users", tags=["users"])


class PasswordReset(BaseModel):
new_password: str
old_password: str | None = None


class UserQuotaUpdate(BaseModel):
quota_message_limit: int | None = None
quota_message_period: str | None = None
Expand Down Expand Up @@ -98,6 +103,43 @@ async def list_users(
return out


@router.post("/{user_id}/reset-password")
async def reset_user_password(
user_id: uuid.UUID,
data: PasswordReset,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Reset a user's password. Admins can reset any user; users can reset their own."""
is_admin = current_user.role in ("platform_admin", "org_admin")
is_self = current_user.id == user_id
if not is_admin and not is_self:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not allowed")

if len(data.new_password) < 6:
raise HTTPException(status_code=400, detail="Password must be at least 6 characters")

if is_self and not is_admin:
if not data.old_password:
raise HTTPException(status_code=400, detail="Current password is required")

result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found")

if is_self and not is_admin:
if not verify_password(data.old_password, user.password_hash):
raise HTTPException(status_code=400, detail="Current password is incorrect")

if is_admin and not is_self and user.tenant_id != current_user.tenant_id:
raise HTTPException(status_code=403, detail="Cannot modify users outside your organization")

user.password_hash = hash_password(data.new_password)
await db.commit()
return {"success": True}


@router.patch("/{user_id}/quota", response_model=UserOut)
async def update_user_quota(
user_id: uuid.UUID,
Expand Down
76 changes: 71 additions & 5 deletions frontend/src/pages/UserManagement.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,11 @@ export default function UserManagement() {
});
const [saving, setSaving] = useState(false);
const [toast, setToast] = useState('');
const [toastType, setToastType] = useState<'success' | 'error'>('success');
const [resetUserId, setResetUserId] = useState<string | null>(null);
const [resetUsername, setResetUsername] = useState('');
const [newPassword, setNewPassword] = useState('');
const [resetting, setResetting] = useState(false);

// Search, sort & pagination
const [searchQuery, setSearchQuery] = useState('');
Expand Down Expand Up @@ -96,17 +101,36 @@ export default function UserManagement() {
method: 'PATCH',
body: JSON.stringify(editForm),
});
setToast(isChinese ? '配额已更新' : 'Quota updated');
setToast(isChinese ? '配额已更新' : 'Quota updated'); setToastType('success');
setTimeout(() => setToast(''), 2000);
setEditingUserId(null);
loadUsers();
} catch (e: any) {
setToast(`❌ ${e.message}`);
setToast(e.message); setToastType('error');
setTimeout(() => setToast(''), 3000);
}
setSaving(false);
};

const handleResetPassword = async () => {
if (!resetUserId || newPassword.length < 6) return;
setResetting(true);
try {
await fetchJson(`/users/${resetUserId}/reset-password`, {
method: 'POST',
body: JSON.stringify({ new_password: newPassword }),
});
setToast(isChinese ? '密码已重置' : 'Password reset successfully'); setToastType('success');
setTimeout(() => setToast(''), 2000);
setResetUserId(null);
setNewPassword('');
} catch (e: any) {
setToast(e.message); setToastType('error');
setTimeout(() => setToast(''), 3000);
}
setResetting(false);
};

const periodLabel = (period: string) => {
if (isChinese) {
const map: Record<string, string> = { permanent: '永久', daily: '每天', weekly: '每周', monthly: '每月' };
Expand Down Expand Up @@ -152,7 +176,7 @@ export default function UserManagement() {
{toast && (
<div style={{
position: 'fixed', top: '20px', right: '20px', padding: '10px 20px',
borderRadius: '8px', background: toast.startsWith('✅') ? 'var(--success)' : 'var(--error)',
borderRadius: '8px', background: toastType === 'success' ? 'var(--success)' : 'var(--error)',
color: '#fff', fontSize: '13px', zIndex: 9999, transition: 'all 0.3s',
}}>
{toast}
Expand Down Expand Up @@ -251,13 +275,20 @@ export default function UserManagement() {
<span style={{ fontSize: '11px', color: 'var(--text-tertiary)' }}> / {user.quota_max_agents}</span>
</div>
<div style={{ fontSize: '12px' }}>{user.quota_agent_ttl_hours}h</div>
<div>
<div style={{ display: 'flex', gap: '6px' }}>
<button
className="btn btn-secondary"
style={{ padding: '4px 10px', fontSize: '11px' }}
onClick={() => editingUserId === user.id ? setEditingUserId(null) : startEdit(user)}
>
{editingUserId === user.id ? t('common.cancel') : '✏️ Edit'}
{editingUserId === user.id ? t('common.cancel') : 'Edit'}
</button>
<button
className="btn btn-secondary"
style={{ padding: '4px 10px', fontSize: '11px' }}
onClick={() => { setResetUserId(user.id); setResetUsername(user.display_name || user.username); setNewPassword(''); }}
>
{isChinese ? '重置密码' : 'Reset Password'}
</button>
</div>
</div>
Expand Down Expand Up @@ -370,6 +401,41 @@ export default function UserManagement() {
)}
</div>
)}

{/* Reset Password Modal */}
{resetUserId && (
<div style={{
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)',
display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000,
}}>
<div className="card" style={{ padding: '24px', width: '360px', background: 'var(--bg-primary)' }}>
<h3 style={{ margin: '0 0 16px', fontSize: '16px' }}>
{isChinese ? `重置 ${resetUsername} 的密码` : `Reset password for ${resetUsername}`}
</h3>
<input
type="password"
className="input"
placeholder={isChinese ? '新密码(至少6位)' : 'New password (min 6 chars)'}
value={newPassword}
onChange={e => setNewPassword(e.target.value)}
style={{ width: '100%', marginBottom: '16px' }}
autoFocus
/>
<div style={{ display: 'flex', gap: '8px', justifyContent: 'flex-end' }}>
<button className="btn btn-secondary" onClick={() => { setResetUserId(null); setNewPassword(''); }}>
{t('common.cancel')}
</button>
<button
className="btn btn-primary"
onClick={handleResetPassword}
disabled={resetting || newPassword.length < 6}
>
{resetting ? '...' : (isChinese ? '确认重置' : 'Confirm')}
</button>
</div>
</div>
</div>
)}
</div>
);
}