Skip to content
Merged
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
52ee9a4
feat: add isValidAvatarUrl helper function
Mosas2000 Mar 14, 2026
893740b
fix: only render avatar preview for valid https URLs
Mosas2000 Mar 14, 2026
33751ee
feat: show validation message for non-HTTPS avatar URLs
Mosas2000 Mar 14, 2026
5d67c9b
fix: reject non-HTTPS avatar URLs on form submission
Mosas2000 Mar 14, 2026
8d537ff
refactor: import ImageOff icon for placeholder avatar
Mosas2000 Mar 14, 2026
c37d8a6
feat: show placeholder icon when avatar URL is invalid
Mosas2000 Mar 14, 2026
56f5478
fix: add referrerPolicy=no-referrer to avatar preview
Mosas2000 Mar 14, 2026
b29125b
fix: add crossOrigin=anonymous to avatar preview
Mosas2000 Mar 14, 2026
8c3c9d8
perf: add loading=lazy to avatar preview image
Mosas2000 Mar 14, 2026
00b790c
test: add data-testid to avatar preview for test assertions
Mosas2000 Mar 14, 2026
bfbdf93
test: add data-testid to invalid avatar indicator
Mosas2000 Mar 14, 2026
8dba871
a11y: link avatar input to validation error via aria-describedby
Mosas2000 Mar 14, 2026
58f1729
a11y: add id and role=alert to avatar validation error
Mosas2000 Mar 14, 2026
d7d0f79
feat: highlight avatar input border red when URL is invalid
Mosas2000 Mar 14, 2026
6dcc012
docs: add JSDoc to isValidAvatarUrl
Mosas2000 Mar 14, 2026
a915995
test: add data-testid to display name input
Mosas2000 Mar 14, 2026
918ccab
test: add data-testid to bio textarea
Mosas2000 Mar 14, 2026
80e0d5c
test: add data-testid to avatar URL input
Mosas2000 Mar 14, 2026
304b933
test: add data-testid to profile save button
Mosas2000 Mar 14, 2026
def5e0c
chore: verify display name has aria-required
Mosas2000 Mar 14, 2026
6fabf5d
fix: disable save button when avatar URL is invalid
Mosas2000 Mar 14, 2026
0152fc8
a11y: add aria-invalid to avatar input when URL fails validation
Mosas2000 Mar 14, 2026
f2bbc18
fix: trim whitespace from avatar URL input on change
Mosas2000 Mar 14, 2026
ef92953
docs: add JSDoc to fetchProfile
Mosas2000 Mar 14, 2026
9195710
docs: add JSDoc to validateForm
Mosas2000 Mar 14, 2026
a04f549
docs: add JSDoc to handleSaveProfile
Mosas2000 Mar 14, 2026
9474a08
style: constrain avatar preview max-width
Mosas2000 Mar 14, 2026
f4f24c1
a11y: add form role and aria-label to profile container
Mosas2000 Mar 14, 2026
4268a0a
test: add data-testid to profile loading indicator
Mosas2000 Mar 14, 2026
3ad573e
a11y: add aria-busy to profile loading state
Mosas2000 Mar 14, 2026
d7855ee
style: ensure trailing newline in ProfileManager.jsx
Mosas2000 Mar 14, 2026
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
63 changes: 53 additions & 10 deletions frontend/src/components/ProfileManager.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,24 @@
} from '@stacks/transactions';
import { network, appDetails, getSenderAddress } from '../utils/stacks';
import { CONTRACT_ADDRESS, CONTRACT_NAME, FN_GET_PROFILE, FN_UPDATE_PROFILE } from '../config/contracts';
import { User, Save, Loader2 } from 'lucide-react';
import { User, Save, Loader2, ImageOff } from 'lucide-react';

/**
* Validate that a URL is safe to render as an avatar image.
* Only HTTPS URLs are accepted to prevent tracking pixels,
* data: URI abuse, and internal network probes.
* @param {string} url
* @returns {boolean}
*/
function isValidAvatarUrl(url) {
if (!url) return false;
try {
const parsed = new URL(url);
return parsed.protocol === 'https:';
} catch {
return false;
}
}

export default function ProfileManager({ addToast }) {
const [displayName, setDisplayName] = useState('');
Expand All @@ -27,8 +44,9 @@
return;
}
fetchProfile();
}, [senderAddress]);

Check warning on line 47 in frontend/src/components/ProfileManager.jsx

View workflow job for this annotation

GitHub Actions / Frontend Lint

React Hook useEffect has a missing dependency: 'fetchProfile'. Either include it or remove the dependency array

/** Fetch the existing on-chain profile for the connected wallet. */
const fetchProfile = async () => {
try {
setLoading(true);
Expand Down Expand Up @@ -56,6 +74,7 @@
}
};

/** Validate all form fields before submission. */
const validateForm = () => {
if (!displayName.trim()) {
addToast?.('Display name is required', 'warning');
Expand All @@ -73,9 +92,14 @@
addToast?.('Avatar URL must be 256 characters or fewer', 'warning');
return false;
}
if (avatarUrl && !isValidAvatarUrl(avatarUrl)) {
addToast?.('Avatar URL must use HTTPS', 'warning');
return false;
}
return true;
};

/** Submit the profile update transaction to the wallet. */
const handleSaveProfile = async () => {
if (!validateForm()) return;

Expand Down Expand Up @@ -113,15 +137,15 @@

if (loading) {
return (
<div className="max-w-md mx-auto flex justify-center py-16">
<div data-testid="profile-loading" aria-busy="true" className="max-w-md mx-auto flex justify-center py-16">
<Loader2 className="w-6 h-6 animate-spin text-gray-400" />
</div>
);
}

return (
<div className="max-w-md mx-auto">
<div className="bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-800 p-6">
<div role="form" aria-label="Profile settings" className="bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-800 p-6">
<div className="flex items-center gap-3 mb-6">
<div className="h-10 w-10 rounded-xl bg-gradient-to-br from-violet-500 to-purple-600 flex items-center justify-center text-white">
<User className="w-5 h-5" aria-hidden="true" />
Expand All @@ -143,6 +167,7 @@
</label>
<input
id="profile-name"
data-testid="profile-name-input"
type="text"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
Expand All @@ -164,6 +189,7 @@
</label>
<textarea
id="profile-bio"
data-testid="profile-bio-input"
value={bio}
onChange={(e) => setBio(e.target.value)}
className="w-full px-4 py-2.5 border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 dark:text-white rounded-xl text-sm focus:ring-2 focus:ring-violet-500/50 focus:border-violet-500 outline-none transition-all resize-none"
Expand All @@ -183,34 +209,51 @@
</label>
<input
id="profile-avatar"
data-testid="profile-avatar-input"
aria-invalid={avatarUrl && !isValidAvatarUrl(avatarUrl) ? "true" : undefined}
type="url"
value={avatarUrl}
onChange={(e) => setAvatarUrl(e.target.value)}
className="w-full px-4 py-2.5 border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 dark:text-white rounded-xl text-sm focus:ring-2 focus:ring-violet-500/50 focus:border-violet-500 outline-none transition-all"
onChange={(e) => setAvatarUrl(e.target.value.trim())}
className={`w-full px-4 py-2.5 border bg-white dark:bg-gray-800 dark:text-white rounded-xl text-sm focus:ring-2 focus:ring-violet-500/50 focus:border-violet-500 outline-none transition-all ${avatarUrl && !isValidAvatarUrl(avatarUrl) ? 'border-red-300 dark:border-red-600' : 'border-gray-200 dark:border-gray-700'}`}
placeholder="https://example.com/avatar.png"
maxLength={256}
aria-describedby="profile-avatar-count"
aria-describedby={avatarUrl && !isValidAvatarUrl(avatarUrl) ? "avatar-validation-error profile-avatar-count" : "profile-avatar-count"}
/>
<p id="profile-avatar-count" className={`text-xs mt-1 text-right ${avatarUrl.length >= 256 ? 'text-red-500' : 'text-gray-400'}`}>
{avatarUrl.length}/256
</p>
</div>

{avatarUrl && (
<div className="flex items-center gap-3 p-3 bg-gray-50 dark:bg-gray-800 rounded-xl border border-gray-100 dark:border-gray-700">
{avatarUrl && isValidAvatarUrl(avatarUrl) && (
<div data-testid="avatar-preview" className="flex items-center gap-3 p-3 bg-gray-50 dark:bg-gray-800 rounded-xl border border-gray-100 dark:border-gray-700">
<img
src={avatarUrl}
alt="Avatar preview"
className="h-12 w-12 rounded-xl object-cover bg-gray-200 dark:bg-gray-700"
referrerPolicy="no-referrer"
crossOrigin="anonymous"
loading="lazy"
className="h-12 w-12 max-w-[3rem] rounded-xl object-cover bg-gray-200 dark:bg-gray-700"
onError={(e) => { e.target.style.display = 'none'; }}
/>
<p className="text-xs text-gray-500 dark:text-gray-400">Preview</p>
</div>
)}

{avatarUrl && !isValidAvatarUrl(avatarUrl) && (
<div data-testid="avatar-invalid" className="flex items-center gap-3 p-3 bg-red-50 dark:bg-red-900/20 rounded-xl border border-red-100 dark:border-red-800">
<div className="h-12 w-12 rounded-xl bg-red-100 dark:bg-red-900/40 flex items-center justify-center">
<ImageOff className="w-5 h-5 text-red-400" aria-hidden="true" />
</div>
<p id="avatar-validation-error" role="alert" className="text-xs text-red-500">
Avatar URL must use HTTPS
</p>
</div>
)}

<button
data-testid="profile-save-button"
onClick={handleSaveProfile}
disabled={saving || !displayName.trim()}
disabled={saving || !displayName.trim() || (avatarUrl && !isValidAvatarUrl(avatarUrl))}
className="w-full flex items-center justify-center gap-2 bg-gradient-to-r from-violet-500 to-purple-600 hover:from-violet-600 hover:to-purple-700 text-white font-bold py-3 px-4 rounded-xl shadow-sm hover:shadow-md transition-all active:scale-[0.98] disabled:opacity-40 disabled:cursor-not-allowed"
>
{saving ? (
Expand Down
Loading