feat(widgets): add localhost:5001 to auto-approved widget domains

This commit is contained in:
2026-02-16 16:49:45 +01:00
parent 7654282d79
commit 38f4178612
28 changed files with 1432 additions and 25 deletions
+33 -19
View File
@@ -2,6 +2,10 @@
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Context
This is a **HealthChat** fork of Element Web (a Matrix client built with React and TypeScript). HealthChat customizations are primarily configuration-based: custom branding, homeserver (matrix.healthchat.ch), SSO auto-redirect, and many advanced features disabled for a simplified UX. The `release/healthchat` branch is the main working branch; `master` tracks upstream.
## Prerequisites
- Node.js >= 22.18
@@ -10,7 +14,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Commands
### Setup
- `yarn install` - Install dependencies
- `yarn install` - Install dependencies (also runs `postinstall` which applies patches via patch-package)
- Copy `config.sample.json` to `config.json` and configure (see docs/config.md)
### Development
@@ -32,12 +36,12 @@ sudo sysctl fs.inotify.max_user_instances=512
- `yarn coverage` - Run tests with coverage reporting
To run a single test file:
- `yarn test path/to/test-file.test.ts`
- `yarn test path/to/test-file-test.ts` (note: test files use `-test.ts` suffix, not `.test.ts`)
- `yarn test:playwright path/to/e2e-test.ts`
### Code Quality
- `yarn lint` - Run all linters (TypeScript, ESLint, Stylelint)
- `yarn lint:js-fix` - Auto-fix ESLint issues
- `yarn lint:js-fix` - Auto-fix ESLint issues (runs Prettier + ESLint)
- `yarn lint:types` - TypeScript type checking only
- `yarn lint:style` - Check CSS/PostCSS styles
@@ -48,8 +52,6 @@ To run a single test file:
## Architecture
This is Element Web, a Matrix client built with React and TypeScript, customized as "HealthChat". Key architectural patterns:
### Component Structure
The application follows a hierarchical component structure:
- `MatrixChat` - Root component managing the entire application state
@@ -57,21 +59,26 @@ The application follows a hierarchical component structure:
- `MainSplit` - Central room view with messages and input
- `RightPanel` - Member list, room info, and contextual panels
Components are organized in `/src/components/`:
- `/structures/` - Major layout components
- `/views/` - Reusable UI components grouped by function (auth, dialogs, messages, rooms, settings)
Components are organized in `src/components/`:
- `structures/` - Major layout components (use class components)
- `views/` - Reusable UI components grouped by function (auth, dialogs, messages, rooms, settings)
### Monorepo Structure
- `packages/shared-components/` - Separate package (`@element-hq/web-shared-components`) with its own build (Vite), tests, and Storybook. Contains shared UI components (audio, avatar, composer, event-tiles, message-body).
### State Management
Uses Flux architecture with:
- Centralized dispatcher in `/src/dispatcher/`
- Stores in `/src/stores/` as singletons (access via `FooStore.instance`)
- Centralized dispatcher in `src/dispatcher/`
- Stores in `src/stores/` as singletons (access via `FooStore.instance`)
- Dispatch actions: `dis.dispatch({ action: Action.FOO, ...payload })` or `dis.fire(Action.FOO)` for action-only
### Styling
- PostCSS files with `.pcss` extension
- PostCSS files with `.pcss` extension, located in `res/css/` (not co-located with components)
- CSS file must match component name: `res/css/views/rooms/_MessageTile.pcss` for `src/components/views/rooms/MessageTile.tsx`
- BEM-like naming: `mx_ComponentName_element`
- Component styles co-located with components (e.g., `_MessageTile.pcss`)
- Theming via CSS custom properties
- Use `$font` variables, not manual font sizes
- Document z-index values, pixel adjustments with comments explaining why
### Matrix Integration
- Uses `matrix-js-sdk` for all Matrix protocol operations
@@ -79,29 +86,36 @@ Uses Flux architecture with:
- Room and event handling through SDK abstractions
### Module System
Plugin architecture in `/src/modules/`:
Plugin architecture in `src/modules/` and `module_system/`:
- Allows runtime extension of functionality
- Module API for registering custom components and handlers
- Build step: `yarn build:module_system`
### Testing Patterns
- Unit tests in `/test/unit-tests/` using Jest
- E2E tests in `/playwright/e2e/` using Playwright
- Unit tests in `test/unit-tests/` mirroring `src/` structure, using Jest
- **Test file naming**: `*-test.[tj]s?(x)` (hyphenated, NOT `.test.ts`)
- E2E tests in `playwright/e2e/` using Playwright with page object models in `playwright/pages/`
- Mock Matrix client and SDK extensively in tests
- Component tests via Storybook
- Use `jest-matrix-react` for component rendering (NOT `@testing-library/react`)
- Test convention: `describe("ComponentName", () => { it("should ...", () => { ... }); });`
### Important Conventions
- TypeScript strict mode - always use explicit types
- TypeScript strict mode - always use explicit types, describe types exhaustively
- Functional components with hooks preferred over class components (except "structures" which use classes)
- Translations use `_t()` function with hierarchical keys
- 4-space indentation, 120 character line limit
- 4-space indentation, 120 character line limit, double quotes, Prettier-formatted
- Component files: UpperCamelCase, utilities: kebab-case
- Use named exports, one component per file
- Use `Optional<T>` type instead of truly optional parameters when callers should explicitly acknowledge missing values
- CSS class names must be prefixed with `mx_` and match component name (e.g., `mx_RoomTile`)
- Import matrix-js-sdk via `matrix-js-sdk/src/matrix` only (not `matrix-js-sdk` or `matrix-js-sdk/src`)
- Use `jest-matrix-react` instead of `@testing-library/react` for component tests
- No `React.forwardRef` - use ref props directly instead
- Use UIStore for window dimensions instead of `window.innerHeight`/`window.innerWidth`
- Use Media helper for MXC URL handling instead of direct `mxcUrlToHttp` calls
- `eslint-ignore` / `@ts-ignore` must have a comment explaining why
- Prefer async/await over promise chaining
- Interfaces for object definitions, types for union/parameter-value-only declarations
- Interface properties terminated with semicolons, not commas
### Linked Development with matrix-js-sdk
For developing against local SDK changes:
@@ -0,0 +1,25 @@
{
"name": "@healthchat/user-directory-module",
"version": "1.0.0",
"description": "HealthChat user directory module for Element Web",
"type": "module",
"main": "dist/user-directory-module.js",
"types": "dist/user-directory-module.d.ts",
"files": ["dist", "src", "package.json"],
"scripts": {
"build": "vite build",
"dev": "vite build --watch",
"lint:types": "tsc --noEmit"
},
"peerDependencies": {
"react": "^19.0.0",
"@element-hq/element-web-module-api": "^1.8.0"
},
"devDependencies": {
"@element-hq/element-web-module-api": "^1.8.0",
"@types/react": "^19.2.2",
"react": "^19.1.0",
"typescript": "^5.9.3",
"vite": "^7.1.9"
}
}
@@ -0,0 +1,46 @@
import type { MatrixClientPeg, OpenIdToken } from "../types.ts";
declare const window: Window & {
mxMatrixClientPeg: MatrixClientPeg;
};
interface CachedToken {
token: OpenIdToken;
acquiredAt: number;
}
let cachedToken: CachedToken | null = null;
/** Margin in seconds before expiry to trigger refresh. */
const REFRESH_MARGIN_SECONDS = 60;
/**
* Get an OpenID token from the Matrix client, with caching.
* Refreshes the token if it's within 60 seconds of expiring.
*/
export async function getOpenIdToken(): Promise<string> {
const now = Date.now();
if (cachedToken) {
const expiresAt = cachedToken.acquiredAt + cachedToken.token.expires_in * 1000;
const stillValid = expiresAt - now > REFRESH_MARGIN_SECONDS * 1000;
if (stillValid) {
return cachedToken.token.access_token;
}
}
const client = window.mxMatrixClientPeg.safeGet();
const token = await client.getOpenIdToken();
cachedToken = {
token,
acquiredAt: now,
};
return token.access_token;
}
/** Clear the cached token (e.g. on logout). */
export function clearCachedToken(): void {
cachedToken = null;
}
@@ -0,0 +1,59 @@
import { getOpenIdToken } from "./auth.ts";
import type {
MatrixUserSearchResult,
DirectChatResponse,
ProfileCompletenessResponse,
InviteResponse,
} from "../types.ts";
export class ApiClient {
constructor(private readonly baseUrl: string) {}
private async request<T>(path: string, options: RequestInit = {}): Promise<T> {
const token = await getOpenIdToken();
const url = `${this.baseUrl}${path}`;
const response = await fetch(url, {
...options,
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}`,
...options.headers,
},
});
if (!response.ok) {
const text = await response.text().catch(() => "");
throw new Error(`API request failed: ${response.status} ${response.statusText}${text ? ` - ${text}` : ""}`);
}
return response.json() as Promise<T>;
}
/** Fetch users, optionally filtered by search query. */
async getUsers(search?: string): Promise<MatrixUserSearchResult> {
const params = search ? `?search=${encodeURIComponent(search)}` : "";
return this.request<MatrixUserSearchResult>(`/api/usermatrix/users${params}`);
}
/** Create or get an existing direct chat room with a target user. */
async createDirectChat(targetUserId: string): Promise<DirectChatResponse> {
return this.request<DirectChatResponse>("/api/usermatrix/direct-chat", {
method: "POST",
body: JSON.stringify({ targetUserId }),
});
}
/** Check if the current user's profile is complete. */
async getProfileCompleteness(): Promise<ProfileCompletenessResponse> {
return this.request<ProfileCompletenessResponse>("/api/usermatrix/profile/completeness");
}
/** Send an invitation email to a new user. */
async sendInvite(email: string, personalNote: string): Promise<InviteResponse> {
return this.request<InviteResponse>("/api/usermatrix/invite", {
method: "POST",
body: JSON.stringify({ email, personalNote }),
});
}
}
@@ -0,0 +1,17 @@
import React from "react";
interface EmptyStateProps {
searchQuery: string;
}
export function EmptyState({ searchQuery }: EmptyStateProps): React.JSX.Element {
return (
<div className="mx_UserDirectory_state mx_UserDirectory_empty">
{searchQuery ? (
<p>Keine Benutzer gefunden f&uuml;r &laquo;{searchQuery}&raquo;.</p>
) : (
<p>Keine Benutzer im Verzeichnis vorhanden.</p>
)}
</div>
);
}
@@ -0,0 +1,17 @@
import React from "react";
interface ErrorStateProps {
message: string;
onRetry: () => void;
}
export function ErrorState({ message, onRetry }: ErrorStateProps): React.JSX.Element {
return (
<div className="mx_UserDirectory_state mx_UserDirectory_error">
<p className="mx_UserDirectory_error_message">Fehler: {message}</p>
<button className="mx_UserDirectory_button mx_UserDirectory_button_retry" onClick={onRetry}>
Erneut versuchen
</button>
</div>
);
}
@@ -0,0 +1,100 @@
import React, { useState } from "react";
import type { ApiClient } from "../api/client.ts";
interface InviteFormProps {
apiClient: ApiClient;
}
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const MIN_NOTE_LENGTH = 10;
export function InviteForm({ apiClient }: InviteFormProps): React.JSX.Element {
const [email, setEmail] = useState("");
const [personalNote, setPersonalNote] = useState("");
const [sending, setSending] = useState(false);
const [success, setSuccess] = useState(false);
const [error, setError] = useState<string | null>(null);
const emailValid = EMAIL_REGEX.test(email);
const noteValid = personalNote.trim().length >= MIN_NOTE_LENGTH;
const canSubmit = emailValid && noteValid && !sending;
async function handleSubmit(e: React.FormEvent): Promise<void> {
e.preventDefault();
if (!canSubmit) return;
setSending(true);
setError(null);
setSuccess(false);
try {
await apiClient.sendInvite(email.trim(), personalNote.trim());
setSuccess(true);
setEmail("");
setPersonalNote("");
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "Einladung konnte nicht gesendet werden");
} finally {
setSending(false);
}
}
return (
<div className="mx_UserDirectory_invite">
<h3 className="mx_UserDirectory_invite_title">Benutzer einladen</h3>
<p className="mx_UserDirectory_invite_description">
Kennen Sie jemanden, der noch nicht bei HealthChat registriert ist?
Senden Sie eine Einladung per E-Mail.
</p>
<form className="mx_UserDirectory_invite_form" onSubmit={handleSubmit}>
<div className="mx_UserDirectory_invite_field">
<label htmlFor="invite-email">E-Mail-Adresse</label>
<input
id="invite-email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="name@example.com"
disabled={sending}
/>
{email && !emailValid && (
<span className="mx_UserDirectory_invite_validation">
Bitte geben Sie eine gültige E-Mail-Adresse ein.
</span>
)}
</div>
<div className="mx_UserDirectory_invite_field">
<label htmlFor="invite-note">Persönliche Nachricht (mind. {MIN_NOTE_LENGTH} Zeichen)</label>
<textarea
id="invite-note"
value={personalNote}
onChange={(e) => setPersonalNote(e.target.value)}
placeholder="Schreiben Sie eine kurze persönliche Nachricht..."
rows={3}
disabled={sending}
/>
{personalNote && !noteValid && (
<span className="mx_UserDirectory_invite_validation">
Die Nachricht muss mindestens {MIN_NOTE_LENGTH} Zeichen lang sein.
</span>
)}
</div>
<button
type="submit"
className="mx_UserDirectory_button mx_UserDirectory_button_invite"
disabled={!canSubmit}
>
{sending ? "Wird gesendet..." : "Einladung senden"}
</button>
{success && (
<p className="mx_UserDirectory_invite_success">
Einladung wurde erfolgreich gesendet!
</p>
)}
{error && (
<p className="mx_UserDirectory_invite_error">{error}</p>
)}
</form>
</div>
);
}
@@ -0,0 +1,10 @@
import React from "react";
export function LoadingState(): React.JSX.Element {
return (
<div className="mx_UserDirectory_state mx_UserDirectory_loading">
<div className="mx_UserDirectory_spinner" />
<p>Benutzerverzeichnis wird geladen...</p>
</div>
);
}
@@ -0,0 +1,32 @@
import React from "react";
import type { ProfileCompletenessResponse } from "../types.ts";
interface ProfileIncompleteWarningProps {
completeness: ProfileCompletenessResponse;
}
const FIELD_LABELS: Record<string, string> = {
DisplayName: "Anzeigename",
Specialty: "Fachgebiet",
PracticeName: "Praxis / Unternehmen",
BusinessCity: "Ort",
BusinessEmail: "E-Mail (geschäftlich)",
BusinessTel: "Telefon (geschäftlich)",
};
export function ProfileIncompleteWarning({ completeness }: ProfileIncompleteWarningProps): React.JSX.Element {
const missingLabels = completeness.missingFields.map((f) => FIELD_LABELS[f] ?? f);
return (
<div className="mx_UserDirectory_profileWarning">
<div className="mx_UserDirectory_profileWarning_icon">!</div>
<div className="mx_UserDirectory_profileWarning_content">
<p className="mx_UserDirectory_profileWarning_title">Profil unvollständig</p>
<p className="mx_UserDirectory_profileWarning_text">
Bitte vervollständigen Sie Ihr Profil, damit andere Benutzer Sie finden können.
Fehlende Angaben: {missingLabels.join(", ")}.
</p>
</div>
</div>
);
}
@@ -0,0 +1,76 @@
import React, { useState } from "react";
import type { MatrixClientPeg, MatrixUser } from "../types.ts";
declare const window: Window & {
mxMatrixClientPeg: MatrixClientPeg;
};
interface UserAvatarProps {
user: MatrixUser;
size?: number;
}
function getInitials(user: MatrixUser): string {
if (user.firstName && user.lastName) {
return `${user.firstName[0]}${user.lastName[0]}`.toUpperCase();
}
if (user.displayName) {
const parts = user.displayName.trim().split(/\s+/);
if (parts.length >= 2) {
return `${parts[0][0]}${parts[1][0]}`.toUpperCase();
}
return parts[0][0].toUpperCase();
}
return "?";
}
/** Color hash for consistent avatar background colors. */
function hashColor(str: string): string {
const colors = [
"#e74c3c", "#e67e22", "#f1c40f", "#2ecc71",
"#1abc9c", "#3498db", "#9b59b6", "#e84393",
];
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash);
}
return colors[Math.abs(hash) % colors.length];
}
export function UserAvatar({ user, size = 48 }: UserAvatarProps): React.JSX.Element {
const [imgError, setImgError] = useState(false);
const avatarUrl = user.avatarMxcUrl
? window.mxMatrixClientPeg.safeGet().mxcUrlToHttp(user.avatarMxcUrl, size, size, "crop")
: null;
if (avatarUrl && !imgError) {
return (
<img
className="mx_UserDirectory_avatar"
src={avatarUrl}
alt={user.displayName}
width={size}
height={size}
onError={() => setImgError(true)}
/>
);
}
const initials = getInitials(user);
const bgColor = hashColor(user.userId);
return (
<div
className="mx_UserDirectory_avatar mx_UserDirectory_avatar_initials"
style={{
width: size,
height: size,
backgroundColor: bgColor,
fontSize: size * 0.4,
}}
>
{initials}
</div>
);
}
@@ -0,0 +1,81 @@
import React, { useState } from "react";
import type { NavigationApi } from "@element-hq/element-web-module-api";
import type { ApiClient } from "../api/client.ts";
import type { MatrixUser } from "../types.ts";
import { UserAvatar } from "./UserAvatar.tsx";
interface UserCardProps {
user: MatrixUser;
apiClient: ApiClient;
navigation: NavigationApi;
}
export function UserCard({ user, apiClient, navigation }: UserCardProps): React.JSX.Element {
const [startingChat, setStartingChat] = useState(false);
const [chatError, setChatError] = useState<string | null>(null);
const displayName = user.displayName || `${user.firstName ?? ""} ${user.lastName ?? ""}`.trim() || user.userId;
async function handleStartChat(): Promise<void> {
setStartingChat(true);
setChatError(null);
try {
const result = await apiClient.createDirectChat(user.userId);
navigation.openRoom(result.roomId);
} catch (err: unknown) {
setChatError(err instanceof Error ? err.message : "Chat konnte nicht gestartet werden");
} finally {
setStartingChat(false);
}
}
return (
<div className="mx_UserDirectory_card">
<div className="mx_UserDirectory_card_header">
<UserAvatar user={user} size={56} />
<div className="mx_UserDirectory_card_name">
<span className="mx_UserDirectory_card_displayName">{displayName}</span>
{user.specialty && (
<span className="mx_UserDirectory_card_specialty">{user.specialty}</span>
)}
</div>
</div>
<div className="mx_UserDirectory_card_details">
{user.practiceName && (
<div className="mx_UserDirectory_card_detail">
<span className="mx_UserDirectory_card_label">Praxis</span>
<span className="mx_UserDirectory_card_value">{user.practiceName}</span>
</div>
)}
{user.businessCity && (
<div className="mx_UserDirectory_card_detail">
<span className="mx_UserDirectory_card_label">Ort</span>
<span className="mx_UserDirectory_card_value">{user.businessCity}</span>
</div>
)}
{user.businessEmail && (
<div className="mx_UserDirectory_card_detail">
<span className="mx_UserDirectory_card_label">E-Mail</span>
<span className="mx_UserDirectory_card_value">{user.businessEmail}</span>
</div>
)}
{user.businessTel && (
<div className="mx_UserDirectory_card_detail">
<span className="mx_UserDirectory_card_label">Telefon</span>
<span className="mx_UserDirectory_card_value">{user.businessTel}</span>
</div>
)}
</div>
<div className="mx_UserDirectory_card_actions">
<button
className="mx_UserDirectory_button mx_UserDirectory_button_chat"
onClick={handleStartChat}
disabled={startingChat}
>
{startingChat ? "Wird gestartet..." : "Chat starten"}
</button>
{chatError && <p className="mx_UserDirectory_card_error">{chatError}</p>}
</div>
</div>
);
}
@@ -0,0 +1,26 @@
import React from "react";
import type { NavigationApi } from "@element-hq/element-web-module-api";
import type { ApiClient } from "../api/client.ts";
import type { MatrixUser } from "../types.ts";
import { UserCard } from "./UserCard.tsx";
interface UserCardGridProps {
users: MatrixUser[];
apiClient: ApiClient;
navigation: NavigationApi;
}
export function UserCardGrid({ users, apiClient, navigation }: UserCardGridProps): React.JSX.Element {
return (
<div className="mx_UserDirectory_grid">
{users.map((user) => (
<UserCard
key={user.userId}
user={user}
apiClient={apiClient}
navigation={navigation}
/>
))}
</div>
);
}
@@ -0,0 +1,43 @@
import React from "react";
interface UserDirectoryHeaderProps {
totalCount: number;
searchQuery: string;
onSearchChange: (query: string) => void;
onRefresh: () => void;
loading: boolean;
}
export function UserDirectoryHeader({
totalCount,
searchQuery,
onSearchChange,
onRefresh,
loading,
}: UserDirectoryHeaderProps): React.JSX.Element {
return (
<div className="mx_UserDirectory_header">
<div className="mx_UserDirectory_header_title">
<h2>Mitglieder</h2>
<span className="mx_UserDirectory_header_count">{totalCount} Benutzer</span>
<button
className="mx_UserDirectory_button mx_UserDirectory_button_refresh"
onClick={onRefresh}
disabled={loading}
title="Aktualisieren"
>
&#x21bb;
</button>
</div>
<div className="mx_UserDirectory_header_search">
<input
type="search"
className="mx_UserDirectory_searchInput"
placeholder="Benutzer suchen..."
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
/>
</div>
</div>
);
}
@@ -0,0 +1,51 @@
import React, { useMemo } from "react";
import type { Api } from "@element-hq/element-web-module-api";
import { ApiClient } from "../api/client.ts";
import { useUsers } from "../hooks/useUsers.ts";
import { useProfileCompleteness } from "../hooks/useProfileCompleteness.ts";
import { UserDirectoryHeader } from "./UserDirectoryHeader.tsx";
import { UserCardGrid } from "./UserCardGrid.tsx";
import { InviteForm } from "./InviteForm.tsx";
import { ProfileIncompleteWarning } from "./ProfileIncompleteWarning.tsx";
import { LoadingState } from "./LoadingState.tsx";
import { ErrorState } from "./ErrorState.tsx";
import { EmptyState } from "./EmptyState.tsx";
interface UserDirectoryPageProps {
api: Api;
apiBaseUrl: string;
}
export function UserDirectoryPage({ api, apiBaseUrl }: UserDirectoryPageProps): React.JSX.Element {
const apiClient = useMemo(() => new ApiClient(apiBaseUrl), [apiBaseUrl]);
const { users, totalCount, loading, error, searchQuery, setSearchQuery, refresh } = useUsers(apiClient);
const { completeness } = useProfileCompleteness(apiClient);
return (
<div className="mx_UserDirectory_page">
{completeness && !completeness.isComplete && (
<ProfileIncompleteWarning completeness={completeness} />
)}
<UserDirectoryHeader
totalCount={totalCount}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
onRefresh={refresh}
loading={loading}
/>
<div className="mx_UserDirectory_content">
{loading && <LoadingState />}
{error && !loading && <ErrorState message={error} onRetry={refresh} />}
{!loading && !error && users.length === 0 && <EmptyState searchQuery={searchQuery} />}
{!loading && !error && users.length > 0 && (
<UserCardGrid users={users} apiClient={apiClient} navigation={api.navigation} />
)}
</div>
<InviteForm apiClient={apiClient} />
</div>
);
}
@@ -0,0 +1,16 @@
import { useState, useEffect } from "react";
/**
* Debounce a value by the given delay in milliseconds.
* Returns the debounced value that only updates after the delay.
*/
export function useDebounce<T>(value: T, delayMs: number): T {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delayMs);
return () => clearTimeout(timer);
}, [value, delayMs]);
return debouncedValue;
}
@@ -0,0 +1,42 @@
import { useState, useEffect } from "react";
import { getOpenIdToken } from "../api/auth.ts";
interface UseOpenIdTokenResult {
token: string | null;
error: string | null;
loading: boolean;
}
/**
* Hook that acquires an OpenID token on mount.
* The token is cached internally by the auth module.
*/
export function useOpenIdToken(): UseOpenIdTokenResult {
const [token, setToken] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
getOpenIdToken()
.then((t) => {
if (!cancelled) {
setToken(t);
setLoading(false);
}
})
.catch((err: unknown) => {
if (!cancelled) {
setError(err instanceof Error ? err.message : "Failed to acquire token");
setLoading(false);
}
});
return () => {
cancelled = true;
};
}, []);
return { token, error, loading };
}
@@ -0,0 +1,44 @@
import { useState, useEffect } from "react";
import type { ApiClient } from "../api/client.ts";
import type { ProfileCompletenessResponse } from "../types.ts";
interface UseProfileCompletenessResult {
completeness: ProfileCompletenessResponse | null;
loading: boolean;
error: string | null;
}
/**
* Hook to check profile completeness on mount.
* Shows a warning if the user's profile is incomplete.
*/
export function useProfileCompleteness(apiClient: ApiClient): UseProfileCompletenessResult {
const [completeness, setCompleteness] = useState<ProfileCompletenessResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
apiClient
.getProfileCompleteness()
.then((result) => {
if (!cancelled) {
setCompleteness(result);
setLoading(false);
}
})
.catch((err: unknown) => {
if (!cancelled) {
setError(err instanceof Error ? err.message : "Failed to check profile");
setLoading(false);
}
});
return () => {
cancelled = true;
};
}, [apiClient]);
return { completeness, loading, error };
}
@@ -0,0 +1,63 @@
import { useState, useEffect, useCallback } from "react";
import type { ApiClient } from "../api/client.ts";
import type { MatrixUser } from "../types.ts";
import { useDebounce } from "./useDebounce.ts";
interface UseUsersResult {
users: MatrixUser[];
totalCount: number;
loading: boolean;
error: string | null;
searchQuery: string;
setSearchQuery: (query: string) => void;
refresh: () => void;
}
/**
* Hook to fetch and search users from the backend API.
* Debounces search input by 300ms.
*/
export function useUsers(apiClient: ApiClient): UseUsersResult {
const [users, setUsers] = useState<MatrixUser[]>([]);
const [totalCount, setTotalCount] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState("");
const [refreshCounter, setRefreshCounter] = useState(0);
const debouncedSearch = useDebounce(searchQuery, 300);
const refresh = useCallback(() => {
setRefreshCounter((c) => c + 1);
}, []);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
const query = debouncedSearch.trim() || undefined;
apiClient
.getUsers(query)
.then((result) => {
if (!cancelled) {
setUsers(result.users);
setTotalCount(result.totalCount);
setLoading(false);
}
})
.catch((err: unknown) => {
if (!cancelled) {
setError(err instanceof Error ? err.message : "Failed to load users");
setLoading(false);
}
});
return () => {
cancelled = true;
};
}, [apiClient, debouncedSearch, refreshCounter]);
return { users, totalCount, loading, error, searchQuery, setSearchQuery, refresh };
}
@@ -0,0 +1,55 @@
import React from "react";
import type { Api, Module, ModuleFactory } from "@element-hq/element-web-module-api";
import { UserDirectoryPage } from "./components/UserDirectoryPage.tsx";
import "./types.ts";
import "./styles/user-directory.css";
/** SVG icon for the space panel button (people group). */
function DirectoryIcon(): React.ReactElement {
return React.createElement(
"svg",
{
width: 24,
height: 24,
viewBox: "0 0 24 24",
fill: "currentColor",
xmlns: "http://www.w3.org/2000/svg",
},
React.createElement("path", {
d: "M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5c-1.66 0-3 1.34-3 3s1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5C6.34 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5c0-2.33-4.67-3.5-7-3.5zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z",
}),
);
}
class UserDirectoryModule implements Module {
static readonly moduleApiVersion = "^1.0.0";
constructor(private readonly api: Api) {}
async load(): Promise<void> {
const config = this.api.config.get("ch.healthchat.user_directory");
const apiBaseUrl = config?.api_base_url;
if (!apiBaseUrl) {
console.warn("[UserDirectoryModule] Missing ch.healthchat.user_directory.api_base_url in config");
return;
}
const api = this.api;
this.api.navigation.registerLocationRenderer("user-directory", () => {
return React.createElement(UserDirectoryPage, { api, apiBaseUrl });
});
this.api.extras.setSpacePanelItem("user-directory", {
label: "Mitglieder",
tooltip: "Benutzerverzeichnis",
icon: React.createElement(DirectoryIcon),
onSelected: () => {
window.location.hash = "#/user-directory";
},
});
}
}
export default UserDirectoryModule satisfies ModuleFactory;
@@ -0,0 +1,18 @@
/**
* React shim that re-exports from window.React.
* Element Web sets window.React in loadPlugins() before loading modules.
* This avoids bundling a separate copy of React.
*/
declare const window: Window & { React: typeof import("react") };
const React = window.React;
export default React;
export const {
useState,
useEffect,
useCallback,
useMemo,
useRef,
createElement,
Fragment,
} = React;
@@ -0,0 +1,401 @@
/* User Directory Module Styles
* Uses Element Web CSS custom properties for theming compatibility.
* All classes prefixed with mx_UserDirectory_ per convention.
*/
.mx_UserDirectory_page {
display: flex;
flex-direction: column;
height: 100%;
overflow-y: auto;
padding: 24px 32px;
box-sizing: border-box;
background: var(--cpd-color-bg-canvas-default, #fff);
color: var(--cpd-color-text-primary, #17191c);
}
/* Header */
.mx_UserDirectory_header {
margin-bottom: 24px;
flex-shrink: 0;
}
.mx_UserDirectory_header_title {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
}
.mx_UserDirectory_header_title h2 {
margin: 0;
font-size: 24px;
font-weight: 600;
color: var(--cpd-color-text-primary, #17191c);
}
.mx_UserDirectory_header_count {
font-size: 14px;
color: var(--cpd-color-text-secondary, #737d8c);
padding: 2px 8px;
background: var(--cpd-color-bg-subtle-secondary, #f3f8fd);
border-radius: 12px;
}
.mx_UserDirectory_header_search {
max-width: 480px;
}
.mx_UserDirectory_searchInput {
width: 100%;
padding: 10px 16px;
border: 1px solid var(--cpd-color-border-interactive-secondary, #c1c6cd);
border-radius: 8px;
font-size: 14px;
background: var(--cpd-color-bg-canvas-default, #fff);
color: var(--cpd-color-text-primary, #17191c);
outline: none;
transition: border-color 0.15s;
box-sizing: border-box;
}
.mx_UserDirectory_searchInput:focus {
border-color: var(--cpd-color-border-interactive-primary, #0467dd);
box-shadow: 0 0 0 2px var(--cpd-color-bg-action-primary-rest, rgba(4, 103, 221, 0.15));
}
.mx_UserDirectory_searchInput::placeholder {
color: var(--cpd-color-text-placeholder, #a9b2bc);
}
/* Buttons */
.mx_UserDirectory_button {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 8px 16px;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: background-color 0.15s, opacity 0.15s;
}
.mx_UserDirectory_button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.mx_UserDirectory_button_chat {
background: var(--cpd-color-bg-action-primary-rest, #0467dd);
color: var(--cpd-color-text-on-solid-primary, #fff);
width: 100%;
}
.mx_UserDirectory_button_chat:hover:not(:disabled) {
background: var(--cpd-color-bg-action-primary-hovered, #035fb8);
}
.mx_UserDirectory_button_refresh {
background: transparent;
color: var(--cpd-color-text-secondary, #737d8c);
padding: 4px 8px;
font-size: 18px;
}
.mx_UserDirectory_button_refresh:hover:not(:disabled) {
color: var(--cpd-color-text-primary, #17191c);
background: var(--cpd-color-bg-subtle-secondary, #f3f8fd);
}
.mx_UserDirectory_button_retry {
background: var(--cpd-color-bg-action-primary-rest, #0467dd);
color: var(--cpd-color-text-on-solid-primary, #fff);
}
.mx_UserDirectory_button_invite {
background: var(--cpd-color-bg-action-primary-rest, #0467dd);
color: var(--cpd-color-text-on-solid-primary, #fff);
}
.mx_UserDirectory_button_invite:hover:not(:disabled) {
background: var(--cpd-color-bg-action-primary-hovered, #035fb8);
}
/* Card Grid */
.mx_UserDirectory_content {
flex: 1;
min-height: 0;
}
.mx_UserDirectory_grid {
display: grid;
grid-template-columns: 1fr;
gap: 16px;
}
@media (min-width: 768px) {
.mx_UserDirectory_grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (min-width: 1200px) {
.mx_UserDirectory_grid {
grid-template-columns: repeat(3, 1fr);
}
}
/* User Card */
.mx_UserDirectory_card {
background: var(--cpd-color-bg-canvas-default, #fff);
border: 1px solid var(--cpd-color-border-interactive-secondary, #e3e8f0);
border-radius: 12px;
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
transition: box-shadow 0.15s;
}
.mx_UserDirectory_card:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.mx_UserDirectory_card_header {
display: flex;
align-items: center;
gap: 12px;
}
.mx_UserDirectory_card_name {
display: flex;
flex-direction: column;
min-width: 0;
}
.mx_UserDirectory_card_displayName {
font-size: 16px;
font-weight: 600;
color: var(--cpd-color-text-primary, #17191c);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mx_UserDirectory_card_specialty {
font-size: 13px;
color: var(--cpd-color-text-secondary, #737d8c);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mx_UserDirectory_card_details {
display: flex;
flex-direction: column;
gap: 4px;
}
.mx_UserDirectory_card_detail {
display: flex;
gap: 8px;
font-size: 13px;
line-height: 1.4;
}
.mx_UserDirectory_card_label {
color: var(--cpd-color-text-secondary, #737d8c);
flex-shrink: 0;
min-width: 56px;
}
.mx_UserDirectory_card_value {
color: var(--cpd-color-text-primary, #17191c);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mx_UserDirectory_card_actions {
margin-top: auto;
}
.mx_UserDirectory_card_error {
margin: 4px 0 0;
font-size: 12px;
color: var(--cpd-color-text-critical-primary, #ff5b55);
}
/* Avatar */
.mx_UserDirectory_avatar {
border-radius: 50%;
object-fit: cover;
flex-shrink: 0;
}
.mx_UserDirectory_avatar_initials {
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-weight: 600;
user-select: none;
}
/* States */
.mx_UserDirectory_state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px 24px;
text-align: center;
color: var(--cpd-color-text-secondary, #737d8c);
}
.mx_UserDirectory_spinner {
width: 32px;
height: 32px;
border: 3px solid var(--cpd-color-border-interactive-secondary, #e3e8f0);
border-top-color: var(--cpd-color-bg-action-primary-rest, #0467dd);
border-radius: 50%;
animation: mx_UserDirectory_spin 0.8s linear infinite;
margin-bottom: 16px;
}
@keyframes mx_UserDirectory_spin {
to {
transform: rotate(360deg);
}
}
.mx_UserDirectory_error_message {
color: var(--cpd-color-text-critical-primary, #ff5b55);
margin-bottom: 12px;
}
/* Profile Warning */
.mx_UserDirectory_profileWarning {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 12px 16px;
background: var(--cpd-color-bg-critical-subtle, #fff5f5);
border: 1px solid var(--cpd-color-border-critical-subtle, #ffc9c6);
border-radius: 8px;
margin-bottom: 16px;
}
.mx_UserDirectory_profileWarning_icon {
width: 24px;
height: 24px;
border-radius: 50%;
background: var(--cpd-color-bg-critical-primary, #ff5b55);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 14px;
flex-shrink: 0;
}
.mx_UserDirectory_profileWarning_title {
font-weight: 600;
font-size: 14px;
margin: 0 0 4px;
color: var(--cpd-color-text-critical-primary, #ff5b55);
}
.mx_UserDirectory_profileWarning_text {
font-size: 13px;
margin: 0;
color: var(--cpd-color-text-secondary, #737d8c);
}
/* Invite Form */
.mx_UserDirectory_invite {
margin-top: 32px;
padding-top: 24px;
border-top: 1px solid var(--cpd-color-border-interactive-secondary, #e3e8f0);
flex-shrink: 0;
}
.mx_UserDirectory_invite_title {
font-size: 18px;
font-weight: 600;
margin: 0 0 8px;
color: var(--cpd-color-text-primary, #17191c);
}
.mx_UserDirectory_invite_description {
font-size: 14px;
color: var(--cpd-color-text-secondary, #737d8c);
margin: 0 0 16px;
}
.mx_UserDirectory_invite_form {
display: flex;
flex-direction: column;
gap: 12px;
max-width: 480px;
}
.mx_UserDirectory_invite_field {
display: flex;
flex-direction: column;
gap: 4px;
}
.mx_UserDirectory_invite_field label {
font-size: 13px;
font-weight: 500;
color: var(--cpd-color-text-secondary, #737d8c);
}
.mx_UserDirectory_invite_field input,
.mx_UserDirectory_invite_field textarea {
padding: 10px 12px;
border: 1px solid var(--cpd-color-border-interactive-secondary, #c1c6cd);
border-radius: 8px;
font-size: 14px;
font-family: inherit;
background: var(--cpd-color-bg-canvas-default, #fff);
color: var(--cpd-color-text-primary, #17191c);
outline: none;
transition: border-color 0.15s;
resize: vertical;
box-sizing: border-box;
}
.mx_UserDirectory_invite_field input:focus,
.mx_UserDirectory_invite_field textarea:focus {
border-color: var(--cpd-color-border-interactive-primary, #0467dd);
}
.mx_UserDirectory_invite_validation {
font-size: 12px;
color: var(--cpd-color-text-critical-primary, #ff5b55);
}
.mx_UserDirectory_invite_success {
font-size: 13px;
color: var(--cpd-color-text-success-primary, #0a8a0a);
margin: 0;
}
.mx_UserDirectory_invite_error {
font-size: 13px;
color: var(--cpd-color-text-critical-primary, #ff5b55);
margin: 0;
}
@@ -0,0 +1,79 @@
/** Extend the module API Config with our custom key. */
declare module "@element-hq/element-web-module-api" {
interface Config {
"ch.healthchat.user_directory": UserDirectoryConfig;
}
}
export interface UserDirectoryConfig {
api_base_url: string;
}
export interface MatrixUser {
userId: string;
displayName: string;
firstName: string;
lastName: string;
title: string;
businessEmail: string;
businessTel: string;
businessCity: string;
practiceName: string;
specialty: string;
avatarMxcUrl: string;
furtherInfo: string;
isActive: boolean;
isAdmin: boolean;
creationTimestamp: string;
lastSeenTimestamp: string;
lastSyncedAt: string;
}
export interface MatrixUserSearchResult {
users: MatrixUser[];
totalCount: number;
searchQuery: string;
}
export interface DirectChatResponse {
roomId: string;
}
export interface ProfileCompletenessResponse {
isComplete: boolean;
missingFields: string[];
hasProfilePhoto: boolean;
message: string;
}
export interface InviteRequest {
email: string;
personalNote: string;
}
export interface InviteResponse {
success: boolean;
message: string;
}
/**
* OpenID token from the Matrix client.
* Used to authenticate with the HealthChat backend.
*/
export interface OpenIdToken {
access_token: string;
token_type: string;
matrix_server_name: string;
expires_in: number;
}
/** Minimal typing for window.mxMatrixClientPeg */
export interface MatrixClientPeg {
safeGet(): MatrixClient;
}
export interface MatrixClient {
getOpenIdToken(): Promise<OpenIdToken>;
mxcUrlToHttp(mxcUrl: string, width?: number, height?: number, resizeMethod?: string): string | null;
getUserId(): string | null;
}
@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "es2022",
"module": "es2022",
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"noEmit": true,
"jsx": "react",
"strict": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"outDir": "./dist",
"lib": ["es2022", "dom", "dom.iterable"],
"noUnusedLocals": true,
"noUnusedParameters": true,
"skipLibCheck": true
},
"include": ["src/**/*.ts", "src/**/*.tsx"]
}
@@ -0,0 +1,73 @@
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { defineConfig, type Plugin } from "vite";
const __dirname = dirname(fileURLToPath(import.meta.url));
/**
* Vite plugin that inlines extracted CSS into the JS entry chunk.
* Injects a <style> tag at runtime so the module is a single .js file.
*/
function inlineCssPlugin(): Plugin {
return {
name: "inline-css",
apply: "build",
enforce: "post",
generateBundle(_, bundle) {
let cssContent = "";
const cssKeys: string[] = [];
for (const [key, chunk] of Object.entries(bundle)) {
if (key.endsWith(".css") && chunk.type === "asset" && typeof chunk.source === "string") {
cssContent += chunk.source;
cssKeys.push(key);
}
}
// Remove CSS files from the bundle
for (const key of cssKeys) {
delete bundle[key];
}
// Inject CSS into the entry JS chunk
if (cssContent) {
for (const chunk of Object.values(bundle)) {
if (chunk.type === "chunk" && chunk.isEntry) {
const injection =
`(function(){` +
`var s=document.createElement("style");` +
`s.textContent=${JSON.stringify(cssContent)};` +
`document.head.appendChild(s)` +
`})();\n`;
chunk.code = injection + chunk.code;
}
}
}
},
};
}
export default defineConfig({
build: {
lib: {
entry: resolve(__dirname, "src/index.ts"),
formats: ["es"],
fileName: "user-directory-module",
},
outDir: "dist",
rollupOptions: {
/* @element-hq/element-web-module-api is type-only at runtime;
the module API object is passed via constructor, not imported. */
external: ["@element-hq/element-web-module-api"],
},
cssCodeSplit: false,
},
resolve: {
alias: {
/* React is provided globally by Element Web (window.React).
Alias to a shim so the bundle uses the host app's React. */
"react": resolve(__dirname, "src/shims/react.ts"),
},
},
plugins: [inlineCssPlugin()],
});
@@ -56,7 +56,7 @@ const showRevokeButton = (
userWidget: boolean | undefined,
): boolean => {
// Don't show revoke button for api.healthchat.ch widgets
if (app.url?.includes("api.healthchat.ch")) return false;
if (app.url?.includes("api.healthchat.ch") || app.url?.includes("localhost:5001")) return false;
const isAllowedWidget =
(isAppWidget(app) &&
+2 -2
View File
@@ -194,7 +194,7 @@ export default class AppTile extends React.Component<IProps, IState> {
// This is a function to make the impact of calling SettingsStore slightly less
private hasPermissionToLoad = (props: IProps): boolean => {
// Auto-approve widgets from api.healthchat.ch domain
if (props.app.url?.includes("api.healthchat.ch")) return true;
if (props.app.url?.includes("api.healthchat.ch") || props.app.url?.includes("localhost:5001")) return true;
if (this.usingLocalWidget()) return true;
if (!props.room) return true; // user widgets always have permissions
@@ -808,7 +808,7 @@ export default class AppTile extends React.Component<IProps, IState> {
);
// Don't show minimize button for api.healthchat.ch widgets
if (!this.props.app.url?.includes("api.healthchat.ch")) {
if (!this.props.app.url?.includes("api.healthchat.ch") && !this.props.app.url?.includes("localhost:5001")) {
layoutButtons.push(
<AccessibleButton
key="minimise"
+1 -1
View File
@@ -20,7 +20,7 @@ export const WidgetPermissionCustomisations: WidgetPermissionsCustomisations<Wid
requestedCapabilities: Set<Capability>,
): Promise<Set<Capability>> => {
// Check if the widget URL contains api.healthchat.ch
if (widget.templateUrl?.includes("api.healthchat.ch")) {
if (widget.templateUrl?.includes("api.healthchat.ch") || widget.templateUrl?.includes("localhost:5001")) {
// Return all requested capabilities as approved
return new Set(requestedCapabilities);
}
+2 -2
View File
@@ -105,7 +105,7 @@ export class ElementWidgetDriver extends WidgetDriver {
this.allowedCapabilities = new Set([MatrixCapabilities.Screenshots, ElementWidgetCapabilities.RequiresClient]);
// Auto-approve all capabilities for api.healthchat.ch widgets
if (this.forWidget.templateUrl?.includes("api.healthchat.ch")) {
if (this.forWidget.templateUrl?.includes("api.healthchat.ch") || this.forWidget.templateUrl?.includes("localhost:5001")) {
// Add all common capabilities that HealthChat widgets might request
this.allowedCapabilities.add(MatrixCapabilities.AlwaysOnScreen);
this.allowedCapabilities.add(MatrixCapabilities.StickerSending);
@@ -617,7 +617,7 @@ export class ElementWidgetDriver extends WidgetDriver {
public async askOpenID(observer: SimpleObservable<IOpenIDUpdate>): Promise<void> {
// Auto-approve OpenID for api.healthchat.ch widgets
if (this.forWidget.templateUrl?.includes("api.healthchat.ch")) {
if (this.forWidget.templateUrl?.includes("api.healthchat.ch") || this.forWidget.templateUrl?.includes("localhost:5001")) {
return observer.update({
state: OpenIDRequestState.Allowed,
token: await MatrixClientPeg.safeGet().getOpenIdToken(),