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:
parent
a2aa70c961
commit
fe5f2403d6
22 changed files with 1057 additions and 8 deletions
144
apps/web/src/app/(main)/chat/page.tsx
Normal file
144
apps/web/src/app/(main)/chat/page.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
57
apps/web/src/app/(main)/files/drive/page.tsx
Normal file
57
apps/web/src/app/(main)/files/drive/page.tsx
Normal 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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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[] }>();
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
121
apps/web/src/components/embed/embedded-app.tsx
Normal file
121
apps/web/src/components/embed/embedded-app.tsx
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState, type ReactNode, type Ref } from "react";
|
||||
import { Button } from "../ui/primitives";
|
||||
import { Loading } from "../brand/loading";
|
||||
|
||||
/** A frame that has not fired `load` by then is reported as unreachable. */
|
||||
const LOAD_TIMEOUT_MS = 30_000;
|
||||
|
||||
/**
|
||||
* Full-height in-app view of a sibling occupymedia.cc service (Element, oCIS
|
||||
* web) — the Hub sidebar stays put and only <main> hosts the frame.
|
||||
*
|
||||
* The frame mounts once per `src` and again on every Retry; `busyLabel` keeps the overlay up
|
||||
* across in-frame navigations the parent knows about (e.g. Element's token
|
||||
* sign-in redirect). A frame that never loads within LOAD_TIMEOUT_MS shows the
|
||||
* error panel. A frame refused by the target's CSP / X-Frame-Options still
|
||||
* fires `load` (with the browser's block page), so "Open in new tab" stays in
|
||||
* the header permanently rather than only in the error panel.
|
||||
*/
|
||||
export function EmbeddedApp({
|
||||
title,
|
||||
src,
|
||||
externalHref,
|
||||
busyLabel = null,
|
||||
error = null,
|
||||
notice,
|
||||
frameRef,
|
||||
onFrameLoad,
|
||||
onRetry,
|
||||
allow,
|
||||
}: {
|
||||
title: string;
|
||||
/** null while the parent is still resolving where to point the frame. */
|
||||
src: string | null;
|
||||
externalHref: string;
|
||||
busyLabel?: string | null;
|
||||
error?: string | null;
|
||||
notice?: ReactNode;
|
||||
frameRef?: Ref<HTMLIFrameElement>;
|
||||
onFrameLoad?: () => void;
|
||||
/** Called on Retry, before the frame remounts — clear parent-side errors here. */
|
||||
onRetry?: () => void;
|
||||
allow?: string;
|
||||
}): React.ReactElement {
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [timedOut, setTimedOut] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setLoaded(false);
|
||||
setTimedOut(false);
|
||||
if (src === null) return;
|
||||
const timer = window.setTimeout(() => setTimedOut(true), LOAD_TIMEOUT_MS);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [src, attempt]);
|
||||
|
||||
function retry(): void {
|
||||
onRetry?.();
|
||||
setAttempt((n) => n + 1);
|
||||
}
|
||||
|
||||
const failure = error ?? (timedOut && !loaded ? `${title} did not respond. It may be down or blocking embedding.` : null);
|
||||
const overlay = failure === null && (src === null || !loaded || busyLabel !== null);
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-3 border-b border-border-subtle bg-surface px-4 py-2">
|
||||
<h1 className="text-sm font-semibold">{title}</h1>
|
||||
{notice !== undefined && <div className="min-w-0 text-2xs text-text-faint">{notice}</div>}
|
||||
<a
|
||||
href={externalHref}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="ml-auto inline-flex min-h-8 items-center gap-1 rounded-md px-2 text-xs text-text-dim transition-colors duration-150 hover:bg-surface-2 hover:text-text"
|
||||
>
|
||||
Open in new tab <span aria-hidden>↗</span>
|
||||
</a>
|
||||
</div>
|
||||
<div className="relative min-h-0 flex-1 bg-bg">
|
||||
{src !== null && failure === null && (
|
||||
<iframe
|
||||
key={attempt}
|
||||
ref={frameRef}
|
||||
src={src}
|
||||
title={title}
|
||||
allow={allow}
|
||||
referrerPolicy="no-referrer"
|
||||
className="absolute inset-0 h-full w-full border-0"
|
||||
onLoad={() => {
|
||||
setLoaded(true);
|
||||
onFrameLoad?.();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{overlay && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 bg-bg">
|
||||
<Loading size={32} label={busyLabel ?? `Loading ${title}…`} />
|
||||
<p className="text-xs text-text-faint">{busyLabel ?? `Loading ${title}…`}</p>
|
||||
</div>
|
||||
)}
|
||||
{failure !== null && (
|
||||
<div role="alert" className="absolute inset-0 flex flex-col items-center justify-center gap-3 p-6 text-center">
|
||||
<p className="max-w-md text-sm text-danger">{failure}</p>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={retry}>Retry</Button>
|
||||
<a
|
||||
href={externalHref}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1.5 rounded-md bg-accent px-3 py-1.5 text-xs font-medium text-white hover:bg-accent-hover"
|
||||
>
|
||||
Open in new tab ↗
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
38
apps/web/src/components/files/files-tabs.tsx
Normal file
38
apps/web/src/components/files/files-tabs.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useFilesLang, type LocalizedText } from "./i18n";
|
||||
|
||||
const TABS: { href: string; label: LocalizedText }[] = [
|
||||
{ href: "/files", label: { en: "Library", es: "Biblioteca" } },
|
||||
{ href: "/files/drive", label: { en: "Drive", es: "Unidad" } },
|
||||
];
|
||||
|
||||
/**
|
||||
* Files section switcher: the Hub's group library (upload, share with a
|
||||
* collective, retention) and the embedded oCIS drive side by side.
|
||||
*/
|
||||
export function FilesTabs(): React.ReactElement {
|
||||
const pathname = usePathname();
|
||||
const { t } = useFilesLang();
|
||||
return (
|
||||
<nav aria-label={t({ en: "Files views", es: "Vistas de archivos" })} className="flex shrink-0 gap-1 border-b border-border-subtle bg-surface px-3 pt-2">
|
||||
{TABS.map((tab) => {
|
||||
const active = tab.href === "/files" ? pathname === "/files" : pathname.startsWith(tab.href);
|
||||
return (
|
||||
<Link
|
||||
key={tab.href}
|
||||
href={tab.href}
|
||||
aria-current={active ? "page" : undefined}
|
||||
className={`-mb-px min-h-9 border-b-2 px-3 py-2 text-xs font-medium transition-colors duration-150 ${
|
||||
active ? "border-accent text-accent" : "border-transparent text-text-dim hover:text-text"
|
||||
}`}
|
||||
>
|
||||
{t(tab.label)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
|
@ -13,6 +13,8 @@ const PAGES = [
|
|||
{ href: "/inbox", label: "Go to Inbox" },
|
||||
{ href: "/archive", label: "Go to Archive" },
|
||||
{ href: "/files", label: "Go to Files" },
|
||||
{ href: "/files/drive", label: "Go to Drive" },
|
||||
{ href: "/chat", label: "Go to Chat" },
|
||||
{ href: "/connections", label: "Go to Channels" },
|
||||
{ href: "/team", label: "Go to Team" },
|
||||
{ href: "/audit", label: "Go to Audit" },
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ const NAV: { href: string; label: string; icon: string; external?: boolean }[] =
|
|||
{ href: "/control-room", label: "Control Room", icon: "video" },
|
||||
{ href: "/compose", label: "Compose", icon: "compose" },
|
||||
{ href: "/calendar", label: "Calendar", icon: "calendar" },
|
||||
{ href: "https://chat.occupymedia.cc", label: "Chat", icon: "chat", external: true },
|
||||
{ href: "/chat", label: "Chat", icon: "chat" },
|
||||
{ href: "/files", label: "Files", icon: "files" },
|
||||
{ href: "/archive", label: "Archive", icon: "files" },
|
||||
{ href: "/inbox", label: "Inbox", icon: "inbox" },
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue