Hello from MCP server

List Files | Just Commands | Repo | Logs

← back |
<template>
  <BaseLayoutNoAuth title="Login">
    <!-- Loading Overlay -->
    <div v-if="isLoading" class="loading-overlay">
      <div class="loading-content">
        <ion-spinner name="crescent" class="loading-spinner"></ion-spinner>
        <p class="loading-message">Initializing your Price Builder...</p>
      </div>
    </div>

    <div class="auth-container">
      <div class="auth-card">
        <!-- Logo/Brand -->
        <div class="auth-header">
          <img src="/images/branding-dark.png" alt="The New Flat Rate" class="auth-logo" />
          <h1 class="auth-title">Welcome Back</h1>
          <p class="auth-subtitle">Sign in to your account</p>
        </div>

        <!-- Error Alert -->
        <ion-alert
          :is-open="showError"
          :header="errorTitle"
          :message="errorMessage"
          :buttons="['OK']"
          @didDismiss="showError = false"
        ></ion-alert>

        <!-- Form -->
        <form @submit.prevent="onSubmit" class="auth-form">
          <div class="form-group">
            <label class="form-label">Email</label>
            <div class="input-wrapper" :class="{ 'has-error': emailError && emailTouched }">
              <ion-icon :icon="mailOutline" class="input-icon"></ion-icon>
              <input
                v-model="email"
                type="email"
                placeholder="you@example.com"
                class="form-input"
                @blur="emailTouched = true"
              />
            </div>
            <span v-if="emailError && emailTouched" class="error-text">{{ emailError }}</span>
          </div>

          <div class="form-group">
            <label class="form-label">Password</label>
            <div class="input-wrapper" :class="{ 'has-error': passwordError && passwordTouched }">
              <ion-icon :icon="lockClosedOutline" class="input-icon"></ion-icon>
              <input
                v-model="password"
                :type="showPassword ? 'text' : 'password'"
                placeholder="Enter your password"
                class="form-input"
                @blur="passwordTouched = true"
              />
              <ion-icon
                :icon="showPassword ? eyeOffOutline : eyeOutline"
                class="input-icon-right"
                @click="showPassword = !showPassword"
              ></ion-icon>
            </div>
            <span v-if="passwordError && passwordTouched" class="error-text">{{ passwordError }}</span>
          </div>

          <button
            type="submit"
            class="submit-btn"
            :disabled="!isFormValid"
          >
            Sign In
          </button>
        </form>

        <!-- Footer Link -->
        <div class="auth-footer">
          <span class="footer-text">Don't have an account?</span>
          <RouterLink to="/auth/register" class="footer-link">Create one</RouterLink>
        </div>
      </div>
    </div>
  </BaseLayoutNoAuth>
</template>

<script setup lang="ts">
import { ref, computed, onMounted } from "vue";
import router from "@/router";
import { IonAlert, IonIcon, IonSpinner } from "@ionic/vue";
import { mailOutline, lockClosedOutline, eyeOutline, eyeOffOutline } from "ionicons/icons";
import { getApi } from "@/dataAccess/getApi";
import { TypedPocketBase } from "@/pocketbase-types";
import BaseLayoutNoAuth from "@/components/BaseLayoutNoAuth.vue";
import { getSQLite, resetSQLiteConnection } from "@/dataAccess/getSQLite";
import { ChangesetProcessor, Change } from "@/dataAccess/changeset-processor";
import { syncDatabase } from "@/dataAccess/getDb";
import changesOne from "@/tnfrData/changesOne.json";
import changesTwo from "@/tnfrData/changesTwo.json";
import changesThree from "@/tnfrData/changesThree.json";
import changesFour from "@/tnfrData/changesFour.json";
import changesFive from "@/tnfrData/changesFive.json";
import changesSix from "@/tnfrData/changesSix.json";
import changesSeven from "@/tnfrData/changesSeven.json";
import changesEight from "@/tnfrData/changesEight.json";
import changesNine from "@/tnfrData/changesNine.json";
import changesTen from "@/tnfrData/changesTen.json";
import changesEleven from "@/tnfrData/changesEleven.json";
import changesTwelve from "@/tnfrData/changesTwelve.json";

const tnfrPlumbingChangesets = [
  ...changesOne,
  ...changesTwo,
  ...changesThree,
  ...changesFour,
  ...changesFive,
  ...changesSix,
  ...changesSeven,
  ...changesEight,
  ...changesNine,
  ...changesTen,
  ...changesEleven,
  ...changesTwelve,
];

const email = ref("");
const password = ref("");
const emailTouched = ref(false);
const passwordTouched = ref(false);
const showPassword = ref(false);
const showError = ref(false);
const errorTitle = ref("");
const errorMessage = ref("");
const isLoading = ref(false);

const emailError = computed(() => {
  if (!email.value) return "";
  const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
  if (!emailRegex.test(email.value)) {
    return "Please enter a valid email address";
  }
  return "";
});

const passwordError = computed(() => {
  if (!password.value) return "";
  if (password.value.length < 10) {
    return "Password must be at least 10 characters";
  }
  return "";
});

const isFormValid = computed(() => {
  return email.value.length > 0 &&
         password.value.length >= 10 &&
         !emailError.value &&
         !passwordError.value;
});

let pb: TypedPocketBase;

async function resetDatabase(): Promise<ChangesetProcessor> {
  console.log('[Login] Resetting database...');
  await resetSQLiteConnection();
  const sql = await getSQLite();
  console.log('[Login] Database recreated with fresh migrations');

  const now = new Date().toISOString();
  await sql.dbConn.execute(`
    INSERT INTO books (id, name, org, parent, refId, created, updated)
    VALUES ('tnfrPlumbing', 'TNFR Plumbing', 'clientApp', NULL, 'tnfrPlumbing', '${now}', '${now}')
  `, false);
  await sql.saveDb();
  console.log('[Login] tnfrPlumbing book record created');

  const epochDate = new Date(0).toISOString();
  const processor = new ChangesetProcessor(sql.dbConn);

  for (const changesetObj of tnfrPlumbingChangesets) {
    await processor.processChangeset(
      changesetObj.changeset as Change[],
      'clientApp',
      true,
      changesetObj.book,
      epochDate
    );
    await sql.saveDb();
  }

  console.log('[Login] Database reset complete');
  return processor;
}

async function onSubmit() {
  if (!isFormValid.value) return;

  isLoading.value = true;

  try {
    await pb
      .collection("users")
      .authWithPassword(email.value, password.value);

    const processor = await resetDatabase();

    console.log('[Login] Syncing changes from API...');
    const syncResult = await syncDatabase();
    console.log('[Login] Sync result:', syncResult);

    // Flush accumulated processing logs now that DB is fully set up
    await processor.flushLogs();

    const returnTo = localStorage.getItem("relogin_returnTo");
    localStorage.removeItem("relogin_returnTo");
    localStorage.removeItem("relogin_email");

    isLoading.value = false;
    router.push(returnTo || "/directory");
  } catch (error: any) {
    isLoading.value = false;
    console.error("Login failed:", error);

    errorTitle.value = "Login Failed";

    if (error.status === 400) {
      if (error.message === "Failed to authenticate.") {
        errorMessage.value = "The email or password you entered is incorrect. Please check your credentials and try again.";
      } else if (error.data?.email?.message) {
        if (error.data.email.message === "Missing required value.") {
          errorMessage.value = "Please enter your email address.";
        } else {
          errorMessage.value = "Please enter a valid email address.";
        }
      } else if (error.data?.password?.message) {
        if (error.data.password.message === "Missing required value.") {
          errorMessage.value = "Please enter your password.";
        } else {
          errorMessage.value = "Please check your password and try again.";
        }
      } else {
        errorMessage.value = "We couldn't log you in. Please check your credentials and try again.";
      }
    } else if (error.status === 403) {
      errorMessage.value = "Login is currently unavailable. Please try again later or contact support.";
    } else if (error.status === 404) {
      errorMessage.value = "We're experiencing technical difficulties. Please try again later.";
    } else if (error.status >= 500) {
      errorMessage.value = "Our servers are experiencing issues. Please try again in a few moments.";
    } else {
      errorMessage.value = error.message || "An unexpected error occurred. Please try again.";
    }

    showError.value = true;
  }
}

onMounted(async () => {
  const api = await getApi(false);
  pb = api.pb;

  const reloginEmail = sessionStorage.getItem("relogin_email");
  if (reloginEmail) {
    email.value = reloginEmail;
  }
});
</script>

<style scoped>
.loading-overlay {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  background: var(--ion-background-color);
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 9999;
}

.loading-content {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 24px;
}

.loading-spinner {
  width: 48px;
  height: 48px;
  color: var(--ion-color-primary);
}

.loading-message {
  font-size: 18px;
  font-weight: 500;
  color: var(--ion-text-color);
  margin: 0;
}

.auth-container {
  min-height: 100%;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 24px;
  background: linear-gradient(135deg, var(--ion-background-color) 0%, var(--ion-background-color-step-100) 100%);
}

.auth-card {
  width: 100%;
  max-width: 420px;
  background: var(--ion-background-color);
  border-radius: 16px;
  padding: 40px 32px;
  box-shadow: 0 4px 24px rgba(0, 0, 0, 0.12);
}

.auth-header {
  text-align: center;
  margin-bottom: 32px;
}

.auth-logo {
  height: 48px;
  margin-bottom: 24px;
}

.auth-title {
  margin: 0 0 8px 0;
  font-size: 28px;
  font-weight: 700;
  color: var(--ion-text-color);
}

.auth-subtitle {
  margin: 0;
  font-size: 15px;
  color: var(--ion-color-medium);
}

.auth-form {
  display: flex;
  flex-direction: column;
  gap: 20px;
}

.form-group {
  display: flex;
  flex-direction: column;
  gap: 6px;
}

.form-label {
  font-size: 14px;
  font-weight: 600;
  color: var(--ion-text-color);
}

.input-wrapper {
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 0 16px;
  height: 52px;
  background: var(--ion-background-color-step-50);
  border: 2px solid var(--ion-background-color-step-100);
  border-radius: 10px;
  transition: border-color 0.2s ease, box-shadow 0.2s ease;
}

.input-wrapper:focus-within {
  border-color: var(--ion-color-primary);
  box-shadow: 0 0 0 3px rgba(var(--ion-color-primary-rgb), 0.15);
}

.input-wrapper.has-error {
  border-color: var(--ion-color-danger);
}

.input-icon {
  font-size: 20px;
  color: var(--ion-color-medium);
  flex-shrink: 0;
}

.input-icon-right {
  font-size: 20px;
  color: var(--ion-color-medium);
  flex-shrink: 0;
  cursor: pointer;
  transition: color 0.2s ease;
}

.input-icon-right:hover {
  color: var(--ion-text-color);
}

.form-input {
  flex: 1;
  border: none;
  background: transparent;
  font-size: 16px;
  color: var(--ion-text-color);
  outline: none;
}

.form-input::placeholder {
  color: var(--ion-color-medium-shade);
}

.error-text {
  font-size: 12px;
  color: var(--ion-color-danger);
  margin-top: 2px;
}

.submit-btn {
  height: 52px;
  margin-top: 8px;
  background: var(--ion-color-primary);
  color: var(--ion-color-primary-contrast);
  border: none;
  border-radius: 10px;
  font-size: 16px;
  font-weight: 600;
  cursor: pointer;
  transition: opacity 0.2s ease, transform 0.1s ease;
}

.submit-btn:hover:not(:disabled) {
  opacity: 0.9;
}

.submit-btn:active:not(:disabled) {
  transform: scale(0.98);
}

.submit-btn:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}

.auth-footer {
  margin-top: 28px;
  text-align: center;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 8px;
}

.footer-text {
  font-size: 14px;
  color: var(--ion-color-medium);
}

.footer-link {
  font-size: 14px;
  font-weight: 600;
  color: var(--ion-color-primary);
  text-decoration: none;
  transition: opacity 0.2s ease;
}

.footer-link:hover {
  opacity: 0.8;
}

@media (max-width: 480px) {
  .auth-card {
    padding: 32px 24px;
  }

  .auth-title {
    font-size: 24px;
  }
}
</style>