Fix project detail milestones fetch + reset-apikey UI
- Fix: ProjectDetailPage now uses /projects/{id}/milestones instead of /milestones?project_id={code} (fixes 422)
- Add: Reset API Key button on UsersPage with permission-based visibility
- Add: One-time key display with copy-to-clipboard
- Protect admin and acc-mgr accounts from deletion in UI
This commit is contained in:
@@ -26,7 +26,7 @@ export default function ProjectDetailPage() {
|
||||
const fetchProject = () => {
|
||||
api.get<Project>(`/projects/${id}`).then(({ data }) => setProject(data))
|
||||
api.get<ProjectMember[]>(`/projects/${id}/members`).then(({ data }) => setMembers(data))
|
||||
api.get<Milestone[]>(`/milestones?project_id=${id}`).then(({ data }) => setMilestones(data))
|
||||
api.get<Milestone[]>(`/projects/${id}/milestones`).then(({ data }) => setMilestones(data))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -9,6 +9,11 @@ interface RoleOption {
|
||||
description?: string | null
|
||||
}
|
||||
|
||||
interface ApiKeyPerms {
|
||||
can_reset_self: boolean
|
||||
can_reset_any: boolean
|
||||
}
|
||||
|
||||
export default function UsersPage() {
|
||||
const { user } = useAuth()
|
||||
const isAdmin = user?.is_admin === true
|
||||
@@ -19,6 +24,8 @@ export default function UsersPage() {
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [message, setMessage] = useState('')
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null)
|
||||
const [apikeyPerms, setApikeyPerms] = useState<ApiKeyPerms>({ can_reset_self: false, can_reset_any: false })
|
||||
const [generatedApiKey, setGeneratedApiKey] = useState<string | null>(null)
|
||||
|
||||
const [createForm, setCreateForm] = useState({
|
||||
username: '',
|
||||
@@ -51,6 +58,7 @@ export default function UsersPage() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedUser) return
|
||||
setGeneratedApiKey(null)
|
||||
setEditForm({
|
||||
email: selectedUser.email,
|
||||
full_name: selectedUser.full_name || '',
|
||||
@@ -71,10 +79,12 @@ export default function UsersPage() {
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const [usersRes, rolesRes] = await Promise.all([
|
||||
const [usersRes, rolesRes, apikeyRes] = await Promise.all([
|
||||
api.get<User[]>('/users'),
|
||||
api.get<RoleOption[]>('/roles'),
|
||||
api.get<ApiKeyPerms>('/auth/me/apikey-permissions').catch(() => ({ data: { can_reset_self: false, can_reset_any: false } })),
|
||||
])
|
||||
setApikeyPerms(apikeyRes.data)
|
||||
const assignableRoles = rolesRes.data
|
||||
.filter((role) => role.name !== 'admin')
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
@@ -152,6 +162,29 @@ export default function UsersPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const canResetApiKey = (targetUser: User) => {
|
||||
if (apikeyPerms.can_reset_any) return true
|
||||
if (apikeyPerms.can_reset_self && targetUser.id === user?.id) return true
|
||||
return false
|
||||
}
|
||||
|
||||
const handleResetApiKey = async () => {
|
||||
if (!selectedUser) return
|
||||
if (!confirm(`Reset API key for ${selectedUser.username}? The old key will be deactivated.`)) return
|
||||
setSaving(true)
|
||||
setMessage('')
|
||||
setGeneratedApiKey(null)
|
||||
try {
|
||||
const { data } = await api.post(`/users/${selectedUser.id}/reset-apikey`)
|
||||
setGeneratedApiKey(data.api_key)
|
||||
setMessage('API key reset successfully. Copy it now — it will not be shown again.')
|
||||
} catch (err: any) {
|
||||
setMessage(err.response?.data?.detail || 'Failed to reset API key')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteUser = async () => {
|
||||
if (!selectedUser) return
|
||||
if (!confirm(`Delete user ${selectedUser.username}? This cannot be undone.`)) return
|
||||
@@ -274,7 +307,7 @@ export default function UsersPage() {
|
||||
<button className="btn-primary" disabled={saving} onClick={handleSaveUser}>
|
||||
{saving ? 'Saving...' : 'Save Changes'}
|
||||
</button>
|
||||
<button className="btn-danger" disabled={saving || user?.id === selectedUser.id} onClick={handleDeleteUser}>
|
||||
<button className="btn-danger" disabled={saving || user?.id === selectedUser.id || selectedUser.is_admin || selectedUser.username === 'acc-mgr'} onClick={handleDeleteUser}>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
@@ -326,6 +359,28 @@ export default function UsersPage() {
|
||||
Active
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{canResetApiKey(selectedUser) && (
|
||||
<div style={{ marginTop: '8px', padding: '12px', border: '1px solid var(--border)', borderRadius: '8px' }}>
|
||||
<div style={{ fontWeight: 600, marginBottom: '10px' }}>API Key</div>
|
||||
<button className="btn-secondary" disabled={saving} onClick={handleResetApiKey}>
|
||||
🔑 Reset API Key
|
||||
</button>
|
||||
{generatedApiKey && (
|
||||
<div style={{ marginTop: 10, padding: '10px', background: 'rgba(16,185,129,.08)', border: '1px solid rgba(16,185,129,.35)', borderRadius: '6px', wordBreak: 'break-all', fontFamily: 'monospace', fontSize: '13px' }}>
|
||||
<div style={{ marginBottom: 6, fontWeight: 600, fontFamily: 'inherit' }}>New API Key (copy now!):</div>
|
||||
<code>{generatedApiKey}</code>
|
||||
<button
|
||||
className="btn-sm"
|
||||
style={{ marginLeft: 8 }}
|
||||
onClick={() => { navigator.clipboard.writeText(generatedApiKey); setMessage('API key copied to clipboard') }}
|
||||
>
|
||||
📋 Copy
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
|
||||
Reference in New Issue
Block a user