mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 12:41:04 +00:00
feat(chat): join users to rooms on sign-in
This commit is contained in:
@@ -67,7 +67,8 @@ export class ChatProvisioningService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async currentHolders(): Promise<PositionHolder[]> {
|
/** Every current holder in the unit, or just one person's rows when `userId` is given. */
|
||||||
|
private async currentHolders(userId?: string): Promise<PositionHolder[]> {
|
||||||
return this.dataSource.query(
|
return this.dataSource.query(
|
||||||
`SELECT p.key AS "positionKey",
|
`SELECT p.key AS "positionKey",
|
||||||
COALESCE(p.name->>'en', p.key) AS "positionName",
|
COALESCE(p.name->>'en', p.key) AS "positionName",
|
||||||
@@ -82,11 +83,53 @@ export class ChatProvisioningService {
|
|||||||
WHERE ep.is_current = true
|
WHERE ep.is_current = true
|
||||||
AND e.is_current = true
|
AND e.is_current = true
|
||||||
AND o.key = $1
|
AND o.key = $1
|
||||||
AND u.key = $2`,
|
AND u.key = $2
|
||||||
[ORG_KEY, UNIT_KEY],
|
${userId ? 'AND e.user_id = $3' : ''}`,
|
||||||
|
userId ? [ORG_KEY, UNIT_KEY, userId] : [ORG_KEY, UNIT_KEY],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Put one person in their rooms right now.
|
||||||
|
*
|
||||||
|
* {@link reconcile} is nightly, so without this a new employee's first
|
||||||
|
* sign-in shows an empty client until 3AM — the SSO handoff creates their
|
||||||
|
* account but joins them to nothing. Called on every /chat/sso, so it is
|
||||||
|
* scoped to the one user (a full reconcile per click would be a room-count
|
||||||
|
* multiple of Matrix calls) and every step is get-or-create.
|
||||||
|
*
|
||||||
|
* Someone holding no current position in the unit joins nothing, by the same
|
||||||
|
* rule the reconcile uses — chat membership follows the org tree.
|
||||||
|
*/
|
||||||
|
async joinUserRooms(userId: string, displayName: string): Promise<number> {
|
||||||
|
const positions = await this.currentHolders(userId);
|
||||||
|
if (positions.length === 0) return 0;
|
||||||
|
|
||||||
|
const mxid = this.matrix.mxidFor(userId, displayName);
|
||||||
|
// The JWT login auto-registers too, but that happens after this runs and
|
||||||
|
// the admin join API 404s on an account that does not exist yet.
|
||||||
|
await this.matrix.ensureUser(mxid, displayName);
|
||||||
|
|
||||||
|
const spaceId = await this.matrix.ensureRoom(SPACE_ALIAS, 'EDR Freight', {
|
||||||
|
isSpace: true,
|
||||||
|
});
|
||||||
|
const generalRoomId = await this.matrix.ensureRoom(GENERAL_ALIAS, 'General', {
|
||||||
|
parentSpaceId: spaceId,
|
||||||
|
});
|
||||||
|
await this.matrix.ensureJoined(generalRoomId, mxid);
|
||||||
|
|
||||||
|
for (const position of positions) {
|
||||||
|
const roomId = await this.matrix.ensureRoom(
|
||||||
|
`dept-${position.positionKey}`,
|
||||||
|
position.positionName,
|
||||||
|
{ parentSpaceId: spaceId },
|
||||||
|
);
|
||||||
|
await this.matrix.ensureJoined(roomId, mxid);
|
||||||
|
}
|
||||||
|
|
||||||
|
return positions.length + 1;
|
||||||
|
}
|
||||||
|
|
||||||
/** Force-joins additions, kicks+deactivates users no longer entitled anywhere. */
|
/** Force-joins additions, kicks+deactivates users no longer entitled anywhere. */
|
||||||
private async syncMembership(
|
private async syncMembership(
|
||||||
roomId: string,
|
roomId: string,
|
||||||
@@ -99,7 +142,7 @@ export class ChatProvisioningService {
|
|||||||
let joined = 0;
|
let joined = 0;
|
||||||
for (const userId of desiredUserIds) {
|
for (const userId of desiredUserIds) {
|
||||||
if (!currentSet.has(userId)) {
|
if (!currentSet.has(userId)) {
|
||||||
await this.matrix.forceJoin(roomId, userId);
|
await this.matrix.ensureJoined(roomId, userId);
|
||||||
joined += 1;
|
joined += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -126,14 +169,16 @@ export class ChatProvisioningService {
|
|||||||
parentSpaceId: spaceId,
|
parentSpaceId: spaceId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const allUserIds = new Set(holders.map((h) => this.matrix.mxid(h.userId)));
|
const allUserIds = new Set(
|
||||||
|
holders.map((h) => this.matrix.mxidFor(h.userId, h.userName)),
|
||||||
|
);
|
||||||
|
|
||||||
// Accounts are otherwise only created lazily on first JWT login (see
|
// Accounts are otherwise only created lazily on first JWT login (see
|
||||||
// ChatSsoService) — force-joining someone who has never clicked "Chat"
|
// ChatSsoService) — force-joining someone who has never clicked "Chat"
|
||||||
// yet 404s ("User not found") without this.
|
// yet 404s ("User not found") without this.
|
||||||
const seenUserIds = new Set<string>();
|
const seenUserIds = new Set<string>();
|
||||||
for (const h of holders) {
|
for (const h of holders) {
|
||||||
const mxid = this.matrix.mxid(h.userId);
|
const mxid = this.matrix.mxidFor(h.userId, h.userName);
|
||||||
if (seenUserIds.has(mxid)) continue;
|
if (seenUserIds.has(mxid)) continue;
|
||||||
seenUserIds.add(mxid);
|
seenUserIds.add(mxid);
|
||||||
await this.matrix.ensureUser(mxid, h.userName);
|
await this.matrix.ensureUser(mxid, h.userName);
|
||||||
@@ -158,7 +203,7 @@ export class ChatProvisioningService {
|
|||||||
name: h.positionName,
|
name: h.positionName,
|
||||||
userIds: new Set<string>(),
|
userIds: new Set<string>(),
|
||||||
};
|
};
|
||||||
entry.userIds.add(this.matrix.mxid(h.userId));
|
entry.userIds.add(this.matrix.mxidFor(h.userId, h.userName));
|
||||||
byPosition.set(h.positionKey, entry);
|
byPosition.set(h.positionKey, entry);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||||
import type { ConfigType } from '@nestjs/config';
|
import type { ConfigType } from '@nestjs/config';
|
||||||
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||||
import { SignJWT } from 'jose';
|
import { SignJWT } from 'jose';
|
||||||
|
|
||||||
import chatConfig from '../../config/chat.config';
|
import chatConfig from '../../config/chat.config';
|
||||||
import { MatrixClient } from './matrix.client';
|
import { ChatProvisioningService } from './chat-provisioning.service';
|
||||||
|
import { MatrixClient, chatLocalpart } from './matrix.client';
|
||||||
|
|
||||||
/** Matrix login_tokens are single-use and expire in 5 minutes (Synapse default). */
|
/** Long enough for one login call, short enough to be worthless if it leaks. */
|
||||||
const JWT_TTL_SECONDS = 60;
|
const JWT_TTL_SECONDS = 60;
|
||||||
|
|
||||||
function displayName(user: TCurrentUser): string {
|
function displayName(user: TCurrentUser): string {
|
||||||
@@ -24,36 +25,67 @@ function displayName(user: TCurrentUser): string {
|
|||||||
*
|
*
|
||||||
* 1. Sign a short-lived JWT asserting this user's id (Synapse's
|
* 1. Sign a short-lived JWT asserting this user's id (Synapse's
|
||||||
* org.matrix.login.jwt auto-registers the account on first use).
|
* org.matrix.login.jwt auto-registers the account on first use).
|
||||||
* 2. Trade that JWT for a real Matrix access token.
|
* 2. Trade that JWT for a real Matrix session.
|
||||||
* 3. Trade the access token for a one-shot login_token.
|
* 3. Hand the caller a link to Element's sso.html shim, which writes that
|
||||||
* 4. Hand the caller a link to Element's sso.html shim, which seeds
|
* session into localStorage and drops the user straight into Element.
|
||||||
* localStorage and forwards the token into Element's own login flow.
|
*
|
||||||
|
* Step 3 used to mint a one-shot login_token and let Element redeem it. That
|
||||||
|
* path is capped at one request per user per minute by a limiter hardcoded in
|
||||||
|
* Synapse, so a second click inside a minute returned M_LIMIT_EXCEEDED — and a
|
||||||
|
* spent token surfaces in Element as "Incorrect username and/or password".
|
||||||
|
* Element accepts a plaintext token out of localStorage (Lifecycle.ts
|
||||||
|
* getStoredToken/tryDecryptToken), so handing over the session we already hold
|
||||||
|
* removes both failure modes and one round-trip.
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ChatSsoService {
|
export class ChatSsoService {
|
||||||
|
private readonly logger = new Logger(ChatSsoService.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(chatConfig.KEY)
|
@Inject(chatConfig.KEY)
|
||||||
private readonly config: ConfigType<typeof chatConfig>,
|
private readonly config: ConfigType<typeof chatConfig>,
|
||||||
private readonly matrix: MatrixClient,
|
private readonly matrix: MatrixClient,
|
||||||
|
private readonly provisioning: ChatProvisioningService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async getSsoUrl(user: TCurrentUser): Promise<{ url: string }> {
|
async getSsoUrl(user: TCurrentUser): Promise<{ url: string }> {
|
||||||
const secret = new TextEncoder().encode(this.config.jwtSecret);
|
const secret = new TextEncoder().encode(this.config.jwtSecret);
|
||||||
const jwt = await new SignJWT({ name: displayName(user) })
|
const name = displayName(user);
|
||||||
|
|
||||||
|
// Before the link, not after: the reconcile that fills rooms is nightly, so
|
||||||
|
// a first sign-in would otherwise open an empty client. Best-effort —
|
||||||
|
// failing to join a room is no reason to refuse someone a sign-in link.
|
||||||
|
try {
|
||||||
|
await this.provisioning.joinUserRooms(user.id, name);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(
|
||||||
|
`Room join on sign-in failed for ${user.id}: ${(err as Error).message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Synapse takes the localpart straight from `sub` on auto-registration, so
|
||||||
|
// this must be byte-identical to what ChatProvisioningService derives for
|
||||||
|
// the same person — otherwise SSO signs them into one account while the
|
||||||
|
// reconcile force-joins a different one into the rooms.
|
||||||
|
const jwt = await new SignJWT({ name })
|
||||||
.setProtectedHeader({ alg: 'HS256' })
|
.setProtectedHeader({ alg: 'HS256' })
|
||||||
.setSubject(user.id)
|
.setSubject(chatLocalpart(user.id, name))
|
||||||
.setIssuer('edr-freight-api')
|
.setIssuer('edr-freight-api')
|
||||||
.setAudience('matrix')
|
.setAudience('matrix')
|
||||||
.setIssuedAt()
|
.setIssuedAt()
|
||||||
.setExpirationTime(`${JWT_TTL_SECONDS}s`)
|
.setExpirationTime(`${JWT_TTL_SECONDS}s`)
|
||||||
.sign(secret);
|
.sign(secret);
|
||||||
|
|
||||||
const { access_token } = await this.matrix.loginWithJwt(jwt);
|
const session = await this.matrix.loginWithJwt(jwt);
|
||||||
const { login_token } = await this.matrix.getLoginToken(access_token);
|
|
||||||
|
|
||||||
const url = new URL(`${this.config.webUrl}/sso.html`);
|
// Session goes in the URL fragment, never the query: a fragment is not sent
|
||||||
url.searchParams.set('t', login_token);
|
// to any server, so the token stays out of Element's access log, and
|
||||||
url.searchParams.set('hs', this.config.publicBaseUrl);
|
// sso.html replaces the entry so it does not linger in history either.
|
||||||
return { url: url.toString() };
|
const params = new URLSearchParams({
|
||||||
|
hs: this.config.publicBaseUrl,
|
||||||
|
t: session.access_token,
|
||||||
|
u: session.user_id,
|
||||||
|
d: session.device_id,
|
||||||
|
});
|
||||||
|
return { url: `${this.config.webUrl}/sso.html#${params.toString()}` };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
35
apps/edr-freight-api/src/modules/chat/matrix.client.spec.ts
Normal file
35
apps/edr-freight-api/src/modules/chat/matrix.client.spec.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { chatLocalpart } from './matrix.client';
|
||||||
|
|
||||||
|
describe('chatLocalpart', () => {
|
||||||
|
it('reads from the name, not the id', () => {
|
||||||
|
expect(
|
||||||
|
chatLocalpart('03f5eb9e-23a0-4413-8d98-8de4b98b1be2', 'Nati Wondi'),
|
||||||
|
).toBe('nati-wondi.03f5eb');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('separates two people who share a name', () => {
|
||||||
|
// Both of these are real dev rows — same name, different employees.
|
||||||
|
const a = chatLocalpart('11111111-1111-4111-8111-111111111111', 'MARKOS REGASA');
|
||||||
|
const b = chatLocalpart('22222222-2222-4222-8222-222222222222', 'Markos REGASA');
|
||||||
|
expect(a).not.toBe(b);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is stable for the same person', () => {
|
||||||
|
const id = '7d798218-09de-47a1-98eb-f61ec44e9280';
|
||||||
|
expect(chatLocalpart(id, 'Naod')).toBe(chatLocalpart(id, 'Naod'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still yields a usable localpart for a name that slugs to nothing', () => {
|
||||||
|
expect(chatLocalpart('7d798218-09de-47a1-98eb-f61ec44e9280', 'ናኦድ')).toBe(
|
||||||
|
'user.7d7982',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('only emits characters Matrix accepts in a localpart', () => {
|
||||||
|
for (const name of ['Mubarek Jemal Hassen', "N'gozi O_Brien", 'ናኦድ', 'José']) {
|
||||||
|
expect(chatLocalpart('7d798218-09de-47a1-98eb-f61ec44e9280', name)).toMatch(
|
||||||
|
/^[a-z0-9._=\-/]+$/,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -14,6 +14,32 @@ import chatConfig from '../../config/chat.config';
|
|||||||
* ChatBridgeService) — one bot/admin account covers both jobs, no separate
|
* ChatBridgeService) — one bot/admin account covers both jobs, no separate
|
||||||
* bot user needed.
|
* bot user needed.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Localpart of a staff member's MXID: their name, plus the first 6 hex of
|
||||||
|
* their freight user id.
|
||||||
|
*
|
||||||
|
* The tail is not decoration. Names collide — 19 of the 114 users in the dev
|
||||||
|
* IAM share a slug with someone else ("MARKOS REGASA" and "Markos REGASA" are
|
||||||
|
* two different people) — and an MXID is permanent, so a bare slug would hand
|
||||||
|
* two employees the same Matrix account and each other's rooms. The id is
|
||||||
|
* already random, so 6 hex of it separates them without a lookup or a mapping
|
||||||
|
* table, and keeps the derivation pure: ChatSsoService (which mints the JWT
|
||||||
|
* `sub`) and ChatProvisioningService (which force-joins rooms) must agree on
|
||||||
|
* this string exactly or they provision two accounts per person.
|
||||||
|
*/
|
||||||
|
export function chatLocalpart(userId: string, displayName: string): string {
|
||||||
|
const slug = displayName
|
||||||
|
// NFKD splits an accent off its letter; the non-alnum sweep below then
|
||||||
|
// folds the leftover mark into the same `-` run as the neighbouring space.
|
||||||
|
.normalize('NFKD')
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '')
|
||||||
|
.slice(0, 40);
|
||||||
|
// Amharic-only names slug to nothing — the tail still makes it unique.
|
||||||
|
return `${slug || 'user'}.${userId.replace(/-/g, '').slice(0, 6)}`;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class MatrixClient {
|
export class MatrixClient {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -21,11 +47,28 @@ export class MatrixClient {
|
|||||||
private readonly config: ConfigType<typeof chatConfig>,
|
private readonly config: ConfigType<typeof chatConfig>,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alias localparts go in a URL path segment, so a `/` in one is fatal:
|
||||||
|
* Synapse decodes the path before routing, and `%2F` splits the request into
|
||||||
|
* a route that doesn't exist ("M_UNRECOGNIZED"). resolveAlias reads that 404
|
||||||
|
* as "no such room" and ensureRoom then tries to create the same broken alias
|
||||||
|
* on every run. Position keys are `edr_freight_app/opn` shaped, so this hits
|
||||||
|
* every dept room but the handful whose key happens to be a bare word.
|
||||||
|
*/
|
||||||
|
private static aliasSafe(alias: string): string {
|
||||||
|
return alias.replace(/[^A-Za-z0-9._=-]/g, '-');
|
||||||
|
}
|
||||||
|
|
||||||
/** `@<localpart>:<server_name>` — the one place this format is assembled. */
|
/** `@<localpart>:<server_name>` — the one place this format is assembled. */
|
||||||
mxid(localpart: string): string {
|
mxid(localpart: string): string {
|
||||||
return `@${localpart}:${this.config.serverName}`;
|
return `@${localpart}:${this.config.serverName}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The MXID of a freight user — see {@link chatLocalpart}. */
|
||||||
|
mxidFor(userId: string, displayName: string): string {
|
||||||
|
return this.mxid(chatLocalpart(userId, displayName));
|
||||||
|
}
|
||||||
|
|
||||||
get serverName(): string {
|
get serverName(): string {
|
||||||
return this.config.serverName;
|
return this.config.serverName;
|
||||||
}
|
}
|
||||||
@@ -123,15 +166,13 @@ export class MatrixClient {
|
|||||||
return Object.keys(res.joined);
|
return Object.keys(res.joined);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Exchange a fresh access token for a one-shot login_token (5 min TTL). */
|
// No getLoginToken here on purpose. POST /_matrix/client/v1/login/get_token
|
||||||
getLoginToken(accessToken: string): Promise<{ login_token: string }> {
|
// is rate limited to 1 request per user per MINUTE, hardcoded in Synapse
|
||||||
return this.request(
|
// (rest/client/login_token_request.py: "Ratelimit aggressively … could be
|
||||||
'POST',
|
// abused by a malicious client to create many sessions") and not settable
|
||||||
'/_matrix/client/v1/login/get_token',
|
// from homeserver.yaml. A second click inside a minute got M_LIMIT_EXCEEDED.
|
||||||
{},
|
// ChatSsoService hands Element the session from loginWithJwt directly
|
||||||
accessToken,
|
// instead, which needs no second call.
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** null when the alias doesn't resolve to a room yet. */
|
/** null when the alias doesn't resolve to a room yet. */
|
||||||
resolveAlias(alias: string): Promise<{ room_id: string } | null> {
|
resolveAlias(alias: string): Promise<{ room_id: string } | null> {
|
||||||
@@ -180,10 +221,11 @@ export class MatrixClient {
|
|||||||
* on every reconcile run and every bridged notification alike.
|
* on every reconcile run and every bridged notification alike.
|
||||||
*/
|
*/
|
||||||
async ensureRoom(
|
async ensureRoom(
|
||||||
alias: string,
|
rawAlias: string,
|
||||||
name: string,
|
name: string,
|
||||||
opts: { isSpace?: boolean; parentSpaceId?: string } = {},
|
opts: { isSpace?: boolean; parentSpaceId?: string } = {},
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
|
const alias = MatrixClient.aliasSafe(rawAlias);
|
||||||
const existing = await this.resolveAlias(`#${alias}:${this.config.serverName}`);
|
const existing = await this.resolveAlias(`#${alias}:${this.config.serverName}`);
|
||||||
if (existing) return existing.room_id;
|
if (existing) return existing.room_id;
|
||||||
|
|
||||||
@@ -228,6 +270,21 @@ export class MatrixClient {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Force-join, treating "already a member" as success. Synapse answers a
|
||||||
|
* repeat join with 403 `M_FORBIDDEN: "<user> is already in the room."`, which
|
||||||
|
* is a failure only if you assumed you knew the membership first. Callers
|
||||||
|
* that just want someone in a room (sign-in, reconcile racing itself) want
|
||||||
|
* this; the raw 403 tells them nothing they can act on.
|
||||||
|
*/
|
||||||
|
async ensureJoined(roomIdOrAlias: string, userId: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.forceJoin(roomIdOrAlias, userId);
|
||||||
|
} catch (err) {
|
||||||
|
if (!/already in the room/i.test((err as Error).message)) throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
kick(roomId: string, userId: string, reason: string): Promise<void> {
|
kick(roomId: string, userId: string, reason: string): Promise<void> {
|
||||||
return this.request(
|
return this.request(
|
||||||
'POST',
|
'POST',
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
|
|
||||||
import { chatApi } from "./chatApi";
|
|
||||||
|
|
||||||
export const CHAT_SSO_KEY = ["chat", "sso"] as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The login_token this resolves to is single-use and expires in 5 minutes
|
|
||||||
* (Synapse default) — the global `staleTime: 0` (queryClient.ts) already
|
|
||||||
* means every fresh mount of the launch page refetches rather than reusing
|
|
||||||
* a possibly-spent link.
|
|
||||||
*/
|
|
||||||
export function useChatSso() {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: CHAT_SSO_KEY,
|
|
||||||
queryFn: chatApi.getSsoUrl,
|
|
||||||
retry: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,17 +1,44 @@
|
|||||||
import { Alert, Button, Card, Center, Loader, Stack, Text } from "@mantine/core";
|
import { Alert, Button, Card, Center, Stack, Text } from "@mantine/core";
|
||||||
import { MessageSquare, TriangleAlert } from "lucide-react";
|
import { MessageSquare, TriangleAlert } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
import { PageContainer, PageHeader } from "@/components/page";
|
import { PageContainer, PageHeader } from "@/components/page";
|
||||||
import { useChatSso } from "@/features/chat/useChatSso";
|
import { chatApi } from "@/features/chat/chatApi";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Chat itself lives at chat.edr.et (Element), not in this app — this page's
|
* Chat itself lives at chat.edr.et (Element), not in this app — this page's
|
||||||
* only job is a fresh one-click sign-in link into it. A real `<a>` (not
|
* only job is a one-click sign-in link into it. No iframe: Element's own CSP
|
||||||
* `window.open()` in a click handler) so the browser never treats it as a
|
* refuses to be framed.
|
||||||
* blocked popup, and no iframe: Element's own CSP refuses to be framed.
|
*
|
||||||
|
* The link is minted per click, never on mount and never cached: Synapse's
|
||||||
|
* login_token is single-use and expires in 5 minutes, and Element reports a
|
||||||
|
* spent one as "Incorrect username and/or password". A held-onto url is
|
||||||
|
* therefore wrong on the second click, on a remount served from cache, and on
|
||||||
|
* any click more than 5 minutes after the page loaded.
|
||||||
*/
|
*/
|
||||||
export default function ChatLaunchPage() {
|
export default function ChatLaunchPage() {
|
||||||
const { data: url, isLoading, isError, refetch } = useChatSso();
|
const [state, setState] = useState<"idle" | "loading" | "error">("idle");
|
||||||
|
|
||||||
|
const open = async () => {
|
||||||
|
// Opened before the await so it still counts as the user's click — a
|
||||||
|
// window.open() after it is treated as a popup and blocked.
|
||||||
|
//
|
||||||
|
// No "noopener" in the features: passing it makes window.open return null,
|
||||||
|
// which would leave this blank tab orphaned and send Element into the
|
||||||
|
// current tab instead. Clearing .opener on the handle does the same job.
|
||||||
|
const tab = window.open("", "_blank");
|
||||||
|
if (tab) tab.opener = null;
|
||||||
|
setState("loading");
|
||||||
|
try {
|
||||||
|
const url = await chatApi.getSsoUrl();
|
||||||
|
if (tab) tab.location.replace(url);
|
||||||
|
else window.location.assign(url); // popup blocked — go in this tab
|
||||||
|
setState("idle");
|
||||||
|
} catch {
|
||||||
|
tab?.close();
|
||||||
|
setState("error");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContainer>
|
<PageContainer>
|
||||||
@@ -19,41 +46,30 @@ export default function ChatLaunchPage() {
|
|||||||
<Card withBorder radius="md" p="xl">
|
<Card withBorder radius="md" p="xl">
|
||||||
<Center>
|
<Center>
|
||||||
<Stack align="center" gap="md" py="xl">
|
<Stack align="center" gap="md" py="xl">
|
||||||
{isLoading && <Loader />}
|
{state === "error" && (
|
||||||
|
<Alert
|
||||||
{isError && (
|
icon={<TriangleAlert size={18} />}
|
||||||
<Stack align="center" gap="sm">
|
color="red"
|
||||||
<Alert
|
title="Couldn't get a sign-in link"
|
||||||
icon={<TriangleAlert size={18} />}
|
variant="light"
|
||||||
color="red"
|
>
|
||||||
title="Couldn't get a sign-in link"
|
Something went wrong reaching chat. Try again.
|
||||||
variant="light"
|
</Alert>
|
||||||
>
|
|
||||||
Something went wrong reaching chat. Try again.
|
|
||||||
</Alert>
|
|
||||||
<Button variant="light" onClick={() => refetch()}>
|
|
||||||
Retry
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{url && (
|
<Stack align="center" gap="sm">
|
||||||
<Stack align="center" gap="sm">
|
<MessageSquare size={40} strokeWidth={1.5} />
|
||||||
<MessageSquare size={40} strokeWidth={1.5} />
|
<Text c="dimmed" ta="center" maw={360}>
|
||||||
<Text c="dimmed" ta="center" maw={360}>
|
Opens EDR Chat in a new tab, already signed in as you.
|
||||||
Opens EDR Chat in a new tab, already signed in as you.
|
</Text>
|
||||||
</Text>
|
<Button
|
||||||
<Button
|
onClick={open}
|
||||||
component="a"
|
loading={state === "loading"}
|
||||||
href={url}
|
leftSection={<MessageSquare size={16} />}
|
||||||
target="_blank"
|
>
|
||||||
rel="noopener noreferrer"
|
Open EDR Chat
|
||||||
leftSection={<MessageSquare size={16} />}
|
</Button>
|
||||||
>
|
</Stack>
|
||||||
Open EDR Chat
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
</Stack>
|
</Stack>
|
||||||
</Center>
|
</Center>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -140,6 +140,10 @@ services:
|
|||||||
context: infrastructure/matrix/element
|
context: infrastructure/matrix/element
|
||||||
ports:
|
ports:
|
||||||
- "${ELEMENT_WEB_PORT:-8080}:80"
|
- "${ELEMENT_WEB_PORT:-8080}:80"
|
||||||
|
# config.json is rendered at container start, not baked — one image serves
|
||||||
|
# dev, staging and prod. See infrastructure/matrix/element/.env.example.
|
||||||
|
env_file:
|
||||||
|
- infrastructure/matrix/element/.env
|
||||||
restart: always
|
restart: always
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
18
infrastructure/matrix/element/40-element-config.sh
Normal file
18
infrastructure/matrix/element/40-element-config.sh
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Renders /app/config.json from config.json.tmpl at container start.
|
||||||
|
#
|
||||||
|
# The homeserver URL and server_name differ per environment, and config.json is
|
||||||
|
# read by the browser rather than the build, so baking it into the image would
|
||||||
|
# mean one image per environment. Dropped into /docker-entrypoint.d, which the
|
||||||
|
# upstream nginx entrypoint runs (in lexical order) before starting nginx —
|
||||||
|
# no ENTRYPOINT override, so the image's own startup work still happens.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
: "${MATRIX_PUBLIC_BASEURL:?MATRIX_PUBLIC_BASEURL is required}"
|
||||||
|
: "${MATRIX_SERVER_NAME:?MATRIX_SERVER_NAME is required}"
|
||||||
|
: "${ELEMENT_PUBLIC_URL:?ELEMENT_PUBLIC_URL is required}"
|
||||||
|
|
||||||
|
envsubst '${MATRIX_PUBLIC_BASEURL} ${MATRIX_SERVER_NAME} ${ELEMENT_PUBLIC_URL}' \
|
||||||
|
< /app/config.json.tmpl > /app/config.json
|
||||||
|
|
||||||
|
echo "element-config: homeserver ${MATRIX_PUBLIC_BASEURL} (${MATRIX_SERVER_NAME})"
|
||||||
@@ -5,5 +5,19 @@
|
|||||||
# Pin the tag; never float on `latest`.
|
# Pin the tag; never float on `latest`.
|
||||||
FROM ghcr.io/element-hq/element-web:v1.11.108
|
FROM ghcr.io/element-hq/element-web:v1.11.108
|
||||||
|
|
||||||
COPY config.json /app/config.json
|
COPY config.json.tmpl /app/config.json.tmpl
|
||||||
COPY sso.html /app/sso.html
|
COPY sso.html /app/sso.html
|
||||||
|
# Replaces the upstream manifest, which names the app "Element" and advertises
|
||||||
|
# the Play/App Store builds under related_applications. Those apps cannot log
|
||||||
|
# in here — this deployment has no password login and no SSO provider, only the
|
||||||
|
# JWT handoff from freight-api — so pointing staff at them is a dead end.
|
||||||
|
COPY manifest.json /app/manifest.json
|
||||||
|
COPY 40-element-config.sh /docker-entrypoint.d/40-element-config.sh
|
||||||
|
|
||||||
|
# The image runs as uid 101 (nginx) but ships /app root-owned, so the startup
|
||||||
|
# hook could not write the rendered config without this.
|
||||||
|
USER root
|
||||||
|
RUN chmod +x /docker-entrypoint.d/40-element-config.sh \
|
||||||
|
&& touch /app/config.json \
|
||||||
|
&& chown nginx:nginx /app/config.json
|
||||||
|
USER nginx
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
{
|
{
|
||||||
"default_server_config": {
|
"default_server_config": {
|
||||||
"m.homeserver": {
|
"m.homeserver": {
|
||||||
"base_url": "https://matrix.edr.et",
|
"base_url": "${MATRIX_PUBLIC_BASEURL}",
|
||||||
"server_name": "matrix.edr.et"
|
"server_name": "${MATRIX_SERVER_NAME}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"brand": "EDR Chat",
|
"brand": "EDR Chat",
|
||||||
"permalink_prefix": "https://chat.edr.et",
|
"permalink_prefix": "${ELEMENT_PUBLIC_URL}",
|
||||||
"disable_guests": true,
|
"disable_guests": true,
|
||||||
"disable_3pid_login": true,
|
"disable_3pid_login": true,
|
||||||
"disable_custom_urls": true,
|
"disable_custom_urls": true,
|
||||||
"default_theme": "light",
|
"default_theme": "light",
|
||||||
|
"mobile_guide_toast": false,
|
||||||
"settingDefaults": {
|
"settingDefaults": {
|
||||||
"UIFeature.registration": false
|
"UIFeature.registration": false
|
||||||
}
|
}
|
||||||
12
infrastructure/matrix/element/manifest.json
Normal file
12
infrastructure/matrix/element/manifest.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "EDR Chat",
|
||||||
|
"short_name": "EDR Chat",
|
||||||
|
"display": "standalone",
|
||||||
|
"theme_color": "#0dbd8b",
|
||||||
|
"start_url": "index.html",
|
||||||
|
"icons": [
|
||||||
|
{ "src": "/vector-icons/150.png", "sizes": "150x150", "type": "image/png" },
|
||||||
|
{ "src": "/vector-icons/300.png", "sizes": "300x300", "type": "image/png" },
|
||||||
|
{ "src": "/vector-icons/1024.png", "sizes": "1024x1024", "type": "image/png" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,16 +1,23 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<!--
|
<!--
|
||||||
Element only honours a `?loginToken=` on `/` if `mx_sso_hs_url` is already
|
Session handoff from freight-api into Element.
|
||||||
in localStorage (element-web apps/web/src/Lifecycle.ts attemptTokenLogin,
|
|
||||||
key defined in apps/web/src/BasePlatform.ts). Normally that key is written
|
|
||||||
by Element itself at the start of an SSO redirect; freight-api's SSO
|
|
||||||
handoff skips that redirect (it already knows the homeserver), so this
|
|
||||||
page seeds the key by hand and forwards straight to the login-token URL.
|
|
||||||
|
|
||||||
freight-api's chat-sso.service.ts links here as
|
freight-api's chat-sso.service.ts links here as
|
||||||
https://chat.edr.et/sso.html?t=<login_token>&hs=<homeserver base_url>.
|
https://chat.edr.et/sso.html#hs=<homeserver>&t=<access_token>&u=<user_id>&d=<device_id>
|
||||||
`hs` is passed rather than hardcoded so this file doesn't need to change if
|
— a fragment, not a query, so the token is never sent to a server and never
|
||||||
MATRIX_PUBLIC_BASEURL ever does.
|
lands in an access log. location.replace() below drops this URL from history
|
||||||
|
as well, so the token does not survive the redirect.
|
||||||
|
|
||||||
|
The keys written here are the ones Element reads on startup
|
||||||
|
(element-web src/Lifecycle.ts getStoredSessionVars/getStoredToken): the token
|
||||||
|
is looked up in IndexedDB first and falls back to localStorage, which Element
|
||||||
|
then migrates into IndexedDB itself. A plaintext token is accepted —
|
||||||
|
tryDecryptToken returns a string token as-is, and only decrypts when it finds
|
||||||
|
an encrypted payload.
|
||||||
|
|
||||||
|
This replaced a ?loginToken= handoff: POST /_matrix/client/v1/login/get_token
|
||||||
|
is capped at one call per user per minute by a limiter hardcoded in Synapse,
|
||||||
|
so clicking Chat twice in a minute failed.
|
||||||
-->
|
-->
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
@@ -19,17 +26,23 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<script>
|
<script>
|
||||||
var params = new URLSearchParams(window.location.search);
|
var params = new URLSearchParams(window.location.hash.slice(1));
|
||||||
var token = params.get("t");
|
|
||||||
var homeserver = params.get("hs");
|
var homeserver = params.get("hs");
|
||||||
if (token && homeserver) {
|
var token = params.get("t");
|
||||||
localStorage.setItem("mx_sso_hs_url", homeserver);
|
var userId = params.get("u");
|
||||||
window.location.replace(
|
var deviceId = params.get("d");
|
||||||
"/?loginToken=" + encodeURIComponent(token),
|
|
||||||
);
|
if (homeserver && token && userId && deviceId) {
|
||||||
|
localStorage.setItem("mx_hs_url", homeserver);
|
||||||
|
localStorage.setItem("mx_user_id", userId);
|
||||||
|
localStorage.setItem("mx_device_id", deviceId);
|
||||||
|
localStorage.setItem("mx_access_token", token);
|
||||||
|
localStorage.setItem("mx_has_access_token", "true");
|
||||||
|
localStorage.setItem("mx_is_guest", "false");
|
||||||
|
window.location.replace("/");
|
||||||
} else {
|
} else {
|
||||||
document.body.textContent =
|
document.body.textContent =
|
||||||
"Missing sign-in token. Go back to the EDR backoffice and click Chat again.";
|
"Missing sign-in details. Go back to the EDR backoffice and click Chat again.";
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -42,6 +42,24 @@ federation_domain_whitelist: []
|
|||||||
enable_registration: false
|
enable_registration: false
|
||||||
encryption_enabled_by_default_for_room_type: "off"
|
encryption_enabled_by_default_for_room_type: "off"
|
||||||
|
|
||||||
|
# Turning rooms' encryption off above is not enough on its own: Element still
|
||||||
|
# bootstraps cross-signing on a user's first login, and from then on gates
|
||||||
|
# EVERY later login behind "Verify this device" (MatrixChat: crossSigningIsSetUp
|
||||||
|
# -> Views.COMPLETE_SECURITY). Nobody on this deployment can clear that gate —
|
||||||
|
# each SSO click is a brand-new device, so there is never a second verified
|
||||||
|
# device to accept the request, and resetting the identity needs UIA, which
|
||||||
|
# password_config.enabled: false makes impossible.
|
||||||
|
#
|
||||||
|
# This tells Element encryption is off here, so it skips the bootstrap
|
||||||
|
# (shouldSkipSetupEncryption) and the gate is never armed. Only helps accounts
|
||||||
|
# that have no cross-signing keys yet — anyone already bootstrapped keeps
|
||||||
|
# hitting the gate until their keys are cleared.
|
||||||
|
extra_well_known_client_content:
|
||||||
|
io.element.e2ee:
|
||||||
|
default: false
|
||||||
|
force_disable: true
|
||||||
|
secure_backup_required: false
|
||||||
|
|
||||||
# Employees authenticate via freight-api's SSO handoff, never a Matrix
|
# Employees authenticate via freight-api's SSO handoff, never a Matrix
|
||||||
# password prompt. This is the entire auth story for this deployment.
|
# password prompt. This is the entire auth story for this deployment.
|
||||||
password_config:
|
password_config:
|
||||||
|
|||||||
@@ -29,6 +29,12 @@ declare -A SERVICE_ENV_TARGET=(
|
|||||||
["passenger_portal"]="apps/edr-passenger-web/portal/.env"
|
["passenger_portal"]="apps/edr-passenger-web/portal/.env"
|
||||||
["passenger_backoffice"]="apps/edr-passenger-web/backoffice/.env"
|
["passenger_backoffice"]="apps/edr-passenger-web/backoffice/.env"
|
||||||
["payment_api"]="apps/edr-payment-api/.env"
|
["payment_api"]="apps/edr-payment-api/.env"
|
||||||
|
["synapse"]="infrastructure/matrix/synapse/.env"
|
||||||
|
# element_web holds no secrets, but its config.json is rendered at container
|
||||||
|
# start from MATRIX_PUBLIC_BASEURL / MATRIX_SERVER_NAME / ELEMENT_PUBLIC_URL
|
||||||
|
# (see infrastructure/matrix/element), so it needs an env file like the rest —
|
||||||
|
# plus the PORT line every service env is required to carry.
|
||||||
|
["element_web"]="infrastructure/matrix/element/.env"
|
||||||
)
|
)
|
||||||
|
|
||||||
for service in "$@"; do
|
for service in "$@"; do
|
||||||
|
|||||||
@@ -32,9 +32,9 @@ declare -A SERVICE_ENV_TARGET=(
|
|||||||
["passenger-backoffice"]="apps/edr-passenger-web/backoffice/.env"
|
["passenger-backoffice"]="apps/edr-passenger-web/backoffice/.env"
|
||||||
["payment-api"]="apps/edr-payment-api/.env"
|
["payment-api"]="apps/edr-payment-api/.env"
|
||||||
["synapse"]="infrastructure/matrix/synapse/.env"
|
["synapse"]="infrastructure/matrix/synapse/.env"
|
||||||
# element-web has no runtime secrets (its config.json is baked into the
|
# element-web holds no secrets, but its config.json is rendered at container
|
||||||
# image), but the sync step still runs unconditionally per service and
|
# start from MATRIX_PUBLIC_BASEURL / MATRIX_SERVER_NAME / ELEMENT_PUBLIC_URL,
|
||||||
# needs a PORT= line to compute ELEMENT_WEB_PORT for docker compose.
|
# and the sync step needs a PORT= line to compute ELEMENT_WEB_PORT anyway.
|
||||||
["element-web"]="infrastructure/matrix/element/.env"
|
["element-web"]="infrastructure/matrix/element/.env"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user