Search code examples
nuxt.jsnuxt-auth

nuxt generate working ok locally for next JS auth reset-password but not when deployed to plesk server


I have a Nuxt JS app setup to use Nuxt Auth. This works fine locally.

Specifically I am generating an email sent to the user with a link to reset their password of the form

http://localhost:3000/reset-password/ca62c3554c8058c9ddf11b709fc451405ffa99f4b22a88d84e087f5b40fb6d1f

When they click it - its picked up by a nuxt route which parses the JWT. Locally I serve it using nuxt start - which serves from the dist directory I believe and so should be a good test for static serving

When I deploy this to a remote lightsail server running Ubuntu and Plesk and Nginx and Apache I deploy it using nuxt generate and copy the content of the generated dist directory to the httpdocs directory. When the same workflow is followed and the user clicks the link it is not caught by one of the nuxt generated static html files and I get a 404. All other nuxt routes are being generated into files ok. What am I missing?

nuxt.config.js

export default {
  target: 'static',
  loading: {
    color: '#3700b3',
    height: '5px',
  },
  env: {
    apiUrl: process.env.NODE_ENV === 'production' ? process.env.PLATFORM_API_URL : 'http://localhost:8000',
    mainUrl: process.env.NODE_ENV === 'production' ? process.env.PLATFORM_URL : 'http://localhost:3000',
    googleSiteKey: process.env.RECAPTCHA_SITE_KEY || '',
  },
  ssr: false,
  head: {
    titleTemplate: `%s - ${process.env.PLATFORM_NAME || 'Some platform name'}`,
    title: process.env.PLATFORM_NAME || 'Some platform name',
    meta: [
      { charset: 'utf-8' },
      { name: 'viewport', content: 'width=device-width, initial-scale=1.0' },
      { hid: 'description', name: 'description', content: 'Virtua Centre' },
    ],
    link: [{ rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' }],
    script: [
      {
        src: 'https://platform.twitter.com/widgets.js',
      },
      {
        src: 'https://js.stripe.com/v3',
      },
    ],
  },
  plugins: [
    {
      src: '@/plugins/vue-page-transition.js',
    },
    {
      src: '@/plugins/plateform-detector.js',
    },
    { src: '~/plugins/TiptapVuetify', mode: 'client' },
    { src: '@/plugins/filters.js' },
    { src: '~/plugins/i18n.js' },
    { src: '~/plugins/locales.js' },
  ],
  components: true,
  buildModules: [
    '@nuxtjs/eslint-module',
    '@nuxtjs/stylelint-module',
    ['@nuxtjs/vuetify'],
    '@nuxtjs/date-fns',
  ],
  modules: [
    'nuxt-i18n',
    '@nuxtjs/axios',
    '@nuxtjs/auth-next',
    ['v-currency-field/nuxt-treeshaking'],
    'vue-currency-filter/nuxt',
    'vuetify-dialog/nuxt',
  ],
  i18n: {
    strategy: 'no_prefix',
    locales: [
      {
        code: 'en',
        name: 'English',
        file: 'en-US.js',
        flag: '/flag-icon/flags/1x1/us.svg',
      },
      {
        code: 'kk',
        name: 'Kazakh',
        file: 'en-KK.js',
        flag: '/flag-icon/flags/1x1/kz.svg',
      },
      {
        code: 'ru',
        name: 'Russian',
        file: 'en-RU.js',
        flag: '/flag-icon/flags/1x1/ru.svg',
      },
    ],
    lazy: true,
    langDir: 'lang',
    defaultLocale: 'en',
    vueI18n: {
      fallbackLocale: 'en',
    },
  },
  axios: {
    credentials: true,
    baseURL: process.env.NODE_ENV === 'production' ? process.env.PLATFORM_API_URL : 'http://localhost:8000',
  },
  auth: {
    redirect: {
      login: '/login',
      logout: false,
      callback: '/',
      home: false,
    },
    strategies: {
      local: {
        token: {
          property: 'data.access_token',
          maxAge: 36000,
        },
        user: {
          property: 'data',
        },
        endpoints: {
          login: { url: '/auth/login', method: 'post' },
          logout: { url: '/logout', method: 'post' },
          user: { url: '/me', method: 'get' },
        },
      },
    },
  },
  vue: {
    config: {
      productionTip: false,
      devtools: true,
    },
  },
  vuetify: {
    theme: {
      themes: {
        light: {
          primary: '#4F91FF',
          secondary: '#00109c',
          success: '#00B485',
          lsmbutton: '#FFBF42',
          error: '#F85032',
        },
      },
    },
  },
  build: {
    extractCSS: true,
    transpile: ['vuetify/lib', 'tiptap-vuetify', 'vee-validate/dist/rules'],
    babel: {
      plugins: [['@babel/plugin-proposal-private-methods', { loose: true }]],
    },
    extend(config, ctx) {
      config.module.rules.push({
        test: /\.(ogg|mp3|wav|mpe?g)$/i,
        loader: 'file-loader',
        options: {
          name: '[path][name].[ext]',
        },
      })
    },
    splitChunks: {
      layouts: true,
    },
  },
}

package.json's scripts section

"scripts": {
  "dev": "nuxt --hostname 127.0.0.1 --port 3000",
  "build": "nuxt build",
  "start": "nuxt start",
  "generate": "nuxt generate",
  "lint:js": "eslint --ext .js,.vue --ignore-path .gitignore .",
  "lintfix": "eslint --fix --ext .vue --ignore-path .gitignore .",
  "lint:style": "stylelint **/*.{vue,css} --ignore-path .gitignore",
  "lint": "npm run lint:js && npm run lint:style",
  "test": "jest"
},

I am using npm

Reading around I can see that the standard solution to handling dynamic routes is to update the config in nuxt.config.js generate.routes section. As detailed within this medium article

This seems to work by grabbing all values from the server at generate time. I don't think this is applicable for authentication tokens since users could register at any time - notably after nuxt generate has been run.

Reset Password Functionality

  • pages
  • reset-password
  • index.vue
  • _token.vue

index.vue

    <template>
      <div v-show="!loading">
        <section
          class="login-bg"
          :class="[$vuetify.breakpoint.mdAndUp ? 'xl-full-width' : 'sm-full-width']"
        >
          <v-row class="justify-center-custom">
            <div
              class="cont mb-5"
              :class="[$vuetify.breakpoint.smAndUp ? 'small-width' : 'full-width']"
            >
              <div class="form-right">
                <div class="card-body">
                  <h3 class="text-center">
                    <!-- TODO: translate -->
                    <strong>Forgot Password?</strong>
                  </h3>
                  <form v-if="!message" @submit.prevent="submit()">
                    <p
                      class="text-center"
                      style="margin-top: 10px; margin-bottom: 10px"
                    >
                      Enter the email ID you used when you joined and we will send
                      you temporary password
                    </p>
                    <br />
                    <div class="form-group mb-50">
                      <label class="text-bold-600">{{ 'E-Mail Address' }}</label>
                      <input
                        id="email"
                        v-model="email"
                        type="email"
                        class="form-control"
                        :class="{ 'is-invalid': error.email }"
                        name="email"
                        required
                        autocomplete="off"
                        autofocus
                      />
                      <span
                        v-if="error.email"
                        class="invalid-feedback"
                        role="alert"
                      >
                        <strong>{{ error.email }}</strong>
                      </span>
                    </div>
                    <button
                      type="submit"
                      class="btn ml-0 btn-login btn-primary w-100"
                    >
                      <span
                        v-if="formSubmitting"
                        class="spinner-border spinner-border-sm mr-1"
                        role="status"
                        aria-hidden="true"
                      ></span>
                      {{ 'Send Password Reset Link' }}
                      <i class="fa fa-arrow-right"></i>
                    </button>
                  </form>
                  <div v-else>
                    <p
                      class="text-center text-success"
                      style="margin-top: 10px; margin-bottom: 10px"
                    >
                      {{ message }}
                    </p>
                  </div>
                </div>
              </div>
            </div>
          </v-row>
        </section>
      </div>
    </template>
    
    <script>
    import AssetLoader from '@/mixins/AssetLoader'
    
    export default {
      layout: 'landing',
      mixins: [AssetLoader],
      data() {
        return {
          error: {
            email: false,
          },
          email: null,
          loading: false,
          formSubmitting: false,
          message: '',
        }
      },
    
      async beforeDestroy() {
        await this.unloadCSS(
          'https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css'
        )
        await this.unloadCSS(
          'https://fonts.googleapis.com/css2?family=Poppins:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,800;1,900&display=swap'
        )
        await this.unloadCSS('/css/mdb.min.css')
        await this.unloadCSS('/css/style.css')
        await this.unloadCSS('/css/landing-school.css')
        await this.unloadCSS('/css/landing-school-options.css')
        await this.unloadCSS(
          'https://maxcdn.bootstrapcdn.com/font-awesome/4.7.0/css/font-awesome.min.css'
        )
        await this.unloadScript('/js/jquery-3.4.1.min.js')
        await this.unloadScript('/js/popper.min.js')
        await this.unloadScript('/js/bootstrap.min.js')
        await this.unloadScript('/js/popup.js')
        await this.unloadScript('/js/owl.carousel.js')
        await this.unloadScript('/js/jquery.nivo.slider.js')
        await this.unloadScript('/js/landing-school.js')
      },
      async mounted() {
        this.loading = true
        await this.loadCSS([
          'https://maxcdn.bootstrapcdn.com/font-awesome/4.7.0/css/font-awesome.min.css',
          'https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css',
          'https://fonts.googleapis.com/css2?family=Poppins:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,800;1,900&display=swap',
          '/css/mdb.min.css',
          '/css/landing-school.css',
          '/css/landing-school-options.css',
        ])
        await this.loadJS([
          '/js/jquery-3.4.1.min.js',
          '/js/popper.min.js',
          '/js/bootstrap.min.js',
          '/js/popup.js',
          '/js/owl.carousel.js',
          '/js/jquery.nivo.slider.js',
          // '/js/landing-school.js'
        ])
        this.loading = false
      },
    
      methods: {
        async submit() {
          this.formSubmitting = true
          this.error.email = ''
          try {
            const { data } = await this.$axios.post('/auth/forgot-password', {
              email: this.email,
            })
            this.message = data.status
            this.email = ''
            this.formSubmitting = false
          } catch (error) {
            this.formSubmitting = false
            if (error?.response?.data?.errorsArray?.length) {
              this.error.email = error?.response?.data?.errorsArray[0]
            } else if (error?.response?.data?.email) {
              this.error.email = error?.response?.data?.email
            } else {
              this.error.email = error.message
            }
          }
        },
      },
    }
    </script>
    
    <style scoped>
    .navbar {
      display: none !important;
    }
    #btn-amazon {
      background: #f90 !important;
      border-color: #f90 !important;
      color: #fff !important;
    }
    #btn-apple {
      background: #7e878b !important;
      border-color: #7e878b !important;
      color: #fff !important;
    }
    #btn-twitter {
      background: #32def4 !important;
      border-color: #32def4 !important;
      color: #fff !important;
    }
    .btn-login {
      background: -webkit-linear-gradient(45deg, #303f9f, #7b1fa2);
      background: linear-gradient(45deg, #303f9f, #7b1fa2);
      box-shadow: 3px 3px 20px 0 rgba(123, 31, 162, 0.5);
    }
    .form-left {
      padding: 114px 10px;
    }
    .cont {
      border-radius: 10px;
      display: block;
    }
    .cont.full-width {
      width: 90%;
    }
    .cont.small-width {
      width: 600px;
    }
    .row.justify-center-custom {
      justify-content: center;
    }
    .login-bg.xl-full-width {
      height: 100vh;
    }
    .login-bg.sm-full-width {
      height: 100%;
    }
    </style>

_token.vue

<template>
  <div v-show="!loading">
    <section
      class="login-bg"
      :class="[$vuetify.breakpoint.mdAndUp ? 'xl-full-width' : 'sm-full-width']"
    >
      <v-row class="justify-center-custom">
        <div
          class="cont mb-5"
          :class="[$vuetify.breakpoint.smAndUp ? 'small-width' : 'full-width']"
        >
          <div class="form-right">
            <div class="card-body">
              <h3 class="text-center">
                <!-- TODO: translate -->
                <strong>Reset Password</strong>
              </h3>
              <form v-if="!message" @submit.prevent="submit()">
                <p
                  class="text-center"
                  style="margin-top: 10px; margin-bottom: 10px"
                >
                  Enter the email ID you used when you joined and we will send
                  you temporary password
                </p>
                <br />
                <div v-if="errors.length" class="card px-3 py-3 mb-3">
                  <ol class="text-danger mb-0" style="list-style-type: none">
                    <li v-for="(error, index) in errors" :key="index">
                      <strong>{{ error }}</strong>
                    </li>
                  </ol>
                </div>
                <div class="form-group mb-50">
                  <label class="text-bold-600">{{ 'E-Mail Address' }}</label>
                  <input
                    v-model="email"
                    type="email"
                    class="form-control"
                    name="email"
                    autocomplete="off"
                    autofocus
                  />
                </div>
                <div class="form-group mb-50">
                  <label class="text-bold-600">{{ 'Password' }}</label>
                  <input
                    v-model="password"
                    type="password"
                    class="form-control"
                    autocomplete="off"
                    autofocus
                  />
                </div>
                <div class="form-group mb-50">
                  <label class="text-bold-600">{{ 'Confirm Password' }}</label>
                  <input
                    v-model="password_confirmation"
                    type="password"
                    class="form-control"
                    autocomplete="off"
                    autofocus
                  />
                </div>
                <button
                  type="submit"
                  class="btn ml-0 btn-login btn-primary w-100"
                >
                  <span
                    v-if="formSubmitting"
                    class="spinner-border spinner-border-sm mr-1"
                    role="status"
                    aria-hidden="true"
                  ></span>
                  {{ 'Reset Password' }}
                  <i class="fa fa-arrow-right"></i>
                </button>
              </form>
              <div v-else>
                <p
                  class="text-center text-success"
                  style="margin-top: 10px; margin-bottom: 10px"
                >
                  {{ message }}
                </p>
                <button
                  type="button"
                  class="btn ml-0 btn-login btn-primary w-100"
                  @click="$router.push('/login')"
                >
                  {{ $t('login_now') }}
                  <i class="fa fa-arrow-right"></i>
                </button>
              </div>
            </div>
          </div>
        </div>
      </v-row>
    </section>
  </div>
</template>

<script>
import AssetLoader from '@/mixins/AssetLoader'

export default {
  layout: 'landing',
  mixins: [AssetLoader],
  data() {
    return {
      errors: [],
      token: null,
      email: null,
      password: null,
      password_confirmation: null,
      loading: false,
      formSubmitting: false,
      message: '',
    }
  },

  created() {
    if (this.$route.params.token) {
      this.token = this.$route.params.token
    }
  },

  async beforeDestroy() {
    await this.unloadCSS(
      'https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css'
    )
    await this.unloadCSS(
      'https://fonts.googleapis.com/css2?family=Poppins:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,800;1,900&display=swap'
    )
    await this.unloadCSS('/css/mdb.min.css')
    await this.unloadCSS('/css/style.css')
    await this.unloadCSS('/css/landing-school.css')
    await this.unloadCSS('/css/landing-school-options.css')
    await this.unloadCSS(
      'https://maxcdn.bootstrapcdn.com/font-awesome/4.7.0/css/font-awesome.min.css'
    )
    await this.unloadScript('/js/jquery-3.4.1.min.js')
    await this.unloadScript('/js/popper.min.js')
    await this.unloadScript('/js/bootstrap.min.js')
    await this.unloadScript('/js/popup.js')
    await this.unloadScript('/js/owl.carousel.js')
    await this.unloadScript('/js/jquery.nivo.slider.js')
    await this.unloadScript('/js/landing-school.js')
  },
  async mounted() {
    this.loading = true
    await this.loadCSS([
      'https://maxcdn.bootstrapcdn.com/font-awesome/4.7.0/css/font-awesome.min.css',
      'https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css',
      'https://fonts.googleapis.com/css2?family=Poppins:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,800;1,900&display=swap',
      '/css/mdb.min.css',
      '/css/landing-school.css',
      '/css/landing-school-options.css',
    ])
    await this.loadJS([
      '/js/jquery-3.4.1.min.js',
      '/js/popper.min.js',
      '/js/bootstrap.min.js',
      '/js/popup.js',
      '/js/owl.carousel.js',
      '/js/jquery.nivo.slider.js',
      // '/js/landing-school.js'
    ])
    this.loading = false
  },

  methods: {
    async submit() {
      this.formSubmitting = true
      this.errorsl = []
      try {
        const { data } = await this.$axios.post('/auth/reset-password', {
          email: this.email,
          token: this.token,
          password_confirmation: this.password_confirmation,
          password: this.password,
        })
        this.message = data.status
        this.email = ''
        this.formSubmitting = false
      } catch (error) {
        this.formSubmitting = false
        if (error?.response?.data?.errorsArray?.length) {
          this.errors = error?.response?.data?.errorsArray
        } else if (error?.response?.data?.email) {
          this.errors = [error?.response?.data?.email]
        }
      }
    },
  },
}
</script>

<style scoped>
.navbar {
  display: none !important;
}
#btn-amazon {
  background: #f90 !important;
  border-color: #f90 !important;
  color: #fff !important;
}
#btn-apple {
  background: #7e878b !important;
  border-color: #7e878b !important;
  color: #fff !important;
}
#btn-twitter {
  background: #32def4 !important;
  border-color: #32def4 !important;
  color: #fff !important;
}
.btn-login {
  background: -webkit-linear-gradient(45deg, #303f9f, #7b1fa2);
  background: linear-gradient(45deg, #303f9f, #7b1fa2);
  box-shadow: 3px 3px 20px 0 rgba(123, 31, 162, 0.5);
}
.form-left {
  padding: 114px 10px;
}
.cont {
  border-radius: 10px;
  display: block;
}
.cont.full-width {
  width: 90%;
}
.cont.small-width {
  width: 600px;
}
.row.justify-center-custom {
  justify-content: center;
}
.login-bg.xl-full-width {
  height: 100vh;
}
.login-bg.sm-full-width {
  height: 100%;
}
</style>

Ultimately the answer for making this work on Plesk Apache was to add an .htaccess file to the same dir as index.html. Contents as:-

<IfModule mod_rewrite.c>
  RewriteEngine On
  RewriteBase /
  RewriteRule ^index\.html$ - [L]
  RewriteCond %{REQUEST_FILENAME} !-f
  RewriteCond %{REQUEST_FILENAME} !-d
  RewriteRule . /index.html [L]
</IfModule>

Explained here


Solution

  • The solution for this on Netlify was to add some specific configuration to the build for redirects. Created netlify.toml in the root of the repo branch being deployed from.

    Netlify.toml contained:-

    [[redirects]]
      from = "/*"
      to = "/index.html"
      status = 200
    

    Guided from this

    I read this as - in cases where nuxt has generated an html file it will serve it and your route will be good. But in cases where it hasn't generated a file with an exact match for the route then you need to call the entry point to the application to initialise it and make the route available that you need.