diff --git a/angular/src/jslib.module.ts b/angular/src/jslib.module.ts index 7a3e502843..f8dc5d0fea 100644 --- a/angular/src/jslib.module.ts +++ b/angular/src/jslib.module.ts @@ -23,6 +23,7 @@ import { StopPropDirective } from "./directives/stop-prop.directive"; import { TrueFalseValueDirective } from "./directives/true-false-value.directive"; import { ColorPasswordCountPipe } from "./pipes/color-password-count.pipe"; import { ColorPasswordPipe } from "./pipes/color-password.pipe"; +import { CreditCardNumberPipe } from "./pipes/credit-card-number.pipe"; import { EllipsisPipe } from "./pipes/ellipsis.pipe"; import { I18nPipe } from "./pipes/i18n.pipe"; import { SearchCiphersPipe } from "./pipes/search-ciphers.pipe"; @@ -44,15 +45,19 @@ import { UserNamePipe } from "./pipes/user-name.pipe"; A11yInvalidDirective, A11yTitleDirective, ApiActionDirective, - AvatarComponent, AutofocusDirective, + AvatarComponent, BlurClickDirective, BoxRowDirective, + CalloutComponent, ColorPasswordCountPipe, ColorPasswordPipe, + CreditCardNumberPipe, EllipsisPipe, + ExportScopeCalloutComponent, FallbackSrcDirective, I18nPipe, + IconComponent, InputStripSpacesDirective, InputVerbatimDirective, NotPremiumDirective, @@ -63,24 +68,25 @@ import { UserNamePipe } from "./pipes/user-name.pipe"; StopPropDirective, TrueFalseValueDirective, UserNamePipe, - CalloutComponent, - IconComponent, - ExportScopeCalloutComponent, ], exports: [ A11yInvalidDirective, A11yTitleDirective, ApiActionDirective, - AvatarComponent, AutofocusDirective, + AvatarComponent, BitwardenToastModule, BlurClickDirective, BoxRowDirective, + CalloutComponent, ColorPasswordCountPipe, ColorPasswordPipe, + CreditCardNumberPipe, EllipsisPipe, + ExportScopeCalloutComponent, FallbackSrcDirective, I18nPipe, + IconComponent, InputStripSpacesDirective, InputVerbatimDirective, NotPremiumDirective, @@ -91,10 +97,7 @@ import { UserNamePipe } from "./pipes/user-name.pipe"; StopPropDirective, TrueFalseValueDirective, UserNamePipe, - CalloutComponent, - IconComponent, - ExportScopeCalloutComponent, ], - providers: [UserNamePipe, SearchPipe, I18nPipe, DatePipe], + providers: [CreditCardNumberPipe, DatePipe, I18nPipe, SearchPipe, UserNamePipe], }) export class JslibModule {} diff --git a/angular/src/pipes/credit-card-number.pipe.ts b/angular/src/pipes/credit-card-number.pipe.ts new file mode 100644 index 0000000000..8e614a6965 --- /dev/null +++ b/angular/src/pipes/credit-card-number.pipe.ts @@ -0,0 +1,64 @@ +import { Pipe, PipeTransform } from "@angular/core"; + +interface CardRuleEntry { + cardLength: number; + blocks: number[]; +} + +// See https://baymard.com/checkout-usability/credit-card-patterns for +// all possible credit card spacing patterns. For now, we just handle +// the below. +const numberFormats: Record = { + Visa: [{ cardLength: 16, blocks: [4, 4, 4, 4] }], + Mastercard: [{ cardLength: 16, blocks: [4, 4, 4, 4] }], + Maestro: [ + { cardLength: 16, blocks: [4, 4, 4, 4] }, + { cardLength: 13, blocks: [4, 4, 5] }, + { cardLength: 15, blocks: [4, 6, 5] }, + { cardLength: 19, blocks: [4, 4, 4, 4, 3] }, + ], + Discover: [{ cardLength: 16, blocks: [4, 4, 4, 4] }], + "Diners Club": [{ cardLength: 14, blocks: [4, 6, 4] }], + JCB: [{ cardLength: 16, blocks: [4, 4, 4, 4] }], + UnionPay: [ + { cardLength: 16, blocks: [4, 4, 4, 4] }, + { cardLength: 19, blocks: [6, 13] }, + ], + Amex: [{ cardLength: 15, blocks: [4, 6, 5] }], + Other: [{ cardLength: 16, blocks: [4, 4, 4, 4] }], +}; + +@Pipe({ name: "creditCardNumber" }) +export class CreditCardNumberPipe implements PipeTransform { + transform(creditCardNumber: string, brand: string): string { + let rules = numberFormats[brand]; + + if (rules == null) { + rules = numberFormats["Other"]; + } + + const cardLength = creditCardNumber.length; + + let matchingRule = rules.find((r) => r.cardLength == cardLength); + if (matchingRule == null) { + matchingRule = rules[0]; + } + + const blocks = matchingRule.blocks; + + let chunks: string[] = []; + let total = 0; + + blocks.forEach((c) => { + chunks.push(creditCardNumber.slice(total, total + c)); + total += c; + }); + + // Append the remaining part + if (cardLength > total) { + chunks.push(creditCardNumber.slice(total)); + } + + return chunks.join(" "); + } +}