feat: add user display name field (#898)

Co-authored-by: Elias Schneider <login@eliasschneider.com>
This commit is contained in:
Kyle Mendell
2025-09-17 10:18:27 -05:00
committed by GitHub
parent 2d6d5df0e7
commit 68373604dd
32 changed files with 280 additions and 112 deletions

View File

@@ -443,7 +443,10 @@
"custom_client_id_description": "Set a custom client ID if this is required by your application. Otherwise, leave it blank to generate a random one.",
"generated": "Generated",
"administration": "Administration",
"group_rdn_attribute_description": "The attribute used in the groups distinguished name (DN). Recommended value: `cn`",
"group_rdn_attribute_description": "The attribute used in the groups distinguished name (DN).",
"display_name_attribute": "Display Name Attribute",
"display_name": "Display Name",
"configure_application_images": "Configure Application Images",
"ui_config_disabled_info_title": "UI Configuration Disabled",
"ui_config_disabled_info_description": "The UI configuration is disabled because the application configuration settings are managed through environment variables. Some settings may not be editable."
}

View File

@@ -5,6 +5,7 @@
import { preventDefault } from '$lib/utils/event-util';
import { createForm } from '$lib/utils/form-util';
import { tryCatch } from '$lib/utils/try-catch-util';
import { emptyToUndefined } from '$lib/utils/zod-util';
import { z } from 'zod/v4';
let {
@@ -24,7 +25,7 @@
const formSchema = z.object({
firstName: z.string().min(1).max(50),
lastName: z.string().max(50).optional(),
lastName: emptyToUndefined(z.string().max(50).optional()),
username: z
.string()
.min(2)

View File

@@ -41,6 +41,7 @@ export type AllAppConfig = AppConfig & {
ldapAttributeUserEmail: string;
ldapAttributeUserFirstName: string;
ldapAttributeUserLastName: string;
ldapAttributeUserDisplayName: string;
ldapAttributeUserProfilePicture: string;
ldapAttributeGroupMember: string;
ldapAttributeGroupUniqueIdentifier: string;

View File

@@ -8,6 +8,7 @@ export type User = {
email: string;
firstName: string;
lastName?: string;
displayName: string;
isAdmin: boolean;
userGroups: UserGroup[];
customClaims: CustomClaim[];
@@ -18,6 +19,6 @@ export type User = {
export type UserCreate = Omit<User, 'id' | 'customClaims' | 'ldapId' | 'userGroups'>;
export type UserSignUp = Omit<UserCreate, 'isAdmin' | 'disabled'> & {
export type UserSignUp = Omit<UserCreate, 'isAdmin' | 'disabled' | 'displayName'> & {
token?: string;
};

View File

@@ -1,8 +1,19 @@
import { setLocale as setParaglideLocale, type Locale } from '$lib/paraglide/runtime';
import {
extractLocaleFromCookie,
setLocale as setParaglideLocale,
type Locale
} from '$lib/paraglide/runtime';
import { setDefaultOptions } from 'date-fns';
import { z } from 'zod/v4';
export async function setLocale(locale: Locale, reload = true) {
await setLocaleForLibraries(locale);
setParaglideLocale(locale, { reload });
}
export async function setLocaleForLibraries(
locale: Locale = (extractLocaleFromCookie() as Locale) || 'en'
) {
const [zodResult, dateFnsResult] = await Promise.allSettled([
import(`../../../node_modules/zod/v4/locales/${locale}.js`),
import(`../../../node_modules/date-fns/locale/${locale}.js`)
@@ -14,8 +25,6 @@ export async function setLocale(locale: Locale, reload = true) {
console.warn(`Failed to load zod locale for ${locale}:`, zodResult.reason);
}
setParaglideLocale(locale, { reload });
if (dateFnsResult.status === 'fulfilled') {
setDefaultOptions({
locale: dateFnsResult.value.default

View File

@@ -1,8 +1,8 @@
import { m } from '$lib/paraglide/messages';
import z from 'zod/v4';
import { z } from 'zod/v4';
export const emptyToUndefined = <T>(validation: z.ZodType<T>) =>
z.preprocess((v) => (v === '' ? undefined : v), validation);
z.preprocess((v) => (v === '' ? undefined : v), validation.optional());
export const optionalUrl = z
.url()

View File

@@ -2,6 +2,7 @@ import AppConfigService from '$lib/services/app-config-service';
import UserService from '$lib/services/user-service';
import appConfigStore from '$lib/stores/application-configuration-store';
import userStore from '$lib/stores/user-store';
import { setLocaleForLibraries } from '$lib/utils/locale.util';
import type { LayoutLoad } from './$types';
export const ssr = false;
@@ -29,6 +30,8 @@ export const load: LayoutLoad = async () => {
appConfigStore.set(appConfig);
}
await setLocaleForLibraries();
return {
user,
appConfig

View File

@@ -8,6 +8,7 @@
import { axiosErrorToast } from '$lib/utils/error-util';
import { preventDefault } from '$lib/utils/event-util';
import { createForm } from '$lib/utils/form-util';
import { emptyToUndefined } from '$lib/utils/zod-util';
import { toast } from 'svelte-sonner';
import { z } from 'zod/v4';
@@ -26,12 +27,14 @@
} = $props();
let isLoading = $state(false);
let hasManualDisplayNameEdit = $state(!!account.displayName);
const userService = new UserService();
const formSchema = z.object({
firstName: z.string().min(1).max(50),
lastName: z.string().max(50).optional(),
lastName: emptyToUndefined(z.string().max(50).optional()),
displayName: z.string().max(100),
username: z
.string()
.min(2)
@@ -44,6 +47,14 @@
const { inputs, ...form } = createForm<FormSchema>(formSchema, account);
function onNameInput() {
if (!hasManualDisplayNameEdit) {
$inputs.displayName.value = `${$inputs.firstName.value}${
$inputs.lastName?.value ? ' ' + $inputs.lastName.value : ''
}`;
}
}
async function onSubmit() {
const data = form.validate();
if (!data) return;
@@ -68,7 +79,6 @@
</script>
<form onsubmit={preventDefault(onSubmit)} class="space-y-6">
<!-- Profile Picture Section -->
<ProfilePictureSettings
{userId}
{isLdapUser}
@@ -76,31 +86,32 @@
resetCallback={resetProfilePicture}
/>
<!-- Divider -->
<hr class="border-border" />
<!-- User Information -->
<fieldset disabled={userInfoInputDisabled}>
<div>
<div class="flex flex-col gap-3 sm:flex-row">
<div class="w-full">
<FormInput label={m.first_name()} bind:input={$inputs.firstName} />
</div>
<div class="w-full">
<FormInput label={m.last_name()} bind:input={$inputs.lastName} />
</div>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<FormInput label={m.first_name()} bind:input={$inputs.firstName} onInput={onNameInput} />
</div>
<div class="mt-3 flex flex-col gap-3 sm:flex-row">
<div class="w-full">
<FormInput label={m.email()} bind:input={$inputs.email} />
</div>
<div class="w-full">
<FormInput label={m.username()} bind:input={$inputs.username} />
</div>
<div>
<FormInput label={m.last_name()} bind:input={$inputs.lastName} onInput={onNameInput} />
</div>
<div>
<FormInput
label={m.display_name()}
bind:input={$inputs.displayName}
onInput={() => (hasManualDisplayNameEdit = true)}
/>
</div>
<div>
<FormInput label={m.username()} bind:input={$inputs.username} />
</div>
<div>
<FormInput label={m.email()} bind:input={$inputs.email} />
</div>
</div>
<div class="flex justify-end pt-2">
<div class="flex justify-end pt-4">
<Button {isLoading} type="submit">{m.save()}</Button>
</div>
</fieldset>

View File

@@ -120,7 +120,12 @@
</div>
<div>
<CollapsibleCard id="application-configuration-images" icon={LucideImage} title={m.images()}>
<CollapsibleCard
id="application-configuration-images"
icon={LucideImage}
title={m.images()}
description={m.configure_application_images()}
>
<UpdateApplicationImages callback={updateImages} />
</CollapsibleCard>
</div>

View File

@@ -38,6 +38,7 @@
ldapAttributeUserEmail: z.string().min(1),
ldapAttributeUserFirstName: z.string().min(1),
ldapAttributeUserLastName: z.string().min(1),
ldapAttributeUserDisplayName: z.string().min(1),
ldapAttributeUserProfilePicture: z.string(),
ldapAttributeGroupMember: z.string(),
ldapAttributeGroupUniqueIdentifier: z.string().min(1),
@@ -159,6 +160,11 @@
placeholder="sn"
bind:input={$inputs.ldapAttributeUserLastName}
/>
<FormInput
label={m.display_name_attribute()}
placeholder="displayName"
bind:input={$inputs.ldapAttributeUserDisplayName}
/>
<FormInput
label={m.user_profile_picture_attribute()}
description={m.the_value_of_this_attribute_can_either_be_a_url_binary_or_base64_encoded_image()}

View File

@@ -1,12 +1,13 @@
<script lang="ts">
import SwitchWithLabel from '$lib/components/form/switch-with-label.svelte';
import FormInput from '$lib/components/form/form-input.svelte';
import SwitchWithLabel from '$lib/components/form/switch-with-label.svelte';
import { Button } from '$lib/components/ui/button';
import { m } from '$lib/paraglide/messages';
import appConfigStore from '$lib/stores/application-configuration-store';
import type { User, UserCreate } from '$lib/types/user.type';
import { preventDefault } from '$lib/utils/event-util';
import { createForm } from '$lib/utils/form-util';
import { emptyToUndefined } from '$lib/utils/zod-util';
import { z } from 'zod/v4';
let {
@@ -19,10 +20,12 @@
let isLoading = $state(false);
let inputDisabled = $derived(!!existingUser?.ldapId && $appConfigStore.ldapEnabled);
let hasManualDisplayNameEdit = $state(!!existingUser?.displayName);
const user = {
firstName: existingUser?.firstName || '',
lastName: existingUser?.lastName || '',
displayName: existingUser?.displayName || '',
email: existingUser?.email || '',
username: existingUser?.username || '',
isAdmin: existingUser?.isAdmin || false,
@@ -31,7 +34,8 @@
const formSchema = z.object({
firstName: z.string().min(1).max(50),
lastName: z.string().max(50),
lastName: emptyToUndefined(z.string().max(50).optional()),
displayName: z.string().max(100),
username: z
.string()
.min(2)
@@ -53,15 +57,29 @@
if (success && !existingUser) form.reset();
isLoading = false;
}
function onNameInput() {
if (!hasManualDisplayNameEdit) {
$inputs.displayName.value = `${$inputs.firstName.value}${
$inputs.lastName?.value ? ' ' + $inputs.lastName.value : ''
}`;
}
}
</script>
<form onsubmit={preventDefault(onSubmit)}>
<fieldset disabled={inputDisabled}>
<div class="grid grid-cols-1 items-start gap-5 md:grid-cols-2">
<FormInput label={m.first_name()} bind:input={$inputs.firstName} />
<FormInput label={m.last_name()} bind:input={$inputs.lastName} />
<FormInput label={m.first_name()} oninput={onNameInput} bind:input={$inputs.firstName} />
<FormInput label={m.last_name()} oninput={onNameInput} bind:input={$inputs.lastName} />
<FormInput
label={m.display_name()}
oninput={() => (hasManualDisplayNameEdit = true)}
bind:input={$inputs.displayName}
/>
<FormInput label={m.username()} bind:input={$inputs.username} />
<FormInput label={m.email()} bind:input={$inputs.email} />
</div>
<div class="mt-5 grid grid-cols-1 items-start gap-5 md:grid-cols-2">
<SwitchWithLabel
id="admin-privileges"
label={m.admin_privileges()}

View File

@@ -103,6 +103,7 @@
columns={[
{ label: m.first_name(), sortColumn: 'firstName' },
{ label: m.last_name(), sortColumn: 'lastName' },
{ label: m.display_name(), sortColumn: 'displayName' },
{ label: m.email(), sortColumn: 'email' },
{ label: m.username(), sortColumn: 'username' },
{ label: m.role(), sortColumn: 'isAdmin' },
@@ -114,6 +115,7 @@
{#snippet rows({ item })}
<Table.Cell>{item.firstName}</Table.Cell>
<Table.Cell>{item.lastName}</Table.Cell>
<Table.Cell>{item.displayName}</Table.Cell>
<Table.Cell>{item.email}</Table.Cell>
<Table.Cell>{item.username}</Table.Cell>
<Table.Cell>