Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
),
]
77 changes: 59 additions & 18 deletions flowbit-backend/core/tests.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)
Expand Down
21 changes: 21 additions & 0 deletions flowbit-backend/core/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
178 changes: 150 additions & 28 deletions flowbit-frontend/src/components/profile/profile-avatar-card.tsx
Original file line number Diff line number Diff line change
@@ -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 = {
Expand All @@ -15,32 +16,85 @@ type ProfileAvatarCardProps = {

export function ProfileAvatarCard({ user, onUserChange, onNotify }: ProfileAvatarCardProps) {
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [errorMessage, setErrorMessage] = useState("");
const [isUploading, setIsUploading] = useState(false);
const [showRemoveConfirm, setShowRemoveConfirm] = useState(false);
const [isRemoving, setIsRemoving] = useState(false);
const inputRef = useRef<HTMLInputElement | null>(null);

function handleFileChange(event: ChangeEvent<HTMLInputElement>) {
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<HTMLInputElement>) {
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);
}
}

Expand All @@ -49,24 +103,74 @@ export function ProfileAvatarCard({ user, onUserChange, onNotify }: ProfileAvata
<div>
<p className="text-[12px] font-semibold uppercase tracking-[0.18em] text-stone-500">Avatar</p>
<h2 className="mt-2 text-2xl font-semibold text-stone-950">Profile photo</h2>
<p className="mt-2 max-w-2xl text-sm leading-6 text-stone-600">
Keep your profile recognizable with a clear square photo. Changes apply across your FlowBit workspace.
</p>
</div>

<div className="mt-5 flex flex-col gap-5 sm:flex-row sm:items-center">
<ProfileAvatar user={user} />
<div className="flex-1">
<label className="inline-flex cursor-pointer items-center gap-2 rounded-[20px] border border-stone-900/10 bg-[#f8f6f2] px-4 py-3 text-sm font-semibold text-stone-700 transition hover:bg-stone-100">
<FontAwesomeIcon icon={faImagePortrait} className="h-4 w-4" />
Choose photo
<input type="file" accept="image/*" className="hidden" onChange={handleFileChange} />
</label>
<p className="mt-3 text-sm text-stone-500">
{selectedFile ? selectedFile.name : "Upload a clear square image for the best result."}
</p>
<div className="mt-4">
<Button size="lg" onClick={handleUpload} disabled={isUploading || !selectedFile}>
<FontAwesomeIcon icon={faUpload} className="h-4 w-4" />
{isUploading ? "Uploading..." : "Upload photo"}
<div className="mt-6 rounded-[24px] bg-[#f8f6f2] p-5">
<div className="flex flex-col gap-6 lg:flex-row lg:items-center">
<div className="flex items-center gap-4">
<div className="relative shrink-0">
{previewUrl ? (
<img
src={previewUrl}
alt="Selected profile preview"
className="h-24 w-24 rounded-[30px] object-cover"
/>
) : (
<ProfileAvatar user={user} className="h-24 w-24 rounded-[30px]" textClassName="text-3xl font-semibold" />
)}
{isUploading ? (
<div className="absolute inset-0 flex items-center justify-center rounded-[30px] bg-stone-950/55 text-white">
<FontAwesomeIcon icon={faSpinner} className="h-5 w-5 animate-spin" />
</div>
) : null}
</div>

<div className="min-w-0">
<p className="text-lg font-semibold text-stone-950">
{user.full_name || user.username}
</p>
<p className="mt-1 text-sm text-stone-500">
{isUploading
? `Uploading ${selectedFile?.name || "photo"}...`
: selectedFile
? `${selectedFile.name} selected. Uploading now.`
: user.avatar_url
? "Your current profile photo is active."
: "You are currently using initials as your avatar."}
</p>
</div>
</div>

<div className="flex flex-1 flex-col gap-3 lg:items-end">
<input ref={inputRef} type="file" accept="image/*" className="hidden" onChange={handleFileChange} />
<Button
size="default"
variant="outline"
className="min-w-[170px]"
onClick={() => inputRef.current?.click()}
disabled={isUploading || isRemoving}
>
<FontAwesomeIcon icon={selectedFile ? faRotate : faImagePortrait} className="h-4 w-4" />
{user.avatar_url ? "Replace photo" : "Choose photo"}
</Button>

<div className="flex flex-wrap gap-3 lg:justify-end">
{user.avatar_url ? (
<Button
size="default"
variant="outline"
className="min-w-[170px] border-red-200 text-red-700 hover:bg-red-50"
onClick={() => setShowRemoveConfirm(true)}
disabled={isUploading || isRemoving}
>
<FontAwesomeIcon icon={faTrashCan} className="h-4 w-4" />
{isRemoving ? "Removing..." : "Remove photo"}
</Button>
) : null}
</div>
</div>
</div>
</div>
Expand All @@ -76,6 +180,24 @@ export function ProfileAvatarCard({ user, onUserChange, onNotify }: ProfileAvata
{errorMessage}
</div>
) : null}

<ProfileDeleteModal
title="Remove current profile photo?"
description="This clears your current avatar and switches the account back to initials everywhere in FlowBit."
isOpen={showRemoveConfirm}
onClose={() => {
if (!isRemoving) {
setShowRemoveConfirm(false);
}
}}
onConfirm={handleRemoveAvatar}
confirmLabel="Confirm photo removal"
isSubmitting={isRemoving}
>
<p className="text-sm leading-6 text-stone-600">
You can upload a new photo again at any time from this profile page.
</p>
</ProfileDeleteModal>
</section>
);
}
Loading
Loading