@drax/identity-vue 3.63.0 → 3.65.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,6 +2,76 @@
2
2
 
3
3
  This template should help get you started developing with Vue 3 in Vite.
4
4
 
5
+ ## Remember-me sessions
6
+
7
+ Remember-me is disabled unless the consuming Vite app defines
8
+ `VITE_DRAX_REMEMBER_ME_ENABLED=true` (exact string). `IdentityLogin` includes the
9
+ exported `IdentityRememberMe` checkbox, localized in English/Spanish and initially
10
+ unchecked. Custom login forms can use `<IdentityRememberMe v-model="rememberMe" />`
11
+ and `useAuth().login(username, password, rememberMe)`. Ordinary logins still call
12
+ `AuthSystem.login` with only two arguments and discard any previous refresh token.
13
+
14
+ Install the persisted-state plugin on Pinia, then await `setupAuthSession()` after
15
+ installing Pinia and **before** installing the router or starting API requests.
16
+ The setup function does not require a component or router context. Enable the
17
+ backend separately with `DRAX_REMEMBER_ME_ENABLED=true`; its optional
18
+ `DRAX_REFRESH_TOKEN_TTL_SECONDS` defaults to 2592000 (30 days). Neither flag
19
+ changes the normal JWT expiration.
20
+
21
+ ```ts
22
+ import {setupAuthSession} from '@drax/identity-vue'
23
+
24
+ app.use(pinia)
25
+ await setupAuthSession()
26
+ app.use(router)
27
+ ```
28
+
29
+ Custom guards should await `refreshAuthSession()` before existing auth/permission
30
+ checks, catch transient maintenance errors, retain recoverable
31
+ credentials and cancel protected navigation until retry rather than treating
32
+ network failure as logout. Zuite's `router/authGuard.ts` provides the complete
33
+ example, including permission checks and login redirection. Publish/upgrade
34
+ `common-front`, `identity-front`, `identity-share` and the identity backend/Vue
35
+ packages together when enabling this feature.
36
+
37
+ Integration contracts:
38
+
39
+ - `AuthSystem.login(username, password, rememberMe?)`, `refresh(refreshToken)` and
40
+ `revokeSession(refreshToken)` must implement the identity-front contract. Login
41
+ and refresh return `{accessToken, refreshToken?, refreshExpiresAt?}`. Expiration
42
+ dates are ISO strings. Front providers must not persist session data.
43
+ - The shared `HttpRestClientFactory.getInstance()` client may implement
44
+ `setBeforeRequest(handler)`. The handler must run **before** copying authorization
45
+ headers. Without this optional method, startup renewal works but automatic
46
+ renewal before subsequent REST requests is unavailable.
47
+ - The hook excludes login/refresh/logout. Renewal uses a 30-second access-token
48
+ expiry margin, single-flight per store/tab, and Web Locks plus synchronous
49
+ `AuthStore` persistence/re-reading across tabs. Browsers without Web Locks only
50
+ have same-tab coordination; concurrent cross-tab rotation is not guaranteed.
51
+ - Profile reload after renewal bypasses only its own `/api/auth/me` preflight to
52
+ avoid recursion. If the profile is absent after a transient failure, subsequent
53
+ session maintenance retries it single-flight even with a fresh JWT, without
54
+ rotating again. Startup does not immediately repeat a failed profile request.
55
+ - `AuthStore.sessionVersion` is a persisted operation epoch changed by clearing
56
+ auth (including login/token-login start and logout). Pending login, refresh and
57
+ profile results check this epoch before committing, including cross-tab
58
+ null-to-null logout. Legacy stores without the field use the initial null epoch.
59
+ Late returned refresh credentials are revoked best-effort.
60
+ - Transient refresh/profile errors retain recoverable credentials; rejected
61
+ credentials (HTTP 401/403, including common-front wrapped errors) clear auth.
62
+ Logout clears local state immediately, with best-effort server revocation even
63
+ when the feature has subsequently been disabled. Offline revocation cannot be
64
+ guaranteed; the backend expiration/revocation policy remains authoritative.
65
+ - Normal sessions are never renewed. Remember-me renewal is REST-only, not a
66
+ GraphQL preflight. Tokens live in localStorage via `AuthStore`; protect the app
67
+ against XSS and do not log or expose refresh tokens.
68
+
69
+ Focused tests:
70
+
71
+ ```sh
72
+ npm exec --workspace @drax/identity-vue -- vitest run test/auth-session.spec.ts test/auth-session.integration.spec.ts test/components/__tests__/IdentityLogin.spec.ts
73
+ ```
74
+
5
75
  ## Recommended IDE Setup
6
76
 
7
77
  [VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "3.63.0",
6
+ "version": "3.65.0",
7
7
  "type": "module",
8
8
  "main": "./src/index.ts",
9
9
  "module": "./src/index.ts",
@@ -18,13 +18,13 @@
18
18
  "format": "prettier --write src/"
19
19
  },
20
20
  "dependencies": {
21
- "@drax/common-front": "^3.57.4",
22
- "@drax/common-vue": "^3.57.4",
21
+ "@drax/common-front": "^3.65.0",
22
+ "@drax/common-vue": "^3.65.0",
23
23
  "@drax/crud-front": "^3.58.0",
24
- "@drax/crud-share": "^3.63.0",
25
- "@drax/crud-vue": "^3.63.0",
26
- "@drax/identity-front": "^3.63.0",
27
- "@drax/identity-share": "^3.23.0"
24
+ "@drax/crud-share": "^3.65.0",
25
+ "@drax/crud-vue": "^3.65.0",
26
+ "@drax/identity-front": "^3.65.0",
27
+ "@drax/identity-share": "^3.65.0"
28
28
  },
29
29
  "peerDependencies": {
30
30
  "pinia": "^3.0.4",
@@ -49,5 +49,5 @@
49
49
  "vue-tsc": "^3.2.4",
50
50
  "vuetify": "^3.11.8"
51
51
  },
52
- "gitHead": "d966183a92b24b2aa5eafd317f64fb448ed89bb6"
52
+ "gitHead": "6e6b1827de6d9f0449d6381f5054fe3e8f00c2ef"
53
53
  }
@@ -3,6 +3,8 @@ import {computed, ref} from 'vue'
3
3
  import {useAuth} from '../../composables/useAuth.js'
4
4
  import IdentityProfileView from "../IdentityProfileView/IdentityProfileView.vue";
5
5
  import {useI18n} from "vue-i18n";
6
+ import IdentityRememberMe from '../IdentityRememberMe.vue'
7
+ import {isRememberMeEnabled} from '../../composables/setupAuthSession'
6
8
  const {t, te} = useI18n()
7
9
 
8
10
  const {login, isAuthenticated} = useAuth()
@@ -18,6 +20,7 @@ const username = ref('')
18
20
  const password = ref('')
19
21
  const authError = ref('')
20
22
  const loading = ref(false)
23
+ const rememberMe = ref(false)
21
24
 
22
25
  const isFormValid = computed(() => username.value.trim() !== '' && password.value.trim() !== '')
23
26
 
@@ -26,7 +29,9 @@ const emit = defineEmits(['loginSuccess'])
26
29
  async function submitLogin() {
27
30
  try {
28
31
  loading.value = true
29
- await login(username.value, password.value)
32
+ authError.value = ''
33
+ if (isRememberMeEnabled() && rememberMe.value) await login(username.value, password.value, true)
34
+ else await login(username.value, password.value)
30
35
  emit('loginSuccess')
31
36
  } catch (e) {
32
37
  const error = e as Error
@@ -87,6 +92,7 @@ function togglePasswordVisibility() {
87
92
  @click:append-inner="togglePasswordVisibility"
88
93
  autocomplete="new-password"
89
94
  ></v-text-field>
95
+ <identity-remember-me v-model="rememberMe" :disabled="loading" />
90
96
  </v-card-text>
91
97
  <v-card-actions>
92
98
  <v-spacer></v-spacer>
@@ -0,0 +1,26 @@
1
+ <script setup lang="ts">
2
+ import {useI18n} from 'vue-i18n'
3
+ import {isRememberMeEnabled} from '../composables/setupAuthSession'
4
+
5
+ defineProps<{disabled?: boolean}>()
6
+ const model = defineModel<boolean>({default: false})
7
+ const {t} = useI18n({
8
+ useScope: 'local',
9
+ messages: {
10
+ en: {rememberMe: 'Remember my session on this device'},
11
+ es: {rememberMe: 'Recordar mi sesión en este dispositivo'},
12
+ },
13
+ })
14
+ </script>
15
+
16
+ <template>
17
+ <v-checkbox
18
+ v-if="isRememberMeEnabled()"
19
+ v-model="model"
20
+ :label="t('rememberMe')"
21
+ density="compact"
22
+ hide-details
23
+ :disabled="disabled"
24
+ data-testid="remember-me"
25
+ />
26
+ </template>
@@ -0,0 +1,183 @@
1
+ import {HttpClientFactory, HttpRestClientFactory} from '@drax/common-front'
2
+ import {AuthSystemFactory} from '@drax/identity-front'
3
+ import {useAuthStore} from '../stores/AuthStore'
4
+
5
+ export const isRememberMeEnabled = () => import.meta.env.VITE_DRAX_REMEMBER_ME_ENABLED === 'true'
6
+
7
+ type AuthStore = ReturnType<typeof useAuthStore>
8
+ const flights = new WeakMap<AuthStore, Promise<void>>()
9
+ const fetchingUser = new WeakSet<AuthStore>()
10
+ const lockName = 'drax-auth-session'
11
+ const revisions = new WeakMap<AuthStore, number>()
12
+ export const authSessionRevision = (store = useAuthStore()) => revisions.get(store) ?? 0
13
+
14
+ export function syncAuthHeaders(store = useAuthStore()) {
15
+ const clients = new Set([HttpClientFactory.getInstance(), HttpRestClientFactory.getInstance()])
16
+ for (const client of clients) {
17
+ if (store.accessToken) client.addHeader('Authorization', `Bearer ${store.accessToken}`)
18
+ else client.removeHeader('Authorization')
19
+ }
20
+ }
21
+
22
+ export function clearAuthSession(store = useAuthStore()) {
23
+ revisions.set(store, authSessionRevision(store) + 1)
24
+ store.clearAuth()
25
+ syncAuthHeaders(store)
26
+ }
27
+
28
+ export function isRejectedCredential(error: unknown): boolean {
29
+ const e = error as {statusCode?: number, status?: number, response?: {status?: number}, cause?: unknown, error?: unknown}
30
+ const status = e?.statusCode ?? e?.status ?? e?.response?.status
31
+ return status === 401 || status === 403 ||
32
+ (!!e?.error && e.error !== error && isRejectedCredential(e.error)) ||
33
+ (!!e?.cause && e.cause !== error && isRejectedCredential(e.cause))
34
+ }
35
+
36
+ function readPersisted() {
37
+ try {
38
+ const raw = localStorage.getItem('AuthStore')
39
+ return raw ? JSON.parse(raw) as AuthStore['$state'] : null
40
+ } catch { return null }
41
+ }
42
+
43
+ export function rereadSession(store: AuthStore) {
44
+ const persisted = readPersisted()
45
+ if (persisted) {
46
+ store.$patch({
47
+ accessToken: persisted.accessToken ?? null,
48
+ sessionVersion: persisted.sessionVersion ?? null,
49
+ refreshToken: persisted.refreshToken ?? null,
50
+ refreshExpiresAt: persisted.refreshExpiresAt ?? null,
51
+ authUser: persisted.authUser ?? null,
52
+ })
53
+ syncAuthHeaders(store)
54
+ }
55
+ }
56
+
57
+ export function isCurrentAuthOperation(store: AuthStore, version: string | null) {
58
+ if (store.sessionVersion !== version) return false
59
+ const persisted = readPersisted()
60
+ // Missing versions in legacy persisted stores represent the initial epoch.
61
+ if (persisted && (persisted.sessionVersion ?? null) !== version) {
62
+ rereadSession(store)
63
+ return false
64
+ }
65
+ return true
66
+ }
67
+
68
+ export function isCurrentAuthSession(store: AuthStore, token: string | null, version = store.sessionVersion) {
69
+ if (!isCurrentAuthOperation(store, version) || store.accessToken !== token) return false
70
+ const persisted = readPersisted()
71
+ if (persisted && (persisted.accessToken !== token || (persisted.refreshToken ?? null) !== store.refreshToken)) {
72
+ rereadSession(store)
73
+ return false
74
+ }
75
+ return true
76
+ }
77
+
78
+ function needsRefresh(token: string | null) {
79
+ try {
80
+ const payload = (token?.split('.')[1] ?? '').replace(/-/g, '+').replace(/_/g, '/')
81
+ const {exp} = JSON.parse(atob(payload))
82
+ return typeof exp !== 'number' || exp * 1000 <= Date.now() + 30_000
83
+ } catch { return true }
84
+ }
85
+
86
+ async function withSessionLock(action: () => Promise<void>) {
87
+ if (typeof navigator !== 'undefined' && navigator.locks?.request) {
88
+ await navigator.locks.request(lockName, action)
89
+ } else {
90
+ await action()
91
+ }
92
+ }
93
+
94
+ export async function revokeSessionToken(token: string) {
95
+ await AuthSystemFactory.getInstance().revokeSession(token)
96
+ }
97
+
98
+ async function fetchSessionUser(store: AuthStore) {
99
+ const token = store.accessToken
100
+ const revision = authSessionRevision(store)
101
+ const version = store.sessionVersion
102
+ fetchingUser.add(store)
103
+ try {
104
+ const user = await AuthSystemFactory.getInstance().me()
105
+ if (authSessionRevision(store) === revision && isCurrentAuthSession(store, token, version)) store.setAuthUser(user)
106
+ } catch (error) {
107
+ if (authSessionRevision(store) === revision && isCurrentAuthSession(store, token, version) && isRejectedCredential(error)) {
108
+ clearAuthSession(store)
109
+ throw error
110
+ }
111
+ // A profile fetch failing offline must not discard successfully rotated credentials.
112
+ } finally {
113
+ fetchingUser.delete(store)
114
+ }
115
+ }
116
+
117
+ export function refreshAuthSession(store = useAuthStore()): Promise<void> {
118
+ if (!isRememberMeEnabled()) return Promise.resolve()
119
+ const running = flights.get(store)
120
+ if (running) return running
121
+ const flight = withSessionLock(async () => {
122
+ rereadSession(store)
123
+ if (!store.refreshToken) return
124
+ if (!needsRefresh(store.accessToken)) {
125
+ if (!store.authUser) await fetchSessionUser(store)
126
+ return
127
+ }
128
+ const token = store.refreshToken
129
+ const accessToken = store.accessToken
130
+ const version = store.sessionVersion
131
+ if (store.refreshExpiresAt && Date.parse(store.refreshExpiresAt) <= Date.now()) {
132
+ clearAuthSession(store)
133
+ return
134
+ }
135
+ try {
136
+ const result = await AuthSystemFactory.getInstance().refresh(token)
137
+ // Logout or a new login may have happened while the request was in flight.
138
+ if (!isCurrentAuthSession(store, accessToken, version) || store.refreshToken !== token) {
139
+ syncAuthHeaders(store)
140
+ if (result.refreshToken) await revokeSessionToken(result.refreshToken).catch(() => {})
141
+ return
142
+ }
143
+ store.setSession(result)
144
+ syncAuthHeaders(store)
145
+ await fetchSessionUser(store)
146
+ } catch (error) {
147
+ if (isCurrentAuthSession(store, accessToken, version) && store.refreshToken === token && isRejectedCredential(error)) {
148
+ clearAuthSession(store)
149
+ }
150
+ throw error
151
+ }
152
+ }).finally(() => { flights.delete(store) })
153
+ flights.set(store, flight)
154
+ return flight
155
+ }
156
+
157
+ /** Call after installing Pinia, before installing the router. No component/router context required. */
158
+ export async function setupAuthSession(): Promise<void> {
159
+ const store = useAuthStore()
160
+ const client = HttpRestClientFactory.getInstance() as ReturnType<typeof HttpRestClientFactory.getInstance> & {
161
+ setBeforeRequest?: (handler: (url: string) => Promise<void>) => void
162
+ }
163
+ syncAuthHeaders(store)
164
+ client.setBeforeRequest?.(async (url) => {
165
+ if (/\/api\/auth\/(login|refresh|logout)\/?(?:[?#]|$)/.test(url)) return
166
+ // The profile fetch uses the fresh JWT and must not await its own refresh promise.
167
+ if (fetchingUser.has(store) && /\/api\/auth\/me\/?(?:[?#]|$)/.test(url)) return
168
+ await refreshAuthSession(store)
169
+ })
170
+ try {
171
+ await refreshAuthSession(store)
172
+ } catch {
173
+ // Offline startup must not destroy a recoverable remembered session.
174
+ return
175
+ }
176
+ if (!store.accessToken || !store.tokenIsValid()) return
177
+ // Remembered maintenance already fetched (or attempted) the missing profile.
178
+ // Do not immediately retry a transient failure in this same setup invocation.
179
+ if (isRememberMeEnabled() && store.refreshToken) return
180
+ try {
181
+ await fetchSessionUser(store)
182
+ } catch { /* Rejected credentials have already been cleared. */ }
183
+ }
@@ -2,6 +2,7 @@ import {useAuthStore} from "../stores/AuthStore";
2
2
  import {AuthHelper, AuthSystemFactory} from "@drax/identity-front";
3
3
  import type {AuthSystem} from "@drax/identity-front";
4
4
  import {useRouter} from 'vue-router'
5
+ import {authSessionRevision, clearAuthSession, isCurrentAuthOperation, isCurrentAuthSession, isRememberMeEnabled, rereadSession, revokeSessionToken, syncAuthHeaders} from './setupAuthSession'
5
6
 
6
7
  export function useAuth() {
7
8
 
@@ -10,26 +11,56 @@ export function useAuth() {
10
11
 
11
12
  const authSystem: AuthSystem = AuthSystemFactory.getInstance()
12
13
 
13
- const login = async (username: string, password: string) => {
14
- const {accessToken} = await authSystem.login(username, password)
15
- authStore.setAccessToken(accessToken)
16
- const authUser = await authSystem.me()
17
- authStore.setAuthUser(authUser)
14
+ const login = async (username: string, password: string, rememberMe = false) => {
15
+ clearAuthSession(authStore)
16
+ const revision = authSessionRevision(authStore)
17
+ const version = authStore.sessionVersion
18
+ const remembered = isRememberMeEnabled() && rememberMe
19
+ const result = remembered
20
+ ? await authSystem.login(username, password, true)
21
+ : await authSystem.login(username, password)
22
+ if (authSessionRevision(authStore) !== revision || !isCurrentAuthOperation(authStore, version)) {
23
+ syncAuthHeaders(authStore)
24
+ if (result.refreshToken) await revokeSessionToken(result.refreshToken).catch(() => {})
25
+ throw new Error('Authentication cancelled')
26
+ }
27
+ if (remembered) authStore.setSession(result)
28
+ else authStore.setAccessToken(result.accessToken)
29
+ syncAuthHeaders(authStore)
30
+ try {
31
+ await fetchAuthUser()
32
+ if (authSessionRevision(authStore) !== revision || !isCurrentAuthOperation(authStore, version)) throw new Error('Authentication cancelled')
33
+ } catch (error) {
34
+ if (authSessionRevision(authStore) === revision && isCurrentAuthOperation(authStore, version)) {
35
+ const token = authStore.refreshToken
36
+ clearAuthSession(authStore)
37
+ if (token) await revokeSessionToken(token).catch(() => {})
38
+ }
39
+ throw error
40
+ }
18
41
  }
19
42
 
20
43
 
21
44
  const switchTenant = async (tenantId: string) => {
22
45
  const {accessToken} = await authSystem.switchTenant(tenantId)
23
- authStore.setAccessToken(accessToken)
24
- const authUser = await authSystem.me()
25
- authStore.setAuthUser(authUser)
46
+ // Switching tenant replaces the JWT, but must not turn an ordinary session into a remembered one.
47
+ authStore.setSession({accessToken, refreshToken: authStore.refreshToken ?? undefined,
48
+ refreshExpiresAt: authStore.refreshExpiresAt ?? undefined})
49
+ syncAuthHeaders(authStore)
50
+ await fetchAuthUser()
26
51
  }
27
52
 
28
53
  const loginWithToken = async (token: string) => {
54
+ clearAuthSession(authStore)
29
55
  authStore.setAccessToken(token)
30
- if(tokenIsValid()){
31
- const authUser = await authSystem.me()
32
- authStore.setAuthUser(authUser)
56
+ const version = authStore.sessionVersion
57
+ syncAuthHeaders(authStore)
58
+ try {
59
+ if(tokenIsValid()) await fetchAuthUser()
60
+ else clearAuthSession(authStore)
61
+ } catch (error) {
62
+ if (isCurrentAuthSession(authStore, token, version)) clearAuthSession(authStore)
63
+ throw error
33
64
  }
34
65
 
35
66
  }
@@ -64,19 +95,30 @@ export function useAuth() {
64
95
  }
65
96
 
66
97
  async function fetchAuthUser() {
98
+ const revision = authSessionRevision(authStore)
99
+ const token = authStore.accessToken
100
+ const version = authStore.sessionVersion
67
101
  const authUser = await authSystem.me()
68
- authStore.setAuthUser(authUser)
102
+ if (authSessionRevision(authStore) === revision && isCurrentAuthSession(authStore, token, version)) {
103
+ authStore.setAuthUser(authUser)
104
+ }
69
105
  return authUser
70
106
  }
71
107
 
72
- function logout() {
73
- clearAuth()
74
- router.push({name: 'Login'})
75
- }
76
-
77
- function clearAuth(){
108
+ async function logout() {
109
+ rereadSession(authStore)
110
+ const token = authStore.refreshToken
111
+ clearAuthSession(authStore)
78
112
  authSystem.logout()
79
- authStore.clearAuth()
113
+ // Clear and navigate immediately, even offline or with a refresh in flight.
114
+ const navigation = router.push({name: 'Login'})
115
+ try {
116
+ if (token) await revokeSessionToken(token)
117
+ } catch {
118
+ // Local logout must succeed even when server revocation is unavailable.
119
+ } finally {
120
+ await navigation
121
+ }
80
122
  }
81
123
 
82
124
  function hasPermission(permission: string | undefined) {
@@ -46,7 +46,9 @@ class UserSessionCrud extends EntityCrud implements IEntityCrud {
46
46
  {title: 'user', key: 'user', align: 'start'},
47
47
  {title: 'userAgent', key: 'userAgent', align: 'start'},
48
48
  {title: 'ip', key: 'ip', align: 'start'},
49
- {title: 'createdAt', key: 'createdAt', align: 'start'}
49
+ {title: 'createdAt', key: 'createdAt', align: 'start'},
50
+ {title: 'rememberMe', key: 'rememberMe', align: 'start'},
51
+ {title: 'refreshExpiresAt', key: 'refreshExpiresAt', align: 'start'}
50
52
  ]
51
53
  }
52
54
 
@@ -92,7 +94,9 @@ class UserSessionCrud extends EntityCrud implements IEntityCrud {
92
94
  {name: 'user', type: 'ref', label: 'user', default: null, ref: 'User', refDisplay: 'username'},
93
95
  {name: 'userAgent', type: 'string', label: 'userAgent', default: ''},
94
96
  {name: 'ip', type: 'string', label: 'ip', default: ''},
95
- {name: 'createdAt', type: 'date', label: 'createdAt', default: null}
97
+ {name: 'createdAt', type: 'date', label: 'createdAt', default: null},
98
+ {name: 'rememberMe', type: 'boolean', label: 'rememberMe', default: null},
99
+ {name: 'refreshExpiresAt', type: 'date', label: 'refreshExpiresAt', default: null}
96
100
  ]
97
101
  }
98
102
 
@@ -136,7 +140,7 @@ class UserSessionCrud extends EntityCrud implements IEntityCrud {
136
140
  }
137
141
 
138
142
  get exportHeaders() {
139
- return ['_id','user','createdAt','userAgent','ip']
143
+ return ['_id','user','createdAt','userAgent','ip','rememberMe','refreshExpiresAt']
140
144
  }
141
145
 
142
146
  get isImportable() {
@@ -11,7 +11,9 @@ const messages = {
11
11
  user:'user',
12
12
  agent:'agent',
13
13
  ip:'ip',
14
- createdAt:'createdAt'
14
+ createdAt:'createdAt',
15
+ rememberMe: 'Remember me',
16
+ refreshExpiresAt: 'Remembered session expires at'
15
17
  }
16
18
  },
17
19
  permission: {
@@ -32,7 +34,9 @@ const messages = {
32
34
  user:'user',
33
35
  agent:'agent',
34
36
  ip:'ip',
35
- createdAt:'createdAt'
37
+ createdAt:'createdAt',
38
+ rememberMe: 'Recordarme',
39
+ refreshExpiresAt: 'Vencimiento de sesión recordada'
36
40
  }
37
41
  },
38
42
  permission: {
package/src/index.ts CHANGED
@@ -1,3 +1,6 @@
1
+ export {setupAuthSession, refreshAuthSession, isRememberMeEnabled} from './composables/setupAuthSession'
2
+ export {default as IdentityRememberMe} from './components/IdentityRememberMe.vue'
3
+
1
4
  import IdentityLogin from "./components/IdentityLogin/IdentityLogin.vue";
2
5
  import IdentityProfileAvatar from "./components/IdentityProfileAvatar/IdentityProfileAvatar.vue";
3
6
  import IdentityProfileDrawer from "./components/IdentityProfileDrawer/IdentityProfileDrawer.vue";
@@ -8,6 +8,9 @@ import {AuthHelper} from "@drax/identity-front";
8
8
  export const useAuthStore = defineStore('AuthStore', {
9
9
  state: () => ({
10
10
  accessToken: null as string | null,
11
+ sessionVersion: null as string | null,
12
+ refreshToken: null as string | null,
13
+ refreshExpiresAt: null as string | null,
11
14
  authUser: null as IAuthFullUser | null,
12
15
  }),
13
16
  getters:{
@@ -23,15 +26,35 @@ export const useAuthStore = defineStore('AuthStore', {
23
26
  },
24
27
  },
25
28
  actions:{
29
+ persistSession(){
30
+ // Persist synchronously so another tab holding the refresh lock sees rotation immediately.
31
+ try {
32
+ localStorage.setItem('AuthStore', JSON.stringify(this.$state))
33
+ } catch { /* Storage may be unavailable (private mode / quota). */ }
34
+ },
26
35
  setAccessToken(accessToken:string){
27
- this.accessToken = accessToken
36
+ this.$patch({accessToken, refreshToken: null, refreshExpiresAt: null})
37
+ this.persistSession()
38
+ },
39
+ setSession(session: {accessToken: string, refreshToken?: string, refreshExpiresAt?: string}){
40
+ this.$patch({
41
+ accessToken: session.accessToken,
42
+ refreshToken: session.refreshToken ?? null,
43
+ refreshExpiresAt: session.refreshExpiresAt ?? null,
44
+ })
45
+ this.persistSession()
28
46
  },
29
47
  setAuthUser(authUser:IAuthFullUser){
30
48
  this.authUser = authUser
49
+ this.persistSession()
31
50
  },
32
51
  clearAuth(){
33
- this.accessToken = null
34
- this.authUser = null
52
+ this.$patch({
53
+ accessToken: null, refreshToken: null, refreshExpiresAt: null, authUser: null,
54
+ // A persisted epoch also invalidates pending work in other tabs, even null -> null.
55
+ sessionVersion: globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random()}`,
56
+ })
57
+ this.persistSession()
35
58
  }
36
59
  },
37
60