feat: add required indicator for required inputs (#993)

This commit is contained in:
Elias Schneider
2025-10-01 13:44:17 +02:00
committed by GitHub
parent 69e2083722
commit 043cce615d
12 changed files with 66 additions and 25 deletions

View File

@@ -27,7 +27,7 @@ type UserCreateDto struct {
Email string `json:"email" binding:"required,email" unorm:"nfc"` Email string `json:"email" binding:"required,email" unorm:"nfc"`
FirstName string `json:"firstName" binding:"required,min=1,max=50" unorm:"nfc"` FirstName string `json:"firstName" binding:"required,min=1,max=50" unorm:"nfc"`
LastName string `json:"lastName" binding:"max=50" unorm:"nfc"` LastName string `json:"lastName" binding:"max=50" unorm:"nfc"`
DisplayName string `json:"displayName" binding:"required,max=100" unorm:"nfc"` DisplayName string `json:"displayName" binding:"required,min=1,max=100" unorm:"nfc"`
IsAdmin bool `json:"isAdmin"` IsAdmin bool `json:"isAdmin"`
Locale *string `json:"locale"` Locale *string `json:"locale"`
Disabled bool `json:"disabled"` Disabled bool `json:"disabled"`

View File

@@ -355,6 +355,11 @@ func (s *LdapService) SyncUsers(ctx context.Context, tx *gorm.DB, client *ldap.C
IsAdmin: isAdmin, IsAdmin: isAdmin,
LdapID: ldapId, LdapID: ldapId,
} }
if newUser.DisplayName == "" {
newUser.DisplayName = strings.TrimSpace(newUser.FirstName + " " + newUser.LastName)
}
dto.Normalize(newUser) dto.Normalize(newUser)
err = newUser.Validate() err = newUser.Validate()

View File

@@ -36,7 +36,7 @@
<div {...restProps}> <div {...restProps}>
{#if label} {#if label}
<Label class="mb-0" for={id}>{label}</Label> <Label required={input?.required} class="mb-0" for={id}>{label}</Label>
{/if} {/if}
{#if description} {#if description}
<p class="text-muted-foreground mt-1 text-xs"> <p class="text-muted-foreground mt-1 text-xs">

View File

@@ -26,8 +26,7 @@
<DropdownMenu.Label class="font-normal"> <DropdownMenu.Label class="font-normal">
<div class="flex flex-col space-y-1"> <div class="flex flex-col space-y-1">
<p class="text-sm leading-none font-medium"> <p class="text-sm leading-none font-medium">
{$userStore?.firstName} {$userStore?.displayName}
{$userStore?.lastName}
</p> </p>
<p class="text-muted-foreground text-xs leading-none">{$userStore?.email}</p> <p class="text-muted-foreground text-xs leading-none">{$userStore?.email}</p>
</div> </div>

View File

@@ -5,16 +5,25 @@
let { let {
ref = $bindable(null), ref = $bindable(null),
class: className, class: className,
required = false,
children,
...restProps ...restProps
}: LabelPrimitive.RootProps = $props(); }: LabelPrimitive.RootProps & {
required?: boolean;
} = $props();
</script> </script>
<LabelPrimitive.Root <LabelPrimitive.Root
bind:ref bind:ref
data-slot="label" data-slot="label"
class={cn( class={cn(
'mb-3 flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50', 'mb-3 flex items-center gap-1.5 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
required && "after:text-[14px] after:text-red-500 after:content-['*']",
className className
)} )}
{...restProps} {...restProps}
/> >
{#if children}
{@render children()}
{/if}
</LabelPrimitive.Root>

View File

@@ -4,6 +4,7 @@ import { z } from 'zod/v4';
export type FormInput<T> = { export type FormInput<T> = {
value: T; value: T;
error: string | null; error: string | null;
required: boolean;
}; };
type FormInputs<T> = { type FormInputs<T> = {
@@ -17,11 +18,18 @@ export function createForm<T extends z.ZodType<any, any>>(schema: T, initialValu
function initializeInputs(initialValues: z.infer<T>): FormInputs<z.infer<T>> { function initializeInputs(initialValues: z.infer<T>): FormInputs<z.infer<T>> {
const inputs: FormInputs<z.infer<T>> = {} as FormInputs<z.infer<T>>; const inputs: FormInputs<z.infer<T>> = {} as FormInputs<z.infer<T>>;
const shape =
schema instanceof z.ZodObject ? (schema.shape as Record<string, z.ZodTypeAny>) : {};
for (const key in initialValues) { for (const key in initialValues) {
if (Object.prototype.hasOwnProperty.call(initialValues, key)) { if (Object.prototype.hasOwnProperty.call(initialValues, key)) {
const fieldSchema = shape[key];
inputs[key as keyof z.infer<T>] = { inputs[key as keyof z.infer<T>] = {
value: initialValues[key as keyof z.infer<T>], value: initialValues[key as keyof z.infer<T>],
error: null error: null,
required: fieldSchema ? isRequired(fieldSchema) : false
}; };
} }
} }
@@ -31,7 +39,6 @@ export function createForm<T extends z.ZodType<any, any>>(schema: T, initialValu
function validate() { function validate() {
let success = true; let success = true;
inputsStore.update((inputs) => { inputsStore.update((inputs) => {
// Extract values from inputs to validate against the schema
const values = Object.fromEntries( const values = Object.fromEntries(
Object.entries(inputs).map(([key, input]) => [key, input.value]) Object.entries(inputs).map(([key, input]) => [key, input.value])
); );
@@ -54,7 +61,7 @@ export function createForm<T extends z.ZodType<any, any>>(schema: T, initialValu
inputs[input as keyof z.infer<T>].error = null; inputs[input as keyof z.infer<T>].error = null;
} }
} }
// Update the input values with the parsed data
for (const key in result.data) { for (const key in result.data) {
if (Object.prototype.hasOwnProperty.call(inputs, key)) { if (Object.prototype.hasOwnProperty.call(inputs, key)) {
inputs[key as keyof z.infer<T>].value = result.data[key]; inputs[key as keyof z.infer<T>].value = result.data[key];
@@ -82,7 +89,9 @@ export function createForm<T extends z.ZodType<any, any>>(schema: T, initialValu
function reset() { function reset() {
inputsStore.update((inputs) => { inputsStore.update((inputs) => {
for (const input of Object.keys(inputs)) { for (const input of Object.keys(inputs)) {
const current = inputs[input as keyof z.infer<T>];
inputs[input as keyof z.infer<T>] = { inputs[input as keyof z.infer<T>] = {
...current,
value: initialValues[input as keyof z.infer<T>], value: initialValues[input as keyof z.infer<T>],
error: null error: null
}; };
@@ -98,7 +107,6 @@ export function createForm<T extends z.ZodType<any, any>>(schema: T, initialValu
}); });
} }
// Trims whitespace from string values and arrays of strings
function trimValue(value: any) { function trimValue(value: any) {
if (typeof value === 'string') { if (typeof value === 'string') {
value = value.trim(); value = value.trim();
@@ -113,6 +121,26 @@ export function createForm<T extends z.ZodType<any, any>>(schema: T, initialValu
return value; return value;
} }
function isRequired(fieldSchema: z.ZodTypeAny): boolean {
// Handle unions like callbackUrlSchema
if (fieldSchema instanceof z.ZodUnion) {
return !fieldSchema.def.options.some((o: any) => {
return o.def.type == 'optional';
});
}
// Handle pipes like emptyToUndefined
if (fieldSchema instanceof z.ZodPipe) {
return isRequired(fieldSchema.def.out as z.ZodTypeAny);
}
// Handle the normal cases
if (fieldSchema instanceof z.ZodOptional || fieldSchema instanceof z.ZodDefault) {
return false;
}
return true;
}
return { return {
schema, schema,
inputs: inputsStore, inputs: inputsStore,

View File

@@ -34,7 +34,7 @@
const formSchema = z.object({ const formSchema = z.object({
firstName: z.string().min(1).max(50), firstName: z.string().min(1).max(50),
lastName: emptyToUndefined(z.string().max(50).optional()), lastName: emptyToUndefined(z.string().max(50).optional()),
displayName: z.string().max(100), displayName: z.string().min(1).max(100),
username: usernameSchema, username: usernameSchema,
email: z.email(), email: z.email(),
isAdmin: z.boolean() isAdmin: z.boolean()

View File

@@ -37,13 +37,13 @@
ldapAttributeUserUsername: z.string().min(1), ldapAttributeUserUsername: z.string().min(1),
ldapAttributeUserEmail: z.string().min(1), ldapAttributeUserEmail: z.string().min(1),
ldapAttributeUserFirstName: z.string().min(1), ldapAttributeUserFirstName: z.string().min(1),
ldapAttributeUserLastName: z.string().min(1), ldapAttributeUserLastName: z.string().optional(),
ldapAttributeUserDisplayName: z.string().min(1), ldapAttributeUserDisplayName: z.string().optional(),
ldapAttributeUserProfilePicture: z.string(), ldapAttributeUserProfilePicture: z.string().optional(),
ldapAttributeGroupMember: z.string(), ldapAttributeGroupMember: z.string().optional(),
ldapAttributeGroupUniqueIdentifier: z.string().min(1), ldapAttributeGroupUniqueIdentifier: z.string().min(1),
ldapAttributeGroupName: z.string().min(1), ldapAttributeGroupName: z.string().min(1),
ldapAttributeAdminGroup: z.string(), ldapAttributeAdminGroup: z.string().optional(),
ldapSoftDeleteUsers: z.boolean() ldapSoftDeleteUsers: z.boolean()
}); });

View File

@@ -82,7 +82,7 @@
<div class="grid grid-cols-1 gap-3 md:grid-cols-2"> <div class="grid grid-cols-1 gap-3 md:grid-cols-2">
<div> <div>
<Label for="issuer-{i}" class="text-xs">Issuer (Required)</Label> <Label required for="issuer-{i}" class="text-xs">Issuer</Label>
<Input <Input
id="issuer-{i}" id="issuer-{i}"
placeholder="https://example.com/" placeholder="https://example.com/"
@@ -96,10 +96,10 @@
</div> </div>
<div> <div>
<Label for="subject-{i}" class="text-xs">Subject (Optional)</Label> <Label for="subject-{i}" class="text-xs">Subject</Label>
<Input <Input
id="subject-{i}" id="subject-{i}"
placeholder="Defaults to the client ID: {client?.id}" placeholder="Defaults to the client ID"
value={identity.subject || ''} value={identity.subject || ''}
oninput={(e) => updateFederatedIdentity(i, 'subject', e.currentTarget.value)} oninput={(e) => updateFederatedIdentity(i, 'subject', e.currentTarget.value)}
aria-invalid={!!getFieldError(i, 'subject')} aria-invalid={!!getFieldError(i, 'subject')}
@@ -110,7 +110,7 @@
</div> </div>
<div> <div>
<Label for="audience-{i}" class="text-xs">Audience (Optional)</Label> <Label for="audience-{i}" class="text-xs">Audience</Label>
<Input <Input
id="audience-{i}" id="audience-{i}"
placeholder="Defaults to the Pocket ID URL" placeholder="Defaults to the Pocket ID URL"
@@ -124,7 +124,7 @@
</div> </div>
<div> <div>
<Label for="jwks-{i}" class="text-xs">JWKS URL (Optional)</Label> <Label for="jwks-{i}" class="text-xs">JWKS URL</Label>
<Input <Input
id="jwks-{i}" id="jwks-{i}"
placeholder="Defaults to {identity.issuer || '<issuer>'}/.well-known/jwks.json" placeholder="Defaults to {identity.issuer || '<issuer>'}/.well-known/jwks.json"

View File

@@ -100,7 +100,7 @@
<Dialog.Title>{m.oidc_data_preview()}</Dialog.Title> <Dialog.Title>{m.oidc_data_preview()}</Dialog.Title>
<Dialog.Description> <Dialog.Description>
{#if user} {#if user}
{m.preview_for_user({ name: user.firstName + ' ' + user.lastName, email: user.email })} {m.preview_for_user({ name: user.displayName, email: user.email })}
{:else} {:else}
{m.preview_the_oidc_data_that_would_be_sent_for_this_user()} {m.preview_the_oidc_data_that_would_be_sent_for_this_user()}
{/if} {/if}

View File

@@ -43,7 +43,7 @@
{selectionDisabled} {selectionDisabled}
> >
{#snippet rows({ item })} {#snippet rows({ item })}
<Table.Cell>{item.firstName} {item.lastName}</Table.Cell> <Table.Cell>{item.displayName}</Table.Cell>
<Table.Cell>{item.email}</Table.Cell> <Table.Cell>{item.email}</Table.Cell>
{/snippet} {/snippet}
</AdvancedTable> </AdvancedTable>

View File

@@ -35,7 +35,7 @@
const formSchema = z.object({ const formSchema = z.object({
firstName: z.string().min(1).max(50), firstName: z.string().min(1).max(50),
lastName: emptyToUndefined(z.string().max(50).optional()), lastName: emptyToUndefined(z.string().max(50).optional()),
displayName: z.string().max(100), displayName: z.string().min(1).max(100),
username: usernameSchema, username: usernameSchema,
email: z.email(), email: z.email(),
isAdmin: z.boolean(), isAdmin: z.boolean(),