Auth and permissions
Sessions are HTTP-only cookies, permissions are a flat list, and no token is ever held in JavaScript.
The flow
main.tscallsbootstrapAuth()before installing the router, because the router's first navigation runs synchronously and the guard reads the auth store.bootstrapAuthasks/auth/mewith_skipAuthRefresh. A 401 there is the normal answer for a signed-out visitor, not an expired session, so it must not trigger a refresh and a hard redirect — that would reload the page and re-run bootstrap forever.- On success it fills the store and rebuilds the CASL ability from the permission list.
- The guard then lets the first navigation through, or sends it to the sign-in page.
The store
import { useAuthStore } from '@modules/auth/stores/auth.store'
const { user, permissions, isAuthenticated } = storeToRefs(useAuthStore())setUser, setPermissions and $reset are its actions. It holds no token, because there is none to hold.
useAuth
import { useAuth } from '@modules/auth/composables/useAuth'
const { login, logout, refreshSession } = useAuth()| Member | Description |
|---|---|
login(credentials) | Signs in, rebuilds the ability, pushes to ?redirect= or the dashboard, and raises a welcome toast. |
logout() | Clears the session and the store and returns to the sign-in page. |
refreshSession() | Asks /auth/refresh and returns whether it worked. |
bootstrapAuth is a standalone export rather than part of this composable: it runs from main.ts where there is no setup context and no router yet.
The cookie contract
Your backend sets an HTTP-only session cookie. The shared axios instance sends withCredentials: true on every request, so the browser attaches and receives it.
For CSRF, the standard double-submit pattern: set an XSRF-TOKEN cookie and the request interceptor sends its value back as an X-CSRF-TOKEN header.
The refresh queue
When a request comes back 401, the response interceptor does four things:
- Allows exactly one refresh in flight at a time.
- Parks every other 401 that lands meanwhile in a queue.
- On success, replays the original request and then drains the queue, each waiter replaying its own.
- On failure, rejects the queue and sends the browser to
/auth/login.
A request is retried at most once, and /auth/refresh is never itself retried: a failing refresh should fall through to the sign-in redirect, not start another refresh.
The pages
| Route | Name | Layout | What it does |
|---|---|---|---|
/auth/login | login | auth | Email and password, with a show and hide toggle, remember me and a forgot link. |
/auth/register | register | auth | Create an account. |
/auth/forgot-password | forgot-password | auth | Ask for a reset email. |
/auth/reset-password | reset-password | auth | Set a new password from a token. Deliberately does not sign the reader in; it returns them to sign in. |
/auth/lock | lock-screen | auth | Re-authenticate an existing session with a password. |
/auth/two-factor | two-factor | auth | Exchange an emailed or texted code for a session. |
/forbidden | forbidden | blank | Where the guard sends a permission miss. |
All seven are public: true, so a signed-out visitor can reach them.
features.humanVerification adds a verification step to sign in and sign up. It ships off, so the demo stays two clicks.
Four more error pages live beside them: /500, /maintenance, /coming-soon and /recover, plus the router's own /404 and /offline.
The mock
VITE_USE_MOCK_AUTH=trueThe shipped .env.development sets it, so npm run dev signs in with no server. Only the exact string true attaches it. When it is on, a request interceptor gives /auth/* its own adapter and the call never reaches the network:
| Path | Mock behavior |
|---|---|
/auth/login, /auth/register | Accept anything and return the Demo User. |
/auth/unlock, /auth/two-factor | The same. |
/auth/me, /auth/refresh | The Demo User while signed in, 401 otherwise. |
/auth/logout | Signs out. |
/auth/forgot-password, /auth/reset-password | Accepted. Reset does not sign the reader in. |
The Demo User is Demo User, demo@example.com, holding { action: 'manage', subject: 'all' }. "Signed in" is a flag in localStorage under sa-mock-auth-session, which is what makes the mock survive a reload.
What it does not do: set a cookie, validate a password, or expire a session. It exists so the pages and their states work before a backend does. A path you add must stay lowercase letters and hyphens, or it slips past the interceptor's /auth/[a-z-]+ match and hits the network.
window.__mockAuth401Once() in the console arms a single 401 on the next /auth/me, so the refresh path can be walked through by hand.
Permissions
/auth/me returns a flat list, and updateAbility turns it into the CASL ability:
{ "action": "read", "subject": "Invoice" }An entry may carry conditions, which CASL matches against the object passed to can. The action manage means every action and the subject all means every subject.
The ability lives in @shared/lib/ability, not in the auth module, because every module needs can() and importing auth for it would break the rule that modules do not import modules.
Three things read it: the route guard through meta.permission, the sidebar through NavItem.permission, and the v-permission directive. The task view, with examples, is Permissions.
The guard
export const authGuard: NavigationGuardWithThis<undefined> = (to) => {
const auth = useAuthStore()
if (to.meta.public)
return true
if (!auth.isAuthenticated)
return { name: 'login', query: { redirect: to.fullPath } }
if (to.meta.permission) {
const [action = '', subject = ''] = to.meta.permission.split(' ')
if (!ability.can(action, subject))
return { name: 'forbidden' }
}
return true
}A module page is never public. The redirect carries the original path, so signing in lands the reader where they were going.
Going to a real backend
Turn the mock off, point /api at your server, and answer the nine auth endpoints. Nothing else changes. The endpoint table and the 422 shape are on Connect your API.
Where to go next
- Permissions — roles, the route meta and the directive.
- Connect your API — what your backend has to answer.