feat: in-app Chat (Matrix JWT SSO) + Drive (embedded oCIS)

- api: matrix-sso module (tuwunel jwt -> login/get_token), POST /matrix/session, GET /matrix/identity, drive controller
- web: /chat full-height Element view, /files Library+Drive tabs, embedded-app frame component
- edge: /hub-sso.html handshake route (origin-pinned CSP), X-Frame-Options strip on files
- oCIS: csp.yaml frame-ancestors app.occupymedia.cc
- server config still required: tuwunel [global.jwt], MATRIX_SSO_JWT_SECRET, oCIS restart
This commit is contained in:
glass beadian 2026-10-13 14:57:07 +00:00
parent a2aa70c961
commit fe5f2403d6
22 changed files with 1057 additions and 8 deletions

View file

@ -0,0 +1,144 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { EmbeddedApp } from "../../../components/embed/embedded-app";
import { api, errorMessage } from "../../../lib/api-client";
import { useApiResource } from "../../../lib/use-api";
/**
* In-app Chat: Element Web, full height inside the Hub shell, signed in as the
* Hub user without a Matrix password.
*
* Handshake (see infra/occupymedia/edge/hub-sso/chat-sso.html):
* 1. GET /matrix/identity → the MXID this Hub account maps to.
* 2. The frame opens Element's origin at /hub-sso.html#expect=<mxid>. If
* Element already holds a live session for that MXID it goes straight to
* Element — no token is minted, the device (and its E2EE keys) survives.
* 3. Otherwise the page posts `tmbh:chat-sso:need-token`; we POST
* /matrix/session and post the single-use m.login.token back, pinned to
* Element's origin. Element completes `/?loginToken=…` and keeps its own
* session from then on.
* Without SSO configured (or the API offline) the frame shows Element's own
* sign-in screen: sign in once there and it stays signed in.
*/
/** Same host the nav linked to before Chat moved in-app. */
const FALLBACK_CHAT_URL = "https://chat.occupymedia.cc";
/** A frame that keeps asking for tokens is failing token login; stop minting. */
const MAX_MINTS = 3;
const ELEMENT_ALLOW = "camera; microphone; display-capture; fullscreen; autoplay; clipboard-read; clipboard-write";
interface MatrixIdentity {
configured: boolean;
homeserverUrl: string | null;
elementUrl: string | null;
userId: string | null;
}
interface MatrixSession {
homeserverUrl: string;
elementUrl: string;
userId: string;
loginToken: string;
expiresInMs: number;
}
const identityView = (identity: MatrixIdentity): MatrixIdentity | null => identity;
function isNeedToken(data: unknown): boolean {
return typeof data === "object" && data !== null && "type" in data && data.type === "tmbh:chat-sso:need-token";
}
function originOf(url: string): string | null {
try {
return new URL(url).origin;
} catch {
return null;
}
}
export default function ChatPage(): React.ReactElement {
const identity = useApiResource<MatrixIdentity, MatrixIdentity | null>("/matrix/identity", identityView, null);
const frameRef = useRef<HTMLIFrameElement>(null);
const mints = useRef(0);
const tokenSent = useRef(false);
const [busy, setBusy] = useState<string | null>(null);
const [mintError, setMintError] = useState<string | null>(null);
const elementOrigin = originOf(identity.data?.elementUrl ?? FALLBACK_CHAT_URL) ?? FALLBACK_CHAT_URL;
const ssoUser = identity.data?.configured === true ? identity.data.userId : null;
const src =
identity.source === "loading"
? null
: ssoUser !== null
? `${elementOrigin}/hub-sso.html#expect=${encodeURIComponent(ssoUser)}`
: `${elementOrigin}/`;
useEffect(() => {
if (ssoUser === null) return;
function onMessage(event: MessageEvent<unknown>): void {
const target = frameRef.current?.contentWindow ?? null;
if (target === null || event.source !== target || event.origin !== elementOrigin || !isNeedToken(event.data)) return;
if (mints.current >= MAX_MINTS) {
setBusy(null);
setMintError("Chat sign-in keeps failing. Open chat in a new tab, or retry.");
return;
}
mints.current += 1;
setBusy("Signing in to chat…");
api
.post<MatrixSession>("/matrix/session")
.then((session) => {
const current = frameRef.current?.contentWindow ?? null;
if (current === null) return;
tokenSent.current = true;
current.postMessage({ type: "tmbh:chat-sso:token", loginToken: session.loginToken }, elementOrigin);
})
.catch((err: unknown) => {
setBusy(null);
setMintError(errorMessage(err));
});
}
window.addEventListener("message", onMessage);
return () => window.removeEventListener("message", onMessage);
}, [ssoUser, elementOrigin]);
// The load after a token was handed over is Element itself: sign-in is done.
const handleFrameLoad = useCallback(() => {
if (!tokenSent.current) return;
tokenSent.current = false;
setBusy(null);
}, []);
const handleRetry = useCallback(() => {
mints.current = 0;
tokenSent.current = false;
setBusy(null);
setMintError(null);
if (identity.error !== null) identity.reload();
}, [identity]);
const notice =
identity.source === "sample"
? "Hub offline — sign in to chat directly."
: identity.source === "live" && identity.error === null && ssoUser === null
? "Automatic sign-in is not set up — sign in once here and chat stays signed in."
: ssoUser !== null
? ssoUser
: undefined;
return (
<EmbeddedApp
title="Chat"
src={identity.error === null ? src : null}
externalHref={`${elementOrigin}/`}
busyLabel={busy}
error={identity.error ?? mintError}
notice={notice}
frameRef={frameRef}
onFrameLoad={handleFrameLoad}
onRetry={handleRetry}
allow={ELEMENT_ALLOW}
/>
);
}

View file

@ -0,0 +1,57 @@
"use client";
import { EmbeddedApp } from "../../../../components/embed/embedded-app";
import { useFilesLang } from "../../../../components/files/i18n";
import { useApiResource } from "../../../../lib/use-api";
/**
* Drive — the oCIS web UI (personal space, shares, office documents) embedded
* full height under the Files tabs. The group Library tab keeps the Hub-owned
* features (upload into a collective, share with a collective, retention).
*
* Sign-in follows GET /drive/session's authMode: "interactive" means oCIS's
* own identity provider — the user signs in once inside the frame and oCIS
* web keeps its tokens (tokenStorageLocal) on the files origin, which this
* frame shares because app.* and files.* are the same site. "oidc" means oCIS
* and the Hub share an issuer and the frame signs in silently.
*/
/** Same host FILES_PUBLIC_BASE defaults to; used only while the API is offline. */
const FALLBACK_DRIVE_URL = "https://files.occupymedia.cc";
interface DriveSession {
driveUrl: string | null;
authMode: "interactive" | "oidc";
}
const driveView = (session: DriveSession): DriveSession | null => session;
export default function DrivePage(): React.ReactElement {
const { t } = useFilesLang();
const drive = useApiResource<DriveSession, DriveSession | null>("/drive/session", driveView, null);
const driveUrl = drive.source === "sample" ? FALLBACK_DRIVE_URL : drive.data?.driveUrl ?? null;
const notConfigured = drive.source === "live" && drive.error === null && driveUrl === null;
const error =
drive.error ??
(notConfigured ? t({ en: "The drive is not configured on this server (FILES_DRIVE_URL).", es: "La unidad no está configurada en este servidor (FILES_DRIVE_URL)." }) : null);
const notice =
drive.data?.authMode === "oidc"
? t({ en: "Collective files live in the Library tab.", es: "Los archivos de los colectivos están en la pestaña Biblioteca." })
: t({
en: "Sign in once inside the drive — it stays signed in. Collective files live in the Library tab.",
es: "Inicia sesión una vez dentro de la unidad y quedará abierta. Los archivos de los colectivos están en la pestaña Biblioteca.",
});
return (
<EmbeddedApp
title={t({ en: "Drive", es: "Unidad" })}
src={drive.source === "loading" || error !== null ? null : `${driveUrl ?? FALLBACK_DRIVE_URL}/`}
externalHref={`${driveUrl ?? FALLBACK_DRIVE_URL}/`}
error={error}
notice={notice}
onRetry={drive.error !== null ? drive.reload : undefined}
allow="fullscreen; clipboard-read; clipboard-write"
/>
);
}

View file

@ -2,12 +2,21 @@
import type { ReactNode } from "react";
import { FilesLangProvider } from "../../../components/files/i18n";
import { FilesTabs } from "../../../components/files/files-tabs";
/**
* Files shell. Like the wiki, the language lives in localStorage per browser
* (lang-context explains why it is not an account setting) and is provided here
* so every Files screen and sub-component reads it through one context.
* so every Files screen and sub-component reads it through one context. The tab
* strip switches between the group Library and the embedded oCIS Drive.
*/
export default function FilesLayout({ children }: { children: ReactNode }): React.ReactElement {
return <FilesLangProvider>{children}</FilesLangProvider>;
return (
<FilesLangProvider>
<div className="flex h-full min-h-0 flex-col">
<FilesTabs />
<div className="min-h-0 flex-1">{children}</div>
</div>
</FilesLangProvider>
);
}

View file

@ -15,7 +15,7 @@ import { useIsMobile } from "../../hooks/use-is-mobile";
const titles: Record<string, string> = {
streams: "Home", search: "Search", inbox: "Inbox", approvals: "Approvals", more: "More", compose: "New post",
calendar: "Calendar", video: "Video", analytics: "Analytics", connections: "Connections", team: "Team",
audit: "Audit log", onboarding: "Welcome", settings: "Settings", archive: "Archive", files: "Files",
audit: "Audit log", onboarding: "Welcome", settings: "Settings", archive: "Archive", files: "Files", chat: "Chat",
};
const rootTabs = new Set(["/streams", "/search", "/inbox", "/more"]);
const scrollPositions = new Map<string, { main: number; nested: number[] }>();

View file

@ -77,7 +77,7 @@ export default function MorePage(): React.ReactElement {
<Row href="/analytics" title="Analytics" />
<Row href="/video" title="Video library" />
<Row href="/files" title="Files" detail="Group file library" />
<Row href="https://element.occupymedia.cc" title="Chat" detail="Matrix chat" external />
<Row href="/chat" title="Chat" detail="Matrix chat" />
<Row href="/onboarding" title="Onboarding" />
<button onClick={() => void logout()} className="flex min-h-[56px] w-full items-center px-4 text-left text-sm font-medium text-danger">Sign out</button>
</div>