From 95c163e37af964a5d9f4dc202a6a1bdd581f0e20 Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Thu, 21 May 2026 23:57:48 +0000 Subject: [PATCH] Add profile picture support - backend avatar storage and OIDC picture claims - profile and admin UI components - admin org cards, tests, and docs --- .gitignore | 1 + docs/API.md | 10 +- frontend/src/admin/AdminOrgDetail.vue | 25 +- frontend/src/admin/AdminUserDetail.vue | 48 +- frontend/src/assets/profile-generic.svg | 5 + frontend/src/components/HostProfileView.vue | 1 + frontend/src/components/Modal.vue | 6 +- frontend/src/components/ProfilePicture.vue | 126 +++++ .../components/ProfilePictureEditorModal.vue | 489 ++++++++++++++++++ frontend/src/components/ProfileView.vue | 56 +- frontend/src/components/UserBasicInfo.vue | 30 +- oidc.md | 8 +- paskia/fastapi/admin/adminapp.py | 8 +- paskia/fastapi/admin/users.py | 4 +- paskia/fastapi/mainapp.py | 1 + paskia/fastapi/oid.py | 8 +- paskia/fastapi/user.py | 91 +++- paskia/util/apistructs.py | 9 +- paskia/util/avatar.py | 102 ++++ paskia/util/oidjwt.py | 39 +- paskia/util/userinfo.py | 4 +- tests/conftest.py | 24 + tests/test_admin.py | 63 ++- tests/test_api.py | 129 ++++- tests/test_user.py | 137 ++++- 25 files changed, 1365 insertions(+), 59 deletions(-) create mode 100644 frontend/src/assets/profile-generic.svg create mode 100644 frontend/src/components/ProfilePicture.vue create mode 100644 frontend/src/components/ProfilePictureEditorModal.vue create mode 100644 paskia/util/avatar.py diff --git a/.gitignore b/.gitignore index 3fe319d..1afc8db 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ dist/ package-lock.json paskia.sqlite *.paskiadb +*.data /paskia/frontend-build /paskia/_version.py coverage-html/ diff --git a/docs/API.md b/docs/API.md index e420a5f..0f99123 100644 --- a/docs/API.md +++ b/docs/API.md @@ -26,13 +26,17 @@ The `validate` and `forward` endpoints take query arguments `perm=` and `max_age | Method | Path | Used for | Notes | |---:|---|---|---| -| PUT | `/auth/api/user/display-name` | Update the user’s display name | Body: JSON `{ "display_name": "..." }` | +| PATCH | `/auth/api/user/display-name` | Update the user’s display name | Body: JSON `{ "display_name": "..." }` | +| GET | `/auth/api/user/{uuid}/profile.webp` | Canonical avatar image URL | Public on the auth host; serves `image/webp` with `ETag` and short-lived cache headers | +| PUT | `/auth/api/user/{uuid}/profile.webp` | Upload or replace a user avatar | Multipart form with `file`; upload must already be square WebP prepared in the browser | +| DELETE | `/auth/api/user/{uuid}/profile.webp` | Remove a user avatar | Allowed for the user, master admin, or org admin for users in the same org | | POST | `/auth/api/user/logout-all` | Terminate all user sessions | Clears current host cookie | | DELETE | `/auth/api/user/session/{session_id}` | Terminate one session | Session IDs are server-issued | | DELETE | `/auth/api/user/credential/{uuid}` | Delete a credential | Requires recent authentication | | POST | `/auth/api/user/create-link` | Create a device-add link | Requires recent authentication | -These are used mostly from the user profile panel and modify the current user. +These are used mostly from the user profile panel. The avatar route is also used by admins when managing other users. +`GET /auth/api/user-info` includes `user.avatar_url` when the user has an uploaded avatar, using the same canonical `/auth/api/user/{uuid}/profile.webp` path. ### Admin API: `/auth/api/admin/*` @@ -72,6 +76,8 @@ E.g. Org admin cannot see anything of the other orgs that he has no admin access | GET | `/auth/api/admin/server-config/` | Get server config | Returns rp_name, auth_host, origins | | PATCH | `/auth/api/admin/server-config/` | Update server config | Body: JSON with rp_name, auth_host, origins | +Admins edit user avatars through the same canonical `/auth/api/user/{uuid}/profile.webp` PUT and DELETE endpoints. + ### WebSockets: `/auth/ws/*` | Path | Used for | Notes | diff --git a/frontend/src/admin/AdminOrgDetail.vue b/frontend/src/admin/AdminOrgDetail.vue index 9458fba..d808c66 100644 --- a/frontend/src/admin/AdminOrgDetail.vue +++ b/frontend/src/admin/AdminOrgDetail.vue @@ -1,6 +1,7 @@ diff --git a/frontend/src/assets/profile-generic.svg b/frontend/src/assets/profile-generic.svg new file mode 100644 index 0000000..63604d4 --- /dev/null +++ b/frontend/src/assets/profile-generic.svg @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/frontend/src/components/HostProfileView.vue b/frontend/src/components/HostProfileView.vue index 142fa79..2128b29 100644 --- a/frontend/src/components/HostProfileView.vue +++ b/frontend/src/components/HostProfileView.vue @@ -10,6 +10,7 @@
- @@ -17,7 +17,9 @@ const props = defineProps({ // Optional: index to help find next sibling when item is deleted focusIndex: { type: Number, default: -1 }, // Optional: selector for finding siblings when restoring focus - focusSiblingSelector: { type: String, default: '' } + focusSiblingSelector: { type: String, default: '' }, + // Optional: extra class name(s) for the modal panel + panelClass: { type: [String, Array, Object], default: '' } }) const emit = defineEmits(['close']) diff --git a/frontend/src/components/ProfilePicture.vue b/frontend/src/components/ProfilePicture.vue new file mode 100644 index 0000000..b85df79 --- /dev/null +++ b/frontend/src/components/ProfilePicture.vue @@ -0,0 +1,126 @@ + + + + + diff --git a/frontend/src/components/ProfilePictureEditorModal.vue b/frontend/src/components/ProfilePictureEditorModal.vue new file mode 100644 index 0000000..b78aff4 --- /dev/null +++ b/frontend/src/components/ProfilePictureEditorModal.vue @@ -0,0 +1,489 @@ + + + + + diff --git a/frontend/src/components/ProfileView.vue b/frontend/src/components/ProfileView.vue index 0e93325..eecf57a 100644 --- a/frontend/src/components/ProfileView.vue +++ b/frontend/src/components/ProfileView.vue @@ -15,6 +15,9 @@ v-if="authStore.userInfo?.user" ref="userBasicInfo" :name="authStore.userInfo.user.display_name" + :avatar-url="authStore.userInfo.user.avatar_url" + :avatar-render-version="avatarRenderVersion" + avatar-clickable :email="authStore.userInfo.user.email" :preferred_username="authStore.userInfo.user.preferred_username" :telephone="authStore.userInfo.user.telephone" @@ -26,6 +29,7 @@ :role-name="authStore.userInfo.role.display_name" update-endpoint="/auth/api/user/info" @saved="authStore.loadUserInfo()" + @avatar-click="openAvatarDialog" @edit="openEditDialog" @keydown="handleUserInfoKeydown" > @@ -131,6 +135,15 @@ + + showEditDialog.value || showRegLink.value) +const hasActiveModal = computed(() => showEditDialog.value || showAvatarDialog.value || showRegLink.value) watch(showEditDialog, (open) => { - if (!open) return + if (!open) { + return + } const user = authStore.userInfo.user editName.value = user.display_name ?? '' editEmail.value = user.email ?? '' @@ -196,7 +213,28 @@ onMounted(() => { updateInterval.value = setInterval(() => { if (authStore.userInfo) authStore.userInfo = { ...authStore.userInfo } }, 60000) }) -onUnmounted(() => { if (updateInterval.value) clearInterval(updateInterval.value) }) +onUnmounted(() => { + if (updateInterval.value) clearInterval(updateInterval.value) +}) + +const currentAvatarEndpoint = computed(() => { + const userUuid = authStore.userInfo?.user?.uuid + if (!userUuid) return null + return `/auth/api/user/${userUuid}/profile.webp` +}) + +const openAvatarDialog = () => { + showAvatarDialog.value = true +} + +const closeAvatarDialog = () => { + showAvatarDialog.value = false +} + +const handleProfilePictureUpdated = async () => { + await authStore.loadUserInfo() + avatarRenderVersion.value += 1 +} const addNewCredential = async () => { try { @@ -245,7 +283,7 @@ const handleBreadcrumbKeydown = (event) => { if (direction === 'down') { event.preventDefault() // Move to user info section - always focus edit button first - focusPreferred(userInfoSection.value, { primarySelector: '.mini-btn', itemSelector: '.mini-btn, .pairing-input' }) + focusPreferred(userInfoSection.value, { primarySelector: '.mini-btn', itemSelector: '.user-picture-btn, .mini-btn, .pairing-input' }) } // ArrowUp at the top does nothing } @@ -257,7 +295,7 @@ const handleUserInfoKeydown = (event) => { if (!direction) return event.preventDefault() - const itemSelector = '.mini-btn, .pairing-input' + const itemSelector = '.user-picture-btn, .mini-btn, .pairing-input' if (direction === 'left' || direction === 'right') { navigateButtonRow(userInfoSection.value, event.target, direction, { itemSelector }) @@ -278,7 +316,7 @@ const handleCredentialNavigateOut = (direction) => { focusPreferredButton(credentialButtons.value) } else if (direction === 'up' || direction === 'left') { // Focus user info section - always focus edit button first - focusPreferred(userInfoSection.value, { primarySelector: '.mini-btn', itemSelector: '.mini-btn, .pairing-input' }) + focusPreferred(userInfoSection.value, { primarySelector: '.mini-btn', itemSelector: '.user-picture-btn, .mini-btn, .pairing-input' }) } } @@ -399,6 +437,7 @@ const saveProfile = async () => { try { editError.value = '' saving.value = true + let changed = false const body = {} if (name !== user.display_name) body.display_name = name if (emailVal !== (user.email || null)) body.email = emailVal @@ -406,6 +445,9 @@ const saveProfile = async () => { if (telephoneVal !== (user.telephone || null)) body.telephone = telephoneVal if (Object.keys(body).length) { await apiJson('/auth/api/user/info', { method: 'PATCH', body }) + changed = true + } + if (changed) { await authStore.loadUserInfo() authStore.showMessage('Profile updated!', 'success', 3000) } diff --git a/frontend/src/components/UserBasicInfo.vue b/frontend/src/components/UserBasicInfo.vue index e0f9e41..0fa6a2c 100644 --- a/frontend/src/components/UserBasicInfo.vue +++ b/frontend/src/components/UserBasicInfo.vue @@ -1,9 +1,20 @@