@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 +192 -1
- package/package.json +7 -7
- package/src/components/IdentityLogin/IdentityLogin.vue +4 -0
- package/src/components/IdentityRegistration/IdentityRegistration.vue +84 -8
- package/src/components/IdentityVerificationResend/IdentityVerificationResend.vue +87 -0
- package/src/components/SwitchTenant/SwitchTenant.vue +2 -2
- package/src/composables/setupAuthSession.ts +22 -16
- package/src/composables/useAuth.ts +9 -1
- package/src/index.ts +1 -0
- package/src/stores/AuthStore.ts +15 -2
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.
|
|
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.
|
|
22
|
-
"@drax/common-vue": "^3.
|
|
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.
|
|
26
|
-
"@drax/identity-front": "^3.
|
|
27
|
-
"@drax/identity-share": "^3.
|
|
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": "
|
|
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
|
-
|
|
44
|
-
|
|
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="
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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";
|
package/src/stores/AuthStore.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|