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:
zhi
2026-03-22 05:39:12 +00:00
parent 4fc120f595
commit ce07ee9021
2 changed files with 58 additions and 3 deletions

View File

@@ -26,7 +26,7 @@ export default function ProjectDetailPage() {
const fetchProject = () => { const fetchProject = () => {
api.get<Project>(`/projects/${id}`).then(({ data }) => setProject(data)) api.get<Project>(`/projects/${id}`).then(({ data }) => setProject(data))
api.get<ProjectMember[]>(`/projects/${id}/members`).then(({ data }) => setMembers(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(() => { useEffect(() => {

View File

@@ -9,6 +9,11 @@ interface RoleOption {
description?: string | null description?: string | null
} }
interface ApiKeyPerms {
can_reset_self: boolean
can_reset_any: boolean
}
export default function UsersPage() { export default function UsersPage() {
const { user } = useAuth() const { user } = useAuth()
const isAdmin = user?.is_admin === true const isAdmin = user?.is_admin === true
@@ -19,6 +24,8 @@ export default function UsersPage() {
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [message, setMessage] = useState('') const [message, setMessage] = useState('')
const [selectedId, setSelectedId] = useState<number | null>(null) 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({ const [createForm, setCreateForm] = useState({
username: '', username: '',
@@ -51,6 +58,7 @@ export default function UsersPage() {
useEffect(() => { useEffect(() => {
if (!selectedUser) return if (!selectedUser) return
setGeneratedApiKey(null)
setEditForm({ setEditForm({
email: selectedUser.email, email: selectedUser.email,
full_name: selectedUser.full_name || '', full_name: selectedUser.full_name || '',
@@ -71,10 +79,12 @@ export default function UsersPage() {
const fetchData = async () => { const fetchData = async () => {
try { try {
const [usersRes, rolesRes] = await Promise.all([ const [usersRes, rolesRes, apikeyRes] = await Promise.all([
api.get<User[]>('/users'), api.get<User[]>('/users'),
api.get<RoleOption[]>('/roles'), 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 const assignableRoles = rolesRes.data
.filter((role) => role.name !== 'admin') .filter((role) => role.name !== 'admin')
.sort((a, b) => a.name.localeCompare(b.name)) .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 () => { const handleDeleteUser = async () => {
if (!selectedUser) return if (!selectedUser) return
if (!confirm(`Delete user ${selectedUser.username}? This cannot be undone.`)) 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}> <button className="btn-primary" disabled={saving} onClick={handleSaveUser}>
{saving ? 'Saving...' : 'Save Changes'} {saving ? 'Saving...' : 'Save Changes'}
</button> </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 Delete
</button> </button>
</div> </div>
@@ -326,6 +359,28 @@ export default function UsersPage() {
Active Active
</label> </label>
</div> </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> </div>
</> </>
) : ( ) : (