feat(gui): edit display name and bio from the Account tab (#4293)

This commit is contained in:
Daniel Salazar authored and GitHub committed 2026-10-21 12:57:15 -07:00
1 parent e18a5c2b54
commit 0760530d0c
6 files changed
+247 -1

No files matched your search

+1 -1
View File
@@ -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
+31
View File
@@ -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 += '<div class="dashboard-settings-grid">';
// Display name and bio card
h += '<div class="dashboard-card dashboard-settings-card">';
h += '<div class="dashboard-settings-card-content">';
h += '<div class="dashboard-settings-card-icon">';
h += '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="5" width="20" height="14" rx="2"/><circle cx="8" cy="12" r="2"/><path d="M14 10h4M14 14h4"/></svg>';
h += '</div>';
h += '<div class="dashboard-settings-card-info">';
h += `<strong>${i18n('profile')}</strong>`;
h += `<span class="dashboard-profile-display-name">${html_encode(window.user?.profile?.displayName || i18n('profile_empty', [], false))}</span>`;
h += '</div>';
h += '</div>';
h += `<button class="button edit-profile">${i18n('edit_profile')}</button>`;
h += '</div>';
// Username card
h += '<div class="dashboard-card dashboard-settings-card">';
h += '<div class="dashboard-settings-card-content">';
@@ -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: {
+116
View File
@@ -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 <https://www.gnu.org/licenses/>.
*/
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 += '<div class="edit-profile" style="padding: 20px; border-bottom: 1px solid #ced7e1;">';
h += '<div class="form-error-msg"></div>';
h += '<div class="form-success-msg"></div>';
h += '<div style="overflow: hidden; margin-bottom: 20px;">';
h += `<label for="edit-profile-display-name-${internal_id}">${i18n('display_name')}</label>`;
h += `<input id="edit-profile-display-name-${internal_id}" type="text" class="edit-profile-display-name" maxlength="${DISPLAY_NAME_MAX_LENGTH}" autocomplete="off" />`;
h += '</div>';
h += '<div style="overflow: hidden; margin-bottom: 20px;">';
h += `<label for="edit-profile-bio-${internal_id}">${i18n('bio')}</label>`;
h += `<textarea id="edit-profile-bio-${internal_id}" class="edit-profile-bio" maxlength="${BIO_MAX_LENGTH}" rows="4" style="display: block; width: 100%; padding: 8px; box-sizing: border-box; resize: vertical; border: 1px solid #ccc; border-radius: 4px; font: inherit; font-size: 14px; color: #393f46;"></textarea>`;
h += '</div>';
h += `<button class="edit-profile-btn button button-primary button-block button-normal">${i18n('save')}</button>`;
h += '</div>';
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;
@@ -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();
});
});
+5
View File
@@ -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})`);
+7
View File
@@ -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',