diff --git a/apps/web/src/app/organizations/manage/group-add-edit.component.html b/apps/web/src/app/organizations/manage/group-add-edit.component.html index 8381a72bed..fe92a23b0d 100644 --- a/apps/web/src/app/organizations/manage/group-add-edit.component.html +++ b/apps/web/src/app/organizations/manage/group-add-edit.component.html @@ -16,7 +16,7 @@ {{ "loading" | i18n }} - + {{ "name" | i18n }} diff --git a/apps/web/src/app/organizations/manage/group-add-edit.component.ts b/apps/web/src/app/organizations/manage/group-add-edit.component.ts index 488248836f..c1148a5bb4 100644 --- a/apps/web/src/app/organizations/manage/group-add-edit.component.ts +++ b/apps/web/src/app/organizations/manage/group-add-edit.component.ts @@ -227,7 +227,16 @@ export class GroupAddEditComponent implements OnInit, OnDestroy { } submit = async () => { + this.groupForm.markAllAsTouched(); + if (this.groupForm.invalid) { + if (this.tabIndex !== GroupAddEditTabType.Info) { + this.platformUtilsService.showToast( + "error", + null, + this.i18nService.t("fieldOnTabRequiresAttention", this.i18nService.t("groupInfo")) + ); + } return; } diff --git a/apps/web/src/app/organizations/members/components/member-dialog/member-dialog.component.html b/apps/web/src/app/organizations/members/components/member-dialog/member-dialog.component.html index 168adc0081..0e8010693c 100644 --- a/apps/web/src/app/organizations/members/components/member-dialog/member-dialog.component.html +++ b/apps/web/src/app/organizations/members/components/member-dialog/member-dialog.component.html @@ -16,7 +16,7 @@ > {{ "loading" | i18n }} - +

{{ "inviteUserDesc" | i18n }}

diff --git a/apps/web/src/app/organizations/members/components/member-dialog/member-dialog.component.ts b/apps/web/src/app/organizations/members/components/member-dialog/member-dialog.component.ts index 18a004555d..06596e101d 100644 --- a/apps/web/src/app/organizations/members/components/member-dialog/member-dialog.component.ts +++ b/apps/web/src/app/organizations/members/components/member-dialog/member-dialog.component.ts @@ -3,8 +3,6 @@ import { Component, Inject, OnDestroy, OnInit } from "@angular/core"; import { FormBuilder, Validators } from "@angular/forms"; import { combineLatest, of, shareReplay, Subject, switchMap, takeUntil } from "rxjs"; -import { ApiService } from "@bitwarden/common/abstractions/api.service"; -import { CollectionService } from "@bitwarden/common/abstractions/collection.service"; import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; import { OrganizationUserService } from "@bitwarden/common/abstractions/organization-user/organization-user.service"; import { OrganizationService } from "@bitwarden/common/abstractions/organization/organization.service.abstraction"; @@ -33,6 +31,8 @@ import { PermissionMode, } from "../../../shared/components/access-selector"; +import { commaSeparatedEmails } from "./validators/comma-separated-emails.validator"; + export enum MemberDialogTab { Role = 0, Groups = 1, @@ -75,7 +75,7 @@ export class MemberDialogComponent implements OnInit, OnDestroy { protected groupAccessItems: AccessItemView[] = []; protected tabIndex: MemberDialogTab; protected formGroup = this.formBuilder.group({ - emails: ["", [Validators.required]], + emails: ["", [Validators.required, commaSeparatedEmails]], type: OrganizationUserType.User, accessAllCollections: false, access: [[] as AccessItemValue[]], @@ -117,9 +117,7 @@ export class MemberDialogComponent implements OnInit, OnDestroy { constructor( @Inject(DIALOG_DATA) protected params: MemberDialogParams, private dialogRef: DialogRef, - private apiService: ApiService, private i18nService: I18nService, - private collectionService: CollectionService, private platformUtilsService: PlatformUtilsService, private organizationService: OrganizationService, private formBuilder: FormBuilder, @@ -291,7 +289,16 @@ export class MemberDialogComponent implements OnInit, OnDestroy { } submit = async () => { + this.formGroup.markAllAsTouched(); + if (this.formGroup.invalid) { + if (this.tabIndex !== MemberDialogTab.Role) { + this.platformUtilsService.showToast( + "error", + null, + this.i18nService.t("fieldOnTabRequiresAttention", this.i18nService.t("role")) + ); + } return; } @@ -323,6 +330,12 @@ export class MemberDialogComponent implements OnInit, OnDestroy { } else { userView.id = this.params.organizationUserId; const emails = [...new Set(this.formGroup.value.emails.trim().split(/\s*,\s*/))]; + if (emails.length > 20) { + this.formGroup.controls.emails.setErrors({ + tooManyEmails: { message: this.i18nService.t("tooManyEmails", 20) }, + }); + return; + } await this.userService.invite(emails, userView); } diff --git a/apps/web/src/app/organizations/members/components/member-dialog/validators/comma-separated-emails.validator.spec.ts b/apps/web/src/app/organizations/members/components/member-dialog/validators/comma-separated-emails.validator.spec.ts new file mode 100644 index 0000000000..44d4823abe --- /dev/null +++ b/apps/web/src/app/organizations/members/components/member-dialog/validators/comma-separated-emails.validator.spec.ts @@ -0,0 +1,46 @@ +import { FormControl } from "@angular/forms"; + +import { commaSeparatedEmails } from "./comma-separated-emails.validator"; + +describe("commaSeparatedEmails", () => { + it("should return no error when input is valid", () => { + const input = createControl(null); + input.setValue("user@bitwarden.com"); + const errors = commaSeparatedEmails(input); + + expect(errors).toBe(null); + }); + + it("should return no error when a single valid email is provided", () => { + const input = createControl("user@bitwarden.com"); + const errors = commaSeparatedEmails(input); + + expect(errors).toBe(null); + }); + + it("should return no error when input has valid emails separated by commas", () => { + const input = createControl("user@bitwarden.com, user1@bitwarden.com, user@bitwarden.com"); + const errors = commaSeparatedEmails(input); + + expect(errors).toBe(null); + }); + + it("should return error when input is invalid", () => { + const input = createControl("lksjflks"); + + const errors = commaSeparatedEmails(input); + + expect(errors).not.toBe(null); + }); + + it("should return error when input contains invalid emails", () => { + const input = createControl("user@bitwarden.com, nonsfonwoei, user1@bitwarden.com"); + const errors = commaSeparatedEmails(input); + + expect(errors).not.toBe(null); + }); +}); + +function createControl(input: string) { + return new FormControl(input); +} diff --git a/apps/web/src/app/organizations/members/components/member-dialog/validators/comma-separated-emails.validator.ts b/apps/web/src/app/organizations/members/components/member-dialog/validators/comma-separated-emails.validator.ts new file mode 100644 index 0000000000..eadad28522 --- /dev/null +++ b/apps/web/src/app/organizations/members/components/member-dialog/validators/comma-separated-emails.validator.ts @@ -0,0 +1,17 @@ +import { AbstractControl, ValidationErrors, Validators } from "@angular/forms"; + +function validateEmails(emails: string) { + return ( + emails + .split(",") + .map((email) => Validators.email({ value: email.trim() })) + .find((_) => _ !== null) === undefined + ); +} + +export function commaSeparatedEmails(control: AbstractControl): ValidationErrors | null { + if (control.value === "" || !control.value || validateEmails(control.value)) { + return null; + } + return { multipleEmails: { message: "multipleInputEmails" } }; +} diff --git a/apps/web/src/app/organizations/shared/components/collection-dialog/collection-dialog.component.html b/apps/web/src/app/organizations/shared/components/collection-dialog/collection-dialog.component.html index 17d5e8645c..f1b165679c 100644 --- a/apps/web/src/app/organizations/shared/components/collection-dialog/collection-dialog.component.html +++ b/apps/web/src/app/organizations/shared/components/collection-dialog/collection-dialog.component.html @@ -15,11 +15,11 @@ - + {{ "name" | i18n }} - + diff --git a/apps/web/src/app/organizations/shared/components/collection-dialog/collection-dialog.component.ts b/apps/web/src/app/organizations/shared/components/collection-dialog/collection-dialog.component.ts index c078c77215..387cf78555 100644 --- a/apps/web/src/app/organizations/shared/components/collection-dialog/collection-dialog.component.ts +++ b/apps/web/src/app/organizations/shared/components/collection-dialog/collection-dialog.component.ts @@ -1,6 +1,6 @@ import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog"; import { Component, Inject, OnDestroy, OnInit } from "@angular/core"; -import { FormBuilder } from "@angular/forms"; +import { FormBuilder, Validators } from "@angular/forms"; import { combineLatest, of, shareReplay, Subject, switchMap, takeUntil } from "rxjs"; import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; @@ -60,7 +60,7 @@ export class CollectionDialogComponent implements OnInit, OnDestroy { protected accessItems: AccessItemView[] = []; protected deletedParentName: string | undefined; protected formGroup = this.formBuilder.group({ - name: ["", BitValidators.forbiddenCharacters(["/"])], + name: ["", [Validators.required, BitValidators.forbiddenCharacters(["/"])]], externalId: "", parent: null as string | null, access: [[] as AccessItemValue[]], @@ -155,7 +155,16 @@ export class CollectionDialogComponent implements OnInit, OnDestroy { } protected submit = async () => { + this.formGroup.markAllAsTouched(); + if (this.formGroup.invalid) { + if (this.tabIndex === CollectionDialogTabType.Access) { + this.platformUtilsService.showToast( + "error", + null, + this.i18nService.t("fieldOnTabRequiresAttention", this.i18nService.t("collectionInfo")) + ); + } return; } diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 40945fc9cd..b7fcc96667 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -2494,6 +2494,15 @@ "editMember": { "message": "Edit member" }, + "fieldOnTabRequiresAttention": { + "message": "A field on the '$TAB$' tab requires your attention.", + "placeholders": { + "tab": { + "content": "$1", + "example": "Collection info" + } + } + }, "inviteUserDesc": { "message": "Invite a new user to your organization by entering their Bitwarden account email address below. If they do not have a Bitwarden account already, they will be prompted to create a new account." }, @@ -5553,6 +5562,18 @@ } } }, + "multipleInputEmails": { + "message": "1 or more emails are invalid" + }, + "tooManyEmails": { + "message": "You can only submit up to $COUNT$ emails at a time", + "placeholders": { + "count": { + "content": "$1", + "example": "20" + } + } + }, "fieldsNeedAttention": { "message": "$COUNT$ field(s) above need your attention.", "placeholders": { diff --git a/libs/components/src/form-field/error.component.ts b/libs/components/src/form-field/error.component.ts index f443a21409..4a8e8fdb2e 100644 --- a/libs/components/src/form-field/error.component.ts +++ b/libs/components/src/form-field/error.component.ts @@ -32,6 +32,8 @@ export class BitErrorComponent { return this.i18nService.t("inputMaxLength", this.error[1]?.requiredLength); case "forbiddenCharacters": return this.i18nService.t("inputForbiddenCharacters", this.error[1]?.characters.join(", ")); + case "multipleEmails": + return this.i18nService.t("multipleInputEmails"); default: // Attempt to show a custom error message. if (this.error[1]?.message) {