Merge dev-2026-03-22 into main #10

Merged
hzhang merged 7 commits from dev-2026-03-22 into main 2026-03-22 14:06:54 +00:00
2 changed files with 58 additions and 3 deletions
Showing only changes of commit ce07ee9021 - Show all commits

View File

@@ -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(() => {

View File

@@ -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>
</>
) : (