|
| 1 | +/** |
| 2 | + * This file implements auth functionality taking into account it's content should be accessed by unregistered users. |
| 3 | + * |
| 4 | + * Concept: |
| 5 | + * 1. Create hardcoded tech user with access to KV store if not exists |
| 6 | + * 2. Check tech user permissions to view login category in KV store |
| 7 | + * 3. Any registered user can update the KV store login token with it's own |
| 8 | + * 4. Login token saved in KV store should expire after certain time |
| 9 | + */ |
| 10 | + |
| 11 | +import { AxiosError } from "axios"; |
| 12 | +import { churchtoolsClient } from "@churchtools/churchtools-client"; |
| 13 | +import type { |
| 14 | + CustomModuleDataCategory, |
| 15 | + CustomModulePermission, |
| 16 | + GlobalPermissions, |
| 17 | + Person, |
| 18 | +} from "./utils/ct-types"; |
| 19 | +import { |
| 20 | + deleteCustomDataValue, |
| 21 | + getCustomDataCategory, |
| 22 | + getCustomDataValues, |
| 23 | +} from "./utils/kv-store"; |
| 24 | +import { |
| 25 | + getLogin, |
| 26 | + resetStoredCategories, |
| 27 | + setLogin, |
| 28 | + type LoginValueData, |
| 29 | +} from "./persistance"; |
| 30 | + |
| 31 | +declare const window: Window & |
| 32 | + typeof globalThis & { |
| 33 | + settings: { base_url?: string }; |
| 34 | + }; |
| 35 | + |
| 36 | +const baseUrl = window.settings?.base_url ?? import.meta.env.VITE_BASE_URL; |
| 37 | + |
| 38 | +const TECH_USERNAME = "ct-iframes-tech-user"; |
| 39 | +const TECH_PASSWORD = `${baseUrl}A1!`; |
| 40 | +// console.log(TECH_USERNAME, TECH_PASSWORD); |
| 41 | + |
| 42 | +/* Helper method to create button base with some formats |
| 43 | + * @param text: text value to be displayed on button |
| 44 | + * @param additionalClasses: list of classes to add e.g. bg-color |
| 45 | + * @param onclick: method to execute |
| 46 | + * @returns: html button |
| 47 | + * |
| 48 | + */ |
| 49 | +function createButton( |
| 50 | + text: string, |
| 51 | + additionalClasses: string[], |
| 52 | + onClick: () => Promise<void>, |
| 53 | +) { |
| 54 | + const btn = document.createElement("button"); |
| 55 | + btn.classList.add( |
| 56 | + "c-button", |
| 57 | + "c-button__S", |
| 58 | + "c-button__primary", |
| 59 | + "rounded-sm", |
| 60 | + "text-body-m-emphasized", |
| 61 | + "gap-2", |
| 62 | + "justify-center", |
| 63 | + "px-4", |
| 64 | + "py-2", |
| 65 | + "m-2", |
| 66 | + "text-white", |
| 67 | + ...additionalClasses, |
| 68 | + ); |
| 69 | + btn.textContent = text; |
| 70 | + btn.onclick = onClick; |
| 71 | + return btn; |
| 72 | +} |
| 73 | + |
| 74 | +/** |
| 75 | + * Generate HTML div allowing user to share current login or revoke token |
| 76 | + * @returns DIV element |
| 77 | + */ |
| 78 | + |
| 79 | +export function generateAuthHTML(): HTMLDivElement { |
| 80 | + const container = document.createElement("div"); |
| 81 | + container.className = "p-4 mb-4 bg-gray-100 rounded shadow text-left"; |
| 82 | + |
| 83 | + container.appendChild( |
| 84 | + createButton( |
| 85 | + "Share current user login", |
| 86 | + ["bg-green-b-bright"], |
| 87 | + shareCurrentLogin, |
| 88 | + ), |
| 89 | + ); |
| 90 | + |
| 91 | + container.appendChild( |
| 92 | + createButton( |
| 93 | + "Revoke saved login token", |
| 94 | + ["bg-orange-b-bright"], |
| 95 | + revokeToken, |
| 96 | + ), |
| 97 | + ); |
| 98 | + |
| 99 | + container.appendChild( |
| 100 | + createButton( |
| 101 | + "Check saved login expiry", |
| 102 | + ["bg-gray-b-bright"], |
| 103 | + async () => { |
| 104 | + const days = await checkTokenExpirationValidity(); |
| 105 | + alert(`Token expires in ${days.toFixed(1)} days`); |
| 106 | + }, |
| 107 | + ), |
| 108 | + ); |
| 109 | + |
| 110 | + return container; |
| 111 | +} |
| 112 | + |
| 113 | +/** Automatically create a new tech user if it doesn't exist */ |
| 114 | +export async function createTechUserIfNotExists(): Promise<void> { |
| 115 | + try { |
| 116 | + const users = await churchtoolsClient.get<Person[]>( |
| 117 | + `/persons?username=${TECH_USERNAME}`, |
| 118 | + ); |
| 119 | + |
| 120 | + if (users.length > 0) { |
| 121 | + console.log("Tech user already exists."); |
| 122 | + return; |
| 123 | + } |
| 124 | + |
| 125 | + const minimalUserInfo = { |
| 126 | + firstName: "CT Iframes", |
| 127 | + lastName: "Tech User", |
| 128 | + cmsUserId: TECH_USERNAME, |
| 129 | + departmentIds: [1], |
| 130 | + statusId: 0, |
| 131 | + campusId: 0, |
| 132 | + email: "no-mail@nomail.xx", |
| 133 | + privacyPolicyAgreementTypeId: 1, |
| 134 | + privacyPolicyAgreementWhoId: 1, |
| 135 | + privacyPolicyAgreementDate: "1900-01-01", |
| 136 | + }; |
| 137 | + |
| 138 | + const newUser = await churchtoolsClient.post( |
| 139 | + "/persons", |
| 140 | + minimalUserInfo, |
| 141 | + ); |
| 142 | + console.log( |
| 143 | + "Tech user created automatically - please ensure correct password and permissions -> see Readme.md", |
| 144 | + newUser, |
| 145 | + ); |
| 146 | + // TODO add permissions to kv store to user #12 |
| 147 | + } catch (error) { |
| 148 | + console.error("Error creating tech user:", error); |
| 149 | + throw error; |
| 150 | + } |
| 151 | +} |
| 152 | +/** This methods calls logout for API and tries login with dedicated tech user |
| 153 | + */ |
| 154 | +export async function loginTechUser(): Promise<boolean> { |
| 155 | + //console.log("Trying login with: ", TECH_USERNAME, TECH_PASSWORD); |
| 156 | + await churchtoolsClient.post("/logout"); |
| 157 | + |
| 158 | + try { |
| 159 | + await churchtoolsClient.post("/login", { |
| 160 | + username: TECH_USERNAME, |
| 161 | + password: TECH_PASSWORD, |
| 162 | + }); |
| 163 | + console.log( |
| 164 | + "Logged in with tech user.", |
| 165 | + await churchtoolsClient.get("/whoami"), |
| 166 | + ); |
| 167 | + return true; |
| 168 | + } catch (err: AxiosError | any) { |
| 169 | + console.error( |
| 170 | + "Error logging in with tech user:", |
| 171 | + err.response?.data?.message, |
| 172 | + ); |
| 173 | + await createTechUserIfNotExists(); |
| 174 | + return false; |
| 175 | + } |
| 176 | +} |
| 177 | + |
| 178 | +/** |
| 179 | + * Make user login using tech user to retrieve stored login and use it |
| 180 | + */ |
| 181 | +export async function loginSavedUser(): Promise<boolean> { |
| 182 | + const techLoginSuccess = await loginTechUser(); |
| 183 | + if (!techLoginSuccess) return false; |
| 184 | + |
| 185 | + const hasPermission = await checkPermissions(false); |
| 186 | + if (!hasPermission) return false; |
| 187 | + |
| 188 | + const login = await getLogin(); |
| 189 | + if (!login) return false; |
| 190 | + |
| 191 | + await churchtoolsClient.post("/logout"); |
| 192 | + |
| 193 | + try { |
| 194 | + await churchtoolsClient.loginWithToken(login.token); |
| 195 | + console.log( |
| 196 | + "Logged in with saved token.", |
| 197 | + await churchtoolsClient.get("/whoami"), |
| 198 | + ); |
| 199 | + return true; |
| 200 | + } catch (err: AxiosError | any) { |
| 201 | + console.error("Token login failed:", err?.response?.data?.message); |
| 202 | + return false; |
| 203 | + } |
| 204 | +} |
| 205 | + |
| 206 | +/** |
| 207 | + * check if current user has permissions to custom data category userd for longin storage |
| 208 | + * @param writeAccess - checks if write access otherwise defaults to read access |
| 209 | + * @returns if permission is granted |
| 210 | + */ |
| 211 | +export async function checkPermissions(writeAccess = false): Promise<boolean> { |
| 212 | + const permissions = |
| 213 | + await churchtoolsClient.get<GlobalPermissions>(`/permissions/global`); |
| 214 | + //console.log("User permissions fetched:", permissions); |
| 215 | + |
| 216 | + const modulePermissions = permissions[ |
| 217 | + "ct-iframes" |
| 218 | + ] as CustomModulePermission; |
| 219 | + //console.log("Module permissions fetched:", modulePermissions); |
| 220 | + |
| 221 | + const categoryPermissions = |
| 222 | + modulePermissions[`${writeAccess ? "edit" : "view"} custom category`] ?? |
| 223 | + []; |
| 224 | + |
| 225 | + const loginCategory = await getCustomDataCategory("login"); |
| 226 | + |
| 227 | + if (!loginCategory) return false; |
| 228 | + |
| 229 | + const hasPermission = categoryPermissions.includes(loginCategory.id); |
| 230 | + console.log("Permission check:", hasPermission ? "OK" : "DENIED"); |
| 231 | + |
| 232 | + return hasPermission; |
| 233 | +} |
| 234 | + |
| 235 | +/** |
| 236 | + * Save login user and token for future iframe usage |
| 237 | + * 1. check if current user has permissions to share login |
| 238 | + * 2. delete previous logins |
| 239 | + * 3. retrive current user token and store it |
| 240 | + */ |
| 241 | +export async function shareCurrentLogin(): Promise<void> { |
| 242 | + if (!(await checkPermissions(true))) { |
| 243 | + console.error("No write access to login storage."); |
| 244 | + return; |
| 245 | + } |
| 246 | + if (!(await getCustomDataCategory("login"))) { |
| 247 | + await resetStoredCategories(); |
| 248 | + } |
| 249 | + |
| 250 | + const myUser = await churchtoolsClient.get<Person>("/whoami"); |
| 251 | + console.log("Current user is:", myUser); |
| 252 | + |
| 253 | + await deleteSavedLogins(); |
| 254 | + |
| 255 | + const token = await churchtoolsClient.get<string>( |
| 256 | + `/persons/${myUser.id}/logintoken`, |
| 257 | + ); |
| 258 | + |
| 259 | + await setLogin(`${myUser.firstName} ${myUser.lastName}`, token); |
| 260 | + |
| 261 | + console.log("Shared current login token."); |
| 262 | +} |
| 263 | + |
| 264 | +/** |
| 265 | + * Revoke user token and remove from stored logins |
| 266 | + */ |
| 267 | +export async function revokeToken(): Promise<void> { |
| 268 | + if (!(await checkPermissions(true))) { |
| 269 | + console.error("No write access to login storage."); |
| 270 | + return; |
| 271 | + } |
| 272 | + |
| 273 | + const myUser = await churchtoolsClient.get<Person>("/whoami"); |
| 274 | + console.log("Current user is:", myUser); |
| 275 | + await churchtoolsClient.deleteApi(`/persons/${myUser.id}/logintoken`); |
| 276 | + |
| 277 | + await deleteSavedLogins(); |
| 278 | + |
| 279 | + console.log("Login token revoked."); |
| 280 | +} |
| 281 | + |
| 282 | +async function deleteSavedLogins(): Promise<void> { |
| 283 | + const category = await getCustomDataCategory("login"); |
| 284 | + if (!category) return; |
| 285 | + |
| 286 | + const loginValues = await getCustomDataValues<LoginValueData>(category.id); |
| 287 | + |
| 288 | + await Promise.all( |
| 289 | + loginValues.map((v) => deleteCustomDataValue(category.id, v.id)), |
| 290 | + ); |
| 291 | +} |
| 292 | + |
| 293 | +/** |
| 294 | + * Check how long the token is valid |
| 295 | + * @returns number of days still valid |
| 296 | + */ |
| 297 | +export async function checkTokenExpirationValidity(): Promise<number> { |
| 298 | + const category = await getCustomDataCategory("login"); |
| 299 | + if (!category) return -1; |
| 300 | + |
| 301 | + const loginValues = await getCustomDataValues<LoginValueData>(category.id); |
| 302 | + |
| 303 | + if (!loginValues.length) { |
| 304 | + console.log("No stored login found."); |
| 305 | + return -1; |
| 306 | + } |
| 307 | + |
| 308 | + const expiresAt = new Date(loginValues[0].expires).getTime(); |
| 309 | + const diffMs = expiresAt - Date.now(); |
| 310 | + const diffDays = diffMs / (1000 * 60 * 60 * 24); // convert ms → days |
| 311 | + |
| 312 | + console.log("Token expires in days:", diffDays); |
| 313 | + |
| 314 | + return diffDays; |
| 315 | +} |
| 316 | + |
| 317 | +/** |
| 318 | + * Loads token and checks if expired - if yes delete it |
| 319 | + */ |
| 320 | +export async function applyTokenExpiration(): Promise<void> { |
| 321 | + const daysLeft = await checkTokenExpirationValidity(); |
| 322 | + if (daysLeft < 0) { |
| 323 | + await revokeToken(); |
| 324 | + } |
| 325 | +} |
0 commit comments