@arex95/vue-core 1.1.15 → 1.1.16

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.
@@ -1,17 +1,34 @@
1
1
  /**
2
- * Provides authentication utilities.
3
- *
4
- * @param {string} [secretKey=getSecretKey()] - Encryption key.
5
- * @returns {Object} Auth composable methods and properties.
2
+ * Interface for the expected structure of a successful login response from the API.
3
+ */
4
+ interface LoginResponse {
5
+ access_token: string;
6
+ refresh_token: string;
7
+ [key: string]: unknown;
8
+ }
9
+ /**
10
+ * Interface for the expected structure of a successful token refresh response from the API.
11
+ */
12
+ interface RefreshResponse {
13
+ access_token: string;
14
+ refresh_token: string;
15
+ [key: string]: unknown;
16
+ }
17
+ /**
18
+ * A Vue composable that provides authentication utilities, enforcing asynchronous access to tokens.
19
+ * All token-related properties and status checks are awaitable functions.
20
+ * @param {string} [secretKey=getSecretKey()] - The encryption/decryption key. Defaults to a globally configured key.
21
+ * @returns {object} An object containing asynchronous authentication methods and properties.
6
22
  */
7
23
  export declare function useAuth(secretKey?: string): {
8
- isAuthenticated: import("vue").ComputedRef<boolean>;
9
- jwt: import("vue").Ref<string | null, string | null>;
10
- refresh_token: import("vue").Ref<string | null, string | null>;
11
- tokenExpiry: import("vue").ComputedRef<number | null>;
12
- login: (params: {} | undefined, isRememberMe: boolean) => Promise<import("axios").AxiosResponse<any, any>>;
13
- refresh: () => Promise<import("axios").AxiosResponse<any, any> | undefined>;
14
- logout: (params?: {}) => Promise<void>;
24
+ getJwt: () => Promise<string | null>;
25
+ getRefreshToken: () => Promise<string | null>;
26
+ isAuthenticated: () => Promise<boolean>;
27
+ getTokenExpiry: () => Promise<number | null>;
28
+ login: (params: object | undefined, isRememberMe: boolean) => Promise<LoginResponse>;
29
+ refresh: () => Promise<RefreshResponse>;
30
+ logout: (params?: object) => Promise<void>;
15
31
  cleanStorage: () => Promise<void>;
16
32
  verifyToken: () => Promise<void>;
17
33
  };
34
+ export {};
@@ -1,4 +1,12 @@
1
- export declare function useApiActivity(sessionTimeoutMin: number, checkIntervalSec: number): {
1
+ /**
2
+ * A Vue composable to monitor API activity and manage session timeouts.
3
+ * It tracks the last user activity and automatically logs out the user
4
+ * if no activity is detected within a specified timeout.
5
+ * @param {number} [sessionTimeoutMin=SESSION_TIMEOUT_MINUTES] - The time in minutes after which an inactive session will time out.
6
+ * @param {number} [checkIntervalSec=CHECK_INTERVAL_SECONDS] - The interval in seconds at which the session timeout is checked.
7
+ * @returns {object} An object containing methods to pause, resume, and manually update the activity timestamp.
8
+ */
9
+ export declare function useApiActivity(sessionTimeoutMin?: number, checkIntervalSec?: number): {
2
10
  pause: import("@vueuse/core").Fn;
3
11
  resume: import("@vueuse/core").Fn;
4
12
  updateTimestamp: () => void;
package/dist/index.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { useTimeoutFn, useBreakpoints, breakpointsTailwind, useWindowSize, computedAsync } from '@vueuse/core';
1
+ import { useTimeoutFn, useBreakpoints, breakpointsTailwind, useWindowSize } from '@vueuse/core';
2
2
  import axios from 'axios';
3
3
  import { useRouter } from 'vue-router';
4
4
  import { useQuery } from '@tanstack/vue-query';
@@ -2989,14 +2989,29 @@ function useSorter(items, criteriaList, selectedCriteria) {
2989
2989
  }).value;
2990
2990
  }
2991
2991
 
2992
+ /**
2993
+ * Converts an ArrayBuffer to a hexadecimal string.
2994
+ * @param {Uint8Array} buffer - The Uint8Array to convert.
2995
+ * @returns {string} The hexadecimal string representation.
2996
+ */
2992
2997
  function ab2hex(buffer) {
2993
2998
  return Array.from(buffer)
2994
2999
  .map((b) => b.toString(16).padStart(2, "0"))
2995
3000
  .join("");
2996
3001
  }
3002
+ /**
3003
+ * Converts a hexadecimal string to a Uint8Array.
3004
+ * @param {string} hex - The hexadecimal string to convert.
3005
+ * @returns {Uint8Array} The Uint8Array representation.
3006
+ */
2997
3007
  function hex2ab(hex) {
2998
3008
  return new Uint8Array(hex.match(/.{1,2}/g).map((byte) => parseInt(byte, 16)));
2999
3009
  }
3010
+ /**
3011
+ * Imports a secret key for cryptographic operations.
3012
+ * @param {string} secretKey - The string secret key.
3013
+ * @returns {Promise<CryptoKey>} A Promise that resolves to the CryptoKey.
3014
+ */
3000
3015
  async function importKey(secretKey) {
3001
3016
  const encoder = new TextEncoder();
3002
3017
  const keyMaterial = await crypto.subtle.digest("SHA-256", encoder.encode(secretKey));
@@ -3005,7 +3020,6 @@ async function importKey(secretKey) {
3005
3020
  /**
3006
3021
  * Encrypts a value using AES-256 CBC encryption with Web Cryptography API.
3007
3022
  * The IV is generated randomly for each encryption and prepended to the ciphertext.
3008
- *
3009
3023
  * @param {string} value - The value to encrypt.
3010
3024
  * @param {string} secretKey - The encryption key string.
3011
3025
  * @returns {Promise<string>} A promise that resolves to the encrypted string (hex IV + hex ciphertext).
@@ -3022,7 +3036,6 @@ const encrypt = async (value, secretKey) => {
3022
3036
  /**
3023
3037
  * Decrypts an AES-256 CBC encrypted value using Web Cryptography API.
3024
3038
  * The IV is extracted from the beginning of the encrypted string.
3025
- *
3026
3039
  * @param {string} value - The encrypted string (hex IV + hex ciphertext).
3027
3040
  * @param {string} secretKey - The decryption key string.
3028
3041
  * @returns {Promise<string>} A promise that resolves to the decrypted string.
@@ -3036,13 +3049,13 @@ const decrypt = async (value, secretKey) => {
3036
3049
  };
3037
3050
  /**
3038
3051
  * Stores an encrypted value in sessionStorage or localStorage.
3039
- *
3040
3052
  * @param {string} key - Storage key.
3041
3053
  * @param {string} value - Value to store.
3042
3054
  * @param {string} secretKey - Encryption key.
3043
- * @param {boolean} isRememberMe - Whether to store in localStorage.
3055
+ * @param {boolean} isRememberMe - Whether to store in localStorage (true) or sessionStorage (false).
3044
3056
  * @param {number} [attempt=0] - Retry attempt count.
3045
- * @returns {Promise<void>}
3057
+ * @returns {Promise<void>} A Promise that resolves when the item is stored.
3058
+ * @throws {Error} If storage is not available after multiple attempts.
3046
3059
  */
3047
3060
  const storeEncryptedItem = async (key, value, secretKey, isRememberMe, attempt = 0) => {
3048
3061
  const storage = isRememberMe ? localStorage : sessionStorage;
@@ -3070,15 +3083,14 @@ const storeEncryptedItem = async (key, value, secretKey, isRememberMe, attempt =
3070
3083
  }
3071
3084
  };
3072
3085
  /**
3073
- * Retrieves and decrypts a stored value.
3074
- *
3086
+ * Retrieves and decrypts a stored value from sessionStorage or localStorage.
3075
3087
  * @param {string} key - Storage key.
3076
3088
  * @param {string} secretKey - Decryption key.
3077
- * @param {boolean} isRememberMe - Whether to retrieve from localStorage.
3078
- * @returns {Promise<string|null>} The decrypted value or null.
3089
+ * @param {boolean} isRememberMe - Whether to retrieve from localStorage (true) or sessionStorage (false).
3090
+ * @returns {Promise<string|null>} The decrypted value or null if not found or decryption fails.
3079
3091
  */
3080
3092
  async function getDecryptedValue(key, secretKey, isRememberMe) {
3081
- const storage = sessionStorage;
3093
+ const storage = isRememberMe ? localStorage : sessionStorage;
3082
3094
  try {
3083
3095
  const value = storage.getItem(key);
3084
3096
  if (!value)
@@ -3091,58 +3103,84 @@ async function getDecryptedValue(key, secretKey, isRememberMe) {
3091
3103
  }
3092
3104
  }
3093
3105
  /**
3094
- * Provides authentication utilities.
3095
- *
3096
- * @param {string} [secretKey=getSecretKey()] - Encryption key.
3097
- * @returns {Object} Auth composable methods and properties.
3106
+ * A Vue composable that provides authentication utilities, enforcing asynchronous access to tokens.
3107
+ * All token-related properties and status checks are awaitable functions.
3108
+ * @param {string} [secretKey=getSecretKey()] - The encryption/decryption key. Defaults to a globally configured key.
3109
+ * @returns {object} An object containing asynchronous authentication methods and properties.
3098
3110
  */
3099
3111
  function useAuth(secretKey = getSecretKey()) {
3100
3112
  const axiosInstance = getAxiosInstance();
3101
3113
  const tokensConfig = getTokenConfig();
3102
3114
  const endpointsConfig = getEndpointsConfig();
3103
- const config = {
3115
+ const authConfig = {
3104
3116
  endpoints: endpointsConfig,
3105
3117
  storageKeys: tokensConfig,
3106
3118
  };
3107
- const jwt = computedAsync(async () => await getDecryptedValue(config.storageKeys.ACCESS_TOKEN, secretKey), null);
3108
- const refresh_token = computedAsync(async () => await getDecryptedValue(config.storageKeys.REFRESH_TOKEN, secretKey), null);
3109
3119
  /**
3110
- * Computes token expiration timestamp.
3120
+ * Asynchronously retrieves and decrypts the Access Token (JWT) from storage.
3121
+ * This function should always be awaited.
3122
+ * @returns {Promise<string|null>} A promise that resolves to the decrypted JWT string or null if not found.
3111
3123
  */
3112
- const tokenExpiry = computed(() => {
3113
- if (!jwt.value)
3124
+ const getJwt = async () => {
3125
+ return await getDecryptedValue(authConfig.storageKeys.ACCESS_TOKEN, secretKey, false);
3126
+ };
3127
+ /**
3128
+ * Asynchronously retrieves and decrypts the Refresh Token from storage.
3129
+ * This function should always be awaited.
3130
+ * @returns {Promise<string|null>} A promise that resolves to the decrypted Refresh Token string or null if not found.
3131
+ */
3132
+ const getRefreshToken = async () => {
3133
+ return await getDecryptedValue(authConfig.storageKeys.REFRESH_TOKEN, secretKey, true);
3134
+ };
3135
+ /**
3136
+ * Asynchronously computes the expiration timestamp of the current Access Token.
3137
+ * Requires awaiting the JWT.
3138
+ * @returns {Promise<number|null>} A promise that resolves to the expiration timestamp in milliseconds (Unix epoch) or null if no valid token is found or parsing fails.
3139
+ */
3140
+ const getTokenExpiry = async () => {
3141
+ const token = await getJwt();
3142
+ if (!token)
3114
3143
  return null;
3115
3144
  try {
3116
- const decoded = jwtDecode(jwt.value);
3145
+ const decoded = jwtDecode(token);
3117
3146
  return decoded.exp ? decoded.exp * 1000 : null;
3118
3147
  }
3119
3148
  catch (error) {
3120
3149
  handleError(error, false);
3121
3150
  return null;
3122
3151
  }
3123
- });
3152
+ };
3124
3153
  /**
3125
- * Checks if the user is authenticated.
3154
+ * Asynchronously checks if the user is currently authenticated and if the Access Token is valid and not expired.
3155
+ * This function should always be awaited.
3156
+ * @returns {Promise<boolean>} A promise that resolves to true if authenticated and token is valid, false otherwise.
3126
3157
  */
3127
- const isAuthenticated = computed(() => {
3128
- if (!jwt.value || jwt.value.length === 0) {
3158
+ const isAuthenticated = async () => {
3159
+ const token = await getJwt();
3160
+ if (!token || token.length === 0) {
3129
3161
  return false;
3130
3162
  }
3131
- if (tokenExpiry.value === null) {
3163
+ const expiry = await getTokenExpiry();
3164
+ if (expiry === null) {
3132
3165
  return false;
3133
3166
  }
3134
- return tokenExpiry.value > Date.now();
3135
- });
3167
+ return expiry > Date.now();
3168
+ };
3136
3169
  /**
3137
- * Handles user login.
3170
+ * Handles user login by making an API request and storing the received tokens.
3171
+ * @param {object} [params={}] - The login credentials or payload.
3172
+ * @param {boolean} isRememberMe - Indicates whether the refresh token should be stored in localStorage.
3173
+ * @returns {Promise<LoginResponse>} A promise that resolves to the API response data on successful login.
3174
+ * @throws {Error} If the login request fails.
3138
3175
  */
3139
3176
  const login = async (params = {}, isRememberMe) => {
3140
3177
  try {
3141
- const response = await axiosInstance.post(config.endpoints.LOGIN, params);
3142
- await storeEncryptedItem(config.storageKeys.ACCESS_TOKEN, response.data.access_token, secretKey, isRememberMe);
3143
- await storeEncryptedItem(config.storageKeys.REFRESH_TOKEN, response.data.refresh_token, secretKey, isRememberMe);
3144
- await jwt.value;
3145
- return response;
3178
+ const response = await axiosInstance.post(authConfig.endpoints.LOGIN, params);
3179
+ const newAccessToken = response.data.access_token;
3180
+ const newRefreshToken = response.data.refresh_token;
3181
+ await storeEncryptedItem(authConfig.storageKeys.ACCESS_TOKEN, newAccessToken, secretKey, false);
3182
+ await storeEncryptedItem(authConfig.storageKeys.REFRESH_TOKEN, newRefreshToken, secretKey, isRememberMe);
3183
+ return response.data;
3146
3184
  }
3147
3185
  catch (error) {
3148
3186
  handleError(error, false);
@@ -3150,26 +3188,34 @@ function useAuth(secretKey = getSecretKey()) {
3150
3188
  }
3151
3189
  };
3152
3190
  /**
3153
- * Refreshes authentication token.
3191
+ * Refreshes the authentication token by making an API request and updating stored tokens.
3192
+ * @returns {Promise<RefreshResponse>} A promise that resolves to the API response data on successful refresh.
3193
+ * @throws {Error} If the refresh request fails, leading to logout.
3154
3194
  */
3155
3195
  const refresh = async () => {
3156
3196
  try {
3157
- const response = await axiosInstance.post(config.endpoints.REFRESH, {});
3158
- await storeEncryptedItem(config.storageKeys.ACCESS_TOKEN, response.data.token, secretKey, false);
3159
- await storeEncryptedItem(config.storageKeys.REFRESH_TOKEN, response.data.refresh_token, secretKey, false);
3160
- return response;
3197
+ const response = await axiosInstance.post(authConfig.endpoints.REFRESH, {});
3198
+ const newAccessToken = response.data.access_token;
3199
+ const newRefreshToken = response.data.refresh_token;
3200
+ await storeEncryptedItem(authConfig.storageKeys.ACCESS_TOKEN, newAccessToken, secretKey, false);
3201
+ await storeEncryptedItem(authConfig.storageKeys.REFRESH_TOKEN, newRefreshToken, secretKey, true);
3202
+ return response.data;
3161
3203
  }
3162
3204
  catch (error) {
3163
3205
  handleError(error, false);
3164
3206
  await logout();
3207
+ throw error;
3165
3208
  }
3166
3209
  };
3167
3210
  /**
3168
- * Logs out the user.
3211
+ * Handles user logout by making an API request and clearing stored authentication data.
3212
+ * Reloads the page after clearing storage.
3213
+ * @param {object} [params={}] - Optional logout payload.
3214
+ * @returns {Promise<void>} A promise that resolves when the logout process is complete.
3169
3215
  */
3170
3216
  const logout = async (params = {}) => {
3171
3217
  try {
3172
- await axiosInstance.post(config.endpoints.LOGOUT, params);
3218
+ await axiosInstance.post(authConfig.endpoints.LOGOUT, params);
3173
3219
  }
3174
3220
  catch (error) {
3175
3221
  handleError(error, false);
@@ -3180,25 +3226,31 @@ function useAuth(secretKey = getSecretKey()) {
3180
3226
  }
3181
3227
  };
3182
3228
  /**
3183
- * Clears stored authentication data.
3229
+ * Clears all stored authentication data (access and refresh tokens) from both sessionStorage and localStorage.
3230
+ * @returns {Promise<void>} A promise that resolves when all relevant storage items are removed.
3184
3231
  */
3185
3232
  const cleanStorage = async () => {
3186
- Object.keys(config.storageKeys).forEach((key) => {
3187
- sessionStorage.removeItem(config.storageKeys[key]);
3188
- localStorage.removeItem(config.storageKeys[key]);
3233
+ Object.keys(authConfig.storageKeys).forEach((key) => {
3234
+ sessionStorage.removeItem(authConfig.storageKeys[key]);
3235
+ localStorage.removeItem(authConfig.storageKeys[key]);
3189
3236
  });
3190
3237
  };
3191
3238
  /**
3192
- * Verifies token validity.
3239
+ * Verifies the validity of the current Access Token. If the token is missing, invalid, or expired,
3240
+ * it handles the error appropriately (e.g., attempts refresh, clears storage, redirects).
3241
+ * This function should always be awaited.
3242
+ * @returns {Promise<void>} A promise that resolves if the token is valid, or rejects with an error.
3243
+ * @throws {Error} If the token is missing, expired, or invalid.
3193
3244
  */
3194
3245
  const verifyToken = async () => {
3195
- if (!jwt.value) {
3246
+ const token = await getJwt();
3247
+ if (!token) {
3196
3248
  handleError("TOKEN_MISSING: No valid token found", true, "/auth-error", "query");
3197
3249
  await cleanStorage();
3198
3250
  throw new Error("TOKEN_MISSING: No valid token found");
3199
3251
  }
3200
3252
  try {
3201
- const decoded = jwtDecode(jwt.value);
3253
+ const decoded = jwtDecode(token);
3202
3254
  if (decoded.exp ? decoded.exp * 1000 < Date.now() : false) {
3203
3255
  handleError("TOKEN_EXPIRED", false);
3204
3256
  await refresh();
@@ -3215,10 +3267,10 @@ function useAuth(secretKey = getSecretKey()) {
3215
3267
  }
3216
3268
  };
3217
3269
  return {
3270
+ getJwt,
3271
+ getRefreshToken,
3218
3272
  isAuthenticated,
3219
- jwt,
3220
- refresh_token,
3221
- tokenExpiry,
3273
+ getTokenExpiry,
3222
3274
  login,
3223
3275
  refresh,
3224
3276
  logout,
package/package.json CHANGED
@@ -1,68 +1,68 @@
1
- {
2
- "name": "@arex95/vue-core",
3
- "version": "1.1.15",
4
- "description": "Opinionated Vue Core",
5
- "main": "dist/index.js",
6
- "module": "dist/index.mjs",
7
- "exports": {
8
- "import": "./dist/index.mjs"
9
- },
10
- "types": "dist/index.d.ts",
11
- "type": "module",
12
- "files": [
13
- "dist"
14
- ],
15
- "scripts": {
16
- "build": "rollup -c",
17
- "changelog": "conventional-changelog -p angular -o CHANGELOG.md -r 0",
18
- "release": "npm version patch && npm run changelog && git add CHANGELOG.md package.json package-lock.json && git commit -m \"chore(release): update changelog\" && git push && npm publish --access public"
19
- },
20
- "repository": {
21
- "type": "git",
22
- "url": "https://github.com/Arex95/npm-arex-core.git"
23
- },
24
- "keywords": [
25
- "vue",
26
- "composables",
27
- "core",
28
- "npm"
29
- ],
30
- "author": "Arturo Rafael Serrano Girón",
31
- "license": "MIT",
32
- "peerDependencies": {
33
- "@tanstack/vue-query": ">=5.0.0",
34
- "@vueuse/core": ">=12.8.2",
35
- "axios": ">=1.6.0",
36
- "jwt-decode": "^4.0.0",
37
- "uuid": ">=11.1.0",
38
- "vue": ">=3.0.0",
39
- "vue-router": ">=4.5.0"
40
- },
41
- "devDependencies": {
42
- "@eslint/js": "^9.23.0",
43
- "@rollup/plugin-commonjs": "^28.0.3",
44
- "@rollup/plugin-json": "^6.1.0",
45
- "@rollup/plugin-node-resolve": "^16.0.0",
46
- "@rollup/plugin-typescript": "^12.1.2",
47
- "@types/crypto-js": "^4.2.2",
48
- "@types/node": "^22.13.10",
49
- "conventional-changelog-cli": "^5.0.0",
50
- "eslint": "^9.23.0",
51
- "eslint-plugin-vue": "^10.0.0",
52
- "globals": "^16.0.0",
53
- "rollup": "^4.35.0",
54
- "ts-node": "^10.9.2",
55
- "tslib": "^2.8.1",
56
- "typescript": "^5.8.2",
57
- "typescript-eslint": "^8.28.0"
58
- },
59
- "dependencies": {
60
- "@tanstack/vue-query": ">=5.0.0",
61
- "@vueuse/core": ">=12.8.2",
62
- "axios": ">=1.6.0",
63
- "jwt-decode": "^4.0.0",
64
- "uuid": ">=11.1.0",
65
- "vue": ">=3.0.0",
66
- "vue-router": ">=4.5.0"
67
- }
68
- }
1
+ {
2
+ "name": "@arex95/vue-core",
3
+ "version": "1.1.16",
4
+ "description": "Opinionated Vue Core",
5
+ "main": "dist/index.js",
6
+ "module": "dist/index.mjs",
7
+ "exports": {
8
+ "import": "./dist/index.mjs"
9
+ },
10
+ "types": "dist/index.d.ts",
11
+ "type": "module",
12
+ "files": [
13
+ "dist"
14
+ ],
15
+ "scripts": {
16
+ "build": "rollup -c",
17
+ "changelog": "conventional-changelog -p angular -o CHANGELOG.md -r 0",
18
+ "release": "npm version patch && npm run changelog && git add CHANGELOG.md package.json package-lock.json && git commit -m \"chore(release): update changelog\" && git push && npm publish --access public"
19
+ },
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "https://github.com/Arex95/npm-arex-core.git"
23
+ },
24
+ "keywords": [
25
+ "vue",
26
+ "composables",
27
+ "core",
28
+ "npm"
29
+ ],
30
+ "author": "Arturo Rafael Serrano Girón",
31
+ "license": "MIT",
32
+ "peerDependencies": {
33
+ "@tanstack/vue-query": ">=5.0.0",
34
+ "@vueuse/core": ">=12.8.2",
35
+ "axios": ">=1.6.0",
36
+ "jwt-decode": "^4.0.0",
37
+ "uuid": ">=11.1.0",
38
+ "vue": ">=3.0.0",
39
+ "vue-router": ">=4.5.0"
40
+ },
41
+ "devDependencies": {
42
+ "@eslint/js": "^9.23.0",
43
+ "@rollup/plugin-commonjs": "^28.0.3",
44
+ "@rollup/plugin-json": "^6.1.0",
45
+ "@rollup/plugin-node-resolve": "^16.0.0",
46
+ "@rollup/plugin-typescript": "^12.1.2",
47
+ "@types/crypto-js": "^4.2.2",
48
+ "@types/node": "^22.13.10",
49
+ "conventional-changelog-cli": "^5.0.0",
50
+ "eslint": "^9.23.0",
51
+ "eslint-plugin-vue": "^10.0.0",
52
+ "globals": "^16.0.0",
53
+ "rollup": "^4.35.0",
54
+ "ts-node": "^10.9.2",
55
+ "tslib": "^2.8.1",
56
+ "typescript": "^5.8.2",
57
+ "typescript-eslint": "^8.28.0"
58
+ },
59
+ "dependencies": {
60
+ "@tanstack/vue-query": ">=5.0.0",
61
+ "@vueuse/core": ">=12.8.2",
62
+ "axios": ">=1.6.0",
63
+ "jwt-decode": "^4.0.0",
64
+ "uuid": ">=11.1.0",
65
+ "vue": ">=3.0.0",
66
+ "vue-router": ">=4.5.0"
67
+ }
68
+ }