@jskit-ai/auth-web 0.1.99 → 0.1.102
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/package.descriptor.mjs +32 -5
- package/package.json +6 -5
- package/src/client/composables/loginView/useLoginViewActions.js +7 -0
- package/src/client/composables/loginView/useLoginViewState.js +50 -4
- package/src/client/composables/loginView/useLoginViewValidation.js +3 -0
- package/src/client/index.js +4 -1
- package/src/client/runtime/authCallbackUrlParams.js +56 -0
- package/src/client/runtime/authGuardRuntime.js +9 -3
- package/src/client/runtime/inject.js +3 -1
- package/src/client/runtime/oauthCallbackRuntime.js +14 -31
- package/src/client/runtime/passwordRecoveryCallbackRuntime.js +82 -0
- package/src/client/stores/useAuthStore.js +2 -0
- package/src/client/views/DefaultLoginView.vue +25 -7
- package/src/client/views/DefaultResetPasswordView.vue +214 -0
- package/src/server/routes/authRoutes.js +1 -1
- package/src/server/services/AuthWebService.js +24 -3
- package/templates/src/pages/auth/reset-password.vue +17 -0
- package/templates/src/views/auth/ResetPasswordView.vue +7 -0
- package/test/authGuardRuntime.test.js +41 -0
- package/test/clientSurface.test.js +33 -0
- package/test/index.test.js +13 -0
- package/test/passwordRecoveryCallbackRuntime.test.js +58 -0
- package/test/providerRuntime.test.js +80 -0
package/package.descriptor.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export default Object.freeze({
|
|
2
2
|
"packageVersion": 1,
|
|
3
3
|
"packageId": "@jskit-ai/auth-web",
|
|
4
|
-
"version": "0.1.
|
|
4
|
+
"version": "0.1.102",
|
|
5
5
|
"kind": "runtime",
|
|
6
6
|
"description": "Auth web module: Fastify auth routes plus web login/sign-out scaffolds.",
|
|
7
7
|
"dependsOn": [
|
|
@@ -158,6 +158,19 @@ export default Object.freeze({
|
|
|
158
158
|
},
|
|
159
159
|
"purpose": "Public sign-out route that clears session then returns to login."
|
|
160
160
|
},
|
|
161
|
+
{
|
|
162
|
+
"id": "auth.reset-password",
|
|
163
|
+
"path": "/auth/reset-password",
|
|
164
|
+
"scope": "surface",
|
|
165
|
+
"surface": "auth",
|
|
166
|
+
"name": "auth-reset-password",
|
|
167
|
+
"componentKey": "auth-reset-password",
|
|
168
|
+
"autoRegister": false,
|
|
169
|
+
"guard": {
|
|
170
|
+
"policy": "public"
|
|
171
|
+
},
|
|
172
|
+
"purpose": "Public password reset screen for recovery links."
|
|
173
|
+
},
|
|
161
174
|
{
|
|
162
175
|
"id": "auth.default-login",
|
|
163
176
|
"path": "/auth/default-login",
|
|
@@ -247,10 +260,10 @@ export default Object.freeze({
|
|
|
247
260
|
"dependencies": {
|
|
248
261
|
"runtime": {
|
|
249
262
|
"@mdi/js": "^7.4.47",
|
|
250
|
-
"@jskit-ai/auth-core": "0.1.
|
|
251
|
-
"@jskit-ai/http-runtime": "0.1.
|
|
252
|
-
"@jskit-ai/kernel": "0.1.
|
|
253
|
-
"@jskit-ai/shell-web": "0.1.
|
|
263
|
+
"@jskit-ai/auth-core": "0.1.100",
|
|
264
|
+
"@jskit-ai/http-runtime": "0.1.100",
|
|
265
|
+
"@jskit-ai/kernel": "0.1.102",
|
|
266
|
+
"@jskit-ai/shell-web": "0.1.101"
|
|
254
267
|
},
|
|
255
268
|
"dev": {}
|
|
256
269
|
},
|
|
@@ -277,6 +290,13 @@ export default Object.freeze({
|
|
|
277
290
|
"category": "auth-web",
|
|
278
291
|
"id": "auth-view-signout"
|
|
279
292
|
},
|
|
293
|
+
{
|
|
294
|
+
"from": "templates/src/views/auth/ResetPasswordView.vue",
|
|
295
|
+
"to": "src/views/auth/ResetPasswordView.vue",
|
|
296
|
+
"reason": "Install minimal password reset container that renders the module-provided DefaultResetPasswordView by default.",
|
|
297
|
+
"category": "auth-web",
|
|
298
|
+
"id": "auth-view-reset-password"
|
|
299
|
+
},
|
|
280
300
|
{
|
|
281
301
|
"from": "templates/src/pages/auth/login.vue",
|
|
282
302
|
"to": "src/pages/auth/login.vue",
|
|
@@ -290,6 +310,13 @@ export default Object.freeze({
|
|
|
290
310
|
"reason": "Provide an auth-surface /auth/signout wrapper that renders the package sign-out view.",
|
|
291
311
|
"category": "auth-web",
|
|
292
312
|
"id": "auth-page-signout"
|
|
313
|
+
},
|
|
314
|
+
{
|
|
315
|
+
"from": "templates/src/pages/auth/reset-password.vue",
|
|
316
|
+
"to": "src/pages/auth/reset-password.vue",
|
|
317
|
+
"reason": "Provide an auth-surface /auth/reset-password wrapper that renders the package password reset view.",
|
|
318
|
+
"category": "auth-web",
|
|
319
|
+
"id": "auth-page-reset-password"
|
|
293
320
|
}
|
|
294
321
|
],
|
|
295
322
|
"text": [
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jskit-ai/auth-web",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.102",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"test": "node --test"
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
"./server/routes/authRoutes": "./src/server/routes/authRoutes.js",
|
|
12
12
|
"./client": "./src/client/index.js",
|
|
13
13
|
"./client/views/DefaultLoginView": "./src/client/views/DefaultLoginView.vue",
|
|
14
|
+
"./client/views/DefaultResetPasswordView": "./src/client/views/DefaultResetPasswordView.vue",
|
|
14
15
|
"./client/views/DefaultSignOutView": "./src/client/views/DefaultSignOutView.vue",
|
|
15
16
|
"./client/runtime/authGuardRuntime": "./src/client/runtime/authGuardRuntime.js",
|
|
16
17
|
"./client/runtime/authHttpClient": "./src/client/runtime/authHttpClient.js",
|
|
@@ -18,11 +19,11 @@
|
|
|
18
19
|
"./client/runtime/useSignOut": "./src/client/runtime/useSignOut.js"
|
|
19
20
|
},
|
|
20
21
|
"dependencies": {
|
|
21
|
-
"@jskit-ai/auth-core": "0.1.
|
|
22
|
+
"@jskit-ai/auth-core": "0.1.100",
|
|
22
23
|
"@mdi/js": "^7.4.47",
|
|
23
|
-
"@jskit-ai/kernel": "0.1.
|
|
24
|
-
"@jskit-ai/shell-web": "0.1.
|
|
25
|
-
"@jskit-ai/http-runtime": "0.1.
|
|
24
|
+
"@jskit-ai/kernel": "0.1.102",
|
|
25
|
+
"@jskit-ai/shell-web": "0.1.101",
|
|
26
|
+
"@jskit-ai/http-runtime": "0.1.100"
|
|
26
27
|
},
|
|
27
28
|
"peerDependencies": {
|
|
28
29
|
"@tanstack/vue-query": "^5.90.5",
|
|
@@ -89,6 +89,13 @@ export function useLoginViewActions({
|
|
|
89
89
|
}
|
|
90
90
|
|
|
91
91
|
function applySessionPayload(payload) {
|
|
92
|
+
if (typeof state.applyCapabilities === "function") {
|
|
93
|
+
state.applyCapabilities(payload || {});
|
|
94
|
+
}
|
|
95
|
+
if (payload?.authCapabilities || payload?.capabilities) {
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
92
99
|
state.oauthProviders.value = Array.isArray(payload?.oauthProviders)
|
|
93
100
|
? payload.oauthProviders
|
|
94
101
|
.map((provider) => {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { computed, ref } from "vue";
|
|
2
|
+
import { normalizeAuthCapabilities } from "@jskit-ai/auth-core/shared/authCapabilities";
|
|
2
3
|
import {
|
|
3
4
|
resolveSurfaceIdFromPlacementPathname,
|
|
4
5
|
resolveSurfaceRootPathFromPlacementContext
|
|
@@ -40,6 +41,7 @@ export function useLoginViewState({ placementContext } = {}) {
|
|
|
40
41
|
const rememberAccountOnDevice = ref(true);
|
|
41
42
|
const rememberedAccount = ref(null);
|
|
42
43
|
const useRememberedAccount = ref(false);
|
|
44
|
+
const authCapabilities = ref(normalizeAuthCapabilities());
|
|
43
45
|
const oauthProviders = ref([]);
|
|
44
46
|
const oauthDefaultProvider = ref("");
|
|
45
47
|
const loading = ref(false);
|
|
@@ -55,6 +57,14 @@ export function useLoginViewState({ placementContext } = {}) {
|
|
|
55
57
|
const isForgot = computed(() => mode.value === FORGOT_MODE);
|
|
56
58
|
const isOtp = computed(() => mode.value === OTP_MODE);
|
|
57
59
|
const isEmailConfirmationPending = computed(() => mode.value === EMAIL_CONFIRMATION_MODE);
|
|
60
|
+
const canUsePasswordLogin = computed(() => authCapabilities.value.features.password.login === true);
|
|
61
|
+
const canRegister = computed(() => authCapabilities.value.features.password.register === true);
|
|
62
|
+
const canRequestPasswordRecovery = computed(
|
|
63
|
+
() => authCapabilities.value.features.passwordRecovery.request === true
|
|
64
|
+
);
|
|
65
|
+
const canUseOtp = computed(() => authCapabilities.value.features.otp.login === true);
|
|
66
|
+
const canUseOAuth = computed(() => authCapabilities.value.features.oauthLogin.enabled === true);
|
|
67
|
+
const canResendEmailConfirmation = computed(() => authCapabilities.value.features.emailConfirmation === true);
|
|
58
68
|
const showRememberedAccount = computed(
|
|
59
69
|
() => (isLogin.value || isOtp.value) && useRememberedAccount.value && Boolean(rememberedAccount.value)
|
|
60
70
|
);
|
|
@@ -160,6 +170,25 @@ export function useLoginViewState({ placementContext } = {}) {
|
|
|
160
170
|
clearRememberedAccountState();
|
|
161
171
|
}
|
|
162
172
|
|
|
173
|
+
function applyCapabilities(payload = {}) {
|
|
174
|
+
authCapabilities.value = normalizeAuthCapabilities(payload.authCapabilities || payload.capabilities || {});
|
|
175
|
+
const oauth = authCapabilities.value.features.oauthLogin;
|
|
176
|
+
if (oauth.enabled) {
|
|
177
|
+
oauthProviders.value = oauth.providers;
|
|
178
|
+
oauthDefaultProvider.value = oauth.defaultProvider || "";
|
|
179
|
+
} else {
|
|
180
|
+
oauthProviders.value = [];
|
|
181
|
+
oauthDefaultProvider.value = "";
|
|
182
|
+
}
|
|
183
|
+
if (
|
|
184
|
+
(mode.value === REGISTER_MODE && !canRegister.value) ||
|
|
185
|
+
(mode.value === FORGOT_MODE && !canRequestPasswordRecovery.value) ||
|
|
186
|
+
(mode.value === OTP_MODE && !canUseOtp.value)
|
|
187
|
+
) {
|
|
188
|
+
switchMode(LOGIN_MODE);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
163
192
|
function switchAccount() {
|
|
164
193
|
clearRememberedAccountHint();
|
|
165
194
|
clearRememberedAccountState();
|
|
@@ -172,21 +201,30 @@ export function useLoginViewState({ placementContext } = {}) {
|
|
|
172
201
|
}
|
|
173
202
|
|
|
174
203
|
function switchMode(nextMode) {
|
|
175
|
-
|
|
204
|
+
let resolvedMode = nextMode;
|
|
205
|
+
if (resolvedMode === REGISTER_MODE && !canRegister.value) {
|
|
206
|
+
resolvedMode = LOGIN_MODE;
|
|
207
|
+
} else if (resolvedMode === FORGOT_MODE && !canRequestPasswordRecovery.value) {
|
|
208
|
+
resolvedMode = LOGIN_MODE;
|
|
209
|
+
} else if (resolvedMode === OTP_MODE && !canUseOtp.value) {
|
|
210
|
+
resolvedMode = LOGIN_MODE;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (resolvedMode === mode.value) {
|
|
176
214
|
return;
|
|
177
215
|
}
|
|
178
216
|
|
|
179
|
-
mode.value =
|
|
217
|
+
mode.value = resolvedMode;
|
|
180
218
|
resetCredentialFields();
|
|
181
219
|
registerConfirmationResendPending.value = false;
|
|
182
|
-
if (
|
|
220
|
+
if (resolvedMode !== EMAIL_CONFIRMATION_MODE) {
|
|
183
221
|
pendingEmailConfirmationAddress.value = "";
|
|
184
222
|
pendingEmailConfirmationMessage.value = "";
|
|
185
223
|
}
|
|
186
224
|
clearTransientMessages();
|
|
187
225
|
resetTransientValidationState();
|
|
188
226
|
|
|
189
|
-
if (
|
|
227
|
+
if (resolvedMode !== LOGIN_MODE && resolvedMode !== OTP_MODE) {
|
|
190
228
|
useRememberedAccount.value = false;
|
|
191
229
|
return;
|
|
192
230
|
}
|
|
@@ -225,6 +263,7 @@ export function useLoginViewState({ placementContext } = {}) {
|
|
|
225
263
|
rememberAccountOnDevice,
|
|
226
264
|
rememberedAccount,
|
|
227
265
|
useRememberedAccount,
|
|
266
|
+
authCapabilities,
|
|
228
267
|
oauthProviders,
|
|
229
268
|
oauthDefaultProvider,
|
|
230
269
|
loading,
|
|
@@ -239,6 +278,12 @@ export function useLoginViewState({ placementContext } = {}) {
|
|
|
239
278
|
isForgot,
|
|
240
279
|
isOtp,
|
|
241
280
|
isEmailConfirmationPending,
|
|
281
|
+
canUsePasswordLogin,
|
|
282
|
+
canRegister,
|
|
283
|
+
canRequestPasswordRecovery,
|
|
284
|
+
canUseOtp,
|
|
285
|
+
canUseOAuth,
|
|
286
|
+
canResendEmailConfirmation,
|
|
242
287
|
showRememberedAccount,
|
|
243
288
|
rememberedAccountDisplayName,
|
|
244
289
|
rememberedAccountMaskedEmail,
|
|
@@ -253,6 +298,7 @@ export function useLoginViewState({ placementContext } = {}) {
|
|
|
253
298
|
resolveNormalizedEmail,
|
|
254
299
|
applyRememberedAccountHint,
|
|
255
300
|
applyRememberedAccountPreference,
|
|
301
|
+
applyCapabilities,
|
|
256
302
|
clearTransientMessages,
|
|
257
303
|
switchMode,
|
|
258
304
|
switchAccount,
|
|
@@ -102,6 +102,9 @@ export function useLoginViewValidation({ state } = {}) {
|
|
|
102
102
|
if (state.loading.value) {
|
|
103
103
|
return false;
|
|
104
104
|
}
|
|
105
|
+
if (state.isLogin.value && !state.canUsePasswordLogin.value) {
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
105
108
|
if (emailErrorMessages.value.length > 0) {
|
|
106
109
|
return false;
|
|
107
110
|
}
|
package/src/client/index.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { AuthWebClientProvider } from "./providers/AuthWebClientProvider.js";
|
|
2
2
|
import DefaultLoginView from "./views/DefaultLoginView.vue";
|
|
3
3
|
import DefaultSignOutView from "./views/DefaultSignOutView.vue";
|
|
4
|
+
import DefaultResetPasswordView from "./views/DefaultResetPasswordView.vue";
|
|
4
5
|
|
|
5
6
|
export { AuthWebClientProvider } from "./providers/AuthWebClientProvider.js";
|
|
6
7
|
export { default as DefaultLoginView } from "./views/DefaultLoginView.vue";
|
|
7
8
|
export { default as DefaultSignOutView } from "./views/DefaultSignOutView.vue";
|
|
9
|
+
export { default as DefaultResetPasswordView } from "./views/DefaultResetPasswordView.vue";
|
|
8
10
|
export { default as AuthProfileWidget } from "./views/AuthProfileWidget.vue";
|
|
9
11
|
export { default as AuthProfileMenuLinkItem } from "./views/AuthProfileMenuLinkItem.vue";
|
|
10
12
|
export { useAuthStore } from "./stores/useAuthStore.js";
|
|
@@ -18,7 +20,8 @@ export {
|
|
|
18
20
|
const routeComponents = Object.freeze({
|
|
19
21
|
"auth-login": DefaultLoginView,
|
|
20
22
|
"auth-signout": DefaultSignOutView,
|
|
21
|
-
"auth-default-login": DefaultLoginView
|
|
23
|
+
"auth-default-login": DefaultLoginView,
|
|
24
|
+
"auth-reset-password": DefaultResetPasswordView
|
|
22
25
|
});
|
|
23
26
|
|
|
24
27
|
const clientProviders = Object.freeze([AuthWebClientProvider]);
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
const AUTH_CALLBACK_URL_BASE = "https://jskit.invalid";
|
|
2
|
+
|
|
3
|
+
function readAuthCallbackUrlParams(url = "") {
|
|
4
|
+
const normalizedUrl = String(url || "").trim();
|
|
5
|
+
if (!normalizedUrl) {
|
|
6
|
+
return null;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
try {
|
|
10
|
+
const parsedUrl = new URL(normalizedUrl, AUTH_CALLBACK_URL_BASE);
|
|
11
|
+
return {
|
|
12
|
+
parsedUrl,
|
|
13
|
+
searchParams: new URLSearchParams(parsedUrl.search || ""),
|
|
14
|
+
hashParams: new URLSearchParams(String(parsedUrl.hash || "").replace(/^#/, ""))
|
|
15
|
+
};
|
|
16
|
+
} catch {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function readAuthCallbackParam(callbackUrlParams, key) {
|
|
22
|
+
const normalizedKey = String(key || "").trim();
|
|
23
|
+
if (!callbackUrlParams || !normalizedKey) {
|
|
24
|
+
return "";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return String(
|
|
28
|
+
callbackUrlParams.searchParams.get(normalizedKey) ||
|
|
29
|
+
callbackUrlParams.hashParams.get(normalizedKey) ||
|
|
30
|
+
""
|
|
31
|
+
).trim();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function stripAuthCallbackParamsFromUrl(url = "", keys = []) {
|
|
35
|
+
const callbackUrlParams = readAuthCallbackUrlParams(url);
|
|
36
|
+
if (!callbackUrlParams) {
|
|
37
|
+
return "";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const normalizedKeys = Array.isArray(keys)
|
|
41
|
+
? keys.map((key) => String(key || "").trim()).filter(Boolean)
|
|
42
|
+
: [];
|
|
43
|
+
normalizedKeys.forEach((key) => {
|
|
44
|
+
callbackUrlParams.parsedUrl.searchParams.delete(key);
|
|
45
|
+
callbackUrlParams.hashParams.delete(key);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const nextHash = callbackUrlParams.hashParams.toString();
|
|
49
|
+
return `${callbackUrlParams.parsedUrl.pathname}${callbackUrlParams.parsedUrl.search}${nextHash ? `#${nextHash}` : ""}`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export {
|
|
53
|
+
readAuthCallbackParam,
|
|
54
|
+
readAuthCallbackUrlParams,
|
|
55
|
+
stripAuthCallbackParamsFromUrl
|
|
56
|
+
};
|
|
@@ -4,11 +4,13 @@ import { isExternalLinkTarget } from "@jskit-ai/kernel/shared/support/linkPath";
|
|
|
4
4
|
import { normalizePathname as normalizeSurfacePathname } from "@jskit-ai/kernel/shared/surface/paths";
|
|
5
5
|
import { createListenerSubscription } from "@jskit-ai/kernel/shared/support/listenerSet";
|
|
6
6
|
import { normalizePermissionList } from "@jskit-ai/kernel/shared/support/permissions";
|
|
7
|
+
import { normalizeAuthCapabilities } from "@jskit-ai/auth-core/shared/authCapabilities";
|
|
7
8
|
|
|
8
9
|
const GLOBAL_GUARD_EVALUATOR_KEY = "__JSKIT_WEB_SHELL_GUARD_EVALUATOR__";
|
|
9
10
|
const AUTH_POLICY_AUTHENTICATED = "authenticated";
|
|
10
11
|
const DEFAULT_SESSION_PATH = AUTH_PATHS.SESSION;
|
|
11
12
|
const DEFAULT_LOGIN_ROUTE = "/auth/login";
|
|
13
|
+
const DEFAULT_RESET_PASSWORD_ROUTE = "/auth/reset-password";
|
|
12
14
|
const DEFAULT_REFRESH_ON_FOREGROUND = false;
|
|
13
15
|
const DEFAULT_REFRESH_ON_RECONNECT = false;
|
|
14
16
|
const DEFAULT_REALTIME_REFRESH_EVENTS = Object.freeze(["users.bootstrap.changed", "auth.session.changed"]);
|
|
@@ -38,7 +40,8 @@ const DEFAULT_AUTH_STATE = Object.freeze({
|
|
|
38
40
|
email: "",
|
|
39
41
|
permissions: Object.freeze([]),
|
|
40
42
|
oauthDefaultProvider: "",
|
|
41
|
-
oauthProviders: Object.freeze([])
|
|
43
|
+
oauthProviders: Object.freeze([]),
|
|
44
|
+
authCapabilities: normalizeAuthCapabilities()
|
|
42
45
|
});
|
|
43
46
|
|
|
44
47
|
function asGlobalObject() {
|
|
@@ -107,6 +110,7 @@ function normalizeAuthState(payload = {}) {
|
|
|
107
110
|
const username = authenticated ? String(payload.username || "").trim() : "";
|
|
108
111
|
const email = authenticated ? String(payload.email || "").trim().toLowerCase() : "";
|
|
109
112
|
const permissions = authenticated ? Object.freeze(normalizePermissionList(payload.permissions)) : Object.freeze([]);
|
|
113
|
+
const authCapabilities = normalizeAuthCapabilities(payload.authCapabilities || payload.capabilities || {});
|
|
110
114
|
|
|
111
115
|
return Object.freeze({
|
|
112
116
|
authenticated,
|
|
@@ -114,7 +118,8 @@ function normalizeAuthState(payload = {}) {
|
|
|
114
118
|
email,
|
|
115
119
|
permissions,
|
|
116
120
|
oauthDefaultProvider,
|
|
117
|
-
oauthProviders
|
|
121
|
+
oauthProviders,
|
|
122
|
+
authCapabilities
|
|
118
123
|
});
|
|
119
124
|
}
|
|
120
125
|
|
|
@@ -307,7 +312,8 @@ function redirectOAuthCallbackToLoginRoute(loginRoute) {
|
|
|
307
312
|
|
|
308
313
|
const currentPathname = normalizePathname(window.location.pathname || "", "/");
|
|
309
314
|
const loginPathname = normalizePathname(normalizedLoginRoute, DEFAULT_LOGIN_ROUTE);
|
|
310
|
-
|
|
315
|
+
const resetPasswordPathname = normalizePathname(DEFAULT_RESET_PASSWORD_ROUTE, DEFAULT_RESET_PASSWORD_ROUTE);
|
|
316
|
+
if (currentPathname === loginPathname || currentPathname === resetPasswordPathname) {
|
|
311
317
|
return false;
|
|
312
318
|
}
|
|
313
319
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { inject } from "vue";
|
|
2
|
+
import { normalizeAuthCapabilities } from "@jskit-ai/auth-core/shared/authCapabilities";
|
|
2
3
|
import { isAuthGuardRuntime } from "./authGuardRuntime.js";
|
|
3
4
|
import { createBrowserOAuthLaunchClient, isAuthOAuthLaunchClient } from "./oauthLaunchClient.js";
|
|
4
5
|
|
|
@@ -9,7 +10,8 @@ const EMPTY_AUTH_GUARD_STATE = Object.freeze({
|
|
|
9
10
|
authenticated: false,
|
|
10
11
|
username: "",
|
|
11
12
|
oauthDefaultProvider: "",
|
|
12
|
-
oauthProviders: Object.freeze([])
|
|
13
|
+
oauthProviders: Object.freeze([]),
|
|
14
|
+
authCapabilities: normalizeAuthCapabilities()
|
|
13
15
|
});
|
|
14
16
|
|
|
15
17
|
const EMPTY_AUTH_GUARD_RUNTIME = Object.freeze({
|
|
@@ -7,46 +7,29 @@ import {
|
|
|
7
7
|
import { AUTH_PATHS } from "@jskit-ai/auth-core/shared/authPaths";
|
|
8
8
|
import { normalizeAuthReturnToPath } from "../lib/returnToPath.js";
|
|
9
9
|
import { authHttpRequest } from "./authHttpClient.js";
|
|
10
|
+
import {
|
|
11
|
+
readAuthCallbackParam,
|
|
12
|
+
readAuthCallbackUrlParams
|
|
13
|
+
} from "./authCallbackUrlParams.js";
|
|
10
14
|
import { ensureCommandSectionValid } from "../composables/loginView/validationHelpers.js";
|
|
11
15
|
|
|
12
|
-
function parseCallbackUrl(url = "") {
|
|
13
|
-
const normalizedUrl = String(url || "").trim();
|
|
14
|
-
if (!normalizedUrl) {
|
|
15
|
-
return null;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
try {
|
|
19
|
-
return new URL(normalizedUrl, "https://jskit.invalid");
|
|
20
|
-
} catch {
|
|
21
|
-
return null;
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
|
|
25
16
|
function readOAuthCallbackParamsFromUrl(url = "") {
|
|
26
|
-
const
|
|
27
|
-
if (!
|
|
17
|
+
const callbackUrlParams = readAuthCallbackUrlParams(url);
|
|
18
|
+
if (!callbackUrlParams) {
|
|
28
19
|
return null;
|
|
29
20
|
}
|
|
30
21
|
|
|
31
|
-
const searchParams =
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
const
|
|
35
|
-
const accessToken = String(searchParams.get("access_token") || hashParams.get("access_token") || "").trim();
|
|
36
|
-
const refreshToken = String(searchParams.get("refresh_token") || hashParams.get("refresh_token") || "").trim();
|
|
22
|
+
const { searchParams } = callbackUrlParams;
|
|
23
|
+
const code = readAuthCallbackParam(callbackUrlParams, "code");
|
|
24
|
+
const accessToken = readAuthCallbackParam(callbackUrlParams, "access_token");
|
|
25
|
+
const refreshToken = readAuthCallbackParam(callbackUrlParams, "refresh_token");
|
|
37
26
|
const errorCode = String(
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
searchParams.get("errorCode") ||
|
|
41
|
-
hashParams.get("errorCode") ||
|
|
42
|
-
""
|
|
27
|
+
readAuthCallbackParam(callbackUrlParams, "error") ||
|
|
28
|
+
readAuthCallbackParam(callbackUrlParams, "errorCode")
|
|
43
29
|
).trim();
|
|
44
30
|
const errorDescription = String(
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
searchParams.get("errorDescription") ||
|
|
48
|
-
hashParams.get("errorDescription") ||
|
|
49
|
-
""
|
|
31
|
+
readAuthCallbackParam(callbackUrlParams, "error_description") ||
|
|
32
|
+
readAuthCallbackParam(callbackUrlParams, "errorDescription")
|
|
50
33
|
).trim();
|
|
51
34
|
const hasSessionPair = Boolean(accessToken && refreshToken);
|
|
52
35
|
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import {
|
|
2
|
+
readAuthCallbackParam,
|
|
3
|
+
readAuthCallbackUrlParams,
|
|
4
|
+
stripAuthCallbackParamsFromUrl
|
|
5
|
+
} from "./authCallbackUrlParams.js";
|
|
6
|
+
|
|
7
|
+
const PASSWORD_RECOVERY_CALLBACK_PARAM_KEYS = Object.freeze([
|
|
8
|
+
"token",
|
|
9
|
+
"code",
|
|
10
|
+
"token_hash",
|
|
11
|
+
"access_token",
|
|
12
|
+
"refresh_token",
|
|
13
|
+
"expires_in",
|
|
14
|
+
"expires_at",
|
|
15
|
+
"token_type",
|
|
16
|
+
"type"
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
function readPasswordRecoveryCallbackPayloadFromUrl(url = "") {
|
|
20
|
+
const callbackUrlParams = readAuthCallbackUrlParams(url);
|
|
21
|
+
if (!callbackUrlParams) {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const accessToken = readAuthCallbackParam(callbackUrlParams, "access_token");
|
|
26
|
+
const refreshToken = readAuthCallbackParam(callbackUrlParams, "refresh_token");
|
|
27
|
+
const tokenHash = readAuthCallbackParam(callbackUrlParams, "token_hash");
|
|
28
|
+
const code =
|
|
29
|
+
readAuthCallbackParam(callbackUrlParams, "token") ||
|
|
30
|
+
readAuthCallbackParam(callbackUrlParams, "code");
|
|
31
|
+
|
|
32
|
+
if (accessToken && refreshToken) {
|
|
33
|
+
return Object.freeze({
|
|
34
|
+
accessToken,
|
|
35
|
+
refreshToken,
|
|
36
|
+
type: "recovery"
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
if (tokenHash) {
|
|
40
|
+
return Object.freeze({
|
|
41
|
+
tokenHash,
|
|
42
|
+
type: "recovery"
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
if (code) {
|
|
46
|
+
return Object.freeze({
|
|
47
|
+
code,
|
|
48
|
+
type: "recovery"
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
if (accessToken || refreshToken) {
|
|
52
|
+
return Object.freeze({
|
|
53
|
+
accessToken,
|
|
54
|
+
refreshToken,
|
|
55
|
+
type: "recovery"
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function stripPasswordRecoveryCallbackParamsFromUrl(url = "") {
|
|
62
|
+
return stripAuthCallbackParamsFromUrl(url, PASSWORD_RECOVERY_CALLBACK_PARAM_KEYS);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function stripPasswordRecoveryCallbackParamsFromLocation() {
|
|
66
|
+
if (typeof window !== "object" || !window.location || !window.history) {
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
window.history.replaceState(
|
|
71
|
+
{},
|
|
72
|
+
"",
|
|
73
|
+
stripPasswordRecoveryCallbackParamsFromUrl(window.location.href)
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export {
|
|
78
|
+
PASSWORD_RECOVERY_CALLBACK_PARAM_KEYS,
|
|
79
|
+
readPasswordRecoveryCallbackPayloadFromUrl,
|
|
80
|
+
stripPasswordRecoveryCallbackParamsFromLocation,
|
|
81
|
+
stripPasswordRecoveryCallbackParamsFromUrl
|
|
82
|
+
};
|
|
@@ -68,6 +68,7 @@ export const useAuthStore = defineStore("jskit.auth-web.auth", () => {
|
|
|
68
68
|
const username = computed(() => String(authState.value.username || ""));
|
|
69
69
|
const oauthProviders = computed(() => authState.value.oauthProviders || EMPTY_AUTH_GUARD_STATE.oauthProviders);
|
|
70
70
|
const oauthDefaultProvider = computed(() => String(authState.value.oauthDefaultProvider || ""));
|
|
71
|
+
const authCapabilities = computed(() => authState.value.authCapabilities || EMPTY_AUTH_GUARD_STATE.authCapabilities);
|
|
71
72
|
|
|
72
73
|
return {
|
|
73
74
|
runtime,
|
|
@@ -76,6 +77,7 @@ export const useAuthStore = defineStore("jskit.auth-web.auth", () => {
|
|
|
76
77
|
username,
|
|
77
78
|
oauthProviders,
|
|
78
79
|
oauthDefaultProvider,
|
|
80
|
+
authCapabilities,
|
|
79
81
|
attachRuntime,
|
|
80
82
|
initialize,
|
|
81
83
|
refresh,
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
Sign in
|
|
36
36
|
</v-btn>
|
|
37
37
|
<v-btn
|
|
38
|
-
v-if="!showRememberedAccount"
|
|
38
|
+
v-if="!showRememberedAccount && canRegister"
|
|
39
39
|
data-testid="auth-mode-register"
|
|
40
40
|
class="text-none"
|
|
41
41
|
:variant="isRegister ? 'flat' : 'text'"
|
|
@@ -59,6 +59,7 @@
|
|
|
59
59
|
Go to main screen
|
|
60
60
|
</v-btn>
|
|
61
61
|
<v-btn
|
|
62
|
+
v-if="canResendEmailConfirmation"
|
|
62
63
|
class="text-none"
|
|
63
64
|
variant="outlined"
|
|
64
65
|
color="secondary"
|
|
@@ -86,6 +87,7 @@
|
|
|
86
87
|
</div>
|
|
87
88
|
|
|
88
89
|
<v-text-field
|
|
90
|
+
v-if="!isLogin || canUsePasswordLogin"
|
|
89
91
|
v-model="email"
|
|
90
92
|
label="Email"
|
|
91
93
|
variant="outlined"
|
|
@@ -98,7 +100,7 @@
|
|
|
98
100
|
/>
|
|
99
101
|
|
|
100
102
|
<v-text-field
|
|
101
|
-
v-if="
|
|
103
|
+
v-if="(isLogin && canUsePasswordLogin) || isRegister"
|
|
102
104
|
v-model="password"
|
|
103
105
|
label="Password"
|
|
104
106
|
:type="showPassword ? 'text' : 'password'"
|
|
@@ -139,13 +141,22 @@
|
|
|
139
141
|
class="mb-3"
|
|
140
142
|
/>
|
|
141
143
|
|
|
142
|
-
<div v-if="isLogin" class="aux-links d-flex justify-end mb-4">
|
|
143
|
-
<v-btn
|
|
144
|
-
|
|
144
|
+
<div v-if="isLogin && (canRequestPasswordRecovery || canUseOtp)" class="aux-links d-flex justify-end mb-4">
|
|
145
|
+
<v-btn
|
|
146
|
+
v-if="canRequestPasswordRecovery"
|
|
147
|
+
variant="text"
|
|
148
|
+
color="secondary"
|
|
149
|
+
@click="switchMode('forgot')"
|
|
150
|
+
>
|
|
151
|
+
Forgot password?
|
|
152
|
+
</v-btn>
|
|
153
|
+
<v-btn v-if="canUseOtp" variant="text" color="secondary" @click="switchMode('otp')">
|
|
154
|
+
Use one-time code
|
|
155
|
+
</v-btn>
|
|
145
156
|
</div>
|
|
146
157
|
|
|
147
158
|
<v-checkbox
|
|
148
|
-
v-if="isLogin || isOtp"
|
|
159
|
+
v-if="(isLogin && canUsePasswordLogin) || isOtp"
|
|
149
160
|
v-model="rememberAccountOnDevice"
|
|
150
161
|
label="Remember this account on this device"
|
|
151
162
|
density="compact"
|
|
@@ -165,7 +176,7 @@
|
|
|
165
176
|
</v-btn>
|
|
166
177
|
</div>
|
|
167
178
|
|
|
168
|
-
<div v-if="isLogin || isRegister" class="oauth-actions d-grid ga-2 mb-4">
|
|
179
|
+
<div v-if="canUseOAuth && (isLogin || isRegister)" class="oauth-actions d-grid ga-2 mb-4">
|
|
169
180
|
<v-btn
|
|
170
181
|
v-for="provider in oauthProviders"
|
|
171
182
|
:key="provider.id"
|
|
@@ -181,6 +192,7 @@
|
|
|
181
192
|
</v-btn>
|
|
182
193
|
</div>
|
|
183
194
|
<v-btn
|
|
195
|
+
v-if="!isLogin || canUsePasswordLogin"
|
|
184
196
|
data-testid="auth-submit"
|
|
185
197
|
block
|
|
186
198
|
color="primary"
|
|
@@ -251,6 +263,12 @@ const {
|
|
|
251
263
|
isLogin,
|
|
252
264
|
isRegister,
|
|
253
265
|
isEmailConfirmationPending,
|
|
266
|
+
canUsePasswordLogin,
|
|
267
|
+
canRegister,
|
|
268
|
+
canRequestPasswordRecovery,
|
|
269
|
+
canUseOtp,
|
|
270
|
+
canUseOAuth,
|
|
271
|
+
canResendEmailConfirmation,
|
|
254
272
|
emailConfirmationMessage,
|
|
255
273
|
showRememberedAccount,
|
|
256
274
|
switchMode,
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="reset-screen" :class="{ 'reset-screen--mobile': isMobileViewport }">
|
|
3
|
+
<v-container
|
|
4
|
+
fluid
|
|
5
|
+
class="reset-shell fill-height d-flex"
|
|
6
|
+
:class="[
|
|
7
|
+
isMobileViewport
|
|
8
|
+
? 'reset-shell--mobile align-stretch justify-stretch pa-0'
|
|
9
|
+
: 'align-center justify-center py-8'
|
|
10
|
+
]"
|
|
11
|
+
>
|
|
12
|
+
<v-card
|
|
13
|
+
class="reset-card"
|
|
14
|
+
:class="{ 'reset-card--mobile': isMobileViewport }"
|
|
15
|
+
:rounded="isMobileViewport ? false : 'lg'"
|
|
16
|
+
:elevation="isMobileViewport ? 0 : 1"
|
|
17
|
+
:border="!isMobileViewport"
|
|
18
|
+
>
|
|
19
|
+
<v-card-text class="reset-content" :class="{ 'reset-content--mobile': isMobileViewport }">
|
|
20
|
+
<div class="reset-header mb-5">
|
|
21
|
+
<h1 class="reset-title">Reset password</h1>
|
|
22
|
+
</div>
|
|
23
|
+
|
|
24
|
+
<v-alert v-if="errorMessage" type="error" variant="tonal" class="mb-4">
|
|
25
|
+
{{ errorMessage }}
|
|
26
|
+
</v-alert>
|
|
27
|
+
|
|
28
|
+
<v-alert v-if="status === 'done'" type="success" variant="tonal" class="mb-4">
|
|
29
|
+
Password updated. Sign in with your new password.
|
|
30
|
+
</v-alert>
|
|
31
|
+
|
|
32
|
+
<v-progress-linear v-if="status === 'checking'" indeterminate color="primary" class="mb-4" />
|
|
33
|
+
|
|
34
|
+
<v-form v-if="status === 'ready'" @submit.prevent="submitReset" novalidate>
|
|
35
|
+
<v-text-field
|
|
36
|
+
v-model="password"
|
|
37
|
+
label="New password"
|
|
38
|
+
:type="showPassword ? 'text' : 'password'"
|
|
39
|
+
variant="outlined"
|
|
40
|
+
density="comfortable"
|
|
41
|
+
autocomplete="new-password"
|
|
42
|
+
:append-inner-icon="showPassword ? mdiEyeOff : mdiEye"
|
|
43
|
+
:error-messages="passwordError"
|
|
44
|
+
class="mb-3"
|
|
45
|
+
@click:append-inner="showPassword = !showPassword"
|
|
46
|
+
/>
|
|
47
|
+
<v-text-field
|
|
48
|
+
v-model="confirmPassword"
|
|
49
|
+
label="Confirm password"
|
|
50
|
+
:type="showConfirmPassword ? 'text' : 'password'"
|
|
51
|
+
variant="outlined"
|
|
52
|
+
density="comfortable"
|
|
53
|
+
autocomplete="new-password"
|
|
54
|
+
:append-inner-icon="showConfirmPassword ? mdiEyeOff : mdiEye"
|
|
55
|
+
:error-messages="confirmPasswordError"
|
|
56
|
+
class="mb-4"
|
|
57
|
+
@click:append-inner="showConfirmPassword = !showConfirmPassword"
|
|
58
|
+
/>
|
|
59
|
+
<v-btn block color="primary" size="large" type="submit" :loading="loading" :disabled="!canSubmit">
|
|
60
|
+
Update password
|
|
61
|
+
</v-btn>
|
|
62
|
+
</v-form>
|
|
63
|
+
|
|
64
|
+
<div v-if="status === 'done' || status === 'error'" class="mt-4 d-flex justify-end">
|
|
65
|
+
<v-btn variant="text" color="secondary" href="/auth/login">Back to sign in</v-btn>
|
|
66
|
+
</div>
|
|
67
|
+
</v-card-text>
|
|
68
|
+
</v-card>
|
|
69
|
+
</v-container>
|
|
70
|
+
</div>
|
|
71
|
+
</template>
|
|
72
|
+
|
|
73
|
+
<script setup>
|
|
74
|
+
import { computed, onMounted, ref } from "vue";
|
|
75
|
+
import { mdiEye, mdiEyeOff } from "@mdi/js";
|
|
76
|
+
import { useDisplay } from "vuetify";
|
|
77
|
+
import { AUTH_PATHS } from "@jskit-ai/auth-core/shared/authPaths";
|
|
78
|
+
import { authPasswordResetCommand } from "@jskit-ai/auth-core/shared/commands/authPasswordResetCommand";
|
|
79
|
+
import { authHttpRequest } from "../runtime/authHttpClient.js";
|
|
80
|
+
import {
|
|
81
|
+
readPasswordRecoveryCallbackPayloadFromUrl,
|
|
82
|
+
stripPasswordRecoveryCallbackParamsFromLocation
|
|
83
|
+
} from "../runtime/passwordRecoveryCallbackRuntime.js";
|
|
84
|
+
import {
|
|
85
|
+
validateCommandSection,
|
|
86
|
+
resolveFieldValidationMessage
|
|
87
|
+
} from "../composables/loginView/validationHelpers.js";
|
|
88
|
+
|
|
89
|
+
const { mobile: isMobileViewport } = useDisplay({ mobileBreakpoint: 960 });
|
|
90
|
+
const status = ref("checking");
|
|
91
|
+
const loading = ref(false);
|
|
92
|
+
const errorMessage = ref("");
|
|
93
|
+
const password = ref("");
|
|
94
|
+
const confirmPassword = ref("");
|
|
95
|
+
const showPassword = ref(false);
|
|
96
|
+
const showConfirmPassword = ref(false);
|
|
97
|
+
|
|
98
|
+
const resetPasswordPayload = computed(() => ({
|
|
99
|
+
password: password.value
|
|
100
|
+
}));
|
|
101
|
+
const resetPasswordValidation = computed(() =>
|
|
102
|
+
validateCommandSection(authPasswordResetCommand, "body", resetPasswordPayload.value)
|
|
103
|
+
);
|
|
104
|
+
const passwordError = computed(() => {
|
|
105
|
+
if (!password.value) {
|
|
106
|
+
return [];
|
|
107
|
+
}
|
|
108
|
+
const message = resolveFieldValidationMessage(resetPasswordValidation.value, "password");
|
|
109
|
+
return message ? [message] : [];
|
|
110
|
+
});
|
|
111
|
+
const confirmPasswordError = computed(() => {
|
|
112
|
+
if (!confirmPassword.value) {
|
|
113
|
+
return [];
|
|
114
|
+
}
|
|
115
|
+
return password.value === confirmPassword.value ? [] : ["Passwords must match."];
|
|
116
|
+
});
|
|
117
|
+
const canSubmit = computed(() =>
|
|
118
|
+
Boolean(password.value) &&
|
|
119
|
+
resetPasswordValidation.value.ok === true &&
|
|
120
|
+
password.value === confirmPassword.value &&
|
|
121
|
+
loading.value !== true
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
function readRecoveryPayload() {
|
|
125
|
+
if (typeof window !== "object" || !window.location) {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
return readPasswordRecoveryCallbackPayloadFromUrl(window.location.href);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function exchangeRecoveryToken() {
|
|
132
|
+
const recoveryPayload = readRecoveryPayload();
|
|
133
|
+
if (!recoveryPayload) {
|
|
134
|
+
status.value = "ready";
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
try {
|
|
138
|
+
await authHttpRequest(AUTH_PATHS.PASSWORD_RECOVERY, {
|
|
139
|
+
method: "POST",
|
|
140
|
+
body: recoveryPayload
|
|
141
|
+
});
|
|
142
|
+
stripPasswordRecoveryCallbackParamsFromLocation();
|
|
143
|
+
status.value = "ready";
|
|
144
|
+
} catch (error) {
|
|
145
|
+
status.value = "error";
|
|
146
|
+
errorMessage.value = String(error?.message || "Recovery link is invalid or expired.");
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function submitReset() {
|
|
151
|
+
if (!canSubmit.value) {
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
loading.value = true;
|
|
155
|
+
errorMessage.value = "";
|
|
156
|
+
try {
|
|
157
|
+
await authHttpRequest(AUTH_PATHS.PASSWORD_RESET, {
|
|
158
|
+
method: "POST",
|
|
159
|
+
body: {
|
|
160
|
+
password: password.value
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
status.value = "done";
|
|
164
|
+
password.value = "";
|
|
165
|
+
confirmPassword.value = "";
|
|
166
|
+
} catch (error) {
|
|
167
|
+
errorMessage.value = String(error?.message || "Unable to update password.");
|
|
168
|
+
} finally {
|
|
169
|
+
loading.value = false;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
onMounted(exchangeRecoveryToken);
|
|
174
|
+
</script>
|
|
175
|
+
|
|
176
|
+
<style scoped>
|
|
177
|
+
.reset-screen {
|
|
178
|
+
position: fixed;
|
|
179
|
+
inset: 0;
|
|
180
|
+
z-index: 1;
|
|
181
|
+
overflow-y: auto;
|
|
182
|
+
min-height: 100dvh;
|
|
183
|
+
background-color: rgb(var(--v-theme-background));
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
.reset-shell {
|
|
187
|
+
min-height: 100dvh;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
.reset-card {
|
|
191
|
+
width: min(480px, 100%);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
.reset-card--mobile {
|
|
195
|
+
width: 100%;
|
|
196
|
+
min-height: 100dvh;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
.reset-content {
|
|
200
|
+
padding: 28px;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
.reset-content--mobile {
|
|
204
|
+
min-height: 100dvh;
|
|
205
|
+
padding: calc(24px + env(safe-area-inset-top, 0px)) 20px calc(32px + env(safe-area-inset-bottom, 0px));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
.reset-title {
|
|
209
|
+
margin: 0;
|
|
210
|
+
font-size: 28px;
|
|
211
|
+
line-height: 1.2;
|
|
212
|
+
color: rgb(var(--v-theme-on-surface));
|
|
213
|
+
}
|
|
214
|
+
</style>
|
|
@@ -239,7 +239,7 @@ function buildRoutes(controller, { includeDevLoginAs = false } = {}) {
|
|
|
239
239
|
{
|
|
240
240
|
path: AUTH_PATHS.PASSWORD_RESET,
|
|
241
241
|
method: "POST",
|
|
242
|
-
auth: "
|
|
242
|
+
auth: "public",
|
|
243
243
|
meta: {
|
|
244
244
|
tags: ["auth"],
|
|
245
245
|
summary: "Set a new password for authenticated recovery session"
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { normalizeAuthCapabilities } from "@jskit-ai/auth-core/shared/authCapabilities";
|
|
1
2
|
import { AUTH_ACTION_IDS } from "../constants/authActionIds.js";
|
|
2
3
|
|
|
3
4
|
class AuthWebService {
|
|
@@ -143,6 +144,15 @@ class AuthWebService {
|
|
|
143
144
|
|
|
144
145
|
getOAuthProviderCatalogPayload() {
|
|
145
146
|
const authService = this.resolveAuthService();
|
|
147
|
+
if (typeof authService.getCapabilities === "function") {
|
|
148
|
+
const capabilities = normalizeAuthCapabilities(authService.getCapabilities());
|
|
149
|
+
const oauth = capabilities.features.oauthLogin;
|
|
150
|
+
return {
|
|
151
|
+
authCapabilities: capabilities,
|
|
152
|
+
oauthProviders: oauth.enabled ? oauth.providers : [],
|
|
153
|
+
oauthDefaultProvider: oauth.enabled ? oauth.defaultProvider : null
|
|
154
|
+
};
|
|
155
|
+
}
|
|
146
156
|
const catalog =
|
|
147
157
|
typeof authService.getOAuthProviderCatalog === "function"
|
|
148
158
|
? authService.getOAuthProviderCatalog()
|
|
@@ -156,12 +166,23 @@ class AuthWebService {
|
|
|
156
166
|
.filter((provider) => provider.id && provider.label)
|
|
157
167
|
: [];
|
|
158
168
|
const defaultProvider = String(catalog?.defaultProvider || "").trim().toLowerCase();
|
|
169
|
+
const normalizedDefaultProvider = providers.some((provider) => provider.id === defaultProvider)
|
|
170
|
+
? defaultProvider
|
|
171
|
+
: null;
|
|
172
|
+
const fallbackCapabilities = normalizeAuthCapabilities({
|
|
173
|
+
features: {
|
|
174
|
+
oauthLogin: {
|
|
175
|
+
enabled: providers.length > 0,
|
|
176
|
+
providers,
|
|
177
|
+
defaultProvider: normalizedDefaultProvider
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
});
|
|
159
181
|
|
|
160
182
|
return {
|
|
183
|
+
authCapabilities: fallbackCapabilities,
|
|
161
184
|
oauthProviders: providers,
|
|
162
|
-
oauthDefaultProvider:
|
|
163
|
-
? defaultProvider
|
|
164
|
-
: null
|
|
185
|
+
oauthDefaultProvider: normalizedDefaultProvider
|
|
165
186
|
};
|
|
166
187
|
}
|
|
167
188
|
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<route lang="json">
|
|
2
|
+
{
|
|
3
|
+
"meta": {
|
|
4
|
+
"guard": {
|
|
5
|
+
"policy": "public"
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
</route>
|
|
10
|
+
|
|
11
|
+
<script setup>
|
|
12
|
+
import DefaultResetPasswordView from "@jskit-ai/auth-web/client/views/DefaultResetPasswordView";
|
|
13
|
+
</script>
|
|
14
|
+
|
|
15
|
+
<template>
|
|
16
|
+
<DefaultResetPasswordView />
|
|
17
|
+
</template>
|
|
@@ -149,6 +149,47 @@ test("auth guard runtime redirects callback hashes on non-login routes to /auth/
|
|
|
149
149
|
}
|
|
150
150
|
});
|
|
151
151
|
|
|
152
|
+
test("auth guard runtime leaves recovery callback hashes on reset-password route", async () => {
|
|
153
|
+
const originalWindow = globalThis.window;
|
|
154
|
+
let redirectedTo = "";
|
|
155
|
+
globalThis.window = {
|
|
156
|
+
location: {
|
|
157
|
+
pathname: "/auth/reset-password",
|
|
158
|
+
search: "",
|
|
159
|
+
hash: "#access_token=access&refresh_token=refresh&type=recovery",
|
|
160
|
+
replace(target) {
|
|
161
|
+
redirectedTo = String(target || "");
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
try {
|
|
167
|
+
const placementRuntime = createPlacementRuntimeStub();
|
|
168
|
+
let fetchCalls = 0;
|
|
169
|
+
const runtime = createAuthGuardRuntime({
|
|
170
|
+
placementRuntime,
|
|
171
|
+
fetchImplementation: async () => {
|
|
172
|
+
fetchCalls += 1;
|
|
173
|
+
return {
|
|
174
|
+
ok: true,
|
|
175
|
+
async json() {
|
|
176
|
+
return {
|
|
177
|
+
authenticated: false
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
const state = await runtime.initialize();
|
|
185
|
+
assert.equal(state.authenticated, false);
|
|
186
|
+
assert.equal(fetchCalls, 1);
|
|
187
|
+
assert.equal(redirectedTo, "");
|
|
188
|
+
} finally {
|
|
189
|
+
globalThis.window = originalWindow;
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
|
|
152
193
|
test("auth guard runtime only updates placement auth context", async () => {
|
|
153
194
|
const placementRuntime = createPlacementRuntimeStub({
|
|
154
195
|
user: {
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
test("auth-web descriptor declares auth surface ui routes", () => {
|
|
13
13
|
const uiRoutes = Array.isArray(descriptor?.metadata?.ui?.routes) ? descriptor.metadata.ui.routes : [];
|
|
14
14
|
const authRoutes = uiRoutes.filter((route) => String(route?.path || "").startsWith("/auth/"));
|
|
15
|
+
const resetRoute = authRoutes.find((route) => route.id === "auth.reset-password");
|
|
15
16
|
|
|
16
17
|
assert.equal(authRoutes.length >= 2, true);
|
|
17
18
|
for (const route of authRoutes) {
|
|
@@ -19,16 +20,20 @@ test("auth-web descriptor declares auth surface ui routes", () => {
|
|
|
19
20
|
assert.equal(String(route?.surface || "").trim().toLowerCase(), "auth");
|
|
20
21
|
assert.equal(String(route?.guard?.policy || "").trim().toLowerCase(), "public");
|
|
21
22
|
}
|
|
23
|
+
assert.equal(resetRoute?.autoRegister, false);
|
|
22
24
|
});
|
|
23
25
|
|
|
24
26
|
test("auth-web auth page templates declare public route guard", () => {
|
|
25
27
|
const loginTemplatePath = fileURLToPath(new URL("../templates/src/pages/auth/login.vue", import.meta.url));
|
|
26
28
|
const signOutTemplatePath = fileURLToPath(new URL("../templates/src/pages/auth/signout.vue", import.meta.url));
|
|
29
|
+
const resetTemplatePath = fileURLToPath(new URL("../templates/src/pages/auth/reset-password.vue", import.meta.url));
|
|
27
30
|
const loginTemplateSource = readFileSync(loginTemplatePath, "utf8");
|
|
28
31
|
const signOutTemplateSource = readFileSync(signOutTemplatePath, "utf8");
|
|
32
|
+
const resetTemplateSource = readFileSync(resetTemplatePath, "utf8");
|
|
29
33
|
|
|
30
34
|
assert.match(loginTemplateSource, /"guard"\s*:\s*\{\s*"policy"\s*:\s*"public"\s*\}/);
|
|
31
35
|
assert.match(signOutTemplateSource, /"guard"\s*:\s*\{\s*"policy"\s*:\s*"public"\s*\}/);
|
|
36
|
+
assert.match(resetTemplateSource, /"guard"\s*:\s*\{\s*"policy"\s*:\s*"public"\s*\}/);
|
|
32
37
|
});
|
|
33
38
|
|
|
34
39
|
test("auth-web exports runtime signout helpers directly", () => {
|
|
@@ -103,6 +108,17 @@ test("default login view avoids decorative login chrome", () => {
|
|
|
103
108
|
assert.doesNotMatch(constantsSource, /\[LOGIN_MODE\]:\s*"Sign in to continue\.?"/);
|
|
104
109
|
});
|
|
105
110
|
|
|
111
|
+
test("default login view gates password login controls by provider capabilities", () => {
|
|
112
|
+
const viewPath = fileURLToPath(new URL("../src/client/views/DefaultLoginView.vue", import.meta.url));
|
|
113
|
+
const validationPath = fileURLToPath(new URL("../src/client/composables/loginView/useLoginViewValidation.js", import.meta.url));
|
|
114
|
+
const viewSource = readFileSync(viewPath, "utf8");
|
|
115
|
+
const validationSource = readFileSync(validationPath, "utf8");
|
|
116
|
+
|
|
117
|
+
assert.match(viewSource, /v-if="!isLogin \|\| canUsePasswordLogin"/);
|
|
118
|
+
assert.match(viewSource, /v-if="\(isLogin && canUsePasswordLogin\) \|\| isRegister"/);
|
|
119
|
+
assert.match(validationSource, /state\.isLogin\.value && !state\.canUsePasswordLogin\.value/);
|
|
120
|
+
});
|
|
121
|
+
|
|
106
122
|
test("auth-web package exports only minimal client runtime/view subpaths", () => {
|
|
107
123
|
const packageJson = JSON.parse(readFileSync(fileURLToPath(new URL("../package.json", import.meta.url)), "utf8"));
|
|
108
124
|
const exportsMap = packageJson && typeof packageJson === "object" ? packageJson.exports : {};
|
|
@@ -111,6 +127,10 @@ test("auth-web package exports only minimal client runtime/view subpaths", () =>
|
|
|
111
127
|
exportsMap["./client/views/DefaultLoginView"],
|
|
112
128
|
"./src/client/views/DefaultLoginView.vue"
|
|
113
129
|
);
|
|
130
|
+
assert.equal(
|
|
131
|
+
exportsMap["./client/views/DefaultResetPasswordView"],
|
|
132
|
+
"./src/client/views/DefaultResetPasswordView.vue"
|
|
133
|
+
);
|
|
114
134
|
assert.equal(
|
|
115
135
|
exportsMap["./client/views/DefaultSignOutView"],
|
|
116
136
|
"./src/client/views/DefaultSignOutView.vue"
|
|
@@ -134,3 +154,16 @@ test("auth-web package exports only minimal client runtime/view subpaths", () =>
|
|
|
134
154
|
assert.equal(exportsMap["./client/views/AuthProfileWidget"], undefined);
|
|
135
155
|
assert.equal(exportsMap["./client/views/AuthProfileMenuLinkItem"], undefined);
|
|
136
156
|
});
|
|
157
|
+
|
|
158
|
+
test("default reset password view preserves recovery session-pair callback tokens", () => {
|
|
159
|
+
const viewPath = fileURLToPath(new URL("../src/client/views/DefaultResetPasswordView.vue", import.meta.url));
|
|
160
|
+
const viewSource = readFileSync(viewPath, "utf8");
|
|
161
|
+
|
|
162
|
+
assert.match(viewSource, /readPasswordRecoveryCallbackPayloadFromUrl\(window\.location\.href\)/);
|
|
163
|
+
assert.match(viewSource, /stripPasswordRecoveryCallbackParamsFromLocation\(\);/);
|
|
164
|
+
assert.match(viewSource, /authPasswordResetCommand/);
|
|
165
|
+
assert.match(viewSource, /validateCommandSection\(authPasswordResetCommand,\s*"body"/);
|
|
166
|
+
assert.match(viewSource, /if \(!recoveryPayload\)\s*\{\s*status\.value = "ready";/);
|
|
167
|
+
assert.doesNotMatch(viewSource, /if \(!recoveryPayload\)\s*\{\s*status\.value = "error";/);
|
|
168
|
+
assert.doesNotMatch(viewSource, /hash\.get\("access_token"\)[\s\S]*code:/);
|
|
169
|
+
});
|
package/test/index.test.js
CHANGED
|
@@ -13,6 +13,19 @@ test("auth fastify adapter exports controller/routes backed by shared command va
|
|
|
13
13
|
assert.ok(authLoginPasswordCommand.operation.body.schema);
|
|
14
14
|
});
|
|
15
15
|
|
|
16
|
+
test("auth reset route remains public and lets provider validate recovery scope", () => {
|
|
17
|
+
const controller = new Proxy({}, {
|
|
18
|
+
get() {
|
|
19
|
+
return () => {};
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
const routes = buildRoutes(controller);
|
|
23
|
+
const resetRoute = routes.find((route) => route.method === "POST" && route.path === "/api/password/reset");
|
|
24
|
+
|
|
25
|
+
assert.ok(resetRoute);
|
|
26
|
+
assert.equal(resetRoute.auth, "public");
|
|
27
|
+
});
|
|
28
|
+
|
|
16
29
|
test("auth-web does not contain src/server/schema", () => {
|
|
17
30
|
const testFilePath = fileURLToPath(import.meta.url);
|
|
18
31
|
const packageRoot = path.resolve(path.dirname(testFilePath), "..");
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
readPasswordRecoveryCallbackPayloadFromUrl,
|
|
5
|
+
stripPasswordRecoveryCallbackParamsFromUrl
|
|
6
|
+
} from "../src/client/runtime/passwordRecoveryCallbackRuntime.js";
|
|
7
|
+
|
|
8
|
+
test("readPasswordRecoveryCallbackPayloadFromUrl preserves Supabase session-pair hashes", () => {
|
|
9
|
+
assert.deepEqual(
|
|
10
|
+
readPasswordRecoveryCallbackPayloadFromUrl(
|
|
11
|
+
"/auth/reset-password#access_token=access&refresh_token=refresh&type=recovery"
|
|
12
|
+
),
|
|
13
|
+
{
|
|
14
|
+
accessToken: "access",
|
|
15
|
+
refreshToken: "refresh",
|
|
16
|
+
type: "recovery"
|
|
17
|
+
}
|
|
18
|
+
);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("readPasswordRecoveryCallbackPayloadFromUrl reads local tokens and token hashes", () => {
|
|
22
|
+
assert.deepEqual(
|
|
23
|
+
readPasswordRecoveryCallbackPayloadFromUrl("/auth/reset-password?token=local-token&type=recovery"),
|
|
24
|
+
{
|
|
25
|
+
code: "local-token",
|
|
26
|
+
type: "recovery"
|
|
27
|
+
}
|
|
28
|
+
);
|
|
29
|
+
assert.deepEqual(
|
|
30
|
+
readPasswordRecoveryCallbackPayloadFromUrl("/auth/reset-password?token_hash=hashed-token&type=recovery"),
|
|
31
|
+
{
|
|
32
|
+
tokenHash: "hashed-token",
|
|
33
|
+
type: "recovery"
|
|
34
|
+
}
|
|
35
|
+
);
|
|
36
|
+
assert.equal(readPasswordRecoveryCallbackPayloadFromUrl("/auth/reset-password"), null);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("readPasswordRecoveryCallbackPayloadFromUrl prefers complete recovery tokens over partial session pairs", () => {
|
|
40
|
+
assert.deepEqual(
|
|
41
|
+
readPasswordRecoveryCallbackPayloadFromUrl(
|
|
42
|
+
"/auth/reset-password?token_hash=hashed-token#access_token=access&type=recovery"
|
|
43
|
+
),
|
|
44
|
+
{
|
|
45
|
+
tokenHash: "hashed-token",
|
|
46
|
+
type: "recovery"
|
|
47
|
+
}
|
|
48
|
+
);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("stripPasswordRecoveryCallbackParamsFromUrl removes credentials but keeps unrelated state", () => {
|
|
52
|
+
assert.equal(
|
|
53
|
+
stripPasswordRecoveryCallbackParamsFromUrl(
|
|
54
|
+
"/auth/reset-password?token=local-token&returnTo=%2Fhome#access_token=access&refresh_token=refresh&tab=reset"
|
|
55
|
+
),
|
|
56
|
+
"/auth/reset-password?returnTo=%2Fhome#tab=reset"
|
|
57
|
+
);
|
|
58
|
+
});
|
|
@@ -227,6 +227,86 @@ test("auth route provider does not resolve authService during boot", async () =>
|
|
|
227
227
|
assert.equal(authServiceResolutions, 1);
|
|
228
228
|
});
|
|
229
229
|
|
|
230
|
+
test("auth session route preserves provider capabilities through response validation", async () => {
|
|
231
|
+
const fastify = createFastifyStub();
|
|
232
|
+
const app = createApplication();
|
|
233
|
+
const httpRuntime = createHttpRuntime({ app, fastify });
|
|
234
|
+
|
|
235
|
+
app.instance("authService", {
|
|
236
|
+
writeSessionCookies() {},
|
|
237
|
+
clearSessionCookies() {},
|
|
238
|
+
getCapabilities() {
|
|
239
|
+
return {
|
|
240
|
+
provider: {
|
|
241
|
+
id: "local",
|
|
242
|
+
label: "Local"
|
|
243
|
+
},
|
|
244
|
+
features: {
|
|
245
|
+
password: {
|
|
246
|
+
login: true,
|
|
247
|
+
register: true,
|
|
248
|
+
change: true,
|
|
249
|
+
methodToggle: false
|
|
250
|
+
},
|
|
251
|
+
passwordRecovery: {
|
|
252
|
+
request: true,
|
|
253
|
+
complete: true,
|
|
254
|
+
delivery: "dev-log"
|
|
255
|
+
},
|
|
256
|
+
otp: {
|
|
257
|
+
login: false
|
|
258
|
+
},
|
|
259
|
+
oauthLogin: {
|
|
260
|
+
enabled: false,
|
|
261
|
+
providers: [],
|
|
262
|
+
defaultProvider: null
|
|
263
|
+
},
|
|
264
|
+
emailConfirmation: false,
|
|
265
|
+
profileUpdate: true,
|
|
266
|
+
providerLinking: {
|
|
267
|
+
start: false,
|
|
268
|
+
unlink: false
|
|
269
|
+
},
|
|
270
|
+
securityStatus: true,
|
|
271
|
+
signOutOtherSessions: true,
|
|
272
|
+
appProfileProjection: false,
|
|
273
|
+
devLoginAs: false
|
|
274
|
+
}
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
});
|
|
278
|
+
app.instance("actionExecutor", {
|
|
279
|
+
async execute({ actionId }) {
|
|
280
|
+
if (actionId === "auth.session.read") {
|
|
281
|
+
return {
|
|
282
|
+
authenticated: false
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
return {};
|
|
286
|
+
}
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
class MockAuthProvider {
|
|
290
|
+
static id = "auth.provider";
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
await app.start({ providers: [MockAuthProvider, AuthWebServiceProvider, AuthRouteServiceProvider] });
|
|
294
|
+
|
|
295
|
+
const registration = httpRuntime.registerRoutes();
|
|
296
|
+
assert.equal(registration.routeCount > 0, true);
|
|
297
|
+
|
|
298
|
+
const sessionRoute = fastify.routes.find((route) => route.method === "GET" && route.url === "/api/session");
|
|
299
|
+
assert.ok(sessionRoute);
|
|
300
|
+
const sessionReply = createReplyStub();
|
|
301
|
+
await sessionRoute.handler({}, sessionReply);
|
|
302
|
+
|
|
303
|
+
assert.equal(sessionReply.statusCode, 200);
|
|
304
|
+
assert.equal(sessionReply.payload.authenticated, false);
|
|
305
|
+
assert.equal(sessionReply.payload.authCapabilities.provider.id, "local");
|
|
306
|
+
assert.equal(sessionReply.payload.authCapabilities.features.password.login, true);
|
|
307
|
+
assert.equal(sessionReply.payload.authCapabilities.features.password.register, true);
|
|
308
|
+
});
|
|
309
|
+
|
|
230
310
|
test("auth session route exposes denial reason when policy clears a rejected authenticated session", async () => {
|
|
231
311
|
const events = [];
|
|
232
312
|
const fastify = createFastifyStub();
|