Rework user profile settings

Accessibility:

- improved semantic layout
- Fixes unlabelled input for custom pronouns. CC @hazy
- Adds labels to dropdowns.
- Shortens certain texts for less verbose screen reader outputs and
  people with slow reading speed.
- Turned optional username rename helper text with low contrast into
  "normal" help text.

UI/UX:

- Removes section about primary email which is no longer managed in the
  profile section.
- Fixes section about primary email not displaying in user settings when notifications are
  not available.
- Removes primary email display, because it is not actually a form
  element here. (Alternatively, we could display it and link to the
account settings for managing the email)
This commit is contained in:
Otto Richter 2024-12-29 12:42:13 +01:00
parent de4053db83
commit 2885ea8da2
11 changed files with 195 additions and 174 deletions

View file

@ -748,13 +748,14 @@ blocked_users = Blocked users
public_profile = Public profile
biography_placeholder = Tell others a little bit about yourself! (Markdown is supported)
location_placeholder = Share your approximate location with others
profile_desc = Control how your profile is shown to other users. Your primary email address will be used for notifications, password recovery and web-based Git operations.
profile_desc = About you
password_username_disabled = Non-local users are not allowed to change their username. Please contact your site administrator for more details.
full_name = Full name
website = Website
location = Location
pronouns = Pronouns
pronouns_custom = Custom
pronouns_custom_label = Custom pronouns
pronouns_unspecified = Unspecified
update_theme = Change theme
update_profile = Update profile
@ -849,7 +850,7 @@ add_email_success = The new email address has been added.
email_preference_set_success = Email preference has been set successfully.
add_openid_success = The new OpenID address has been added.
keep_email_private = Hide email address
keep_email_private_popup = This will hide your email address from your profile. It will no longer be the default for commits made via the web interface, like file uploads and edits, and will not be used for merge commits. Instead a special address %s can be used to associate commits with your account. Note that changing this option will not affect existing commits.
keep_email_private_popup = Your email address will not be shown on your profile and will not be the default for commits made via the web interface, like file uploads, edits, and merge commits. Instead, a special address %s can be used to link commits to your account. This option will not affect existing commits.
openid_desc = OpenID lets you delegate authentication to an external provider.
manage_ssh_keys = Manage SSH keys

View file

@ -40,9 +40,9 @@
</h4>
<div class="ui attached segment">
<div class="ui list">
<div class="tw-mb-2">{{ctx.Locale.Tr "settings.email_desc"}}</div>
{{if $.EnableNotifyMail}}
<div class="item">
<div class="tw-mb-2">{{ctx.Locale.Tr "settings.email_desc"}}</div>
<form action="{{AppSubUrl}}/user/settings/account/email" class="ui form" method="post">
{{$.CsrfTokenHtml}}
<input name="_method" type="hidden" value="NOTIFICATION">

View file

@ -4,26 +4,31 @@
{{ctx.Locale.Tr "settings.public_profile"}}
</h4>
<div class="ui attached segment">
<p>{{ctx.Locale.Tr "settings.profile_desc"}}</p>
<form class="ui form" action="{{.Link}}" method="post">
{{.CsrfTokenHtml}}
<div class="required field {{if .Err_Name}}error{{end}}">
<label for="username">{{ctx.Locale.Tr "username"}}
<span class="text red tw-hidden" id="name-change-prompt"> {{ctx.Locale.Tr "settings.change_username_prompt"}}</span>
<span class="text red tw-hidden" id="name-change-redirect-prompt"> {{ctx.Locale.Tr "settings.change_username_redirect_prompt"}}</span>
</label>
<input id="username" name="name" value="{{.SignedUser.Name}}" data-name="{{.SignedUser.Name}}" autofocus required {{if or (not .SignedUser.IsLocal) .IsReverseProxy}}disabled{{end}} maxlength="40">
<fieldset>
<legend>{{ctx.Locale.Tr "settings.profile_desc"}}</legend>
<label {{if .Err_Name}}class="field error"{{end}}>
{{ctx.Locale.Tr "username"}}
<input name="name" value="{{.SignedUser.Name}}" data-name="{{.SignedUser.Name}}" autofocus required {{if or (not .SignedUser.IsLocal) .IsReverseProxy}}disabled{{end}} maxlength="40">
{{if or (not .SignedUser.IsLocal) .IsReverseProxy}}
<p class="help text blue">{{ctx.Locale.Tr "settings.password_username_disabled"}}</p>
<span class="help">{{ctx.Locale.Tr "settings.password_username_disabled"}}</span>
{{else}}
<span class="help">
{{ctx.Locale.Tr "settings.change_username_prompt"}}
{{ctx.Locale.Tr "settings.change_username_redirect_prompt"}}
</span>
{{end}}
</div>
<div class="field {{if .Err_FullName}}error{{end}}">
<label for="full_name">{{ctx.Locale.Tr "settings.full_name"}}</label>
<input id="full_name" name="full_name" value="{{.SignedUser.FullName}}" maxlength="100">
</div>
<div class="inline field">
<span class="inline field"><label for="pronouns">{{ctx.Locale.Tr "settings.pronouns"}}</label></span>
<div id="pronouns-dropdown" style="display: none" class="ui selection dropdown">
</label>
<label {{if .Err_FullName}}class="field error"{{end}}>
{{ctx.Locale.Tr "settings.full_name"}}
<input name="full_name" value="{{.SignedUser.FullName}}" maxlength="100">
</label>
<label id="label-pronouns" class="tw-hidden">
{{ctx.Locale.Tr "settings.pronouns"}}
<div id="pronouns-dropdown" class="ui selection dropdown" aria-labelledby="label-pronouns">
<input type="hidden" value="{{.SignedUser.Pronouns}}">
<div class="text">
{{if .PronounsAreCustom}}
@ -43,43 +48,40 @@
<div class="item{{if eq "it/its" .SignedUser.Pronouns}} active selected{{end}}" data-value="it/its">it/its</div>
<div class="item{{if eq "any pronouns" .SignedUser.Pronouns}} active selected{{end}}" data-value="any pronouns">any pronouns</div>
{{if .PronounsAreCustom}}
<div class="item active selected" data-value="{{.SignedUser.Pronouns}}"><p>{{ctx.Locale.Tr "settings.pronouns_custom"}}</p></div>
<div class="item active selected" data-value="{{.SignedUser.Pronouns}}">{{ctx.Locale.Tr "settings.pronouns_custom"}}</div>
{{else}}
<div class="item" data-value="!"><i>{{ctx.Locale.Tr "settings.pronouns_custom"}}</i></div>
{{end}}
</div>
</div>
<input id="pronouns-custom" name="pronouns" value="{{.SignedUser.Pronouns}}" maxlength="50">
</div>
{{if not .SignedUser.KeepEmailPrivate}}
<div class="field">
<label>{{ctx.Locale.Tr "email"}}</label>
<p id="signed-user-email">{{.SignedUser.Email}}</p>
</div>
{{end}}
<div class="field {{if .Err_Biography}}error{{end}}">
<label for="biography">{{ctx.Locale.Tr "user.user_bio"}}</label>
<textarea id="biography" name="biography" rows="2" placeholder="{{ctx.Locale.Tr "settings.biography_placeholder"}}" maxlength="255">{{.SignedUser.Description}}</textarea>
</div>
<div class="field {{if .Err_Website}}error{{end}}">
<label for="website">{{ctx.Locale.Tr "settings.website"}}</label>
<input id="website" name="website" type="url" value="{{.SignedUser.Website}}" maxlength="255">
</div>
<div class="field">
<label for="location">{{ctx.Locale.Tr "settings.location"}}</label>
<input id="location" name="location" placeholder="{{ctx.Locale.Tr "settings.location_placeholder"}}" value="{{.SignedUser.Location}}" maxlength="50">
</div>
</label>
<label id="label-pronouns-custom">
{{ctx.Locale.Tr "settings.pronouns_custom_label"}}
<input name="pronouns" value="{{.SignedUser.Pronouns}}" maxlength="50">
</label>
<div class="divider"></div>
<!-- private block -->
<label {{if .Err_Biography}}class="field error"{{end}}>
{{ctx.Locale.Tr "user.user_bio"}}
<textarea name="biography" rows="2" placeholder="{{ctx.Locale.Tr "settings.biography_placeholder"}}" maxlength="255">{{.SignedUser.Description}}</textarea>
</label>
<div class="field" id="privacy-user-settings">
<label><strong>{{ctx.Locale.Tr "settings.privacy"}}</strong></label>
</div>
<label {{if .Err_Website}}class="field error"{{end}}>
{{ctx.Locale.Tr "settings.website"}}
<input name="website" type="url" value="{{.SignedUser.Website}}" maxlength="255">
</label>
<div class="inline field {{if .Err_Visibility}}error{{end}}">
<span class="inline required field" id="visibility-setting"><label>{{ctx.Locale.Tr "settings.visibility"}}</label></span>
<div class="ui selection type dropdown">
<label>
{{ctx.Locale.Tr "settings.location"}}
<input name="location" placeholder="{{ctx.Locale.Tr "settings.location_placeholder"}}" value="{{.SignedUser.Location}}" maxlength="50">
</label>
</fieldset>
<fieldset>
<legend id="privacy-user-settings">{{ctx.Locale.Tr "settings.privacy"}}</legend>
<label id="visibility-setting" {{if .Err_Visibility}}class="field error"{{end}}>
{{ctx.Locale.Tr "settings.visibility"}}
<div class="ui selection type dropdown" aria-labelledby="visibility-setting">
{{if .SignedUser.Visibility.IsPublic}}<input type="hidden" id="visibility" name="visibility" value="0">{{end}}
{{if .SignedUser.Visibility.IsLimited}}<input type="hidden" id="visibility" name="visibility" value="1">{{end}}
{{if .SignedUser.Visibility.IsPrivate}}<input type="hidden" id="visibility" name="visibility" value="2">{{end}}
@ -101,29 +103,22 @@
{{end}}
</div>
</div>
</div>
</label>
<div class="field">
<div class="ui checkbox">
<label>{{ctx.Locale.Tr "settings.keep_email_private"}}</label>
<label>
<input name="keep_email_private" type="checkbox" {{if .SignedUser.KeepEmailPrivate}}checked{{end}}>
</div>
<span class="help tw-block">{{ctx.Locale.Tr "settings.keep_email_private_popup" .SignedUser.GetPlaceholderEmail}}</span>
</div>
{{ctx.Locale.Tr "settings.keep_email_private"}}
<span class="help">{{ctx.Locale.Tr "settings.keep_email_private_popup" .SignedUser.GetPlaceholderEmail}}</span>
</label>
<div class="field">
<div class="ui checkbox" id="keep-activity-private">
<label>{{ctx.Locale.Tr "settings.keep_activity_private"}}</label>
<label id="keep-activity-private">
<input name="keep_activity_private" type="checkbox" {{if .SignedUser.KeepActivityPrivate}}checked{{end}}>
</div>
<span class="help tw-block">{{ctx.Locale.Tr "settings.keep_activity_private.description" (printf "/%s?tab=activity" .SignedUser.Name)}}</span>
</div>
{{ctx.Locale.Tr "settings.keep_activity_private"}}
<span class="help">{{ctx.Locale.Tr "settings.keep_activity_private.description" (printf "/%s?tab=activity" .SignedUser.Name)}}</span>
</label>
</fieldset>
<div class="divider"></div>
<div class="field">
<button class="ui primary button">{{ctx.Locale.Tr "settings.update_profile"}}</button>
</div>
</form>
</div>

View file

@ -0,0 +1,65 @@
// @watch start
// templates/user/settings/**.tmpl
// web_src/css/{form,user}.css
// @watch end
import {expect} from '@playwright/test';
import {test, save_visual, login_user, login} from './utils_e2e.ts';
import {validate_form} from './shared/forms.ts';
test.beforeAll(async ({browser}, workerInfo) => {
await login_user(browser, workerInfo, 'user2');
});
test('User: Profile settings', async ({browser}, workerInfo) => {
const page = await login({browser}, workerInfo);
await page.goto('/user/settings');
await page.getByLabel('Full name').fill('SecondUser');
await page.locator('#pronouns-dropdown').click();
await page.getByRole('option', {name: 'she/her'}).click();
await page.getByPlaceholder('Tell others a little bit').fill('I am a playwright test running for several seconds.');
await page.getByPlaceholder('Tell others a little bit').press('Tab');
await page.getByLabel('Website').fill('https://forgejo.org');
await page.getByPlaceholder('Share your approximate').fill('on a computer chip');
await page.getByLabel('User visibility').click();
await page.getByLabel('Visible only to signed-in').click();
await page.getByLabel('Hide email address Your email').uncheck();
await page.getByLabel('Hide activity from profile').check();
await validate_form({page}, 'fieldset');
await save_visual(page);
await page.getByRole('button', {name: 'Update profile'}).click();
await expect(page.getByText('Your profile has been updated.')).toBeVisible();
await page.getByRole('link', {name: 'public activity'}).click();
await expect(page.getByText('Your activity is only visible')).toBeVisible();
await save_visual(page);
await page.goto('/user2');
await expect(page.getByText('SecondUser')).toBeVisible();
await expect(page.getByText('on a computer chip')).toBeVisible();
await expect(page.locator('li').filter({hasText: 'user2@example.com'})).toBeVisible();
await expect(page.locator('li').filter({hasText: 'https://forgejo.org'})).toBeVisible();
await expect(page.getByText('I am a playwright test')).toBeVisible();
await save_visual(page);
await page.goto('/user/settings');
await page.locator('#pronouns-dropdown').click();
await page.getByRole('option', {name: 'Custom'}).click();
await page.getByLabel('Custom pronouns').fill('rob/ot');
await page.getByLabel('User visibility').click();
await page.getByLabel('Visible to everyone').click();
await page.getByLabel('Hide email address Your email').check();
await page.getByLabel('Hide activity from profile').uncheck();
await expect(page.getByText('Your profile has been updated.')).toBeHidden();
await validate_form({page}, 'fieldset');
await save_visual(page);
await page.getByRole('button', {name: 'Update profile'}).click();
await expect(page.getByText('Your profile has been updated.')).toBeVisible();
await page.goto('/user2');
await expect(page.getByText('SecondUser')).toBeVisible();
await expect(page.locator('li').filter({hasText: 'user2@example.com'})).toBeHidden();
await page.goto('/user2?tab=activity');
await expect(page.getByText('Your activity is visible to everyone')).toBeVisible();
});

View file

@ -105,6 +105,7 @@ export async function save_visual(page: Page) {
page.locator('#repo_migrating'),
// update order of recently created repos is not fully deterministic
page.locator('.flex-item-main').filter({hasText: 'relative time in repo'}),
page.locator('#activity-feed'),
// dynamic IDs in fixed-size inputs
page.locator('input[value*="dyn-id-"]'),
],

View file

@ -185,7 +185,6 @@ func TestLDAPUserSignin(t *testing.T) {
assert.Equal(t, u.UserName, htmlDoc.GetInputValueByName("name"))
assert.Equal(t, u.FullName, htmlDoc.GetInputValueByName("full_name"))
assert.Equal(t, u.Email, htmlDoc.Find("#signed-user-email").Text())
}
func TestLDAPAuthChange(t *testing.T) {

View file

@ -157,23 +157,3 @@ func TestSettingSecurityAuthSource(t *testing.T) {
assert.Contains(t, resp.Body.String(), `gitlab-active`)
assert.Contains(t, resp.Body.String(), `gitlab-inactive`)
}
func TestSettingShowUserEmailSettings(t *testing.T) {
defer tests.PrepareTestEnv(t)()
// user1: keep_email_private = false, user2: keep_email_private = true
// user1 can see own visible email
session := loginUser(t, "user1")
req := NewRequest(t, "GET", "/user/settings")
resp := session.MakeRequest(t, req, http.StatusOK)
htmlDoc := NewHTMLParser(t, resp.Body)
assert.Contains(t, htmlDoc.doc.Find("#signed-user-email").Text(), "user1@example.com")
// user2 cannot see own hidden email
session = loginUser(t, "user2")
req = NewRequest(t, "GET", "/user/settings")
resp = session.MakeRequest(t, req, http.StatusOK)
htmlDoc = NewHTMLParser(t, resp.Body)
assert.NotContains(t, htmlDoc.doc.Find("#signed-user-email").Text(), "user2@example.com")
}

View file

@ -42,25 +42,16 @@ func TestUserProfileActivity(t *testing.T) {
// Verify the hint for all types of users: admin, self, guest
testUser2ActivityVisibility(t, userAdmin, "This activity is visible to everyone, but as an administrator you can also see interactions in private spaces.", true)
hintLink := testUser2ActivityVisibility(t, userRegular, "Your activity is visible to everyone, except for interactions in private spaces. Configure.", true)
testUser2ActivityVisibility(t, userRegular, "Your activity is visible to everyone, except for interactions in private spaces. Configure.", true)
testUser2ActivityVisibility(t, userGuest, "", true)
// When viewing own profile, the user is offered to configure activity visibility. Verify that the link is correct and works, also check that it links back to the activity tab.
linkCorrect := assert.EqualValues(t, "/user/settings#keep-activity-private", hintLink)
if linkCorrect {
page := NewHTMLParser(t, userRegular.MakeRequest(t, NewRequest(t, "GET", hintLink), http.StatusOK).Body)
activityLink, exists := page.Find(".field:has(.checkbox#keep-activity-private) .help a").Attr("href")
assert.True(t, exists)
assert.EqualValues(t, "/user2?tab=activity", activityLink)
}
// = Private profile, but public activity =
// Set profile visibility of user2 to private
testChangeUserProfileVisibility(t, userRegular, structs.VisibleTypePrivate)
// When profile activity is configured as public, but the profile is private, tell the user about this and link to visibility settings.
hintLink = testUser2ActivityVisibility(t, userRegular, "Your activity is only visible to you and the instance administrators because your profile is private. Configure.", true)
hintLink := testUser2ActivityVisibility(t, userRegular, "Your activity is only visible to you and the instance administrators because your profile is private. Configure.", true)
assert.EqualValues(t, "/user/settings#visibility-setting", hintLink)
// When the profile is private, tell the admin about this.

View file

@ -1,5 +1,5 @@
fieldset {
margin: 0.5em 0 1em;
margin: 0.2em 0 0.3em;
padding: 0;
}
@ -8,6 +8,12 @@ fieldset legend {
margin-bottom: 0.75em;
}
fieldset + fieldset > legend {
width: 100%;
padding-top: 1em;
border-top: 1px solid var(--color-secondary);
}
fieldset label {
display: block;
margin-bottom: 0.6em;

View file

@ -143,7 +143,3 @@
.notifications-item:hover .notifications-updated {
display: none;
}
#pronouns-dropdown, #pronouns-custom {
width: 140px;
}

View file

@ -1,7 +1,8 @@
import {hideElem, showElem} from '../utils/dom.js';
function onPronounsDropdownUpdate() {
const pronounsCustom = document.getElementById('pronouns-custom');
const pronounsCustom = document.getElementById('label-pronouns-custom');
const pronounsCustomInput = pronounsCustom.querySelector('input');
const pronounsDropdown = document.getElementById('pronouns-dropdown');
const pronounsInput = pronounsDropdown.querySelector('input');
// must be kept in sync with `routers/web/user/setting/profile.go`
@ -15,49 +16,35 @@ function onPronounsDropdownUpdate() {
);
if (isCustom) {
if (pronounsInput.value === '!') {
pronounsCustom.value = '';
pronounsCustomInput.value = '';
} else {
pronounsCustom.value = pronounsInput.value;
pronounsCustomInput.value = pronounsInput.value;
}
pronounsCustom.style.display = '';
showElem(pronounsCustom);
} else {
pronounsCustom.style.display = 'none';
hideElem(pronounsCustom);
}
}
function onPronounsCustomUpdate() {
const pronounsCustom = document.getElementById('pronouns-custom');
const pronounsCustomInput = document.querySelector('#label-pronouns-custom input');
const pronounsInput = document.querySelector('#pronouns-dropdown input');
pronounsInput.value = pronounsCustom.value;
pronounsInput.value = pronounsCustomInput.value;
}
export function initUserSettings() {
if (!document.querySelectorAll('.user.settings.profile').length) return;
const usernameInput = document.getElementById('username');
if (!usernameInput) return;
usernameInput.addEventListener('input', function () {
const prompt = document.getElementById('name-change-prompt');
const promptRedirect = document.getElementById('name-change-redirect-prompt');
if (this.value.toLowerCase() !== this.getAttribute('data-name').toLowerCase()) {
showElem(prompt);
showElem(promptRedirect);
} else {
hideElem(prompt);
hideElem(promptRedirect);
}
});
const pronounsDropdown = document.getElementById('pronouns-dropdown');
const pronounsCustom = document.getElementById('pronouns-custom');
const pronounsDropdown = document.getElementById('label-pronouns');
const pronounsCustomInput = document.querySelector('#label-pronouns-custom input');
const pronounsInput = pronounsDropdown.querySelector('input');
// If JS is disabled, the page will show the custom input, as the dropdown requires JS to work.
// JS progressively enhances the input by adding a dropdown, but it works regardless.
pronounsCustom.removeAttribute('name');
pronounsCustomInput.removeAttribute('name');
pronounsInput.setAttribute('name', 'pronouns');
pronounsDropdown.style.display = '';
showElem(pronounsDropdown);
onPronounsDropdownUpdate();
pronounsInput.addEventListener('change', onPronounsDropdownUpdate);
pronounsCustom.addEventListener('input', onPronounsCustomUpdate);
pronounsCustomInput.addEventListener('input', onPronounsCustomUpdate);
}