diff --git a/.husky/pre-commit b/.husky/pre-commit index ffbf882..9b49e3e 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,7 +1,11 @@ export NX_DAEMON=false export PATH="$HOME/.local/share/fnm/node-versions/v24.14.1/installation/bin:$HOME/.local/share/pnpm:$PATH" -pnpm exec prettier --check . +# Run lint-staged: format staged files with prettier, then ESLint for staged TS/HTML. +# This is fast because it only operates on changed files. +pnpm exec lint-staged + +# Run focused lint + unit tests on the whole workspace (not just staged files). +# e2e is too slow for pre-commit; it runs in pre-push and CI instead. pnpm exec nx run-many -t lint pnpm exec nx run-many -t test -pnpm exec nx run-many -t e2e --parallel=1 diff --git a/.husky/pre-push b/.husky/pre-push new file mode 100755 index 0000000..5d6914c --- /dev/null +++ b/.husky/pre-push @@ -0,0 +1,7 @@ +export NX_DAEMON=false +export PATH="$HOME/.local/share/fnm/node-versions/v24.14.1/installation/bin:$HOME/.local/share/pnpm:$PATH" + +# Run E2E before push. The full gateway must be bootable; if it isn't, the test +# runner will surface that as a connection error and the push is blocked. +# This catches integration regressions before they reach the remote CI runner. +pnpm exec nx e2e app-e2e diff --git a/apps/web/app/src/app/activation/activation.html b/apps/web/app/src/app/activation/activation.html index fe367c4..abeec56 100644 --- a/apps/web/app/src/app/activation/activation.html +++ b/apps/web/app/src/app/activation/activation.html @@ -36,23 +36,29 @@ Named API access keys -
- + + + + {{ + labelError() + }} + Generate key - - - @if (labelError(); as message) { - {{ message }} - } +
@if ((activationData()?.apiKeys?.length ?? 0) === 0) { diff --git a/apps/web/app/src/app/activation/activation.ts b/apps/web/app/src/app/activation/activation.ts index 2804e3a..060b084 100644 --- a/apps/web/app/src/app/activation/activation.ts +++ b/apps/web/app/src/app/activation/activation.ts @@ -1,5 +1,6 @@ import { HttpErrorResponse } from '@angular/common/http'; -import { Component, inject, signal, OnInit } from '@angular/core'; +import { Component, computed, inject, signal, OnInit } from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; import { Router } from '@angular/router'; @@ -12,11 +13,14 @@ import type { } from '../shared/activation/activation.models'; import { Clipboard } from '../shared/clipboard/clipboard'; import { PROJECTS_URL } from '../shared/constants/routes'; +import { controlError } from '../shared/form/form-errors'; import { Alert } from '../shared/ui/overlays/alert/alert'; import { Badge } from '../shared/ui/data/badge/badge'; import { Button } from '../shared/ui/actions/button/button'; import { Card } from '../shared/ui/layout/card/card'; import { ErrorMessage } from '../shared/ui/forms/error-message/error-message'; +import { Field } from '../shared/ui/forms/field/field'; +import { Form as AppForm } from '../shared/ui/forms/form/form'; import { Heading } from '../shared/ui/typography/heading/heading'; import { Input } from '../shared/ui/forms/input/input'; import { Label } from '../shared/ui/forms/label/label'; @@ -32,7 +36,20 @@ type ConfigTab = 'env' | 'opencode' | 'themis'; host: { class: /* tw */ 'block min-h-full w-full', }, - imports: [Alert, Badge, Button, Card, ErrorMessage, Heading, Input, Label, Loader, ReactiveFormsModule], + imports: [ + Alert, + AppForm, + Badge, + Button, + Card, + ErrorMessage, + Field, + Heading, + Input, + Label, + Loader, + ReactiveFormsModule, + ], selector: 'app-activation', templateUrl: './activation.html', styleUrl: './activation.css', @@ -49,16 +66,29 @@ export class Activation implements OnInit { }), }); + private readonly labelValueChanges = toSignal(this.apiKeyForm.controls.label.valueChanges, { + initialValue: this.apiKeyForm.controls.label.status, + }); + readonly activationData = signal(null); readonly continuing = signal(false); readonly copyMessage = signal(''); readonly creatingKey = signal(false); readonly errorMessage = signal(''); readonly generatedKey = signal(null); - readonly labelError = signal(''); readonly loading = signal(true); readonly revokingKeyId = signal(''); readonly selectedConfigTab = signal('themis'); + readonly submitted = signal(false); + + readonly labelError = computed(() => { + this.labelValueChanges(); + + return controlError(this.apiKeyForm.controls.label, { + required: 'Enter a label for the API key.', + maxlength: 'Use 80 characters or fewer.', + }); + }); async ngOnInit() { await this.loadActivationState(); @@ -66,9 +96,6 @@ export class Activation implements OnInit { async createApiKey() { if (this.apiKeyForm.invalid) { - this.apiKeyForm.markAllAsTouched(); - this.updateLabelError(); - return; } @@ -229,30 +256,6 @@ export class Activation implements OnInit { return this.activationData()?.milestones.includes(milestone) ?? false; } - updateLabelError() { - const control = this.apiKeyForm.controls.label; - - if (!control.touched || !control.invalid) { - this.labelError.set(''); - - return; - } - - if (control.hasError('required')) { - this.labelError.set('Enter a label for the API key.'); - - return; - } - - if (control.hasError('maxlength')) { - this.labelError.set('Use 80 characters or fewer.'); - - return; - } - - this.labelError.set('This field is invalid.'); - } - private async loadActivationState() { this.loading.set(true); this.errorMessage.set(''); diff --git a/apps/web/app/src/app/auth/forgotten-password/forgotten-password.html b/apps/web/app/src/app/auth/forgotten-password/forgotten-password.html index 670b683..cdd4912 100644 --- a/apps/web/app/src/app/auth/forgotten-password/forgotten-password.html +++ b/apps/web/app/src/app/auth/forgotten-password/forgotten-password.html @@ -52,22 +52,21 @@ }} } -
+ Email - @if (emailError(); as message) { - {{ message }} - } + {{ + emailError() + }} Send recovery link - +

Remembered your password? diff --git a/apps/web/app/src/app/auth/forgotten-password/forgotten-password.ts b/apps/web/app/src/app/auth/forgotten-password/forgotten-password.ts index a99da6e..4dc0358 100644 --- a/apps/web/app/src/app/auth/forgotten-password/forgotten-password.ts +++ b/apps/web/app/src/app/auth/forgotten-password/forgotten-password.ts @@ -1,5 +1,6 @@ import { HttpErrorResponse } from '@angular/common/http'; -import { Component, inject, signal } from '@angular/core'; +import { Component, computed, inject, signal } from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; import { Router } from '@angular/router'; @@ -12,6 +13,7 @@ import { AuthLayout } from '../../shared/ui/layout/auth-layout/auth-layout'; import { Button } from '../../shared/ui/actions/button/button'; import { ErrorMessage } from '../../shared/ui/forms/error-message/error-message'; import { Field } from '../../shared/ui/forms/field/field'; +import { Form as AppForm } from '../../shared/ui/forms/form/form'; import { Input } from '../../shared/ui/forms/input/input'; import { Label } from '../../shared/ui/forms/label/label'; import { Link } from '../../shared/ui/typography/link/link'; @@ -24,7 +26,7 @@ type ForgottenPasswordForm = FormGroup<{ host: { class: /* tw */ 'block min-h-full w-full', }, - imports: [Alert, AuthCard, AuthLayout, Button, ErrorMessage, Field, Input, Label, Link, ReactiveFormsModule], + imports: [Alert, AppForm, AuthCard, AuthLayout, Button, ErrorMessage, Field, Input, Label, Link, ReactiveFormsModule], selector: 'app-forgotten-password', templateUrl: './forgotten-password.html', styleUrl: './forgotten-password.css', @@ -40,27 +42,26 @@ export class ForgottenPassword { }), }); + private readonly emailValueChanges = toSignal(this.form.controls.email.valueChanges, { + initialValue: this.form.controls.email.status, + }); + readonly submitting = signal(false); + readonly submitted = signal(false); readonly successEmail = signal(''); readonly errorMessage = signal(''); - readonly emailError = signal(''); - updateEmailError() { - this.emailError.set(this.emailErrorMessage()); - } + readonly emailError = computed(() => { + this.emailValueChanges(); - emailErrorMessage() { return controlError(this.form.controls.email, { email: $localize`:@@forgottenPasswordEmailErrorInvalid:Enter a valid email address (e.g. you@company.com).`, required: $localize`:@@forgottenPasswordEmailErrorRequired:Enter your email address.`, }); - } + }); async submit() { if (this.form.invalid) { - this.form.markAllAsTouched(); - this.updateEmailError(); - return; } diff --git a/apps/web/app/src/app/auth/reset-password/reset-password.html b/apps/web/app/src/app/auth/reset-password/reset-password.html index 154622c..59756b1 100644 --- a/apps/web/app/src/app/auth/reset-password/reset-password.html +++ b/apps/web/app/src/app/auth/reset-password/reset-password.html @@ -53,69 +53,48 @@ }}

-
- - Verification code - - @if (pinError(); as message) { - {{ message }} - } - - - Verify code -
+ } @else { -
+ New password - @if (passwordError(); as message) { - {{ message }} - } - + Confirm new password - @if (confirmPasswordError(); as message) { - {{ message }} - } + {{ + confirmPasswordError() + }} Update password - +
} @if (errorMessage()) { diff --git a/apps/web/app/src/app/auth/reset-password/reset-password.ts b/apps/web/app/src/app/auth/reset-password/reset-password.ts index 4c45ff5..e0b6e2b 100644 --- a/apps/web/app/src/app/auth/reset-password/reset-password.ts +++ b/apps/web/app/src/app/auth/reset-password/reset-password.ts @@ -1,29 +1,26 @@ import { HttpErrorResponse } from '@angular/common/http'; import { Component, computed, inject, signal } from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; import { Router, RouterLink } from '@angular/router'; import { Auth } from '../../shared/auth/auth'; import { SIGN_IN_URL } from '../../shared/constants/routes'; -import { controlError } from '../../shared/form/form-errors'; import { Alert } from '../../shared/ui/overlays/alert/alert'; import { AuthCard } from '../../shared/ui/layout/auth-card/auth-card'; import { AuthLayout } from '../../shared/ui/layout/auth-layout/auth-layout'; import { Button } from '../../shared/ui/actions/button/button'; import { ErrorMessage } from '../../shared/ui/forms/error-message/error-message'; import { Field } from '../../shared/ui/forms/field/field'; -import { Input as TextInput } from '../../shared/ui/forms/input/input'; +import { Form as AppForm } from '../../shared/ui/forms/form/form'; import { Label } from '../../shared/ui/forms/label/label'; import { Link } from '../../shared/ui/typography/link/link'; import { PasswordInput } from '../../shared/ui/forms/password-input/password-input'; import { PasswordStrength } from '../../shared/ui/forms/password-strength/password-strength'; +import { VerificationCodeForm } from '../verification-code-form/verification-code-form'; type ResetStep = 'otp' | 'password' | 'success'; -type OtpForm = FormGroup<{ - pin: FormControl; -}>; - type PasswordForm = FormGroup<{ password: FormControl; confirmPassword: FormControl; @@ -35,6 +32,7 @@ type PasswordForm = FormGroup<{ }, imports: [ Alert, + AppForm, AuthCard, AuthLayout, Button, @@ -46,7 +44,7 @@ type PasswordForm = FormGroup<{ PasswordStrength, ReactiveFormsModule, RouterLink, - TextInput, + VerificationCodeForm, ], selector: 'app-reset-password', templateUrl: './reset-password.html', @@ -58,20 +56,13 @@ export class ResetPassword { readonly step = signal('otp'); readonly submitting = signal(false); + readonly submitted = signal(false); readonly errorMessage = signal(''); - readonly passwordError = signal(''); - readonly confirmPasswordError = signal(''); - readonly pinError = signal(''); + readonly pinManualError = signal(null); readonly challenge = this.auth.pendingChallenge; readonly pendingEmail = computed(() => this.challenge()?.email ?? null); - - readonly otpForm: OtpForm = new FormGroup({ - pin: new FormControl('', { - nonNullable: true, - validators: [Validators.required, Validators.minLength(6), Validators.maxLength(6)], - }), - }); + readonly verificationSubmitting = this.auth.verificationSubmitting; readonly passwordForm: PasswordForm = new FormGroup({ password: new FormControl('', { @@ -84,12 +75,33 @@ export class ResetPassword { }), }); + private readonly passwordValueChanges = toSignal(this.passwordForm.controls.password.valueChanges, { + initialValue: this.passwordForm.controls.password.status, + }); + readonly passwordValue = computed(() => this.passwordForm.controls.password.value); - async submitOtp() { - const pin = this.otpForm.controls.pin.value; + readonly confirmPasswordError = computed(() => { + this.passwordValueChanges(); + const control = this.passwordForm.controls.confirmPassword; + const expected = this.passwordForm.controls.password.value; + + if (control.hasError('required')) { + return $localize`:@@resetPasswordConfirmErrorRequired:Re-enter your new password.`; + } + + if (expected && control.value !== expected) { + return $localize`:@@resetPasswordConfirmErrorMismatch:Passwords don't match.`; + } + + return ''; + }); + + async onOtpSubmit(pin: string) { const challenge = this.challenge(); + this.pinManualError.set(null); + if (!challenge) { this.errorMessage.set( $localize`:@@resetPasswordMissingChallenge:The recovery session has expired. Please request a new code.`, @@ -98,12 +110,6 @@ export class ResetPassword { return; } - if (!/^\d{6}$/.test(pin)) { - this.pinError.set($localize`:@@resetPasswordPinErrorRequired:Enter the 6-digit code.`); - - return; - } - this.submitting.set(true); this.errorMessage.set(''); @@ -116,18 +122,17 @@ export class ResetPassword { ? (error.error?.message ?? $localize`:@@resetPasswordVerifyFailed:That code didn't work. Try again.`) : $localize`:@@resetPasswordVerifyFailed:That code didn't work. Try again.`, ); + this.pinManualError.set($localize`:@@resetPasswordPinErrorMismatch:That code didn't work. Try again.`); } finally { this.submitting.set(false); } } - async submitPassword() { - this.passwordError.set(this.passwordErrorMessage()); - this.confirmPasswordError.set(this.confirmPasswordErrorMessage()); - - if (this.passwordForm.invalid || this.passwordError() || this.confirmPasswordError()) { - this.passwordForm.markAllAsTouched(); - + async onPasswordSubmit() { + if ( + this.passwordForm.invalid || + this.passwordForm.controls.password.value !== this.passwordForm.controls.confirmPassword.value + ) { return; } @@ -149,35 +154,5 @@ export class ResetPassword { } } - passwordErrorMessage() { - return controlError(this.passwordForm.controls.password, { - minlength: $localize`:@@resetPasswordPasswordErrorMinlength:Use at least 8 characters.`, - required: $localize`:@@resetPasswordPasswordErrorRequired:Choose a new password.`, - }); - } - - confirmPasswordErrorMessage() { - const control = this.passwordForm.controls.confirmPassword; - const expected = this.passwordForm.controls.password.value; - - if (control.hasError('required')) { - return $localize`:@@resetPasswordConfirmErrorRequired:Re-enter your new password.`; - } - - if (expected && control.value !== expected) { - return $localize`:@@resetPasswordConfirmErrorMismatch:Passwords don't match.`; - } - - return ''; - } - - pinErrorMessage() { - return controlError(this.otpForm.controls.pin, { - minlength: $localize`:@@resetPasswordPinErrorLength:Enter the 6-digit code.`, - maxlength: $localize`:@@resetPasswordPinErrorLength:Enter the 6-digit code.`, - required: $localize`:@@resetPasswordPinErrorRequired:Enter the 6-digit code.`, - }); - } - protected readonly signInUrl = SIGN_IN_URL; } diff --git a/apps/web/app/src/app/auth/sign-in/sign-in.html b/apps/web/app/src/app/auth/sign-in/sign-in.html index 6c36817..8394518 100644 --- a/apps/web/app/src/app/auth/sign-in/sign-in.html +++ b/apps/web/app/src/app/auth/sign-in/sign-in.html @@ -26,22 +26,21 @@ }} } -
+ Email - @if (emailError(); as message) { - {{ message }} - } + {{ + emailError() + }} @@ -56,22 +55,22 @@ - @if (passwordError(); as message) { - {{ message }} - } + {{ + passwordError() + }} @@ -84,7 +83,7 @@ [loading]="submitting()" >Sign in - +

New to Themis?

diff --git a/apps/web/app/src/app/auth/sign-in/sign-in.ts b/apps/web/app/src/app/auth/sign-in/sign-in.ts index 2764a3a..de13d9e 100644 --- a/apps/web/app/src/app/auth/sign-in/sign-in.ts +++ b/apps/web/app/src/app/auth/sign-in/sign-in.ts @@ -1,5 +1,6 @@ import { HttpErrorResponse } from '@angular/common/http'; -import { Component, inject, signal } from '@angular/core'; +import { Component, computed, inject, signal } from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; import { Router, RouterLink } from '@angular/router'; @@ -13,6 +14,7 @@ import { Button } from '../../shared/ui/actions/button/button'; import { Checkbox } from '../../shared/ui/forms/checkbox/checkbox'; import { ErrorMessage } from '../../shared/ui/forms/error-message/error-message'; import { Field } from '../../shared/ui/forms/field/field'; +import { Form as AppForm } from '../../shared/ui/forms/form/form'; import { Input } from '../../shared/ui/forms/input/input'; import { Label } from '../../shared/ui/forms/label/label'; import { Link } from '../../shared/ui/typography/link/link'; @@ -30,6 +32,7 @@ type SignInForm = FormGroup<{ }, imports: [ Alert, + AppForm, AuthCard, AuthLayout, Button, @@ -65,41 +68,37 @@ export class SignIn { }), }); - readonly submitting = this.auth.submitting; + private readonly emailValueChanges = toSignal(this.form.controls.email.valueChanges, { + initialValue: this.form.controls.email.value, + }); + private readonly passwordValueChanges = toSignal(this.form.controls.password.valueChanges, { + initialValue: this.form.controls.password.value, + }); + readonly submitting = this.auth.submitting; + readonly submitted = signal(false); readonly errorMessage = signal(''); - readonly emailError = signal(''); - readonly passwordError = signal(''); + readonly emailError = computed(() => { + this.emailValueChanges(); - emailErrorMessage() { return controlError(this.form.controls.email, { email: $localize`:@@signInEmailErrorInvalid:Enter a valid email address (e.g. you@company.com).`, required: $localize`:@@signInEmailErrorRequired:Enter your email address.`, }); - } + }); + + readonly passwordError = computed(() => { + this.passwordValueChanges(); - passwordErrorMessage() { return controlError(this.form.controls.password, { minlength: $localize`:@@signInPasswordErrorMinlength:Use at least 8 characters.`, required: $localize`:@@signInPasswordErrorRequired:Enter your password.`, }); - } - - updateEmailError() { - this.emailError.set(this.emailErrorMessage()); - } - - updatePasswordError() { - this.passwordError.set(this.passwordErrorMessage()); - } + }); async submit() { if (this.form.invalid) { - this.form.markAllAsTouched(); - this.updateEmailError(); - this.updatePasswordError(); - return; } diff --git a/apps/web/app/src/app/auth/sign-up/sign-up.html b/apps/web/app/src/app/auth/sign-up/sign-up.html index 129d67d..e260d8b 100644 --- a/apps/web/app/src/app/auth/sign-up/sign-up.html +++ b/apps/web/app/src/app/auth/sign-up/sign-up.html @@ -26,22 +26,21 @@ }} } -
+ Email - @if (emailError(); as message) { - {{ message }} - } + {{ + emailError() + }} @@ -49,32 +48,32 @@ - @if (passwordError(); as message) { - {{ message }} - } + {{ + passwordError() + }} - + Confirm password - @if (confirmPasswordError(); as message) { - {{ message }} - } + {{ + confirmPasswordError() + }} Create account - +

Already have an account?

diff --git a/apps/web/app/src/app/auth/sign-up/sign-up.ts b/apps/web/app/src/app/auth/sign-up/sign-up.ts index 1b3ef86..f0893fb 100644 --- a/apps/web/app/src/app/auth/sign-up/sign-up.ts +++ b/apps/web/app/src/app/auth/sign-up/sign-up.ts @@ -1,5 +1,6 @@ import { HttpErrorResponse } from '@angular/common/http'; import { Component, computed, inject, signal } from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; import { Router, RouterLink } from '@angular/router'; @@ -13,6 +14,7 @@ import { Button } from '../../shared/ui/actions/button/button'; import { Description } from '../../shared/ui/forms/description/description'; import { ErrorMessage } from '../../shared/ui/forms/error-message/error-message'; import { Field } from '../../shared/ui/forms/field/field'; +import { Form as AppForm } from '../../shared/ui/forms/form/form'; import { Input } from '../../shared/ui/forms/input/input'; import { Label } from '../../shared/ui/forms/label/label'; import { Link } from '../../shared/ui/typography/link/link'; @@ -31,6 +33,7 @@ type SignUpForm = FormGroup<{ }, imports: [ Alert, + AppForm, AuthCard, AuthLayout, Button, @@ -68,42 +71,39 @@ export class SignUp { }), }); - readonly submitting = this.auth.submitting; + private readonly emailValueChanges = toSignal(this.form.controls.email.valueChanges, { + initialValue: this.form.controls.email.status, + }); + private readonly passwordValueChanges = toSignal(this.form.controls.password.valueChanges, { + initialValue: this.form.controls.password.status, + }); + readonly submitting = this.auth.submitting; + readonly submitted = signal(false); readonly errorMessage = signal(''); - readonly emailError = signal(''); - readonly passwordError = signal(''); - readonly confirmPasswordError = signal(''); readonly passwordValue = computed(() => this.form.controls.password.value); - updateEmailError() { - this.emailError.set(this.emailErrorMessage()); - } + readonly emailError = computed(() => { + this.emailValueChanges(); - updatePasswordError() { - this.passwordError.set(this.passwordErrorMessage()); - } - - updateConfirmPasswordError() { - this.confirmPasswordError.set(this.confirmPasswordErrorMessage()); - } - - emailErrorMessage() { return controlError(this.form.controls.email, { email: $localize`:@@signUpEmailErrorInvalid:Enter a valid email address (e.g. you@company.com).`, required: $localize`:@@signUpEmailErrorRequired:Enter your email address.`, }); - } + }); + + readonly passwordError = computed(() => { + this.passwordValueChanges(); - passwordErrorMessage() { return controlError(this.form.controls.password, { minlength: $localize`:@@signUpPasswordErrorMinlength:Use at least 8 characters.`, required: $localize`:@@signUpPasswordErrorRequired:Choose a password.`, }); - } + }); - confirmPasswordErrorMessage() { + readonly confirmPasswordError = computed(() => { + this.passwordValueChanges(); const control = this.form.controls.confirmPassword; const expected = this.form.controls.password.value; @@ -116,21 +116,10 @@ export class SignUp { } return ''; - } + }); async submit() { - if (this.form.invalid) { - this.form.markAllAsTouched(); - this.updateEmailError(); - this.updatePasswordError(); - this.updateConfirmPasswordError(); - - return; - } - - if (this.form.controls.password.value !== this.form.controls.confirmPassword.value) { - this.updateConfirmPasswordError(); - + if (this.form.invalid || this.form.controls.password.value !== this.form.controls.confirmPassword.value) { return; } diff --git a/apps/web/app/src/app/auth/verification-code-form/verification-code-form.html b/apps/web/app/src/app/auth/verification-code-form/verification-code-form.html index 6a30af7..b9219ef 100644 --- a/apps/web/app/src/app/auth/verification-code-form/verification-code-form.html +++ b/apps/web/app/src/app/auth/verification-code-form/verification-code-form.html @@ -5,21 +5,20 @@ {{ statusMessage() }} } -
- + + Verification code Enter the 6-digit code from your email. - @if (pinError; as message) { - {{ message }} - } + {{ + resolvedPinError() + }}
@@ -30,4 +29,4 @@ Resend code
- +
diff --git a/apps/web/app/src/app/auth/verification-code-form/verification-code-form.ts b/apps/web/app/src/app/auth/verification-code-form/verification-code-form.ts index 3e99c40..f8ac531 100644 --- a/apps/web/app/src/app/auth/verification-code-form/verification-code-form.ts +++ b/apps/web/app/src/app/auth/verification-code-form/verification-code-form.ts @@ -1,4 +1,5 @@ -import { Component, input, output } from '@angular/core'; +import { Component, computed, input, output, signal } from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; import { controlError } from '../../shared/form/form-errors'; @@ -7,6 +8,7 @@ import { Button } from '../../shared/ui/actions/button/button'; import { Description } from '../../shared/ui/forms/description/description'; import { ErrorMessage } from '../../shared/ui/forms/error-message/error-message'; import { Field } from '../../shared/ui/forms/field/field'; +import { Form as AppForm } from '../../shared/ui/forms/form/form'; import { Label } from '../../shared/ui/forms/label/label'; import { PinInput } from '../../shared/ui/forms/pin-input/pin-input'; @@ -19,12 +21,13 @@ type VerificationForm = FormGroup<{ class: /* tw */ 'block', }, selector: 'app-verification-code-form', - imports: [Alert, Button, Description, ErrorMessage, Field, Label, PinInput, ReactiveFormsModule], + imports: [Alert, AppForm, Button, Description, ErrorMessage, Field, Label, PinInput, ReactiveFormsModule], templateUrl: './verification-code-form.html', styleUrl: './verification-code-form.css', }) export class VerificationCodeForm { readonly errorMessage = input(''); + readonly pinManualError = input(null); readonly statusMessage = input(''); readonly submitting = input(false); @@ -38,25 +41,26 @@ export class VerificationCodeForm { }), }); - pinError = ''; + private readonly pinValueChanges = toSignal(this.form.controls.pin.valueChanges, { + initialValue: this.form.controls.pin.status, + }); + + readonly submitted = signal(false); + + readonly pinError = computed(() => { + this.pinValueChanges(); - pinErrorMessage() { return controlError(this.form.controls.pin, { maxlength: $localize`:@@verificationCodeErrorLength:Enter the full 6-digit code.`, minlength: $localize`:@@verificationCodeErrorLength:Enter the full 6-digit code.`, required: $localize`:@@verificationCodeErrorRequired:Enter the verification code.`, }); - } + }); - updatePinError() { - this.pinError = this.pinErrorMessage(); - } + readonly resolvedPinError = computed(() => this.pinError() || this.pinManualError() || ''); submit() { if (this.form.invalid) { - this.form.markAllAsTouched(); - this.updatePinError(); - return; } diff --git a/apps/web/app/src/app/auth/verify-device/verify-device.html b/apps/web/app/src/app/auth/verify-device/verify-device.html index 3304da9..4aa88ee 100644 --- a/apps/web/app/src/app/auth/verify-device/verify-device.html +++ b/apps/web/app/src/app/auth/verify-device/verify-device.html @@ -24,6 +24,7 @@ (null); readonly statusMessage = signal(''); async submit(pin: string) { + this.pinManualError.set(null); this.errorMessage.set(''); this.statusMessage.set(''); @@ -40,10 +42,16 @@ export class VerifyDevice { ? (error.error?.message ?? $localize`:@@verifyDeviceFailed:Device verification failed.`) : $localize`:@@verifyDeviceFailed:Device verification failed.`, ); + this.pinManualError.set( + error instanceof HttpErrorResponse + ? $localize`:@@verifyDevicePinMismatch:The code didn't match. Try again.` + : $localize`:@@verifyDevicePinMismatch:The code didn't match. Try again.`, + ); } } async resend() { + this.pinManualError.set(null); this.errorMessage.set(''); this.statusMessage.set(''); diff --git a/apps/web/app/src/app/auth/verify-email/verify-email.html b/apps/web/app/src/app/auth/verify-email/verify-email.html index 2675e3b..6fe552f 100644 --- a/apps/web/app/src/app/auth/verify-email/verify-email.html +++ b/apps/web/app/src/app/auth/verify-email/verify-email.html @@ -32,6 +32,7 @@ (null); readonly statusMessage = signal(''); async submit(pin: string) { + this.pinManualError.set(null); this.errorMessage.set(''); this.statusMessage.set(''); @@ -40,10 +42,16 @@ export class VerifyEmail { ? (error.error?.message ?? $localize`:@@verifyEmailFailed:Verification failed.`) : $localize`:@@verifyEmailFailed:Verification failed.`, ); + this.pinManualError.set( + error instanceof HttpErrorResponse + ? $localize`:@@verifyEmailPinMismatch:The code didn't match. Try again.` + : $localize`:@@verifyEmailPinMismatch:The code didn't match. Try again.`, + ); } } async resend() { + this.pinManualError.set(null); this.errorMessage.set(''); this.statusMessage.set(''); diff --git a/apps/web/app/src/app/projects/project-new/project-new.html b/apps/web/app/src/app/projects/project-new/project-new.html index 7bf4e02..bb47c99 100644 --- a/apps/web/app/src/app/projects/project-new/project-new.html +++ b/apps/web/app/src/app/projects/project-new/project-new.html @@ -15,21 +15,21 @@ } -
+ Project name - @if (nameError(); as message) { - {{ message }} - } + {{ + nameError() + }} @@ -39,16 +39,15 @@ > - @if (summaryError(); as message) { - {{ message }} - } + {{ + summaryError() + }}
@@ -57,7 +56,7 @@ Create project
- +
diff --git a/apps/web/app/src/app/projects/project-new/project-new.ts b/apps/web/app/src/app/projects/project-new/project-new.ts index 2d5b60c..1a6bc55 100644 --- a/apps/web/app/src/app/projects/project-new/project-new.ts +++ b/apps/web/app/src/app/projects/project-new/project-new.ts @@ -1,8 +1,10 @@ -import { Component, inject, signal } from '@angular/core'; +import { Component, computed, inject, signal } from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; import { Router, RouterLink } from '@angular/router'; import { PROJECTS_URL } from '../../shared/constants/routes'; +import { controlError } from '../../shared/form/form-errors'; import { ProjectsApi } from '../../shared/projects/projects'; import { Alert } from '../../shared/ui/overlays/alert/alert'; import { Button } from '../../shared/ui/actions/button/button'; @@ -11,6 +13,7 @@ import { Container } from '../../shared/ui/layout/container/container'; import { Description } from '../../shared/ui/forms/description/description'; import { ErrorMessage } from '../../shared/ui/forms/error-message/error-message'; import { Field } from '../../shared/ui/forms/field/field'; +import { Form as AppForm } from '../../shared/ui/forms/form/form'; import { Heading } from '../../shared/ui/typography/heading/heading'; import { Input } from '../../shared/ui/forms/input/input'; import { Label } from '../../shared/ui/forms/label/label'; @@ -29,6 +32,7 @@ type NewProjectForm = FormGroup<{ }, imports: [ Alert, + AppForm, Button, Card, Container, @@ -63,58 +67,37 @@ export class ProjectNew { }), }); - readonly nameError = signal(''); - readonly summaryError = signal(''); + private readonly nameValueChanges = toSignal(this.form.controls.name.valueChanges, { + initialValue: this.form.controls.name.status, + }); + private readonly summaryValueChanges = toSignal(this.form.controls.summary.valueChanges, { + initialValue: this.form.controls.summary.status, + }); + readonly submitting = signal(false); + readonly submitted = signal(false); readonly errorMessage = signal(''); readonly projectsUrl = PROJECTS_URL; - updateNameError() { - this.nameError.set(this.nameErrorMessage()); - } - - updateSummaryError() { - this.summaryError.set(this.summaryErrorMessage()); - } - - nameErrorMessage() { - const control = this.form.controls.name; - - if (!control.touched || !control.invalid) { - return ''; - } - - if (control.hasError('required')) { - return 'Enter a project name.'; - } - - if (control.hasError('maxlength')) { - return 'Use 120 characters or fewer.'; - } - - return 'This field is invalid.'; - } - - summaryErrorMessage() { - const control = this.form.controls.summary; + readonly nameError = computed(() => { + this.nameValueChanges(); - if (!control.touched || !control.invalid) { - return ''; - } + return controlError(this.form.controls.name, { + required: 'Enter a project name.', + maxlength: 'Use 120 characters or fewer.', + }); + }); - if (control.hasError('maxlength')) { - return 'Use 500 characters or fewer.'; - } + readonly summaryError = computed(() => { + this.summaryValueChanges(); - return 'This field is invalid.'; - } + return controlError(this.form.controls.summary, { + maxlength: 'Use 500 characters or fewer.', + }); + }); async submit() { if (this.form.invalid) { - this.form.markAllAsTouched(); - this.updateNameError(); - this.updateSummaryError(); - return; } diff --git a/apps/web/app/src/app/shared/form/form-errors.spec.ts b/apps/web/app/src/app/shared/form/form-errors.spec.ts new file mode 100644 index 0000000..09da218 --- /dev/null +++ b/apps/web/app/src/app/shared/form/form-errors.spec.ts @@ -0,0 +1,39 @@ +import { FormControl } from '@angular/forms'; + +import { controlError } from './form-errors'; + +describe('controlError', () => { + it('returns an empty string when the control is missing', () => { + expect(controlError(null, { required: 'Required.' })).toBe(''); + }); + + it('returns an empty string when the control is valid', () => { + const control = new FormControl('value', { nonNullable: true }); + const messages = { required: 'Required.' }; + + expect(controlError(control, messages)).toBe(''); + }); + + it('returns the first matching message for the active error key', () => { + const control = new FormControl('', { nonNullable: true }); + + control.addValidators(() => ({ required: true })); + control.updateValueAndValidity(); + + const messages = { + email: 'Enter a valid email address.', + required: 'This field is required.', + }; + + expect(controlError(control, messages)).toBe(messages.required); + }); + + it('falls back to the default message when no message matches', () => { + const control = new FormControl('', { nonNullable: true }); + + control.addValidators(() => ({ unknown: true })); + control.updateValueAndValidity(); + + expect(controlError(control, { required: 'Required.' })).toBe('This field is invalid.'); + }); +}); diff --git a/apps/web/app/src/app/shared/form/form-errors.ts b/apps/web/app/src/app/shared/form/form-errors.ts index f88b82b..e60ae5d 100644 --- a/apps/web/app/src/app/shared/form/form-errors.ts +++ b/apps/web/app/src/app/shared/form/form-errors.ts @@ -1,7 +1,7 @@ import type { AbstractControl } from '@angular/forms'; -export function controlError(control: AbstractControl | null, messages: Record) { - if (!control || !control.touched || !control.invalid) { +export function controlError(control: AbstractControl | null, messages: Record): string { + if (!control || !control.invalid) { return ''; } diff --git a/apps/web/app/src/app/shared/ui/forms/checkbox/checkbox.html b/apps/web/app/src/app/shared/ui/forms/checkbox/checkbox.html index d08acc7..48b2d34 100644 --- a/apps/web/app/src/app/shared/ui/forms/checkbox/checkbox.html +++ b/apps/web/app/src/app/shared/ui/forms/checkbox/checkbox.html @@ -3,6 +3,7 @@ [attr.aria-invalid]="invalid()" [attr.id]="controlId()" [attr.name]="name()" + [attr.required]="required() ? '' : null" [checked]="checked()" [class]="classes()" [disabled]="disabled() || formDisabled()" diff --git a/apps/web/app/src/app/shared/ui/forms/checkbox/checkbox.ts b/apps/web/app/src/app/shared/ui/forms/checkbox/checkbox.ts index 5cd12af..712bdf7 100644 --- a/apps/web/app/src/app/shared/ui/forms/checkbox/checkbox.ts +++ b/apps/web/app/src/app/shared/ui/forms/checkbox/checkbox.ts @@ -4,7 +4,12 @@ import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; import { uiClass } from '../../classes'; @Component({ - host: { class: /* tw */ 'inline-flex' }, + host: { + class: /* tw */ 'inline-flex', + 'data-control': '', + '[attr.data-invalid]': 'invalid() ? "" : null', + }, + imports: [], providers: [{ multi: true, provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => Checkbox) }], selector: 'app-checkbox', templateUrl: './checkbox.html', @@ -16,6 +21,7 @@ export class Checkbox implements ControlValueAccessor { readonly disabled = input(false, { transform: booleanAttribute }); readonly invalid = input(false, { transform: booleanAttribute }); readonly name = input(null); + readonly required = input(false, { transform: booleanAttribute }); readonly checkedChange = output(); readonly checked = signal(false); @@ -23,7 +29,6 @@ export class Checkbox implements ControlValueAccessor { readonly classes = computed(() => uiClass( 'ui-focus-ring ui-touch-target min-h-5 min-w-5 appearance-none rounded border border-zinc-950/10 dark:border-white/10 bg-zinc-50 dark:bg-zinc-900 text-blue-600 accent-blue-600 disabled:cursor-not-allowed disabled:opacity-50 dark:text-blue-400 dark:accent-blue-500', - this.invalid() ? 'border-red-600 dark:border-red-500' : 'border-zinc-500/40 dark:border-zinc-400/40', ), ); diff --git a/apps/web/app/src/app/shared/ui/forms/color-picker/color-picker.html b/apps/web/app/src/app/shared/ui/forms/color-picker/color-picker.html index a89f67e..33cf8d5 100644 --- a/apps/web/app/src/app/shared/ui/forms/color-picker/color-picker.html +++ b/apps/web/app/src/app/shared/ui/forms/color-picker/color-picker.html @@ -10,6 +10,7 @@ + +
+ `, +}) +class Host { + readonly invalid = signal(false); + readonly manualError = signal(null); +} + +describe('Field', () => { + let fixture: ComponentFixture; + let host: Host; + + beforeEach(async () => { + await TestBed.configureTestingModule({ imports: [Host] }).compileComponents(); + fixture = TestBed.createComponent(Host); + host = fixture.componentInstance; + await fixture.whenStable(); + }); + + it('always carries data-control on the host', async () => { + const field = fixture.nativeElement.querySelector('app-field') as HTMLElement; + + expect(field.getAttribute('data-control')).toBe(''); + expect(field.hasAttribute('data-invalid')).toBe(false); + expect(field.hasAttribute('data-manual-invalid')).toBe(false); + }); + + it('flips data-invalid when [invalid] is set to true', async () => { + host.invalid.set(true); + fixture.detectChanges(); + await fixture.whenStable(); + + const field = fixture.nativeElement.querySelector('app-field') as HTMLElement; + + expect(field.getAttribute('data-invalid')).toBe(''); + }); + + it('flips data-manual-invalid when [manualError] is non-empty', async () => { + host.manualError.set('Something went wrong.'); + fixture.detectChanges(); + await fixture.whenStable(); + + const field = fixture.nativeElement.querySelector('app-field') as HTMLElement; + + expect(field.getAttribute('data-manual-invalid')).toBe(''); + }); + + it('drops data-manual-invalid when [manualError] resets to empty', async () => { + host.manualError.set('Something went wrong.'); + fixture.detectChanges(); + await fixture.whenStable(); + host.manualError.set(''); + fixture.detectChanges(); + await fixture.whenStable(); + + const field = fixture.nativeElement.querySelector('app-field') as HTMLElement; + + expect(field.hasAttribute('data-manual-invalid')).toBe(false); + }); + + it('keeps both attributes set when both inputs are truthy', async () => { + host.invalid.set(true); + host.manualError.set('Cross-field mismatch.'); + fixture.detectChanges(); + await fixture.whenStable(); + + const field = fixture.nativeElement.querySelector('app-field') as HTMLElement; + + expect(field.getAttribute('data-invalid')).toBe(''); + expect(field.getAttribute('data-manual-invalid')).toBe(''); + }); +}); diff --git a/apps/web/app/src/app/shared/ui/forms/field/field.ts b/apps/web/app/src/app/shared/ui/forms/field/field.ts index c44942e..9a6baaf 100644 --- a/apps/web/app/src/app/shared/ui/forms/field/field.ts +++ b/apps/web/app/src/app/shared/ui/forms/field/field.ts @@ -1,10 +1,13 @@ -import { Component, computed, input } from '@angular/core'; +import { booleanAttribute, Component, computed, input } from '@angular/core'; import { uiClass } from '../../classes'; @Component({ host: { class: /* tw */ 'block', + 'data-control': '', + '[attr.data-invalid]': 'invalid() ? "" : null', + '[attr.data-manual-invalid]': 'manualError() ? "" : null', }, selector: 'app-field', templateUrl: './field.html', @@ -12,6 +15,8 @@ import { uiClass } from '../../classes'; }) export class Field { readonly compact = input(false); + readonly invalid = input(false, { transform: booleanAttribute }); + readonly manualError = input(null); readonly classes = computed(() => uiClass('grid', this.compact() ? 'gap-1.5' : 'gap-2')); } diff --git a/apps/web/app/src/app/shared/ui/forms/form/form.css b/apps/web/app/src/app/shared/ui/forms/form/form.css new file mode 100644 index 0000000..e69de29 diff --git a/apps/web/app/src/app/shared/ui/forms/form/form.html b/apps/web/app/src/app/shared/ui/forms/form/form.html new file mode 100644 index 0000000..3555311 --- /dev/null +++ b/apps/web/app/src/app/shared/ui/forms/form/form.html @@ -0,0 +1,8 @@ +
+ + diff --git a/apps/web/app/src/app/shared/ui/forms/form/form.spec.ts b/apps/web/app/src/app/shared/ui/forms/form/form.spec.ts new file mode 100644 index 0000000..eee8b31 --- /dev/null +++ b/apps/web/app/src/app/shared/ui/forms/form/form.spec.ts @@ -0,0 +1,63 @@ +import { Component, signal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { FormBuilder, ReactiveFormsModule } from '@angular/forms'; + +import { Form } from './form'; + +@Component({ + imports: [Form, ReactiveFormsModule], + template: ` + + + + `, +}) +class Host { + readonly submitted = signal(false); + readonly form = new FormBuilder().nonNullable.group({ + email: ['', []], + }); + onSubmit(): void { + // counted as an emission indicator; no DOM work required. + } +} + +describe('Form', () => { + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ imports: [Host] }).compileComponents(); + fixture = TestBed.createComponent(Host); + fixture.detectChanges(); + await fixture.whenStable(); + }); + + it('flips data-submitted when the inner
is submitted', async () => { + const form = fixture.nativeElement.querySelector('app-form') as HTMLElement; + + expect(form.hasAttribute('data-submitted')).toBe(false); + + const innerForm = fixture.nativeElement.querySelector('form') as HTMLFormElement; + + innerForm.dispatchEvent(new Event('submit', { cancelable: true, bubbles: true })); + fixture.detectChanges(); + await fixture.whenStable(); + + expect(fixture.componentInstance.submitted()).toBe(true); + expect(form.getAttribute('data-submitted')).toBe(''); + }); + + it('mirrors an externally-set submitted signal onto the host', async () => { + fixture.componentInstance.submitted.set(true); + fixture.detectChanges(); + await fixture.whenStable(); + + const form = fixture.nativeElement.querySelector('app-form') as HTMLElement; + + expect(form.getAttribute('data-submitted')).toBe(''); + }); + + it('forwards the novalidate attribute to the inner ', async () => { + expect((fixture.nativeElement.querySelector('form') as HTMLFormElement).hasAttribute('novalidate')).toBe(true); + }); +}); diff --git a/apps/web/app/src/app/shared/ui/forms/form/form.ts b/apps/web/app/src/app/shared/ui/forms/form/form.ts new file mode 100644 index 0000000..01be2f1 --- /dev/null +++ b/apps/web/app/src/app/shared/ui/forms/form/form.ts @@ -0,0 +1,26 @@ +import { booleanAttribute, Component, input, model, output } from '@angular/core'; +import { FormGroup, ReactiveFormsModule } from '@angular/forms'; + +@Component({ + host: { + class: /* tw */ 'block', + '[attr.data-submitted]': 'submitted() ? "" : null', + }, + imports: [ReactiveFormsModule], + selector: 'app-form', + templateUrl: './form.html', + styleUrl: './form.css', +}) +export class Form { + readonly submitted = model(false); + readonly formGroup = input(new FormGroup({})); + readonly className = input(''); + readonly novalidate = input(true, { transform: booleanAttribute }); + readonly ngSubmit = output(); + + onSubmit(event: Event): void { + event.preventDefault(); + this.submitted.set(true); + this.ngSubmit.emit(); + } +} diff --git a/apps/web/app/src/app/shared/ui/forms/input/input.html b/apps/web/app/src/app/shared/ui/forms/input/input.html index 6199481..bf2477d 100644 --- a/apps/web/app/src/app/shared/ui/forms/input/input.html +++ b/apps/web/app/src/app/shared/ui/forms/input/input.html @@ -3,12 +3,17 @@ [attr.aria-invalid]="invalid()" [attr.autocomplete]="autocomplete()" [attr.id]="controlId()" + [attr.max]="max()" + [attr.maxlength]="maxLength()" + [attr.min]="min()" + [attr.minlength]="minLength()" [attr.name]="name()" + [attr.pattern]="pattern()" + [attr.placeholder]="placeholder()" + [attr.required]="required() ? '' : null" + [attr.type]="type()" [class]="classes()" [disabled]="disabled() || formDisabled()" - [placeholder]="placeholder()" - [required]="required()" - [type]="type()" [value]="value()" data-slot="control" (blur)="markTouched()" diff --git a/apps/web/app/src/app/shared/ui/forms/input/input.ts b/apps/web/app/src/app/shared/ui/forms/input/input.ts index cbdfd88..5a8b3fa 100644 --- a/apps/web/app/src/app/shared/ui/forms/input/input.ts +++ b/apps/web/app/src/app/shared/ui/forms/input/input.ts @@ -1,4 +1,13 @@ -import { booleanAttribute, Component, computed, forwardRef, input, output, signal } from '@angular/core'; +import { + booleanAttribute, + Component, + computed, + forwardRef, + input, + numberAttribute, + output, + signal, +} from '@angular/core'; import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; import { uiClass } from '../../classes'; @@ -24,7 +33,12 @@ export class Input implements ControlValueAccessor { readonly controlId = input(null); readonly disabled = input(false, { transform: booleanAttribute }); readonly invalid = input(false, { transform: booleanAttribute }); + readonly max = input(null); + readonly maxLength = input(null, { transform: numberAttribute }); + readonly min = input(null); + readonly minLength = input(null, { transform: numberAttribute }); readonly name = input(null); + readonly pattern = input(null); readonly placeholder = input(''); readonly required = input(false, { transform: booleanAttribute }); readonly type = input('text'); @@ -36,9 +50,7 @@ export class Input implements ControlValueAccessor { readonly classes = computed(() => uiClass( 'ui-focus-ring w-full rounded-[var(--radius-control)] border bg-zinc-50 dark:bg-zinc-900 px-3 py-2.5 text-sm text-zinc-950 dark:text-zinc-50 placeholder:text-zinc-500 dark:text-zinc-400 disabled:cursor-not-allowed disabled:opacity-50', - this.invalid() - ? 'border-red-600 dark:border-red-500' - : 'border-[color:var(--color-border)] focus-visible:border-blue-600 dark:border-blue-500', + 'border-[color:var(--color-border)] focus-visible:border-blue-600 dark:border-blue-500', ), ); diff --git a/apps/web/app/src/app/shared/ui/forms/password-input/password-input.html b/apps/web/app/src/app/shared/ui/forms/password-input/password-input.html index c5ec743..fa55a76 100644 --- a/apps/web/app/src/app/shared/ui/forms/password-input/password-input.html +++ b/apps/web/app/src/app/shared/ui/forms/password-input/password-input.html @@ -4,16 +4,17 @@ [attr.aria-invalid]="invalid()" [attr.autocomplete]="autocomplete()" [attr.id]="controlId()" + [attr.maxlength]="maxLength()" + [attr.minlength]="minLength()" [attr.name]="name()" [attr.pattern]="pattern()" + [attr.required]="required() ? '' : null" [class]="inputClasses()" [disabled]="disabled() || formDisabled() || loading()" [placeholder]="placeholder()" [type]="type()" [value]="value()" data-slot="control" - minlength="8" - maxlength="64" (blur)="markTouched()" (input)="updateValue($event)" /> diff --git a/apps/web/app/src/app/shared/ui/forms/password-input/password-input.ts b/apps/web/app/src/app/shared/ui/forms/password-input/password-input.ts index a060ba1..7d061da 100644 --- a/apps/web/app/src/app/shared/ui/forms/password-input/password-input.ts +++ b/apps/web/app/src/app/shared/ui/forms/password-input/password-input.ts @@ -1,4 +1,13 @@ -import { booleanAttribute, Component, computed, forwardRef, input, output, signal } from '@angular/core'; +import { + booleanAttribute, + Component, + computed, + forwardRef, + input, + numberAttribute, + output, + signal, +} from '@angular/core'; import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; import { Icon } from '../../media/icon/icon'; @@ -29,9 +38,12 @@ export class PasswordInput implements ControlValueAccessor { readonly disabled = input(false, { transform: booleanAttribute }); readonly invalid = input(false, { transform: booleanAttribute }); readonly loading = input(false, { transform: booleanAttribute }); + readonly maxLength = input(64, { transform: numberAttribute }); + readonly minLength = input(8, { transform: numberAttribute }); readonly name = input(null); readonly pattern = input("^(?=.*[A-Za-z])(?=.*[0-9])(?=.*[._\\-'@$!%*#?&])[A-Za-z0-9._\\-'@$!%*#?&]{8,}$"); readonly placeholder = input(''); + readonly required = input(false, { transform: booleanAttribute }); readonly variant = input('text'); readonly valueChange = output(); @@ -49,9 +61,7 @@ export class PasswordInput implements ControlValueAccessor { uiClass( 'ui-focus-ring w-full rounded-[var(--radius-control)] border bg-zinc-50 dark:bg-zinc-900 px-3 py-2.5 text-sm text-zinc-950 dark:text-zinc-50 placeholder:text-zinc-500 dark:text-zinc-400 disabled:cursor-not-allowed disabled:opacity-50', this.isTextVariant() ? 'pr-16' : 'pr-12', - this.invalid() - ? 'border-red-600 dark:border-red-500' - : 'border-[color:var(--color-border)] focus-visible:border-blue-600 dark:border-blue-500', + 'border-[color:var(--color-border)] focus-visible:border-blue-600 dark:border-blue-500', this.loading() && 'pointer-events-none !text-transparent', ), ); diff --git a/apps/web/app/src/app/shared/ui/forms/pin-input/pin-input.html b/apps/web/app/src/app/shared/ui/forms/pin-input/pin-input.html index 2c88b70..94670f7 100644 --- a/apps/web/app/src/app/shared/ui/forms/pin-input/pin-input.html +++ b/apps/web/app/src/app/shared/ui/forms/pin-input/pin-input.html @@ -1,4 +1,4 @@ -
+
@if (label()) {