From 0760530d0cb65ae77961247ec46d76b80d4ed831 Mon Sep 17 00:00:00 2001 From: Daniel Salazar Date: Wed, 21 Oct 2026 12:57:15 -0700 Subject: [PATCH] feat(gui): edit display name and bio from the Account tab (#4293) --- src/docs/src/Auth/getProfile.md | 2 +- src/gui/src/UI/Dashboard/TabAccount.js | 31 ++++++ src/gui/src/UI/UIWindowEditProfile.js | 116 +++++++++++++++++++++ src/gui/src/UI/UIWindowEditProfile.test.js | 87 ++++++++++++++++ src/gui/src/helpers.js | 5 + src/gui/src/i18n/translations/en.js | 7 ++ 6 files changed, 247 insertions(+), 1 deletion(-) create mode 100644 src/gui/src/UI/UIWindowEditProfile.js create mode 100644 src/gui/src/UI/UIWindowEditProfile.test.js diff --git a/src/docs/src/Auth/getProfile.md b/src/docs/src/Auth/getProfile.md index 59abcc5e6..c9f34e9b7 100644 --- a/src/docs/src/Auth/getProfile.md +++ b/src/docs/src/Auth/getProfile.md @@ -4,7 +4,7 @@ description: Read a user's profile, returning null when it is not available. platforms: [websites, apps, nodejs, workers] --- -Returns a user's profile: their picture, display name, and bio. A user can always read their own profile. Another user's profile is available only while that user is on a paid plan; otherwise the method resolves to `null`, the same as for a user who does not exist. +Returns a user's profile: their picture, display name, and bio. Users set these on the [Account page](https://puter.com/dashboard#account) of their Puter dashboard. A user can always read their own profile. Another user's profile is available only while that user is on a paid plan; otherwise the method resolves to `null`, the same as for a user who does not exist. ## Syntax diff --git a/src/gui/src/UI/Dashboard/TabAccount.js b/src/gui/src/UI/Dashboard/TabAccount.js index 92c1c508f..a677c21a5 100644 --- a/src/gui/src/UI/Dashboard/TabAccount.js +++ b/src/gui/src/UI/Dashboard/TabAccount.js @@ -22,6 +22,7 @@ import UIWindowChangeEmail from '../Settings/UIWindowChangeEmail.js'; import UIWindowChangeUsername from '../UIWindowChangeUsername.js'; import UIWindowConfirmUserDeletion from '../Settings/UIWindowConfirmUserDeletion.js'; import UIWindowCopyToken from '../UIWindowCopyToken.js'; +import UIWindowEditProfile from '../UIWindowEditProfile.js'; import UIWindow from '../UIWindow.js'; import UIProfilePictureCropModal from './UIProfilePictureCropModal.js'; import { isOrgSeat, orgSeatTeamName } from './orgSeat.js'; @@ -59,6 +60,20 @@ const TabAccount = { // Account settings cards h += '
'; + // Display name and bio card + h += '
'; + h += '
'; + h += '
'; + h += ''; + h += '
'; + h += '
'; + h += `${i18n('profile')}`; + h += `${html_encode(window.user?.profile?.displayName || i18n('profile_empty', [], false))}`; + h += '
'; + h += '
'; + h += ``; + h += '
'; + // Username card h += '
'; h += '
'; @@ -163,6 +178,22 @@ const TabAccount = { }, init ($el_window) { + $el_window.find('.dashboard-section-account .edit-profile').on('click', function (e) { + UIWindowEditProfile({ + window_options: { + parent_uuid: $el_window.attr('data-element_uuid'), + backdrop: true, + close_on_backdrop_click: true, + parent_center: true, + stay_on_top: true, + has_head: false, + }, + onSave: (profile) => { + $el_window.find('.dashboard-section-account .dashboard-profile-display-name') + .text(profile.displayName || i18n('profile_empty', [], false)); + }, + }); + }); $el_window.find('.dashboard-section-account .change-password').on('click', function (e) { UIWindowChangePassword({ window_options: { diff --git a/src/gui/src/UI/UIWindowEditProfile.js b/src/gui/src/UI/UIWindowEditProfile.js new file mode 100644 index 000000000..60a21d5d8 --- /dev/null +++ b/src/gui/src/UI/UIWindowEditProfile.js @@ -0,0 +1,116 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import UIWindow from './UIWindow.js'; + +// Keep in sync with ProfileService on the backend. +const DISPLAY_NAME_MAX_LENGTH = 64; +const BIO_MAX_LENGTH = 280; + +/** + * Edits the signed-in user's display name and bio. `options.onSave` receives + * the saved profile. + */ +async function UIWindowEditProfile (options) { + options = options ?? {}; + + const internal_id = window.uuidv4(); + let h = ''; + h += '
'; + h += '
'; + h += '
'; + h += '
'; + h += ``; + h += ``; + h += '
'; + h += '
'; + h += ``; + h += ``; + h += '
'; + h += ``; + h += '
'; + + const el_window = await UIWindow({ + title: i18n('edit_profile'), + app: 'edit-profile', + single_instance: true, + icon: null, + uid: null, + is_dir: false, + body_content: h, + has_head: true, + selectable_body: false, + draggable_body: false, + allow_context_menu: false, + is_resizable: false, + is_droppable: false, + init_center: true, + allow_native_ctxmenu: false, + allow_user_select: false, + width: 350, + height: 'auto', + dominant: true, + show_in_taskbar: false, + onAppend: function (this_window) { + $(this_window).find('.edit-profile-display-name').get(0)?.focus({ preventScroll: true }); + }, + window_class: 'window-publishWebsite', + body_css: { + width: 'initial', + height: '100%', + 'background-color': 'rgb(245 247 249)', + 'backdrop-filter': 'blur(3px)', + }, + ...options.window_options, + }); + + const $fields = $(el_window).find('.edit-profile-display-name, .edit-profile-bio'); + $(el_window).find('.edit-profile-display-name').val(window.user?.profile?.displayName ?? ''); + $(el_window).find('.edit-profile-bio').val(window.user?.profile?.bio ?? ''); + + $(el_window).find('.edit-profile-btn').on('click', async function () { + const $btn = $(el_window).find('.edit-profile-btn'); + $(el_window).find('.form-success-msg, .form-error-msg').hide(); + $btn.addClass('disabled'); + $fields.attr('disabled', true); + + // An empty field clears it. + const patch = { + displayName: String($(el_window).find('.edit-profile-display-name').val() ?? '').trim() || null, + bio: String($(el_window).find('.edit-profile-bio').val() ?? '').trim() || null, + }; + // update_profile resolves with no profile when the request failed. + const profile = await window.update_profile(window.user.username, patch); + + $btn.removeClass('disabled'); + $fields.attr('disabled', false); + if ( ! profile ) { + $(el_window).find('.form-error-msg').html(i18n('profile_save_failed')); + $(el_window).find('.form-error-msg').fadeIn(); + return; + } + $(el_window).find('.edit-profile-display-name').val(profile.displayName ?? ''); + $(el_window).find('.edit-profile-bio').val(profile.bio ?? ''); + $(el_window).find('.form-success-msg').html(i18n('profile_saved')); + $(el_window).find('.form-success-msg').fadeIn(); + options.onSave?.(profile); + }); +} + +export default UIWindowEditProfile; diff --git a/src/gui/src/UI/UIWindowEditProfile.test.js b/src/gui/src/UI/UIWindowEditProfile.test.js new file mode 100644 index 000000000..e14fc3836 --- /dev/null +++ b/src/gui/src/UI/UIWindowEditProfile.test.js @@ -0,0 +1,87 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('./UIWindow.js', () => ({ + default: vi.fn(async () => ({})), +})); + +globalThis.i18n = (key) => key; + +/** Minimal jQuery stand-in: a selector -> value map drives the handler. */ +const fields = {}; +const shown = {}; +let clickHandler = null; +const node = (sel) => { + const self = { + val: (v) => { + if ( v === undefined ) return fields[sel]; + fields[sel] = v; + return self; + }, + on: (evt, fn) => { + if ( evt === 'click' && sel === '.edit-profile-btn' ) clickHandler = fn; + return self; + }, + html: (v) => { + shown[sel] = v; + return self; + }, + show: () => self, + hide: () => self, + fadeIn: () => self, + addClass: () => self, + removeClass: () => self, + attr: () => self, + get: () => undefined, + }; + return self; +}; +globalThis.$ = () => ({ find: node }); + +const { default: UIWindowEditProfile } = await import('./UIWindowEditProfile.js'); + +describe('the edit-profile window', () => { + beforeEach(() => { + for ( const key of Object.keys(fields) ) delete fields[key]; + for ( const key of Object.keys(shown) ) delete shown[key]; + clickHandler = null; + globalThis.window = { + uuidv4: () => 'test-id', + user: { username: 'alice', profile: { displayName: 'Alice', bio: 'Hi there' } }, + update_profile: vi.fn(async (_username, patch) => patch), + }; + }); + + it('prefills the current display name and bio', async () => { + await UIWindowEditProfile({}); + + expect(fields['.edit-profile-display-name']).toBe('Alice'); + expect(fields['.edit-profile-bio']).toBe('Hi there'); + }); + + it('saves trimmed values and clears empty fields', async () => { + const onSave = vi.fn(); + await UIWindowEditProfile({ onSave }); + fields['.edit-profile-display-name'] = ' Alice Smith '; + fields['.edit-profile-bio'] = ' '; + + await clickHandler(); + + expect(window.update_profile).toHaveBeenCalledWith('alice', { + displayName: 'Alice Smith', + bio: null, + }); + expect(onSave).toHaveBeenCalledWith({ displayName: 'Alice Smith', bio: null }); + expect(shown['.form-success-msg']).toBe('profile_saved'); + }); + + it('shows an error and skips onSave when the save fails', async () => { + window.update_profile = vi.fn(async () => undefined); + const onSave = vi.fn(); + await UIWindowEditProfile({ onSave }); + + await clickHandler(); + + expect(shown['.form-error-msg']).toBe('profile_save_failed'); + expect(onSave).not.toHaveBeenCalled(); + }); +}); diff --git a/src/gui/src/helpers.js b/src/gui/src/helpers.js index 5d074f3cb..c8a7926c9 100644 --- a/src/gui/src/helpers.js +++ b/src/gui/src/helpers.js @@ -630,6 +630,11 @@ window.update_auth_data = async (auth_token, user) => { // ---------------------------------------------------- user.profile = {}; puter.auth.getProfile().then((profile) => { + window.user.profile.displayName = profile?.displayName ?? null; + window.user.profile.bio = profile?.bio ?? null; + if ( profile?.displayName ) { + $('.dashboard-profile-display-name').text(profile.displayName); + } if ( profile?.picture ) { window.user.profile.picture = html_encode(profile.picture); $('.profile-pic').css('background-image', `url(${window.user.profile.picture})`); diff --git a/src/gui/src/i18n/translations/en.js b/src/gui/src/i18n/translations/en.js index 1bc50b6da..e83676bcd 100644 --- a/src/gui/src/i18n/translations/en.js +++ b/src/gui/src/i18n/translations/en.js @@ -66,6 +66,7 @@ const en = { auto_arrange: 'Auto Arrange', back: 'Back', background: 'Background', + bio: 'Bio', browse: 'Browse', browser: 'Browser', browser_version: 'Browser Version', @@ -172,6 +173,7 @@ const en = { disable_2fa_confirm: 'Are you sure you want to disable 2FA?', disable_2fa_instructions: 'Confirm your identity and enter the code from your authenticator app to disable 2FA.', disassociate_dir: 'Disassociate Directory', + display_name: 'Display Name', directory: 'Directory', disassociate: 'disassociate', disk_storage: 'Disk Storage', @@ -307,6 +309,7 @@ const en = { apps_load_failed: 'Failed to load apps', done_editing: 'Done editing', edit_apps: 'Edit apps', + edit_profile: 'Edit Profile', folder_pages: 'Folder pages', next_page: 'Next page', no_apps_installed: 'No apps installed yet', @@ -388,12 +391,16 @@ const en = { process_type_app: 'App', process_type_init: 'Init', process_type_ui: 'UI', + profile: 'Profile', + profile_empty: 'Add a display name and bio', profile_picture_adjust_hint: 'Drag to reposition. Scroll or use the slider to zoom.', profile_picture_adjust_hint_touch: 'Drag to move. Pinch or use the slider to zoom.', profile_picture_hint: 'Click the avatar to change your profile picture', profile_picture_load_failed: 'This image could not be opened. Please try another file.', profile_picture_remove_failed: 'Your photo could not be removed. Please try again.', profile_picture_save_failed: 'Your photo could not be saved. Please try again.', + profile_save_failed: 'Your profile could not be saved. Please try again.', + profile_saved: 'Profile saved.', properties: 'Properties', public: 'Public', publish: 'Publish',