@drax/identity-vue 3.66.0 → 3.68.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
@@ -58,7 +58,7 @@ Integration contracts:
58
58
  null-to-null logout. Legacy stores without the field use the initial null epoch.
59
59
  Late returned refresh credentials are revoked best-effort.
60
60
  - Transient refresh/profile errors retain recoverable credentials; rejected
61
- credentials (HTTP 401/403, including common-front wrapped errors) clear auth.
61
+ credentials (HTTP 401/403/498, including common-front wrapped errors) clear auth.
62
62
  Logout clears local state immediately, with best-effort server revocation even
63
63
  when the feature has subsequently been disabled. Offline revocation cannot be
64
64
  guaranteed; the backend expiration/revocation policy remains authoritative.
@@ -72,6 +72,197 @@ Focused tests:
72
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
73
  ```
74
74
 
75
+ ## Expired sessions and external login migration
76
+
77
+ Session maintenance runs even with remember-me disabled or without a refresh token.
78
+ Expired/malformed access tokens without renewal are cleared together with the profile,
79
+ refresh credentials, persisted state and shared authorization headers. With remember-me
80
+ disabled, a live access token remains usable, but legacy refresh credentials are discarded.
81
+ `AuthStore.isAuth` checks local JWT validity, not mere token presence. This check is not
82
+ signature verification: the backend remains authoritative.
83
+
84
+ Store token/clear actions synchronize both shared clients. `syncAuthHeaders()` never
85
+ installs an expired/malformed JWT; transient refresh errors preserve recoverable tokens
86
+ in storage, remove expired authorization from the clients and reject the authenticated
87
+ REST preflight before fetch. There is no navigation or automatic request replay.
88
+ Removing a header directly is only a transport operation and intentionally does **not**
89
+ clear the store: use session/store actions for credential changes. Direct `$patch` is
90
+ not a session API; normal request maintenance re-reads persisted state.
91
+
92
+ External providers must mark the credential exchange as independent using the existing
93
+ HTTP option, even if the old Drax session is recoverable:
94
+
95
+ ```ts
96
+ const response = await this.httpClient.post('/api/google/login', {credential}, {
97
+ timeout: 120000,
98
+ removeHeaders: ['Authorization'],
99
+ })
100
+ await loginWithToken(response.accessToken)
101
+ ```
102
+
103
+ Do not remove/restore global headers or set the returned token manually before
104
+ `loginWithToken`. The request option excludes Authorization case-insensitively and
105
+ skips the session preflight without changing shared state. Its 401 is the external
106
+ credential's rejection, not a reason to clear the existing Drax session. No global
107
+ response interceptor performs logout. This does not disable browser CORS preflight.
108
+
109
+ Consumer changes:
110
+
111
+ - **GoogleProvider:** add `removeHeaders: ['Authorization']` to the login POST and
112
+ remove the redundant `setHttpClientToken(response.accessToken)` call; keep
113
+ `await loginWithToken(response.accessToken)` after a successful exchange. See
114
+ Zuite's `src/modules/base/providers/GoogleProvider.ts`.
115
+ - **LoginPage:** delete the workaround that checks invalid access tokens and calls
116
+ `logout()`. Session cleanup belongs to maintenance, not a page (logout also navigates).
117
+ - **Bootstrap:** retain `app.use(pinia); await setupAuthSession(); app.use(router)`;
118
+ install Pinia's persisted-state plugin before these calls. No extra cleanup needed.
119
+ - **Router:** retain the existing `await refreshAuthSession()` plus catch and cancel
120
+ protected navigation while `!isAuthenticated() && isRememberMeEnabled() &&
121
+ authStore.refreshToken`. Do not convert transient failures into logout or redirects.
122
+ - **Backend external route:** replace the Google-specific middleware wrapper with
123
+ the supported Fastify route option `{config: {identityJwt: 'ignore'}}` on the POST
124
+ route. Keep its external credential verifier and normal authorization checks.
125
+ A public route with optional JWT authentication still rejects a supplied invalid
126
+ Bearer; only this explicit option ignores it. See identity-back's
127
+ `docs/jwt-route-policy.md`.
128
+
129
+ The two local workarounds can therefore be removed after upgrading compatible packages
130
+ and declaring the external route policy. Google SDK/signature verification itself is
131
+ consumer-owned; Drax tests use a deterministic external verifier, not Google's service.
132
+ REST request-time maintenance requires the shared client's optional `setBeforeRequest`;
133
+ GraphQL and custom clients without that hook do not get automatic preflight renewal.
134
+
135
+ ## Registro con creación de tenant (opcional, REST)
136
+
137
+ En el **backend**:
138
+
139
+ ```dotenv
140
+ DRAX_REGISTRATION_TENANT_ENABLED=true
141
+ DRAX_DEFAULT_ROLE=Member
142
+ ```
143
+
144
+ Cargá `LoadIdentityConfigFromEnv()`, registrá `UserRoutes` y creá previamente el rol
145
+ configurado. Sin la flag, o con `false`, el registro no crea tenants. No se necesita
146
+ una variable Vite: `IdentityRegistration`, usado por `RegistrationPage`, consulta
147
+ `GET /api/auth/registration-options` → `{tenantRequired: boolean}`. Mientras no pueda
148
+ cargar estas opciones, bloquea el envío y permite reintentar.
149
+
150
+ Si está activo, el formulario exige un nombre de organización y envía
151
+ `tenantName` en `POST /api/users/register`. El backend crea un tenant **nuevo** y
152
+ asigna el usuario a ese tenant; rechaza nombres existentes con HTTP 422,
153
+ `tenantName: validation.unique`. No vincula visitantes a tenants existentes. Los
154
+ nombres se recortan y la unicidad es exacta y sensible a mayúsculas, igual que el
155
+ índice MongoDB existente. SQLite también aplica un índice único, incluyendo bases
156
+ ya creadas; si hay duplicados previos, hay que resolverlos manualmente antes de
157
+ inicializar. No se borran ni renombran datos automáticamente.
158
+
159
+ Para formularios propios, usá `useAuth().registrationOptions()` y
160
+ `useAuth().register({...datos, tenantName})`. El rol y el ID de tenant los determina
161
+ el servidor; enviar `role`, `tenant` o `active` no los sobreescribe. El usuario
162
+ continúa inactivo hasta la verificación existente por email/teléfono; no hay
163
+ login automático. Si falla la creación del usuario antes de persistir, se elimina
164
+ el tenant recién creado; si ya se persistió o falla después el email, se conserva
165
+ la asociación. No es una transacción distribuida entre bases/servicios.
166
+
167
+ Desplegá backend y frontend compatibles: el formulario actualizado requiere el
168
+ nuevo endpoint incluso cuando la flag está apagada. El registro GraphQL no está
169
+ implementado; su provider devuelve `tenantRequired: false` para las opciones.
170
+
171
+ ## Verificación de email y reenvío (REST)
172
+
173
+ `register(form)` conserva `{success, message}` y ahora devuelve el tipo público
174
+ `IRegistrationResponse` de `@drax/identity-front`, con
175
+ `verificationEmailSent?: boolean`. `true` indica aceptación por SMTP, **no entrega**;
176
+ `false` indica que la cuenta se creó pero el correo quedó pendiente. La ausencia
177
+ mantiene el comportamiento anterior. Un fallo SMTP ya no implica un error de
178
+ registro ni habilita la cuenta: no hay login automático.
179
+
180
+ `IdentityRegistration` muestra una advertencia si el valor es `false` y mantiene
181
+ el mensaje de éxito existente para `true` o ausente. Ofrece un botón de acceso al
182
+ reenvío (`auth.verificationResendTitle`), con el email prellenado, tanto desde el
183
+ formulario como después del éxito/advertencia o de un error (duplicado o red).
184
+ Al abrirlo, el reenvío **reemplaza** el formulario o el resultado del registro y
185
+ oculta el botón de acceso. «Volver al registro» (`auth.backToRegistration`) restaura
186
+ el estado anterior, conservando inputs, email, tenant y resultado de éxito/advertencia.
187
+ No es necesario crear otra cuenta ni agregar rutas.
188
+
189
+ El acceso al reenvío está **solo en registro**, separado del formulario principal
190
+ con un margen superior. `IdentityLogin` conserva los enlaces de registro y de
191
+ recuperación de contraseña según sus props, pero no muestra el acceso a verificación.
192
+ El reenvío solicita email explícitamente. Hay un único formulario visible, sin
193
+ formularios simultáneos ni anidados, y un único título de reenvío:
194
+ `auth.verificationFormTitle` («Account verification» / «Verificación de cuenta»).
195
+
196
+ El componente reutilizable está exportado:
197
+
198
+ ```vue
199
+ <script setup lang="ts">
200
+ import {IdentityVerificationResend} from '@drax/identity-vue'
201
+ </script>
202
+
203
+ <template>
204
+ <IdentityVerificationResend email="persona@example.com" />
205
+ </template>
206
+ ```
207
+
208
+ La prop `email` es opcional y editable. La prop opcional `backLabel?: string`
209
+ muestra un botón de vuelta de tipo `button` que emite `back`; está deshabilitado
210
+ mientras se procesa la solicitud. El componente padre decide qué restaurar al
211
+ recibir ese evento. Sin `backLabel`, el uso standalone conserva un único botón de
212
+ envío. El botón submit queda alineado a la derecha mediante un `v-spacer` anterior.
213
+ Formularios propios pueden usar `useAuth().resendVerificationEmail(email)` o
214
+ `AuthSystem.resendVerificationEmail(email)`.
215
+ REST hace `POST /api/users/verification/resend` con `{email}`. Tanto este endpoint
216
+ como el registro son anónimos: no envían `Authorization`, no disparan el preflight
217
+ de refresh y no borran las cabeceras del cliente compartido.
218
+
219
+ El resultado de reenvío es siempre neutral para cuentas inexistentes, verificadas,
220
+ limitadas o con fallo SMTP: `{success: true, message: 'If an account is pending
221
+ verification, activation instructions will be sent.'}`. La UI usa traducciones
222
+ es/en neutrales, sin confirmar existencia de la cuenta ni envío o entrega del
223
+ correo. Valida el email, muestra errores de red/`ClientError` y bloquea el envío
224
+ durante la solicitud. No impone una cuenta regresiva fija: el cooldown lo decide
225
+ el servidor (60 segundos por defecto, configurable). GraphQL rechaza esta operación
226
+ explícitamente como no implementada; no se crea una query ni mutación ficticia.
227
+ Los providers personalizados anteriores pueden omitir el método opcional de
228
+ `IAuthProvider`; sus APIs previas siguen funcionando y `AuthSystem` rechaza el
229
+ reenvío con un error explícito si el provider no lo soporta.
230
+
231
+ ### Backend: vencimiento, límites y recuperación de cuentas anteriores
232
+
233
+ Configuración opcional, cargada con `LoadIdentityConfigFromEnv()`:
234
+
235
+ ```dotenv
236
+ DRAX_EMAIL_VERIFICATION_TTL_SECONDS=86400
237
+ DRAX_EMAIL_VERIFICATION_RESEND_COOLDOWN_SECONDS=60
238
+ ```
239
+
240
+ Ambos valores deben ser segundos enteros positivos. El código es de un solo uso y
241
+ vence a las 24 horas por defecto. Reenviar reutiliza un código vigente sin extender
242
+ su vencimiento; rota el código únicamente si venció o si el registro anterior no
243
+ tenía vencimiento. Los enlaces antiguos sin vencimiento ya no activan cuentas:
244
+ para recuperarlas, solicitá un reenvío con el email original desde login/registro.
245
+ No hace falta borrar ni volver a crear usuario o tenant. Solo son elegibles cuentas
246
+ con `origin: 'Registry'` y email sin verificar; el reenvío no altera sus datos ni
247
+ reactiva cuentas verificadas que hayan sido desactivadas administrativamente.
248
+
249
+ El cooldown por email se reclama atómicamente en la base de datos, también cuando
250
+ SMTP falla. Se admiten 10 intentos de reenvío por IP cada 15 minutos por proceso,
251
+ con memoria acotada; incluso los emails inexistentes consumen ese límite. En un
252
+ despliegue multiinstancia, agregá un rate limiter compartido/gateway y configurá
253
+ correctamente la IP de cliente detrás del proxy. No hay cola ni reintentos
254
+ automáticos de correo: el usuario solicita el reenvío. La persistencia SQLite se
255
+ actualiza de forma aditiva al inicializar el repositorio; no se borran registros.
256
+
257
+ Actualizá backend, `identity-share`, `identity-front` e `identity-vue` de forma
258
+ coherente y desplegá backend antes de la UI nueva. En el monorepo compilá primero
259
+ `identity-share` para que los tipos utilizados por los demás paquetes estén al día.
260
+
261
+ Pruebas enfocadas: `test/verification-resend.spec.ts`, `test/registration.spec.ts`,
262
+ `test/components/__tests__/IdentityLogin.spec.ts`,
263
+ `test/auth-session.integration.spec.ts` y
264
+ `../identity-front/test/verification-resend.test.ts`.
265
+
75
266
  ## Recommended IDE Setup
76
267
 
77
268
  [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.66.0",
6
+ "version": "3.68.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.65.0",
22
- "@drax/common-vue": "^3.65.0",
21
+ "@drax/common-front": "^3.68.0",
22
+ "@drax/common-vue": "^3.68.0",
23
23
  "@drax/crud-front": "^3.58.0",
24
24
  "@drax/crud-share": "^3.65.0",
25
- "@drax/crud-vue": "^3.66.0",
26
- "@drax/identity-front": "^3.65.0",
27
- "@drax/identity-share": "^3.65.0"
25
+ "@drax/crud-vue": "^3.68.0",
26
+ "@drax/identity-front": "^3.68.0",
27
+ "@drax/identity-share": "^3.67.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": "904410e22198dc6f96d64db10ba96a3ffd6ec777"
52
+ "gitHead": "c8300dd678d4048416da13f9065d6749e715c3bf"
53
53
  }
@@ -4,6 +4,7 @@ import {useAuth} from '../../composables/useAuth.js'
4
4
  import IdentityProfileView from "../IdentityProfileView/IdentityProfileView.vue";
5
5
  import {useI18n} from "vue-i18n";
6
6
  import IdentityRememberMe from '../IdentityRememberMe.vue'
7
+
7
8
  import {isRememberMeEnabled} from '../../composables/setupAuthSession'
8
9
  const {t, te} = useI18n()
9
10
 
@@ -22,6 +23,7 @@ const authError = ref('')
22
23
  const loading = ref(false)
23
24
  const rememberMe = ref(false)
24
25
 
26
+
25
27
  const isFormValid = computed(() => username.value.trim() !== '' && password.value.trim() !== '')
26
28
 
27
29
  const emit = defineEmits(['loginSuccess'])
@@ -60,6 +62,7 @@ function togglePasswordVisibility() {
60
62
  </v-card>
61
63
  </template>
62
64
 
65
+
63
66
  <template v-else>
64
67
 
65
68
  <v-form @submit.prevent="submitLogin">
@@ -124,6 +127,7 @@ function togglePasswordVisibility() {
124
127
  </v-card>
125
128
  </v-form>
126
129
 
130
+
127
131
  </template>
128
132
  </template>
129
133
 
@@ -1,18 +1,42 @@
1
1
  <script setup lang="ts">
2
- import {ref} from 'vue'
2
+ import {onMounted, ref} from 'vue'
3
+ import type {IRegistrationOptions} from '@drax/identity-front'
3
4
  import {useAuth} from "../../composables/useAuth.js";
4
5
  import {ClientError} from "@drax/common-front";
5
6
  import type {IClientInputError} from "@drax/common-front";
6
7
  import {useI18nValidation} from "@drax/common-vue";
7
8
  import {useI18n} from "vue-i18n";
8
9
  import {useRouter} from "vue-router"
10
+ import IdentityVerificationResend from '../IdentityVerificationResend/IdentityVerificationResend.vue'
9
11
 
10
12
  const {t,te} = useI18n()
11
13
  const {$ta} = useI18nValidation()
12
14
 
13
15
  const router = useRouter()
14
16
 
15
- const {register} = useAuth()
17
+ const {register, registrationOptions} = useAuth()
18
+ const options = ref<IRegistrationOptions | null>(null)
19
+ const optionsLoading = ref(true)
20
+ const optionsError = ref(false)
21
+ const tenantName = ref('')
22
+ const rform = ref<{validate: () => Promise<{valid: boolean}>} | null>(null)
23
+
24
+ async function loadRegistrationOptions() {
25
+ optionsLoading.value = true
26
+ optionsError.value = false
27
+ options.value = null
28
+ try {
29
+ const result = await registrationOptions()
30
+ if (typeof result?.tenantRequired !== 'boolean') throw new Error('Invalid registration options')
31
+ options.value = result
32
+ } catch {
33
+ optionsError.value = true
34
+ } finally {
35
+ optionsLoading.value = false
36
+ }
37
+ }
38
+
39
+ onMounted(loadRegistrationOptions)
16
40
 
17
41
  const variant = ref<"outlined" | "plain" | "filled" | "underlined" | "solo" | "solo-inverted" | "solo-filled" | undefined>('filled')
18
42
 
@@ -29,6 +53,8 @@ const inputErrors = ref<IClientInputError|undefined>({currentPassword: [], newPa
29
53
  const errorMsg = ref('')
30
54
  const loading = ref(false)
31
55
  const success = ref(false)
56
+ const verificationEmailSent = ref<boolean>()
57
+ const showVerificationResend = ref(false)
32
58
 
33
59
  let passwordVisibility = ref(false)
34
60
  let confirmPasswordVisibility = ref(false)
@@ -39,10 +65,23 @@ function confirmPasswordRule(value: string) {
39
65
  }
40
66
 
41
67
  async function submitRegistration() {
68
+ if (!options.value || optionsLoading.value || loading.value) return
69
+ loading.value = true
70
+ errorMsg.value = ''
71
+ inputErrors.value = undefined
42
72
  try {
43
- loading.value = true
44
- const result = await register(form.value)
73
+ if (options.value.tenantRequired && !tenantName.value.trim()) {
74
+ inputErrors.value = {tenantName: ['validation.required']}
75
+ return
76
+ }
77
+ if (!(await rform.value?.validate())?.valid) return
78
+ const payload = {
79
+ ...form.value,
80
+ ...(options.value.tenantRequired ? {tenantName: tenantName.value.trim()} : {})
81
+ }
82
+ const result = await register(payload)
45
83
  success.value = result.success
84
+ verificationEmailSent.value = result.verificationEmailSent
46
85
  } catch (err) {
47
86
  if (err instanceof ClientError) {
48
87
  inputErrors.value = err.inputErrors
@@ -61,12 +100,18 @@ async function submitRegistration() {
61
100
 
62
101
  <template>
63
102
  <v-sheet>
103
+ <identity-verification-resend
104
+ v-if="showVerificationResend"
105
+ :email="form.email"
106
+ :back-label="t('auth.backToRegistration')"
107
+ @back="showVerificationResend = false"
108
+ />
64
109
 
65
- <template v-if="success">
110
+ <template v-else-if="success">
66
111
  <v-card>
67
112
  <v-card-text>
68
- <v-alert type="success">
69
- {{ t('user.events.registrationComplete') }}
113
+ <v-alert :type="verificationEmailSent === false ? 'warning' : 'success'">
114
+ {{ verificationEmailSent === false ? t('auth.registrationVerificationPending') : t('user.events.registrationComplete') }}
70
115
  </v-alert>
71
116
  </v-card-text>
72
117
  <v-card-text class="text-center">
@@ -81,12 +126,29 @@ async function submitRegistration() {
81
126
  <v-form ref="rform" @submit.prevent="submitRegistration">
82
127
  <v-card variant="elevated">
83
128
  <v-card-title class="pa-4">{{ t('user.action.register') }}</v-card-title>
129
+ <v-card-text v-if="optionsLoading">
130
+ <v-alert type="info">{{ t('auth.registrationOptionsLoading') }}</v-alert>
131
+ </v-card-text>
132
+ <v-card-text v-if="optionsError">
133
+ <v-alert type="error">{{ t('auth.registrationOptionsError') }}</v-alert>
134
+ <v-btn @click="loadRegistrationOptions">{{ t('auth.registrationOptionsRetry') }}</v-btn>
135
+ </v-card-text>
84
136
  <v-card-text v-if="errorMsg">
85
137
  <v-alert type="error">
86
138
  {{ te(errorMsg) ?t(errorMsg) : errorMsg }}
87
139
  </v-alert>
88
140
  </v-card-text>
89
141
  <v-card-text>
142
+ <v-text-field
143
+ v-if="options?.tenantRequired"
144
+ id="tenant-name-input"
145
+ v-model="tenantName"
146
+ :label="t('auth.registrationTenantName')"
147
+ :variant="variant"
148
+ required
149
+ :rules="[(v: string) => !!v?.trim() || t('validation.required')]"
150
+ :error-messages="$ta(inputErrors?.tenantName)"
151
+ ></v-text-field>
90
152
  <v-text-field
91
153
  id="name-input"
92
154
  :label="t('user.field.name')"
@@ -167,7 +229,7 @@ async function submitRegistration() {
167
229
  color="grey"
168
230
  variant="text"
169
231
  id="submit-button"
170
- type="submit"
232
+ type="button"
171
233
  href="/login"
172
234
  >
173
235
  {{ t('auth.login') }}
@@ -179,6 +241,7 @@ async function submitRegistration() {
179
241
  id="submit-button"
180
242
  type="submit"
181
243
  :loading="loading"
244
+ :disabled="!options || optionsLoading || loading"
182
245
  >
183
246
  {{ t('action.sent') }}
184
247
  </v-btn>
@@ -186,7 +249,20 @@ async function submitRegistration() {
186
249
  </v-card>
187
250
  </v-form>
188
251
  </template>
252
+
253
+
189
254
  </v-sheet>
255
+
256
+ <div v-if="!showVerificationResend" class="mt-2 text-center">
257
+ <v-btn
258
+ type="button"
259
+ variant="text"
260
+ color="secondary"
261
+ @click="showVerificationResend = true"
262
+ >
263
+ {{ t('auth.verificationResendTitle') }}
264
+ </v-btn>
265
+ </div>
190
266
  </template>
191
267
 
192
268
  <style scoped lang="sass">
@@ -0,0 +1,87 @@
1
+ <script setup lang="ts">
2
+ import {ref, watch} from 'vue'
3
+ import {ClientError} from '@drax/common-front'
4
+ import type {IClientInputError} from '@drax/common-front'
5
+ import {useI18nValidation} from '@drax/common-vue'
6
+ import {useI18n} from 'vue-i18n'
7
+ import {useAuth} from '../../composables/useAuth.js'
8
+
9
+ const props = withDefaults(defineProps<{email?: string, backLabel?: string}>(), {email: ''})
10
+ const emit = defineEmits<{back: []}>()
11
+ const {t, te} = useI18n()
12
+ const {$ta} = useI18nValidation()
13
+ const {resendVerificationEmail} = useAuth()
14
+ const email = ref(props.email)
15
+ const loading = ref(false)
16
+ const success = ref(false)
17
+ const errorMsg = ref('')
18
+ const inputErrors = ref<IClientInputError>()
19
+
20
+ watch(() => props.email, value => { email.value = value })
21
+ watch(email, () => {
22
+ success.value = false
23
+ errorMsg.value = ''
24
+ inputErrors.value = undefined
25
+ })
26
+
27
+ async function submitResend() {
28
+ if (loading.value) return
29
+ success.value = false
30
+ errorMsg.value = ''
31
+ inputErrors.value = undefined
32
+ const address = email.value.trim()
33
+ if (!address || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(address)) {
34
+ inputErrors.value = {email: [address ? 'auth.verificationEmailInvalid' : 'validation.required']}
35
+ return
36
+ }
37
+ loading.value = true
38
+ try {
39
+ const result = await resendVerificationEmail(address)
40
+ if (!result.success) throw new Error('auth.verificationResendError')
41
+ // A neutral response does not confirm an account exists or that mail was sent.
42
+ success.value = true
43
+ } catch (error) {
44
+ if (error instanceof ClientError) inputErrors.value = error.inputErrors
45
+ errorMsg.value = error instanceof Error ? error.message : 'auth.verificationResendError'
46
+ } finally {
47
+ loading.value = false
48
+ }
49
+ }
50
+ </script>
51
+
52
+ <template>
53
+ <v-form @submit.prevent="submitResend" novalidate>
54
+ <v-card variant="flat">
55
+ <v-card-title>{{ t('auth.verificationFormTitle') }}</v-card-title>
56
+ <v-card-text>
57
+ <v-alert v-if="success" type="success" role="status" class="mb-4">
58
+ {{ t('auth.verificationResendNeutral') }}
59
+ </v-alert>
60
+ <v-alert v-if="errorMsg" type="error" role="alert" class="mb-4">
61
+ {{ te(errorMsg) ? t(errorMsg) : errorMsg }}
62
+ </v-alert>
63
+ <v-text-field
64
+ v-model="email"
65
+ name="verification-email"
66
+ type="email"
67
+ autocomplete="email"
68
+ :label="t('user.field.email')"
69
+ prepend-inner-icon="mdi-email"
70
+ variant="outlined"
71
+ required
72
+ :disabled="loading"
73
+ :error-messages="$ta(inputErrors?.email)"
74
+ />
75
+ </v-card-text>
76
+ <v-card-actions>
77
+ <v-btn v-if="backLabel" type="button" variant="text" :disabled="loading" @click="emit('back')">
78
+ {{ backLabel }}
79
+ </v-btn>
80
+ <v-spacer />
81
+ <v-btn type="submit" color="primary" variant="tonal" :loading="loading" :disabled="loading">
82
+ {{ t('auth.verificationResend') }}
83
+ </v-btn>
84
+ </v-card-actions>
85
+ </v-card>
86
+ </v-form>
87
+ </template>
@@ -6,7 +6,7 @@ import {useAuthStore} from "../../stores/AuthStore"
6
6
  import {useI18n} from "vue-i18n"
7
7
 
8
8
  const authStore = useAuthStore()
9
- const {switchTenant, hasPermission} = useAuth()
9
+ const {switchTenant, hasPermission, isAuthenticated} = useAuth()
10
10
 
11
11
  const {t} = useI18n()
12
12
  const tenant = ref();
@@ -19,7 +19,7 @@ async function changeTenant() {
19
19
  </script>
20
20
 
21
21
  <template>
22
- <v-toolbar v-if="hasPermission('user:switchTenant')">
22
+ <v-toolbar v-if="isAuthenticated() && hasPermission('user:switchTenant')">
23
23
  <v-toolbar-title>
24
24
  <b>{{t('tenant.current')}}:</b> {{authStore.authUser?.tenant ? authStore.authUser?.tenant?.name : '-'}}
25
25
  </v-toolbar-title>
@@ -1,5 +1,5 @@
1
- import {HttpClientFactory, HttpRestClientFactory} from '@drax/common-front'
2
- import {AuthSystemFactory} from '@drax/identity-front'
1
+ import {HttpRestClientFactory} from '@drax/common-front'
2
+ import {AuthHelper, AuthSystemFactory} from '@drax/identity-front'
3
3
  import {useAuthStore} from '../stores/AuthStore'
4
4
 
5
5
  export const isRememberMeEnabled = () => import.meta.env.VITE_DRAX_REMEMBER_ME_ENABLED === 'true'
@@ -12,11 +12,7 @@ const revisions = new WeakMap<AuthStore, number>()
12
12
  export const authSessionRevision = (store = useAuthStore()) => revisions.get(store) ?? 0
13
13
 
14
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
- }
15
+ store.syncAuthHeaders()
20
16
  }
21
17
 
22
18
  export function clearAuthSession(store = useAuthStore()) {
@@ -28,7 +24,7 @@ export function clearAuthSession(store = useAuthStore()) {
28
24
  export function isRejectedCredential(error: unknown): boolean {
29
25
  const e = error as {statusCode?: number, status?: number, response?: {status?: number}, cause?: unknown, error?: unknown}
30
26
  const status = e?.statusCode ?? e?.status ?? e?.response?.status
31
- return status === 401 || status === 403 ||
27
+ return status === 401 || status === 403 || status === 498 ||
32
28
  (!!e?.error && e.error !== error && isRejectedCredential(e.error)) ||
33
29
  (!!e?.cause && e.cause !== error && isRejectedCredential(e.cause))
34
30
  }
@@ -76,11 +72,7 @@ export function isCurrentAuthSession(store: AuthStore, token: string | null, ver
76
72
  }
77
73
 
78
74
  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 }
75
+ return !AuthHelper.isJWTValid(token ?? '', 30)
84
76
  }
85
77
 
86
78
  async function withSessionLock(action: () => Promise<void>) {
@@ -115,12 +107,25 @@ async function fetchSessionUser(store: AuthStore) {
115
107
  }
116
108
 
117
109
  export function refreshAuthSession(store = useAuthStore()): Promise<void> {
118
- if (!isRememberMeEnabled()) return Promise.resolve()
110
+
119
111
  const running = flights.get(store)
120
112
  if (running) return running
121
113
  const flight = withSessionLock(async () => {
122
114
  rereadSession(store)
123
- if (!store.refreshToken) return
115
+ const valid = AuthHelper.isJWTValid(store.accessToken ?? '')
116
+ if (!isRememberMeEnabled() || !store.refreshToken) {
117
+ if (!valid) {
118
+ if (store.accessToken || store.refreshToken || store.refreshExpiresAt || store.authUser) clearAuthSession(store)
119
+ else syncAuthHeaders(store)
120
+ } else {
121
+ // Disabling remember-me must not invalidate an otherwise live ordinary JWT.
122
+ if (!isRememberMeEnabled() && (store.refreshToken || store.refreshExpiresAt)) {
123
+ store.setAccessToken(store.accessToken!)
124
+ }
125
+ syncAuthHeaders(store)
126
+ }
127
+ return
128
+ }
124
129
  if (!needsRefresh(store.accessToken)) {
125
130
  if (!store.authUser) await fetchSessionUser(store)
126
131
  return
@@ -162,7 +167,8 @@ export async function setupAuthSession(): Promise<void> {
162
167
  }
163
168
  syncAuthHeaders(store)
164
169
  client.setBeforeRequest?.(async (url) => {
165
- if (/\/api\/auth\/(login|refresh|logout)\/?(?:[?#]|$)/.test(url)) return
170
+ if (/\/api\/auth\/(login|refresh|logout|registration-options)\/?(?:[?#]|$)/.test(url)) return
171
+ if (/\/api\/users\/(register|verification\/resend)\/?(?:[?#]|$)/.test(url)) return
166
172
  // The profile fetch uses the fresh JWT and must not await its own refresh promise.
167
173
  if (fetchingUser.has(store) && /\/api\/auth\/me\/?(?:[?#]|$)/.test(url)) return
168
174
  await refreshAuthSession(store)
@@ -81,6 +81,14 @@ export function useAuth() {
81
81
  return await authSystem.recoveryPasswordComplete(recoveryCode, newPassword)
82
82
  }
83
83
 
84
+ const registrationOptions = async () => {
85
+ return await authSystem.registrationOptions()
86
+ }
87
+
88
+ const resendVerificationEmail = async (email: string) => {
89
+ return await authSystem.resendVerificationEmail(email)
90
+ }
91
+
84
92
  const register = async (form: any) => {
85
93
  return await authSystem.register(form)
86
94
  }
@@ -147,7 +155,7 @@ export function useAuth() {
147
155
  isAuthenticated, fetchAuthUser,
148
156
  changeOwnPassword, changeAvatar, passwordPolicy,
149
157
  recoveryPasswordRequest, recoveryPasswordComplete,
150
- register, switchTenant
158
+ register, registrationOptions, resendVerificationEmail, switchTenant
151
159
  }
152
160
 
153
161
  }
package/src/index.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export {setupAuthSession, refreshAuthSession, isRememberMeEnabled} from './composables/setupAuthSession'
2
2
  export {default as IdentityRememberMe} from './components/IdentityRememberMe.vue'
3
+ export {default as IdentityVerificationResend} from './components/IdentityVerificationResend/IdentityVerificationResend.vue'
3
4
 
4
5
  import IdentityLogin from "./components/IdentityLogin/IdentityLogin.vue";
5
6
  import IdentityProfileAvatar from "./components/IdentityProfileAvatar/IdentityProfileAvatar.vue";
@@ -4,6 +4,7 @@ import type {IAuthFullUser} from "@drax/identity-front";
4
4
  //@ts-ignore
5
5
  import type from 'pinia-plugin-persistedstate'
6
6
  import {AuthHelper} from "@drax/identity-front";
7
+ import {HttpClientFactory, HttpRestClientFactory} from '@drax/common-front'
7
8
 
8
9
  export const useAuthStore = defineStore('AuthStore', {
9
10
  state: () => ({
@@ -15,8 +16,7 @@ export const useAuthStore = defineStore('AuthStore', {
15
16
  }),
16
17
  getters:{
17
18
  isAuth: (state): boolean => {
18
- //TODO verify token
19
- return !!state.accessToken
19
+ return !!state.accessToken && AuthHelper.isJWTValid(state.accessToken)
20
20
  },
21
21
  hasPermission: (state) => (permission:string) => {
22
22
  return state.authUser && state.authUser.role && state.authUser.role.permissions ? state.authUser.role.permissions.includes(permission) : false
@@ -26,6 +26,16 @@ export const useAuthStore = defineStore('AuthStore', {
26
26
  },
27
27
  },
28
28
  actions:{
29
+ syncAuthHeaders(){
30
+ const clients = new Set([HttpClientFactory.getInstance(), HttpRestClientFactory.getInstance()])
31
+ for (const client of clients) {
32
+ for (const header of Object.keys(client.baseHeaders ?? {})) {
33
+ if (header.toLowerCase() === 'authorization' && header !== 'Authorization') client.removeHeader(header)
34
+ }
35
+ if (this.accessToken && AuthHelper.isJWTValid(this.accessToken)) client.addHeader('Authorization', `Bearer ${this.accessToken}`)
36
+ else client.removeHeader('Authorization')
37
+ }
38
+ },
29
39
  persistSession(){
30
40
  // Persist synchronously so another tab holding the refresh lock sees rotation immediately.
31
41
  try {
@@ -35,6 +45,7 @@ export const useAuthStore = defineStore('AuthStore', {
35
45
  setAccessToken(accessToken:string){
36
46
  this.$patch({accessToken, refreshToken: null, refreshExpiresAt: null})
37
47
  this.persistSession()
48
+ this.syncAuthHeaders()
38
49
  },
39
50
  setSession(session: {accessToken: string, refreshToken?: string, refreshExpiresAt?: string}){
40
51
  this.$patch({
@@ -43,6 +54,7 @@ export const useAuthStore = defineStore('AuthStore', {
43
54
  refreshExpiresAt: session.refreshExpiresAt ?? null,
44
55
  })
45
56
  this.persistSession()
57
+ this.syncAuthHeaders()
46
58
  },
47
59
  setAuthUser(authUser:IAuthFullUser){
48
60
  this.authUser = authUser
@@ -55,6 +67,7 @@ export const useAuthStore = defineStore('AuthStore', {
55
67
  sessionVersion: globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random()}`,
56
68
  })
57
69
  this.persistSession()
70
+ this.syncAuthHeaders()
58
71
  }
59
72
  },
60
73