State Management
Pinia store patterns.
Overview
The application uses 10 Pinia stores for global state:
| Store | File | Purpose |
|---|---|---|
| Auth | auth.ts | Session state, user info, login/logout, session timeout |
| Authorization | authorization.ts | User permissions, roles, menu visibility |
| Branding | branding.ts | Institution branding (logo, colors, name) |
| Holds | holds.ts | Cached hold state for the hold banner, which renders on every page |
| Instance | instance.ts | Deployment kind — restricted is the /records instance that refuses most writes |
| Lookups | lookups.ts | Cached lookup table values |
| Menu Layout | menuLayout.ts | Dynamic sidebar menu configuration |
| Recent Pages | recentPages.ts | The last pages visited, scoped per active role |
| Scroll Positions | scrollPositions.ts | Scroll position per page, restored on a "back to results" router.push as well as browser back |
| Settings | settings.ts | Application-wide settings |
Store Pattern
All stores use the Composition API (setup function) style:
// stores/lookups.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { LookupValue } from '@/api/types'
import { lookupsApi } from '@/api/services/lookups.api'
export const useLookupsStore = defineStore('lookups', () => {
// State
const cache = ref<Record<string, LookupValue[]>>({})
const loading = ref(false)
// Getters
const getByType = computed(() => {
return (typeCode: string) => cache.value[typeCode] || []
})
// Actions
async function fetchLookup(typeCode: string) {
if (cache.value[typeCode]) return // Already cached
loading.value = true
try {
const response = await lookupsApi.getByType(typeCode)
cache.value[typeCode] = response.data.results || response.data
} finally {
loading.value = false
}
}
function clearCache() {
cache.value = {}
}
return {
cache,
loading,
getByType,
fetchLookup,
clearCache
}
})Using Stores
In Components
<script setup lang="ts">
import { onMounted } from 'vue'
import { useLookupsStore } from '@/stores/lookups'
const lookupsStore = useLookupsStore()
onMounted(() => {
lookupsStore.fetchLookup('COURSE_STATUS')
})
</script>
<template>
<Select
:options="lookupsStore.getByType('COURSE_STATUS')"
optionLabel="display_value"
optionValue="id"
/>
</template>With Destructuring
Use storeToRefs to preserve reactivity when destructuring:
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { useLookupsStore } from '@/stores/lookups'
const lookupsStore = useLookupsStore()
const { cache, loading } = storeToRefs(lookupsStore)
const { fetchLookup } = lookupsStore // Actions don't need storeToRefs
</script>Auth Store
The auth store manages session-based authentication. The server sets HttpOnly cookies; the frontend only tracks the user object and session activity.
// stores/auth.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { User, LoginCredentials } from '@/api/types'
import { authApi } from '@/api/services/auth.api'
export const useAuthStore = defineStore('auth', () => {
// State
const user = ref<User | null>(null)
const lastActivity = ref<number>(Date.now())
const isLoading = ref(false)
const sessionTimeoutMs = ref<number>(240 * 60 * 1000) // 4 hours default
// Getters
const isAuthenticated = computed(() => !!user.value)
const isAdmin = computed(() => user.value?.is_superuser || user.value?.is_staff || false)
const isStudent = computed(() => user.value?.is_student || false)
const isFaculty = computed(() => user.value?.is_faculty || false)
// Actions
/**
* Login with credentials.
* Server sets HttpOnly cookies; we just store the user.
*/
async function login(credentials: LoginCredentials): Promise<boolean> {
isLoading.value = true
try {
const response = await authApi.login(credentials)
if (response.success && response.data) {
user.value = response.data.user
lastActivity.value = Date.now()
return true
}
return false
} finally {
isLoading.value = false
}
}
/**
* Fetch current user from session.
* Called on app init to check if session cookie is still valid.
*/
async function fetchUser() {
try {
const response = await authApi.me()
user.value = response.data
} catch {
user.value = null
}
}
/**
* Logout - tells server to destroy session.
*/
async function logout() {
try {
await authApi.logout()
} finally {
user.value = null
}
}
return {
user,
isAuthenticated,
isAdmin,
isStudent,
isFaculty,
isLoading,
login,
fetchUser,
logout
}
})INFO
There are no tokens stored in localStorage. Authentication is handled entirely via HttpOnly session cookies (sis_session) set by the server. The CSRF token (sis_csrf) is read from a non-HttpOnly cookie by the Axios interceptor.
Session Persistence
The auth store checks for an active session on app startup by calling GET /api/v1/auth/me. If the session cookie is valid, the user object is populated. If the session has expired, the user is redirected to the login page.
// In App.vue or router guard
const authStore = useAuthStore()
await authStore.fetchUser()
if (!authStore.isAuthenticated) {
router.push('/login')
}