diff --git a/flowbit-backend/core/migrations/0022_emailverificationtoken.py b/flowbit-backend/core/migrations/0022_emailverificationtoken.py index c706875..50b91f3 100644 --- a/flowbit-backend/core/migrations/0022_emailverificationtoken.py +++ b/flowbit-backend/core/migrations/0022_emailverificationtoken.py @@ -37,10 +37,10 @@ class Migration(migrations.Migration): ), migrations.AddIndex( model_name="emailverificationtoken", - index=models.Index(fields=["selector"], name="core_emailv_selecto_3d1c2c_idx"), + index=models.Index(fields=["selector"], name="core_emailv_selecto_ddf40e_idx"), ), migrations.AddIndex( model_name="emailverificationtoken", - index=models.Index(fields=["expires_at"], name="core_emailv_expires_21b7a3_idx"), + index=models.Index(fields=["expires_at"], name="core_emailv_expires_591e95_idx"), ), ] diff --git a/flowbit-backend/core/tests.py b/flowbit-backend/core/tests.py index b60cff5..2bc44f3 100644 --- a/flowbit-backend/core/tests.py +++ b/flowbit-backend/core/tests.py @@ -1,6 +1,7 @@ from io import StringIO from decimal import Decimal from datetime import datetime, time, timedelta +import tempfile from unittest.mock import patch from django.core.management import call_command @@ -545,25 +546,65 @@ def test_avatar_upload_updates_profile(self): token = Token.objects.create(user=self.user) self.client.credentials(HTTP_AUTHORIZATION=f'Token {token.key}') - avatar_file = SimpleUploadedFile( - 'avatar.png', - ( - b'\x89PNG\r\n\x1a\n' - b'\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x02\x00\x00\x00\x90wS\xde' - b'\x00\x00\x00\x0cIDATx\x9cc```\x00\x00\x00\x04\x00\x01\xf6\x178U' - b'\x00\x00\x00\x00IEND\xaeB`\x82' - ), - content_type='image/png', - ) - - response = self.client.post('/api/auth/avatar/', {'avatar': avatar_file}) + with tempfile.TemporaryDirectory() as media_root: + with override_settings( + MEDIA_ROOT=media_root, + STORAGES={ + "default": {"BACKEND": "django.core.files.storage.FileSystemStorage"}, + "staticfiles": {"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage"}, + }, + ): + avatar_file = SimpleUploadedFile( + 'avatar.png', + ( + b'\x89PNG\r\n\x1a\n' + b'\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x02\x00\x00\x00\x90wS\xde' + b'\x00\x00\x00\x0cIDATx\x9cc```\x00\x00\x00\x04\x00\x01\xf6\x178U' + b'\x00\x00\x00\x00IEND\xaeB`\x82' + ), + content_type='image/png', + ) + + response = self.client.post('/api/auth/avatar/', {'avatar': avatar_file}) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.user.refresh_from_db() + self.assertTrue(bool(self.user.profile.avatar)) + self.assertIsNotNone(response.data['user']['avatar_url']) + self.assertIn('?v=', response.data['user']['avatar_url']) + self.assertTrue(AuditLog.objects.filter(action='auth.avatar_updated', target_id=self.user.id).exists()) + + def test_avatar_delete_clears_profile_photo(self): + token = Token.objects.create(user=self.user) + self.client.credentials(HTTP_AUTHORIZATION=f'Token {token.key}') - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.user.refresh_from_db() - self.assertTrue(bool(self.user.profile.avatar)) - self.assertIsNotNone(response.data['user']['avatar_url']) - self.assertIn('?v=', response.data['user']['avatar_url']) - self.assertTrue(AuditLog.objects.filter(action='auth.avatar_updated', target_id=self.user.id).exists()) + with tempfile.TemporaryDirectory() as media_root: + with override_settings( + MEDIA_ROOT=media_root, + STORAGES={ + "default": {"BACKEND": "django.core.files.storage.FileSystemStorage"}, + "staticfiles": {"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage"}, + }, + ): + avatar_file = SimpleUploadedFile( + 'avatar.png', + ( + b'\x89PNG\r\n\x1a\n' + b'\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x02\x00\x00\x00\x90wS\xde' + b'\x00\x00\x00\x0cIDATx\x9cc```\x00\x00\x00\x04\x00\x01\xf6\x178U' + b'\x00\x00\x00\x00IEND\xaeB`\x82' + ), + content_type='image/png', + ) + self.client.post('/api/auth/avatar/', {'avatar': avatar_file}) + + response = self.client.delete('/api/auth/avatar/') + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.user.refresh_from_db() + self.assertFalse(bool(self.user.profile.avatar)) + self.assertIsNone(response.data['user']['avatar_url']) + self.assertTrue(AuditLog.objects.filter(action='auth.avatar_removed', target_id=self.user.id).exists()) def test_regular_user_cannot_delete_account_without_admin_override_code(self): token = Token.objects.create(user=self.user) diff --git a/flowbit-backend/core/views.py b/flowbit-backend/core/views.py index 87924de..afd059f 100644 --- a/flowbit-backend/core/views.py +++ b/flowbit-backend/core/views.py @@ -5150,6 +5150,27 @@ def post(self, request): status=status.HTTP_200_OK, ) + def delete(self, request): + profile, _ = Profile.objects.get_or_create(user=request.user) + previous_avatar = profile.avatar.name if profile.avatar else '' + if profile.avatar: + profile.avatar.delete(save=False) + profile.avatar = None + profile.save(update_fields=['avatar', 'updated_at']) + + record_audit_log( + request, + 'auth.avatar_removed', + target=request.user, + details=f"User '{request.user.username}' removed profile avatar", + changes={'before_avatar': previous_avatar, 'after_avatar': ''}, + ) + + return Response( + {'user': UserProfileSerializer(request.user, context={'request': request}).data}, + status=status.HTTP_200_OK, + ) + class UserManagementViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, mixins.DestroyModelMixin, viewsets.GenericViewSet): queryset = User.objects.all().order_by('username') diff --git a/flowbit-frontend/src/components/profile/profile-avatar-card.tsx b/flowbit-frontend/src/components/profile/profile-avatar-card.tsx index c11783b..7222b12 100644 --- a/flowbit-frontend/src/components/profile/profile-avatar-card.tsx +++ b/flowbit-frontend/src/components/profile/profile-avatar-card.tsx @@ -1,10 +1,11 @@ "use client"; -import { ChangeEvent, useState } from "react"; +import { ChangeEvent, useEffect, useRef, useState } from "react"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { faImagePortrait, faUpload } from "@fortawesome/free-solid-svg-icons"; +import { faImagePortrait, faRotate, faSpinner, faTrashCan } from "@fortawesome/free-solid-svg-icons"; import { Button } from "@/components/ui/button"; -import { uploadProfileAvatar, type AuthUser } from "@/lib/auth-client"; +import { ProfileDeleteModal } from "@/components/profile/profile-delete-modal"; +import { removeProfileAvatar, uploadProfileAvatar, type AuthUser } from "@/lib/auth-client"; import { ProfileAvatar } from "@/components/profile/profile-avatar"; type ProfileAvatarCardProps = { @@ -15,32 +16,85 @@ type ProfileAvatarCardProps = { export function ProfileAvatarCard({ user, onUserChange, onNotify }: ProfileAvatarCardProps) { const [selectedFile, setSelectedFile] = useState(null); + const [previewUrl, setPreviewUrl] = useState(null); const [errorMessage, setErrorMessage] = useState(""); const [isUploading, setIsUploading] = useState(false); + const [showRemoveConfirm, setShowRemoveConfirm] = useState(false); + const [isRemoving, setIsRemoving] = useState(false); + const inputRef = useRef(null); - function handleFileChange(event: ChangeEvent) { - setSelectedFile(event.target.files?.[0] || null); + useEffect(() => { + return () => { + if (previewUrl) { + URL.revokeObjectURL(previewUrl); + } + }; + }, [previewUrl]); + + async function uploadSelectedFile(file: File) { setErrorMessage(""); + setIsUploading(true); + + try { + const updatedUser = await uploadProfileAvatar(file); + onUserChange(updatedUser); + setSelectedFile(null); + if (previewUrl) { + URL.revokeObjectURL(previewUrl); + } + setPreviewUrl(null); + if (inputRef.current) { + inputRef.current.value = ""; + } + onNotify("Profile photo updated successfully."); + } catch (error) { + setErrorMessage(error instanceof Error ? error.message : "Unable to upload profile photo."); + if (inputRef.current) { + inputRef.current.value = ""; + } + } finally { + setIsUploading(false); + } } - async function handleUpload() { - if (!selectedFile) { - setErrorMessage("Choose an image to upload."); + function handleFileChange(event: ChangeEvent) { + const file = event.target.files?.[0] || null; + setSelectedFile(file); + setErrorMessage(""); + if (previewUrl) { + URL.revokeObjectURL(previewUrl); + } + if (!file) { + setPreviewUrl(null); return; } + const nextPreviewUrl = URL.createObjectURL(file); + setPreviewUrl(nextPreviewUrl); + void uploadSelectedFile(file); + } + + async function handleRemoveAvatar() { setErrorMessage(""); - setIsUploading(true); + setIsRemoving(true); try { - const updatedUser = await uploadProfileAvatar(selectedFile); + const updatedUser = await removeProfileAvatar(); onUserChange(updatedUser); + if (previewUrl) { + URL.revokeObjectURL(previewUrl); + } + setPreviewUrl(null); setSelectedFile(null); - onNotify("Profile photo updated successfully."); + setShowRemoveConfirm(false); + if (inputRef.current) { + inputRef.current.value = ""; + } + onNotify("Profile photo removed successfully."); } catch (error) { - setErrorMessage(error instanceof Error ? error.message : "Unable to upload profile photo."); + setErrorMessage(error instanceof Error ? error.message : "Unable to remove profile photo."); } finally { - setIsUploading(false); + setIsRemoving(false); } } @@ -49,24 +103,74 @@ export function ProfileAvatarCard({ user, onUserChange, onNotify }: ProfileAvata

Avatar

Profile photo

+

+ Keep your profile recognizable with a clear square photo. Changes apply across your FlowBit workspace. +

-
- -
- -

- {selectedFile ? selectedFile.name : "Upload a clear square image for the best result."} -

-
- + +
+ {user.avatar_url ? ( + + ) : null} +
@@ -76,6 +180,24 @@ export function ProfileAvatarCard({ user, onUserChange, onNotify }: ProfileAvata {errorMessage} ) : null} + + { + if (!isRemoving) { + setShowRemoveConfirm(false); + } + }} + onConfirm={handleRemoveAvatar} + confirmLabel="Confirm photo removal" + isSubmitting={isRemoving} + > +

+ You can upload a new photo again at any time from this profile page. +

+
); } diff --git a/flowbit-frontend/src/components/profile/profile-details-card.tsx b/flowbit-frontend/src/components/profile/profile-details-card.tsx index 2ce0bdc..c60af29 100644 --- a/flowbit-frontend/src/components/profile/profile-details-card.tsx +++ b/flowbit-frontend/src/components/profile/profile-details-card.tsx @@ -1,8 +1,8 @@ "use client"; -import { useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { faFloppyDisk } from "@fortawesome/free-solid-svg-icons"; +import { faCircleCheck, faFloppyDisk, faPenToSquare } from "@fortawesome/free-solid-svg-icons"; import { AuthInput } from "@/components/auth/auth-input"; import { Button } from "@/components/ui/button"; import { updateCurrentUserProfile, type AuthUser } from "@/lib/auth-client"; @@ -13,6 +13,20 @@ type ProfileDetailsCardProps = { onNotify: (message: string) => void; }; +function normalizeProfileForm(values: { + full_name: string; + username: string; + email: string; + phone_number: string; +}) { + return { + full_name: values.full_name.trim(), + username: values.username.trim(), + email: values.email.trim(), + phone_number: values.phone_number.trim(), + }; +} + export function ProfileDetailsCard({ user, onUserChange, onNotify }: ProfileDetailsCardProps) { const [formValues, setFormValues] = useState({ full_name: user.full_name || "", @@ -29,6 +43,36 @@ export function ProfileDetailsCard({ user, onUserChange, onNotify }: ProfileDeta const [errorMessage, setErrorMessage] = useState(""); const [isSaving, setIsSaving] = useState(false); + const normalizedInitialValues = useMemo( + () => + normalizeProfileForm({ + full_name: user.full_name || "", + username: user.username, + email: user.email || "", + phone_number: user.phone_number || "", + }), + [user.full_name, user.username, user.email, user.phone_number], + ); + const normalizedFormValues = useMemo(() => normalizeProfileForm(formValues), [formValues]); + const hasChanges = + normalizedFormValues.full_name !== normalizedInitialValues.full_name || + normalizedFormValues.username !== normalizedInitialValues.username || + normalizedFormValues.email !== normalizedInitialValues.email || + normalizedFormValues.phone_number !== normalizedInitialValues.phone_number; + + useEffect(() => { + if (hasChanges || isSaving) { + return; + } + + setFormValues({ + full_name: user.full_name || "", + username: user.username, + email: user.email || "", + phone_number: user.phone_number || "", + }); + }, [user.full_name, user.username, user.email, user.phone_number, hasChanges, isSaving]); + function validateForm() { const nextErrors: { full_name?: string; @@ -75,7 +119,7 @@ export function ProfileDetailsCard({ user, onUserChange, onNotify }: ProfileDeta setIsSaving(true); try { - const updatedUser = await updateCurrentUserProfile(formValues); + const updatedUser = await updateCurrentUserProfile(normalizedFormValues); onUserChange(updatedUser); setFormValues({ full_name: updatedUser.full_name || "", @@ -104,64 +148,94 @@ export function ProfileDetailsCard({ user, onUserChange, onNotify }: ProfileDeta

Account Details

Edit profile information

- You can update your full name, username, and phone number here. + Update the account details shown across your FlowBit workspace. Changes stay local until you save them.

-
- { - setFormValues((current) => ({ ...current, full_name: event.target.value })); - setFieldErrors((current) => ({ ...current, full_name: undefined })); - }} - /> - - { - setFormValues((current) => ({ ...current, username: event.target.value })); - setFieldErrors((current) => ({ ...current, username: undefined })); - }} - /> - - { - setFormValues((current) => ({ ...current, email: event.target.value })); - setFieldErrors((current) => ({ ...current, email: undefined })); - }} - /> - - { - setFormValues((current) => ({ ...current, phone_number: event.target.value })); - setFieldErrors((current) => ({ ...current, phone_number: undefined })); - }} - /> +
+
+
+

Profile editing

+

+ {hasChanges ? "You have unsaved changes." : "Everything is saved and up to date."} +

+
+ + + {hasChanges ? "Needs save" : "Saved"} + +
+ +
+
+

Identity details

+
+ { + setFormValues((current) => ({ ...current, full_name: event.target.value })); + setFieldErrors((current) => ({ ...current, full_name: undefined })); + }} + /> + + { + setFormValues((current) => ({ ...current, username: event.target.value })); + setFieldErrors((current) => ({ ...current, username: undefined })); + }} + /> +
+
+
+

Contact details

+
+ { + setFormValues((current) => ({ ...current, email: event.target.value })); + setFieldErrors((current) => ({ ...current, email: undefined })); + }} + /> + + { + setFormValues((current) => ({ ...current, phone_number: event.target.value })); + setFieldErrors((current) => ({ ...current, phone_number: undefined })); + }} + /> +
+
+
{errorMessage ? ( @@ -170,8 +244,11 @@ export function ProfileDetailsCard({ user, onUserChange, onNotify }: ProfileDeta
) : null} -
- diff --git a/flowbit-frontend/src/components/profile/profile-overview-card.tsx b/flowbit-frontend/src/components/profile/profile-overview-card.tsx index 24128f4..0cb01f6 100644 --- a/flowbit-frontend/src/components/profile/profile-overview-card.tsx +++ b/flowbit-frontend/src/components/profile/profile-overview-card.tsx @@ -8,28 +8,31 @@ type ProfileOverviewCardProps = { export function ProfileOverviewCard({ user }: ProfileOverviewCardProps) { return (
-
-
- -
-

User Profile

-

{user.full_name || user.username}

-

@{user.username}

+
+
+
+ +
+

User Profile

+
+

{user.full_name || user.username}

+ + {user.role || "User"} + +
+

@{user.username}

+
-
-
-

Role

-

{user.role}

+
+
+

Contact Email

+

{user.email || "Not provided"}

-
-

Email

-

{user.email || "Not provided"}

-
-
-

Phone

-

{user.phone_number || "Not provided"}

+
+

Phone Number

+

{user.phone_number || "Not provided"}

diff --git a/flowbit-frontend/src/lib/auth-client.ts b/flowbit-frontend/src/lib/auth-client.ts index dc9c6be..8801c58 100644 --- a/flowbit-frontend/src/lib/auth-client.ts +++ b/flowbit-frontend/src/lib/auth-client.ts @@ -258,6 +258,24 @@ export async function uploadProfileAvatar(file: File) { return data.user as AuthUser; } +export async function removeProfileAvatar() { + const token = getStoredToken(); + if (!token) { + throw new Error("No session found."); + } + + const response = await apiRequest<{ user: AuthUser }>("/auth/avatar/", { + method: "DELETE", + headers: authHeaders(token), + }); + + if (typeof window !== "undefined") { + window.localStorage.setItem(AUTH_USER_STORAGE_KEY, JSON.stringify(response.user)); + } + + return response.user; +} + export async function logoutFromBackend() { const token = getStoredToken(); if (token) {