Skip to main content

Overview

Kitsu uses session-based authentication with the Zou backend API. The authentication module handles login, logout, session validation, and password management.

Authentication Module

The authentication logic is located in src/lib/auth.js and integrated with the Vuex login module (src/store/modules/login.js).

Login Flow

Basic Login

1

User Enters Credentials

User provides email and password in the login form
2

Dispatch Login Action

Component dispatches the logIn Vuex action
3

API Request

Action sends credentials to /api/auth/login
4

Session Created

Backend creates a session and returns user data
5

State Updated

User data is committed to Vuex store

Login Implementation

<template>
  <div class="login-form">
    <input 
      v-model="email" 
      type="email" 
      placeholder="Email"
    />
    <input 
      v-model="password" 
      type="password" 
      placeholder="Password"
    />
    <button 
      @click="handleLogin" 
      :disabled="isLoginLoading"
    >
      {{ isLoginLoading ? 'Logging in...' : 'Login' }}
    </button>
    <p v-if="isLoginError" class="error">
      Invalid credentials
    </p>
  </div>
</template>

<script>
import { mapActions, mapGetters } from 'vuex'

export default {
  computed: {
    ...mapGetters(['isLoginLoading', 'isLoginError']),
    
    email: {
      get() { return this.$store.state.login.email },
      set(value) { this.changeEmail(value) }
    },
    
    password: {
      get() { return this.$store.state.login.password },
      set(value) { this.changePassword(value) }
    }
  },
  
  methods: {
    ...mapActions(['changeEmail', 'changePassword', 'logIn']),
    
    async handleLogin() {
      this.logIn({
        callback: (err, success) => {
          if (success) {
            this.$router.push('/')
          }
        }
      })
    }
  }
}
</script>

Two-Factor Authentication

Kitsu supports two-factor authentication (2FA):

2FA Flow

1

Initial Login

User provides email and password
2

2FA Required

API responds with two_factor_authentication_required: true
3

User Provides 2FA Code

User enters OTP code from authenticator app or FIDO2 device
4

Verify 2FA

Login request is retried with 2FA payload
5

Session Created

If valid, session is created and user is logged in

2FA Implementation

import { coerceTwoFactorPayload } from '@/lib/webauthn'

const payload = {
  email: state.email,
  password: state.password,
  ...coerceTwoFactorPayload(twoFactorPayload)
}

auth.logIn(payload, err => {
  if (err) {
    if (err.missing_OTP) {
      // Show 2FA input form
      this.show2FAForm = true
      this.preferredMethod = err.preferred_two_factor_authentication
    } else if (err.wrong_OTP) {
      // Show error: Invalid 2FA code
      this.otpError = 'Invalid authentication code'
    }
  }
})

Session Validation

Check Authentication Status

On app load, verify if the user has a valid session:
src/lib/auth.js
isServerLoggedIn(callback) {
  superagent.get('/api/auth/authenticated').end((err, res) => {
    if (err && res && [401, 422].includes(res.statusCode)) {
      // Not authenticated
      store.commit(USER_LOGIN_FAIL)
      callback(null)
    } else if (err) {
      // Server error
      callback(err)
    } else {
      // Authenticated
      const user = res.body.user
      const organisation = res.body.organisation || {}
      store.commit(SET_ORGANISATION, organisation)
      store.commit(USER_LOGIN, user)
      callback(null)
    }
  })
}

Route Guards

Protect routes that require authentication:
src/lib/auth.js
requireAuth(to, from, next) {
  const finalize = () => {
    if (!store.state.user.isAuthenticated) {
      store.commit(DATA_LOADING_END)
      next({
        name: 'login',
        query: { redirect: to.fullPath }
      })
    } else {
      next()
    }
  }

  store.commit(DATA_LOADING_START)

  if (store.state.user.user === null) {
    auth.isServerLoggedIn(err => {
      if (err) {
        next({
          name: 'server-down',
          query: { redirect: to.fullPath }
        })
      } else {
        finalize()
      }
    })
  } else {
    finalize()
  }
}
Apply to routes:
src/router/routes.js
import auth from '@/lib/auth'

const routes = [
  {
    path: '/productions',
    component: Productions,
    beforeEnter: auth.requireAuth
  }
]

Logout

Logout Flow

// In component
methods: {
  ...mapActions(['logout']),
  
  async handleLogout() {
    await this.logout()
    this.$router.push('/login')
  }
}

// Vuex action
actions: {
  async logout({ commit }) {
    // Disconnect real-time socket
    this.$socket.disconnect()
    
    // Call logout API
    await auth.logout()
    
    // Clear all state
    commit(RESET_ALL)
  }
}

// Auth library
auth.logout = async function() {
  try {
    await superagent.get('/api/auth/logout')
  } finally {
    store.commit(USER_LOGOUT)
    this.postBroadcastMessage('logout')
  }
}
The logout API call happens even if it fails, and the user is logged out locally regardless.

Password Reset

Request Password Reset

// In component
methods: {
  ...mapActions(['resetPassword']),
  
  async handlePasswordReset() {
    try {
      await this.resetPassword(this.email)
      this.message = 'Password reset email sent'
    } catch (err) {
      this.error = 'Failed to send reset email'
    }
  }
}

// Vuex action
actions: {
  resetPassword({}, email) {
    return auth.resetPassword(email)
  }
}

// Auth library
auth.resetPassword = function(email) {
  const data = { email }
  return client.ppost('/api/auth/reset-password', data)
}

Change Password with Token

// In component (password reset page)
methods: {
  ...mapActions(['resetChangePassword']),
  
  async handlePasswordChange() {
    if (!auth.isPasswordValid(this.password, this.password2)) {
      this.error = 'Passwords must match and be at least 8 characters'
      return
    }
    
    try {
      await this.resetChangePassword({
        email: this.$route.query.email,
        token: this.$route.query.token,
        password: this.password,
        password2: this.password2
      })
      this.$router.push('/')
    } catch (err) {
      this.error = 'Invalid or expired reset token'
    }
  }
}

// Vuex action
actions: {
  async resetChangePassword({ commit }, { email, token, password, password2 }) {
    await auth.resetChangePassword(email, token, password, password2)
    commit(LOGIN_SUCCESS)
  }
}

// Auth library
auth.resetChangePassword = function(email, token, password, password2) {
  const data = { email, token, password, password2 }
  return client.pput('/api/auth/reset-password', data)
}

Password Validation

auth.isPasswordValid = function(password, password2) {
  return password.length >= 8 && password === password2
}

Cross-Tab Synchronization

Kitsu synchronizes logout across browser tabs using BroadcastChannel:
src/lib/auth.js
getBroadcastChannel() {
  if (!channel && 'BroadcastChannel' in window) {
    channel = new BroadcastChannel('auth')
  }
  return channel
},

postBroadcastMessage(message) {
  const channel = this.getBroadcastChannel()
  channel?.postMessage(message)
}
Listen for messages:
const channel = auth.getBroadcastChannel()
channel?.addEventListener('message', (event) => {
  if (event.data === 'logout') {
    // User logged out in another tab
    store.commit(USER_LOGOUT)
    router.push('/login')
  }
})

User State

The user module (src/store/modules/user.js) manages the current user’s state:
const state = {
  user: null,
  isAuthenticated: false,
  organisation: {}
}

const getters = {
  user: state => state.user,
  isAuthenticated: state => state.isAuthenticated,
  isCurrentUserAdmin: state => state.user?.role === 'admin',
  isCurrentUserManager: state => {
    return state.user?.role === 'admin' || 
           state.user?.role === 'manager'
  }
}

Best Practices

Use Route Guards

Protect authenticated routes with auth.requireAuth

Handle 401 Errors

The API client automatically redirects on 401

Validate Sessions

Check session validity on app load

Secure Credentials

Never log or expose user credentials

Error Handling

Handle common authentication errors:
auth.logIn(payload, err => {
  if (err) {
    if (err.wrong_OTP) {
      this.error = 'Invalid authentication code'
    } else if (err.missing_OTP) {
      this.show2FAForm = true
    } else if (err.too_many_failed_login_attemps) {
      this.error = 'Too many failed attempts. Try again later.'
    } else if (err.default_password) {
      this.$router.push({ 
        name: 'change-password', 
        query: { token: err.token } 
      })
    } else if (err.server_error) {
      this.error = 'Server error. Please try again.'
    } else {
      this.error = 'Invalid email or password'
    }
  }
})

Next Steps

Entities API

Learn how to work with entities

Tasks API

Manage tasks and comments

Build docs developers (and LLMs) love