@sowell/quasar-app-extension-auth 1.6.0 → 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.
@@ -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
+ }
@@ -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
  };
@@ -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