Menu

Change language

GitHub repo

Auth Mfa

blockauth

Two-step verification surface. 6-digit OTP pin input auto-submits on entry, shows verifying state and an inline error on mismatch, has a 30-second resend cooldown, and swaps to a Verified card with a Continue button on success. Emits `verify` (code), `resend`, and `continue`. `demoCode` prop controls the value the built-in mock validator accepts.

Installation

$npx shadcn@latest add https://uipkge.dev/r/lit/auth-mfa.json
Manifest:https://uipkge.dev/r/lit/auth-mfa.jsonInstalls to:src/uip/blocks/auth-mfa/

Variants

Loading interactive previews…

Props

NameType / ValuesDefaultRequired
headingstringTwo-step verificationoptional
descriptionstringEnter the 6-digit code from your authenticator app.optional
continue-hrefstring/optional
recovery-hrefstring#optional
demo-codestring123456optional

Schema

Type aliases exported from this item's source. Use these to shape the data you pass in.

HTMLElementTagNameMap
interface HTMLElementTagNameMap {
    'uip-auth-mfa': UipAuthMfa
  }
}

npm dependencies

Files installed (1)

  • src/uip/blocks/auth-mfa/auth-mfa.ts7 kB
    import { LitElement, css, html, nothing } from 'lit'
    import { RotateCw, ShieldCheck } from 'lucide'
    import { cn } from '../../lib/utils'
    import { tailwind } from '../../lib/styles'
    import { ThemeController } from '../../lib/theme'
    import { icon } from '../../lib/icon'
    // Side-effect imports: register the composed elements for standalone use
    // (consumers who install only this block still get working primitives).
    import '../../components/button/button'
    import '../../components/card/card'
    import '../../components/pin-input/pin-input'
    import type { UipPinInput } from '../../components/pin-input/pin-input'
    
    /**
     * <uip-auth-mfa> — the registry AuthMfa block as a web component.
     *
     * Class strings are React's AuthMfa verbatim. React state (useState /
     * useEffect) becomes @state fields; the code-length effect becomes an
     * `@input` check that auto-submits at 6 digits. `onVerify` (code) /
     * `onResend` / `onContinue` become `verify` / `resend` / `continue`
     * CustomEvents (bubbles, composed). `demoCode` is the value the built-in
     * mock validator accepts.
     */
    export class UipAuthMfa extends LitElement {
      static shadowRootOptions = { ...LitElement.shadowRootOptions, delegatesFocus: true }
      // :host display can't be a utility class (the host has no template of its own).
      static styles = [tailwind, css`:host { display: block; }`]
    
      static properties = {
        heading: {},
        description: {},
        continueHref: { attribute: 'continue-href' },
        recoveryHref: { attribute: 'recovery-href' },
        demoCode: { attribute: 'demo-code' },
        code: { state: true },
        verifying: { state: true },
        verified: { state: true },
        error: { state: true },
        resendIn: { state: true },
      }
    
      /** React's `title` (`title` is taken by HTMLElement). */
      heading = 'Two-step verification'
      description = 'Enter the 6-digit code from your authenticator app.'
      continueHref = '/'
      recoveryHref = '#'
      demoCode = '123456'
      private code = ''
      private verifying = false
      private verified = false
      private error = ''
      private resendIn = 0
      private verifyTimer?: number
      private resendTimer?: number
    
      constructor() {
        super()
        new ThemeController(this)
      }
    
      connectedCallback() {
        super.connectedCallback()
        this.setAttribute('data-uipkge', '')
        this.setAttribute('data-slot', 'auth-mfa')
      }
    
      disconnectedCallback() {
        super.disconnectedCallback()
        if (this.verifyTimer) window.clearTimeout(this.verifyTimer)
        if (this.resendTimer) window.clearInterval(this.resendTimer)
      }
    
      private onCodeInput(e: Event) {
        const next = (e.target as UipPinInput).value
        this.code = next
        if (next.length !== 6 || this.verifying) return
        this.error = ''
        this.verifying = true
        this.dispatchEvent(new CustomEvent('verify', { detail: next, bubbles: true, composed: true }))
        this.verifyTimer = window.setTimeout(() => {
          this.verifying = false
          if (this.code === this.demoCode) {
            this.verified = true
          } else {
            this.error = `Invalid code. Try ${this.demoCode} for the demo.`
            this.code = ''
          }
        }, 700)
      }
    
      private startResendCooldown() {
        this.resendIn = 30
        this.dispatchEvent(new CustomEvent('resend', { bubbles: true, composed: true }))
        if (this.resendTimer) window.clearInterval(this.resendTimer)
        this.resendTimer = window.setInterval(() => {
          this.resendIn -= 1
          if (this.resendIn <= 0) {
            this.resendIn = 0
            if (this.resendTimer) window.clearInterval(this.resendTimer)
          }
        }, 1000)
      }
    
      render() {
        return html`
          <div data-slot="auth-mfa" class=${cn('bg-background flex min-h-svh items-center justify-center p-6')}>
            <uip-card class="w-full max-w-sm">
              ${!this.verified
                ? html`
                    <uip-card-header class="text-center">
                      <div
                        class="bg-primary/10 text-primary mx-auto mb-2 flex size-12 items-center justify-center rounded-full"
                      >
                        ${icon(ShieldCheck, 'shield-check', 'size-6')}
                      </div>
                      <uip-card-title class="text-2xl">${this.heading}</uip-card-title>
                      <uip-card-description>${this.description}</uip-card-description>
                    </uip-card-header>
                    <uip-card-content class="space-y-4">
                      <div class="flex justify-center">
                        <uip-pin-input
                          .value=${this.code}
                          ?disabled=${this.verifying}
                          @input=${this.onCodeInput}
                        ></uip-pin-input>
                      </div>
                      ${this.verifying
                        ? html`<p class="text-muted-foreground text-center text-sm">Verifying…</p>`
                        : nothing}
                      ${this.error ? html`<p class="text-destructive text-center text-sm">${this.error}</p>` : nothing}
                      <div class="text-center">
                        ${this.resendIn === 0
                          ? html`<button
                              type="button"
                              class="text-muted-foreground hover:text-foreground inline-flex items-center gap-1 text-xs underline-offset-4 hover:underline"
                              @click=${this.startResendCooldown}
                            >
                              ${icon(RotateCw, 'rotate-cw', 'size-3')} Resend code
                            </button>`
                          : html`<p class="text-muted-foreground text-xs">Resend available in ${this.resendIn}s</p>`}
                      </div>
                    </uip-card-content>
                    <uip-card-footer class="justify-center">
                      <p class="text-muted-foreground text-xs">
                        Lost your device?
                        <a href=${this.recoveryHref} class="text-foreground underline-offset-4 hover:underline">
                          Use a recovery code
                        </a>
                      </p>
                    </uip-card-footer>
                  `
                : html`
                    <uip-card-content class="space-y-4 pt-6 text-center">
                      <div
                        class="bg-success/10 text-success mx-auto flex size-12 items-center justify-center rounded-full"
                      >
                        ${icon(ShieldCheck, 'shield-check', 'size-6')}
                      </div>
                      <div class="space-y-1">
                        <h3 class="text-lg font-semibold">Verified</h3>
                        <p class="text-muted-foreground text-sm">You're all set. Continuing to your dashboard…</p>
                      </div>
                      <a
                        href=${this.continueHref}
                        @click=${() => this.dispatchEvent(new CustomEvent('continue', { bubbles: true, composed: true }))}
                      >
                        <uip-button class="w-full">Continue</uip-button>
                      </a>
                    </uip-card-content>
                  `}
            </uip-card>
          </div>
        `
      }
    }
    
    customElements.get('uip-auth-mfa') || customElements.define('uip-auth-mfa', UipAuthMfa)
    
    declare global {
      interface HTMLElementTagNameMap {
        'uip-auth-mfa': UipAuthMfa
      }
    }
    

Raw manifest:https://uipkge.dev/r/lit/auth-mfa.json