@sowell/quasar-app-extension-auth 1.6.1 → 1.7.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/package.json +1 -1
- package/src/boot/router-guards.ts +2 -0
- package/src/components/pages/AuthReporter.vue +50 -17
- package/src/components/pages/FirstLogin.vue +604 -0
- package/src/components/pages/ResetPassword.vue +117 -14
- package/src/components/pages/SetPassword.vue +35 -0
- package/src/composables/auth.ts +16 -1
- package/src/composables/resetPassword.ts +24 -6
- package/src/composables/useMagicLink.ts +210 -0
- package/src/composables/useValidateMagicLink.ts +183 -0
- package/src/injects/prompts.ts +5 -0
- package/src/prompts.js +35 -0
- package/src/routes/index.ts +27 -1
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { computed, ref, Ref } from "vue"
|
|
2
|
+
import { useI18n } from "vue-i18n"
|
|
3
|
+
import { AxiosError } from "axios"
|
|
4
|
+
import {
|
|
5
|
+
ionEyeOutline,
|
|
6
|
+
ionEyeOffOutline,
|
|
7
|
+
ionLockClosedOutline
|
|
8
|
+
} from "@quasar/extras/ionicons-v5"
|
|
9
|
+
|
|
10
|
+
import { $api, $prompts, $router } from "../injects"
|
|
11
|
+
|
|
12
|
+
interface ApiErrorPayload {
|
|
13
|
+
errors?: Array<{ detail?: string; title?: string }>
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function useValidateMagicLink(magicToken: Ref<string>) {
|
|
17
|
+
const { t } = useI18n()
|
|
18
|
+
const api = $api()
|
|
19
|
+
const router = $router()
|
|
20
|
+
const {
|
|
21
|
+
AUTH_SERVER_VALIDATE_MAGICLINK_ROUTE,
|
|
22
|
+
LOCAL_LOGIN_ROUTE,
|
|
23
|
+
LOCAL_FIRST_LOGIN_ROUTE
|
|
24
|
+
} = $prompts()
|
|
25
|
+
|
|
26
|
+
const password = ref("")
|
|
27
|
+
const confirmPassword = ref("")
|
|
28
|
+
const loading = ref(false)
|
|
29
|
+
const passwordUpdated = ref(false)
|
|
30
|
+
const linkExpired = ref(false)
|
|
31
|
+
const serverError = ref("")
|
|
32
|
+
const showPassword = ref(false)
|
|
33
|
+
const highlightInvalidRequirements = ref(false)
|
|
34
|
+
type ValidatableInput = { resetValidation: () => void }
|
|
35
|
+
const confirmPasswordInput = ref<ValidatableInput | null>(null)
|
|
36
|
+
|
|
37
|
+
const hasMinLength = computed(() => password.value.length >= 8)
|
|
38
|
+
const hasUppercase = computed(() => /[A-Z]/.test(password.value))
|
|
39
|
+
const hasLowercase = computed(() => /[a-z]/.test(password.value))
|
|
40
|
+
const hasNumber = computed(() => /[0-9]/.test(password.value))
|
|
41
|
+
const hasLetter = computed(() => /[a-zA-Z]/.test(password.value))
|
|
42
|
+
const hasSpecialChar = computed(() => /[^a-zA-Z0-9\s]/.test(password.value))
|
|
43
|
+
|
|
44
|
+
const isPasswordValid = computed(
|
|
45
|
+
() =>
|
|
46
|
+
hasMinLength.value &&
|
|
47
|
+
hasUppercase.value &&
|
|
48
|
+
hasLowercase.value &&
|
|
49
|
+
hasNumber.value &&
|
|
50
|
+
hasLetter.value &&
|
|
51
|
+
hasSpecialChar.value
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
const passwordRequirements = computed(() => [
|
|
55
|
+
{
|
|
56
|
+
label: t("auth.passwordRequirement.minLength"),
|
|
57
|
+
valid: hasMinLength.value,
|
|
58
|
+
invalid: highlightInvalidRequirements.value && !hasMinLength.value
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
label: t("auth.passwordRequirement.uppercase"),
|
|
62
|
+
valid: hasUppercase.value,
|
|
63
|
+
invalid: highlightInvalidRequirements.value && !hasUppercase.value
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
label: t("auth.passwordRequirement.lowercase"),
|
|
67
|
+
valid: hasLowercase.value,
|
|
68
|
+
invalid: highlightInvalidRequirements.value && !hasLowercase.value
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
label: t("auth.passwordRequirement.number"),
|
|
72
|
+
valid: hasNumber.value,
|
|
73
|
+
invalid: highlightInvalidRequirements.value && !hasNumber.value
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
label: t("auth.passwordRequirement.letter"),
|
|
77
|
+
valid: hasLetter.value,
|
|
78
|
+
invalid: highlightInvalidRequirements.value && !hasLetter.value
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
label: t("auth.passwordRequirement.specialChar"),
|
|
82
|
+
valid: hasSpecialChar.value,
|
|
83
|
+
invalid: highlightInvalidRequirements.value && !hasSpecialChar.value
|
|
84
|
+
}
|
|
85
|
+
])
|
|
86
|
+
|
|
87
|
+
const canSubmit = computed(
|
|
88
|
+
() =>
|
|
89
|
+
isPasswordValid.value &&
|
|
90
|
+
confirmPassword.value === password.value &&
|
|
91
|
+
confirmPassword.value !== ""
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
// NOTE: rules return a boolean, never a string - the requirements checklist below
|
|
95
|
+
// the fields already explains exactly what's missing (green/gray/red), so the
|
|
96
|
+
// field itself only needs to turn red, not repeat the message underneath.
|
|
97
|
+
const validations = {
|
|
98
|
+
passwordRules: [
|
|
99
|
+
(val: string) => !!val || false,
|
|
100
|
+
() => isPasswordValid.value || false
|
|
101
|
+
],
|
|
102
|
+
passwordConfirmationRules: [
|
|
103
|
+
(val: string) => !!val || false,
|
|
104
|
+
(val: string) => val === password.value || false
|
|
105
|
+
]
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const readApiError = (error: unknown): string | undefined => {
|
|
109
|
+
const axiosError = error as AxiosError<ApiErrorPayload>
|
|
110
|
+
const apiError = axiosError.response?.data?.errors?.[0]
|
|
111
|
+
if (!apiError) return undefined
|
|
112
|
+
if (apiError.detail) return apiError.detail
|
|
113
|
+
if (apiError.title && apiError.title.toLowerCase() !== "unprocessable content") {
|
|
114
|
+
return apiError.title
|
|
115
|
+
}
|
|
116
|
+
return undefined
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Called via QForm's @validation-error - fires when a q-input's own :rules
|
|
120
|
+
// rejects the submit BEFORE @submit (and therefore onSubmit) ever runs, e.g. an
|
|
121
|
+
// empty password field. Without this the checklist below stays grey forever.
|
|
122
|
+
const handleInvalidSubmit = () => {
|
|
123
|
+
highlightInvalidRequirements.value = true
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const onSubmit = async () => {
|
|
127
|
+
if (!canSubmit.value) {
|
|
128
|
+
highlightInvalidRequirements.value = true
|
|
129
|
+
return
|
|
130
|
+
}
|
|
131
|
+
serverError.value = ""
|
|
132
|
+
loading.value = true
|
|
133
|
+
try {
|
|
134
|
+
await api.post(AUTH_SERVER_VALIDATE_MAGICLINK_ROUTE, {
|
|
135
|
+
magic: magicToken.value,
|
|
136
|
+
password: password.value
|
|
137
|
+
})
|
|
138
|
+
passwordUpdated.value = true
|
|
139
|
+
} catch (error) {
|
|
140
|
+
const axiosError = error as AxiosError
|
|
141
|
+
if (axiosError.response?.status === 401) {
|
|
142
|
+
linkExpired.value = true
|
|
143
|
+
} else {
|
|
144
|
+
serverError.value = readApiError(error) || t("auth.error.unprocessableRequest")
|
|
145
|
+
}
|
|
146
|
+
} finally {
|
|
147
|
+
loading.value = false
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const goToLoginPage = () => {
|
|
152
|
+
router.push(LOCAL_LOGIN_ROUTE)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const goToFirstLogin = () => {
|
|
156
|
+
router.push(LOCAL_FIRST_LOGIN_ROUTE || "/first-login")
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
password,
|
|
161
|
+
confirmPassword,
|
|
162
|
+
loading,
|
|
163
|
+
passwordUpdated,
|
|
164
|
+
linkExpired,
|
|
165
|
+
serverError,
|
|
166
|
+
showPassword,
|
|
167
|
+
highlightInvalidRequirements,
|
|
168
|
+
confirmPasswordInput,
|
|
169
|
+
validations,
|
|
170
|
+
passwordRequirements,
|
|
171
|
+
canSubmit,
|
|
172
|
+
LOCAL_LOGIN_ROUTE,
|
|
173
|
+
onSubmit,
|
|
174
|
+
handleInvalidSubmit,
|
|
175
|
+
goToLoginPage,
|
|
176
|
+
goToFirstLogin,
|
|
177
|
+
icons: {
|
|
178
|
+
ionEyeOutline,
|
|
179
|
+
ionEyeOffOutline,
|
|
180
|
+
ionLockClosedOutline
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
package/src/injects/prompts.ts
CHANGED
|
@@ -19,6 +19,11 @@ export interface PromptsInterface {
|
|
|
19
19
|
UI_PAGE: string
|
|
20
20
|
APP_VERSION: string
|
|
21
21
|
USE_SSO: boolean
|
|
22
|
+
ENABLE_MAGIC_LINK: string
|
|
23
|
+
LOCAL_FIRST_LOGIN_ROUTE: string
|
|
24
|
+
LOCAL_SET_PASSWORD_ROUTE: string
|
|
25
|
+
AUTH_SERVER_REQUEST_MAGICLINK_ROUTE: string
|
|
26
|
+
AUTH_SERVER_VALIDATE_MAGICLINK_ROUTE: string
|
|
22
27
|
}
|
|
23
28
|
export const $prompts = (): PromptsInterface => {
|
|
24
29
|
const $prompts = inject<Record<"@sowell/auth", PromptsInterface>>("$prompts")
|
package/src/prompts.js
CHANGED
|
@@ -133,5 +133,40 @@ module.exports = function () {
|
|
|
133
133
|
message: "Version de l'application",
|
|
134
134
|
default: "",
|
|
135
135
|
},
|
|
136
|
+
{
|
|
137
|
+
name: "ENABLE_MAGIC_LINK",
|
|
138
|
+
type: "input",
|
|
139
|
+
required: false,
|
|
140
|
+
message: "Show the 'first login / activate account' magic-link CTA on the login page (Y/N)",
|
|
141
|
+
default: "N",
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
name: "LOCAL_FIRST_LOGIN_ROUTE",
|
|
145
|
+
type: "input",
|
|
146
|
+
required: false,
|
|
147
|
+
message: "Route for the first-login / magic-link request flow (page)",
|
|
148
|
+
default: "/first-login",
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
name: "LOCAL_SET_PASSWORD_ROUTE",
|
|
152
|
+
type: "input",
|
|
153
|
+
required: false,
|
|
154
|
+
message: "Route for the magic-link set-password flow (page)",
|
|
155
|
+
default: "/set-password",
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
name: "AUTH_SERVER_REQUEST_MAGICLINK_ROUTE",
|
|
159
|
+
type: "input",
|
|
160
|
+
required: false,
|
|
161
|
+
message: "API endpoint to request a magic link (without host)",
|
|
162
|
+
default: "/request-magiclink",
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
name: "AUTH_SERVER_VALIDATE_MAGICLINK_ROUTE",
|
|
166
|
+
type: "input",
|
|
167
|
+
required: false,
|
|
168
|
+
message: "API endpoint to validate a magic link and set the password (without host)",
|
|
169
|
+
default: "/validate-magiclink",
|
|
170
|
+
},
|
|
136
171
|
];
|
|
137
172
|
};
|
package/src/routes/index.ts
CHANGED
|
@@ -7,10 +7,15 @@ const {
|
|
|
7
7
|
LOCAL_UPDATE_PASSWORD_ROUTE,
|
|
8
8
|
X_REQUESTED_ROLES,
|
|
9
9
|
UI_PAGE,
|
|
10
|
-
LOCAL_OTP_ROUTE
|
|
10
|
+
LOCAL_OTP_ROUTE,
|
|
11
|
+
ENABLE_MAGIC_LINK,
|
|
12
|
+
LOCAL_FIRST_LOGIN_ROUTE,
|
|
13
|
+
LOCAL_SET_PASSWORD_ROUTE
|
|
11
14
|
}
|
|
12
15
|
} = prompts
|
|
13
16
|
|
|
17
|
+
const isMagicLinkEnabled = (ENABLE_MAGIC_LINK || "").toLowerCase() === "y"
|
|
18
|
+
|
|
14
19
|
// FIXME: Utiliser le UI_PAGE pour choisir les page a utiliser pour la version web ou mobile (auth et reset password)
|
|
15
20
|
const webAuthRoles = ["superadmin", "clean_manager", "incivility_manager", "manager"]
|
|
16
21
|
const AdminAuthRoles = ["admin"]
|
|
@@ -73,4 +78,25 @@ const routes: RouteRecordRaw[] = [
|
|
|
73
78
|
},
|
|
74
79
|
]
|
|
75
80
|
|
|
81
|
+
if (isMagicLinkEnabled) {
|
|
82
|
+
routes.push(
|
|
83
|
+
{
|
|
84
|
+
path: LOCAL_FIRST_LOGIN_ROUTE || "/first-login",
|
|
85
|
+
name: "first_login",
|
|
86
|
+
component: () =>
|
|
87
|
+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
88
|
+
// @ts-ignore
|
|
89
|
+
import("@sowell/auth/pages/FirstLogin.vue"),
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
path: LOCAL_SET_PASSWORD_ROUTE || "/set-password",
|
|
93
|
+
name: "set_password",
|
|
94
|
+
component: () =>
|
|
95
|
+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
96
|
+
// @ts-ignore
|
|
97
|
+
import("@sowell/auth/pages/SetPassword.vue"),
|
|
98
|
+
}
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
|
|
76
102
|
export default routes
|