Implement session termination in admin API, for completeness.

This commit is contained in:
Leo Vasanko
2025-12-03 01:20:52 +00:00
parent ca8d65ad25
commit bd13dbd1a0
4 changed files with 84 additions and 7 deletions
+7 -2
View File
@@ -501,16 +501,20 @@ async function toggleOrgPermission(org, permId, checked) {
function openDialog(type, data) { dialog.value = { type, data, busy: false, error: '' } }
function closeDialog() { dialog.value = { type: null, data: null, busy: false, error: '' } }
async function onUserNameSaved() {
async function refreshUserDetail() {
await loadOrgs()
if (selectedUser.value) {
try {
const r = await fetch(`/auth/api/admin/orgs/${selectedUser.value.org_uuid}/users/${selectedUser.value.uuid}`)
const r = await fetch(`/auth/api/admin/orgs/${selectedUser.value.org_uuid}/users/${selectedUser.value.uuid}`)
const jd = await r.json()
if (!r.ok || jd.detail) throw new Error(jd.detail || 'Reload failed')
userDetail.value = jd
} catch (e) { authStore.showMessage(e.message || 'Failed to reload user', 'error') }
}
}
async function onUserNameSaved() {
await refreshUserDetail()
authStore.showMessage('User renamed', 'success', 1500)
}
@@ -625,6 +629,7 @@ async function submitDialog() {
@go-overview="goOverview"
@open-org="openOrg"
@on-user-name-saved="onUserNameSaved"
@refresh-user-detail="refreshUserDetail"
@edit-user-name="editUserName"
@close-reg-modal="showRegModal = false"
/>
+33 -3
View File
@@ -14,9 +14,10 @@ const props = defineProps({
showRegModal: Boolean
})
const emit = defineEmits(['generateUserRegistrationLink', 'goOverview', 'openOrg', 'onUserNameSaved', 'closeRegModal', 'editUserName'])
const emit = defineEmits(['generateUserRegistrationLink', 'goOverview', 'openOrg', 'onUserNameSaved', 'closeRegModal', 'editUserName', 'refreshUserDetail'])
const authStore = useAuthStore()
const terminatingSessions = ref({})
function onLinkCopied() {
authStore.showMessage('Link copied to clipboard!')
@@ -39,6 +40,34 @@ function handleDelete(credential) {
.catch(err => console.error('Delete credential error', err))
}
async function handleTerminateSession(session) {
const sessionId = session?.id
if (!sessionId) return
terminatingSessions.value = { ...terminatingSessions.value, [sessionId]: true }
try {
const res = await fetch(`/auth/api/admin/orgs/${props.selectedUser.org_uuid}/users/${props.selectedUser.uuid}/sessions/${sessionId}`, { method: 'DELETE' })
const data = await res.json()
if (data.status === 'ok') {
if (data.current_session_terminated) {
sessionStorage.clear()
location.reload()
return
}
emit('refreshUserDetail') // Refresh without showing rename message
authStore.showMessage('Session terminated', 'success', 2500)
} else {
authStore.showMessage(data.detail || 'Failed to terminate session', 'error')
}
} catch (err) {
console.error('Terminate session error', err)
authStore.showMessage('Failed to terminate session', 'error')
} finally {
const next = { ...terminatingSessions.value }
delete next[sessionId]
terminatingSessions.value = next
}
}
</script>
<template>
@@ -84,9 +113,10 @@ function handleDelete(credential) {
</section>
<SessionList
:sessions="userDetail.sessions || []"
:allow-terminate="false"
:terminating-sessions="terminatingSessions"
:empty-message="'This user has no active sessions.'"
:section-description="'View the active sessions for this user.'"
:section-description="'View and manage the active sessions for this user.'"
@terminate="handleTerminateSession"
/>
</template>
<div class="actions ancillary-actions">
-2
View File
@@ -24,7 +24,6 @@
<span v-if="session.is_current" class="badge badge-current">Current</span>
<span v-else-if="isSameNetwork(session.ip)" class="badge">Same IP</span>
<button
v-if="allowTerminate"
@click="$emit('terminate', session)"
class="btn-card-delete"
:disabled="isTerminating(session.id)"
@@ -54,7 +53,6 @@ import { formatDate } from '@/utils/helpers'
const props = defineProps({
sessions: { type: Array, default: () => [] },
allowTerminate: { type: Boolean, default: true },
emptyMessage: { type: String, default: 'You currently have no other active sessions.' },
sectionDescription: { type: String, default: "Review where you're signed in and end any sessions you no longer recognize." },
terminatingSessions: { type: Object, default: () => ({}) }
+44
View File
@@ -660,6 +660,50 @@ async def admin_delete_user_credential(
return {"status": "ok"}
@app.delete("/orgs/{org_uuid}/users/{user_uuid}/sessions/{session_id}")
async def admin_delete_user_session(
org_uuid: UUID,
user_uuid: UUID,
session_id: str,
request: Request,
auth=AUTH_COOKIE,
):
try:
user_org, _role_name = await db.instance.get_user_organization(user_uuid)
except ValueError:
raise HTTPException(status_code=404, detail="User not found")
if user_org.uuid != org_uuid:
raise HTTPException(status_code=404, detail="User not found in organization")
ctx = await authz.verify(
auth,
["auth:admin", f"auth:org:{org_uuid}"],
match=permutil.has_any,
host=request.headers.get("host"),
)
if (
"auth:admin" not in ctx.role.permissions
and f"auth:org:{org_uuid}" not in ctx.role.permissions
):
raise HTTPException(status_code=403, detail="Insufficient permissions")
try:
target_key = tokens.decode_session_key(session_id)
except ValueError as exc:
raise HTTPException(
status_code=400, detail="Invalid session identifier"
) from exc
target_session = await db.instance.get_session(target_key)
if not target_session or target_session.user_uuid != user_uuid:
raise HTTPException(status_code=404, detail="Session not found")
await db.instance.delete_session(target_key)
# Check if admin terminated their own session
current_terminated = target_key == session_key(auth)
return {"status": "ok", "current_session_terminated": current_terminated}
# -------------------- Permissions (global) --------------------