Building Authentication UI

In this lesson, we'll build complete sign up, sign in, and sign out functionality using Vue components and the Amplify auth composable.

Two Approaches

AWS Amplify offers two ways to implement auth UI:

Approach Pros Cons
Amplify UI Components Pre-built, handles all flows Less customization, larger bundle
Custom Components Full control, matches your design More code to write and maintain

We'll build custom components to understand exactly how authentication works.

Create Auth Pages

Sign Up Page

Create pages/auth/signup.vue:

<script setup lang="ts">
definePageMeta({
  layout: 'auth',
});

const { signUp, isLoading, error, clearError } = useAmplifyAuth();

const form = reactive({
  email: '',
  password: '',
  confirmPassword: '',
});

const validationError = ref('');

const handleSubmit = async () => {
  clearError();
  validationError.value = '';

  // Validate passwords match
  if (form.password !== form.confirmPassword) {
    validationError.value = 'Passwords do not match';
    return;
  }

  // Validate password strength
  if (form.password.length < 8) {
    validationError.value = 'Password must be at least 8 characters';
    return;
  }

  try {
    const result = await signUp({
      username: form.email,
      password: form.password,
      options: {
        userAttributes: {
          email: form.email,
        },
      },
    });

    if (result.nextStep.signUpStep === 'CONFIRM_SIGN_UP') {
      // Navigate to confirmation page with email
      navigateTo({
        path: '/auth/confirm',
        query: { email: form.email },
      });
    }
  } catch (e) {
    // Error is already captured in composable
    console.error('Sign up failed:', e);
  }
};
</script>

<template>
  <div class="auth-container">
    <div class="auth-card">
      <h1>Create Account</h1>
      <p class="subtitle">Sign up to get started</p>

      <form @submit.prevent="handleSubmit">
        <div class="form-group">
          <label for="email">Email</label>
          <input
            id="email"
            v-model="form.email"
            type="email"
            placeholder="you@example.com"
            required
            :disabled="isLoading"
          />
        </div>

        <div class="form-group">
          <label for="password">Password</label>
          <input
            id="password"
            v-model="form.password"
            type="password"
            placeholder="At least 8 characters"
            required
            :disabled="isLoading"
          />
        </div>

        <div class="form-group">
          <label for="confirmPassword">Confirm Password</label>
          <input
            id="confirmPassword"
            v-model="form.confirmPassword"
            type="password"
            placeholder="Confirm your password"
            required
            :disabled="isLoading"
          />
        </div>

        <p v-if="validationError || error" class="error-message">
          {{ validationError || error }}
        </p>

        <button type="submit" class="btn-primary" :disabled="isLoading">
          {{ isLoading ? 'Creating Account...' : 'Create Account' }}
        </button>
      </form>

      <p class="auth-link">
        Already have an account?
        <NuxtLink to="/auth/signin">Sign In</NuxtLink>
      </p>
    </div>
  </div>
</template>

<style scoped>
.auth-container {
  min-height: 100vh;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 1rem;
  background: #f3f4f6;
}

.auth-card {
  background: white;
  padding: 2rem;
  border-radius: 8px;
  box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
  width: 100%;
  max-width: 400px;
}

.auth-card h1 {
  margin: 0 0 0.5rem;
  font-size: 1.5rem;
}

.subtitle {
  color: #6b7280;
  margin-bottom: 1.5rem;
}

.form-group {
  margin-bottom: 1rem;
}

.form-group label {
  display: block;
  margin-bottom: 0.5rem;
  font-weight: 500;
}

.form-group input {
  width: 100%;
  padding: 0.75rem;
  border: 1px solid #d1d5db;
  border-radius: 6px;
  font-size: 1rem;
}

.form-group input:focus {
  outline: none;
  border-color: #ff9900;
  box-shadow: 0 0 0 3px rgba(255, 153, 0, 0.2);
}

.error-message {
  color: #dc2626;
  font-size: 0.875rem;
  margin-bottom: 1rem;
}

.btn-primary {
  width: 100%;
  padding: 0.75rem;
  background: #ff9900;
  color: white;
  border: none;
  border-radius: 6px;
  font-size: 1rem;
  font-weight: 600;
  cursor: pointer;
  transition: background 0.2s;
}

.btn-primary:hover:not(:disabled) {
  background: #e68a00;
}

.btn-primary:disabled {
  opacity: 0.6;
  cursor: not-allowed;
}

.auth-link {
  text-align: center;
  margin-top: 1.5rem;
  color: #6b7280;
}

.auth-link a {
  color: #ff9900;
  font-weight: 500;
}
</style>

Email Confirmation Page

Create pages/auth/confirm.vue:

<script setup lang="ts">
definePageMeta({
  layout: 'auth',
});

const route = useRoute();
const { confirmSignUp, signIn, isLoading, error, clearError } = useAmplifyAuth();

const email = computed(() => route.query.email as string || '');
const code = ref('');

const handleConfirm = async () => {
  clearError();

  if (!email.value) {
    return navigateTo('/auth/signup');
  }

  try {
    const result = await confirmSignUp(email.value, code.value);
    
    if (result.isSignUpComplete) {
      // Sign in automatically after confirmation
      // Note: You may want to redirect to sign-in page instead
      navigateTo('/auth/signin?confirmed=true');
    }
  } catch (e) {
    console.error('Confirmation failed:', e);
  }
};
</script>

<template>
  <div class="auth-container">
    <div class="auth-card">
      <h1>Verify Your Email</h1>
      <p class="subtitle">
        We sent a verification code to<br />
        <strong>{{ email }}</strong>
      </p>

      <form @submit.prevent="handleConfirm">
        <div class="form-group">
          <label for="code">Verification Code</label>
          <input
            id="code"
            v-model="code"
            type="text"
            placeholder="Enter 6-digit code"
            required
            :disabled="isLoading"
            autocomplete="one-time-code"
          />
        </div>

        <p v-if="error" class="error-message">{{ error }}</p>

        <button type="submit" class="btn-primary" :disabled="isLoading">
          {{ isLoading ? 'Verifying...' : 'Verify Email' }}
        </button>
      </form>

      <p class="auth-link">
        Didn't receive a code?
        <button class="link-button">Resend Code</button>
      </p>
    </div>
  </div>
</template>

<style scoped>
/* Same styles as signup.vue plus: */
.auth-container {
  min-height: 100vh;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 1rem;
  background: #f3f4f6;
}

.auth-card {
  background: white;
  padding: 2rem;
  border-radius: 8px;
  box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
  width: 100%;
  max-width: 400px;
}

.auth-card h1 {
  margin: 0 0 0.5rem;
  font-size: 1.5rem;
}

.subtitle {
  color: #6b7280;
  margin-bottom: 1.5rem;
}

.form-group {
  margin-bottom: 1rem;
}

.form-group label {
  display: block;
  margin-bottom: 0.5rem;
  font-weight: 500;
}

.form-group input {
  width: 100%;
  padding: 0.75rem;
  border: 1px solid #d1d5db;
  border-radius: 6px;
  font-size: 1rem;
  text-align: center;
  letter-spacing: 0.5em;
  font-size: 1.25rem;
}

.form-group input:focus {
  outline: none;
  border-color: #ff9900;
  box-shadow: 0 0 0 3px rgba(255, 153, 0, 0.2);
}

.error-message {
  color: #dc2626;
  font-size: 0.875rem;
  margin-bottom: 1rem;
}

.btn-primary {
  width: 100%;
  padding: 0.75rem;
  background: #ff9900;
  color: white;
  border: none;
  border-radius: 6px;
  font-size: 1rem;
  font-weight: 600;
  cursor: pointer;
  transition: background 0.2s;
}

.btn-primary:hover:not(:disabled) {
  background: #e68a00;
}

.btn-primary:disabled {
  opacity: 0.6;
  cursor: not-allowed;
}

.auth-link {
  text-align: center;
  margin-top: 1.5rem;
  color: #6b7280;
}

.link-button {
  background: none;
  border: none;
  color: #ff9900;
  font-weight: 500;
  cursor: pointer;
  font-size: 1rem;
}

.link-button:hover {
  text-decoration: underline;
}
</style>

Sign In Page

Create pages/auth/signin.vue:

<script setup lang="ts">
definePageMeta({
  layout: 'auth',
});

const route = useRoute();
const { signIn, isLoading, error, clearError } = useAmplifyAuth();

const form = reactive({
  email: '',
  password: '',
});

const showConfirmedMessage = computed(() => route.query.confirmed === 'true');

const handleSubmit = async () => {
  clearError();

  try {
    const result = await signIn({
      username: form.email,
      password: form.password,
    });

    if (result.isSignedIn) {
      // Redirect to home or intended destination
      const redirectTo = route.query.redirect as string || '/';
      navigateTo(redirectTo);
    }
  } catch (e) {
    console.error('Sign in failed:', e);
  }
};
</script>

<template>
  <div class="auth-container">
    <div class="auth-card">
      <h1>Welcome Back</h1>
      <p class="subtitle">Sign in to your account</p>

      <div v-if="showConfirmedMessage" class="success-message">
        Email verified! You can now sign in.
      </div>

      <form @submit.prevent="handleSubmit">
        <div class="form-group">
          <label for="email">Email</label>
          <input
            id="email"
            v-model="form.email"
            type="email"
            placeholder="you@example.com"
            required
            :disabled="isLoading"
          />
        </div>

        <div class="form-group">
          <label for="password">Password</label>
          <input
            id="password"
            v-model="form.password"
            type="password"
            placeholder="Your password"
            required
            :disabled="isLoading"
          />
        </div>

        <p v-if="error" class="error-message">{{ error }}</p>

        <button type="submit" class="btn-primary" :disabled="isLoading">
          {{ isLoading ? 'Signing In...' : 'Sign In' }}
        </button>
      </form>

      <p class="auth-link">
        <NuxtLink to="/auth/forgot-password">Forgot your password?</NuxtLink>
      </p>

      <hr class="divider" />

      <p class="auth-link">
        Don't have an account?
        <NuxtLink to="/auth/signup">Sign Up</NuxtLink>
      </p>
    </div>
  </div>
</template>

<style scoped>
.auth-container {
  min-height: 100vh;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 1rem;
  background: #f3f4f6;
}

.auth-card {
  background: white;
  padding: 2rem;
  border-radius: 8px;
  box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
  width: 100%;
  max-width: 400px;
}

.auth-card h1 {
  margin: 0 0 0.5rem;
  font-size: 1.5rem;
}

.subtitle {
  color: #6b7280;
  margin-bottom: 1.5rem;
}

.success-message {
  background: #d1fae5;
  color: #065f46;
  padding: 0.75rem;
  border-radius: 6px;
  margin-bottom: 1rem;
  font-size: 0.875rem;
}

.form-group {
  margin-bottom: 1rem;
}

.form-group label {
  display: block;
  margin-bottom: 0.5rem;
  font-weight: 500;
}

.form-group input {
  width: 100%;
  padding: 0.75rem;
  border: 1px solid #d1d5db;
  border-radius: 6px;
  font-size: 1rem;
}

.form-group input:focus {
  outline: none;
  border-color: #ff9900;
  box-shadow: 0 0 0 3px rgba(255, 153, 0, 0.2);
}

.error-message {
  color: #dc2626;
  font-size: 0.875rem;
  margin-bottom: 1rem;
}

.btn-primary {
  width: 100%;
  padding: 0.75rem;
  background: #ff9900;
  color: white;
  border: none;
  border-radius: 6px;
  font-size: 1rem;
  font-weight: 600;
  cursor: pointer;
  transition: background 0.2s;
}

.btn-primary:hover:not(:disabled) {
  background: #e68a00;
}

.btn-primary:disabled {
  opacity: 0.6;
  cursor: not-allowed;
}

.divider {
  margin: 1.5rem 0;
  border: none;
  border-top: 1px solid #e5e7eb;
}

.auth-link {
  text-align: center;
  margin-top: 1rem;
  color: #6b7280;
}

.auth-link a {
  color: #ff9900;
  font-weight: 500;
}
</style>

Create Auth Layout

Create a minimal layout for auth pages at layouts/auth.vue:

<template>
  <div class="auth-layout">
    <slot />
  </div>
</template>

<style scoped>
.auth-layout {
  min-height: 100vh;
}
</style>

Add Sign Out Button

Add a sign out button to your main layout or navigation. Here's a component you can use anywhere:

Create components/UserMenu.vue:

<script setup lang="ts">
const { user, isAuthenticated, signOut, isLoading } = useAmplifyAuth();

const handleSignOut = async () => {
  await signOut();
  navigateTo('/auth/signin');
};
</script>

<template>
  <div class="user-menu">
    <template v-if="isAuthenticated">
      <span class="user-email">{{ user?.signInDetails?.loginId }}</span>
      <button 
        class="btn-signout" 
        @click="handleSignOut"
        :disabled="isLoading"
      >
        Sign Out
      </button>
    </template>
    <template v-else>
      <NuxtLink to="/auth/signin" class="btn-signin">Sign In</NuxtLink>
    </template>
  </div>
</template>

<style scoped>
.user-menu {
  display: flex;
  align-items: center;
  gap: 1rem;
}

.user-email {
  font-size: 0.875rem;
  color: #6b7280;
}

.btn-signout {
  padding: 0.5rem 1rem;
  background: transparent;
  border: 1px solid #d1d5db;
  border-radius: 6px;
  cursor: pointer;
  font-size: 0.875rem;
}

.btn-signout:hover {
  background: #f3f4f6;
}

.btn-signin {
  padding: 0.5rem 1rem;
  background: #ff9900;
  color: white;
  border-radius: 6px;
  text-decoration: none;
  font-weight: 500;
}

.btn-signin:hover {
  background: #e68a00;
}
</style>

Initialize Auth on App Load

Check if the user is authenticated when the app loads. Create a plugin at plugins/auth.client.ts:

// plugins/auth.client.ts
export default defineNuxtPlugin(async () => {
  const { checkAuth } = useAmplifyAuth();
  
  // Check authentication status on app load
  await checkAuth();
});
💡
Client-Side Only

The .client.ts suffix ensures this plugin only runs in the browser, not during server-side rendering. Auth checks require browser storage access.

Test the Auth Flow

  1. Start your Nuxt dev server: npm run dev
  2. Start the Amplify sandbox: npx ampx sandbox
  3. Navigate to /auth/signup
  4. Create an account with a real email
  5. Check your email for the verification code
  6. Enter the code on the confirmation page
  7. Sign in with your credentials
✓
Using Local Mock

If you're using the local mock (covered in Module 7), the verification code will appear as a browser alert instead of being emailed.

Summary

  • Created sign up, confirmation, and sign in pages
  • Built a UserMenu component for sign out
  • Used the auth composable for all auth operations
  • Created a client plugin to check auth on app load
  • Handled loading states and error messages