mirror of
https://github.com/HeyPuter/puter.git
synced 2026-10-25 08:09:15 +00:00
feat(gui): edit display name and bio from the Account tab (#4293)
This commit is contained in:
1 parent
e18a5c2b54
commit
0760530d0c
6 files changed
+247
-1
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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})`);
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user