Skip to content

Commit b4e5e33

Browse files
committed
feat: implemented public login snippets (#3)
1 parent 16fa4d9 commit b4e5e33

4 files changed

Lines changed: 492 additions & 34 deletions

File tree

README.md

Lines changed: 15 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -2,37 +2,21 @@
22

33
This extension provides additional iframes which can be used to embed churchtools information into restricted websites e.g. https://www.gemeindebaukasten.de/ used in ELKW.
44

5-
## Getting Started
6-
7-
### Prerequisites
8-
9-
- Node.js (version compatible with the project)
10-
- npm or yarn
11-
12-
### Installation
13-
14-
1. Clone the repository
15-
2. Install dependencies:
16-
```bash
17-
npm install
18-
```
19-
20-
### Optional: Using Dev Container
21-
22-
This project includes a dev container configuration. If you use VS Code with the "Dev Containers" extension, you can:
23-
24-
1. Clone the repository
25-
2. Open it in VS Code
26-
3. Click the Remote Indicator in the bottom-left corner of VS Code status bar
27-
4. Select "Reopen in Container"
28-
29-
The container includes the tools mentioned in the prerequisites pre-installed and also runs `npm install` on startup.
30-
31-
## Configuration
32-
33-
Copy `.env-example` to `.env` and fill in your data.
34-
35-
In the `.env` file, configure the necessary constants for your project. This file is included in `.gitignore` to prevent sensitive data from being committed to version control.
5+
## Usage ...
6+
In order to use this extension without user login - (embed on public web pages)
7+
1. Create a user "ct-iframes-tech-user" and set password to "<BASEURL>A1!" (replace with your own base URL inlcuding https:// and no / at end)
8+
This user will automatically be created if login with this configuration is not possible. Password needs to be set manually!
9+
2. Make sure "view", "view custom category" and "view custom data" permission are allowed for this user - NO Other rights should be available - not even show persons ...
10+
3. Allow full access to the user who should be used to display the information - NOT the tech user
11+
it might be better to use a dedicated user which only has access to the information required.
12+
4. Open the extension in ChuchTools Web View and "Share current user login"
13+
14+
WARNING - sharing the user token technically allows anybody with access to this token to make changes on behalf of this person.
15+
Please be aware that this might compromise your security as the tech user setup is the same for all instances unless changed manually in the code.
16+
It is strongly adivsed to use a seperate user with minimal permissions - USE WITH CAUTION !
17+
18+
## Troubleshooting
19+
Issues with login / permissions or similar are logged on JS Console - if anything does not work as expected try to use developer tools console to read these messages.
3620

3721
## Development and Deployment
3822

src/auth.ts

Lines changed: 325 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,325 @@
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

Comments
 (0)