Kanidm group-gated rooms and minimal mesh calling

Rooms can now require a Kanidm group (via the `groups` OIDC claim,
mapped by `oauth2 update-claim-map` server-side) - dev/ops require
`developers`, enforced at every message path (send, history, SSE).

Adds a minimal WebRTC mesh call feature scoped to the lobby room,
signaled over a separate `call.room.*` NATS subject kept out of the
chat archive: public STUN only, no TURN, no SFU - small groups on
friendly networks, by design.
This commit is contained in:
2026-07-27 23:52:27 +02:00
parent 46bdad1629
commit 650ed50c21
12 changed files with 875 additions and 26 deletions
+206 -3
View File
@@ -7,7 +7,10 @@ use leptos_router::{
};
use crate::auth::{current_user, User};
use crate::chat::{is_valid_room, room_subject, ChatMessage, SendMessage, DEFAULT_ROOM, ROOMS};
use crate::chat::{
is_authorized_for_room, is_valid_room, room_subject, ChatMessage, SendMessage, DEFAULT_ROOM,
ROOMS,
};
#[cfg(feature = "hydrate")]
use crate::chat::room_history;
@@ -75,7 +78,23 @@ fn ChatPage() -> impl IntoView {
{move || {
user.get()
.map(|res| match res {
Ok(Some(u)) => view! { <ChatShell room user=u/> }.into_any(),
Ok(Some(u)) => {
// `room`'s own Memo only knows about is_valid_room (no
// user context available that early) - fall back to
// DEFAULT_ROOM here too if this user isn't authorized
// for the room the URL asked for, same as an unknown
// room name already does.
let u2 = u.clone();
let effective_room = Memo::new(move |_| {
let r = room.get();
if is_authorized_for_room(&u2, &r) {
r
} else {
DEFAULT_ROOM.to_string()
}
});
view! { <ChatShell room=effective_room user=u/> }.into_any()
}
_ => view! { <LoginGate/> }.into_any(),
})
}}
@@ -83,6 +102,28 @@ fn ChatPage() -> impl IntoView {
}
}
// ---------------------------------------------------------------------------
// brand mark - the "voice pulse" ornament, currentColor so it always
// matches whatever text color surrounds it (sidebar wordmark vs. the much
// larger gate title)
// ---------------------------------------------------------------------------
#[component]
fn PulseMark() -> impl IntoView {
view! {
<svg
class="pulse-mark"
viewBox="-15 -70 430 140"
fill="none"
stroke="currentColor"
stroke-width="18"
stroke-linecap="round"
>
<path d="M0,0 H60 Q80,-24 100,0 Q120,24 140,0 Q162,-52 184,0 Q206,52 228,0 Q250,-24 270,0 Q290,24 310,0 H400"></path>
</svg>
}
}
// ---------------------------------------------------------------------------
// unauthenticated: the gate
// ---------------------------------------------------------------------------
@@ -94,8 +135,10 @@ fn LoginGate() -> impl IntoView {
<div class="gate-card">
<div class="gate-badge">"MESSAGE BUS · AUTH REQUIRED"</div>
<h1 class="gate-title">
<PulseMark/>
"CN" <span class="gate-title-accent">"ATS"</span>
</h1>
<p class="wordmark-sub gate-tagline">"chat over the bus"</p>
<p class="gate-sub">
"Realtime chat carried on NATS subjects. Identity issued by your Kanidm realm — no separate passwords, no local accounts."
</p>
@@ -210,11 +253,13 @@ fn ChatShell(room: Memo<String>, user: User) -> impl IntoView {
};
let me = user.username.clone();
let call_me = me.clone();
view! {
<div class="console">
<nav class="rail">
<div class="wordmark">
<PulseMark/>
"CN" <span class="wordmark-accent">"ATS"</span>
<span class="wordmark-sub">"chat over the bus"</span>
</div>
@@ -223,7 +268,8 @@ fn ChatShell(room: Memo<String>, user: User) -> impl IntoView {
<div class="rooms">
{ROOMS
.iter()
.map(|(name, desc)| {
.filter(|(name, _, _)| is_authorized_for_room(&user, name))
.map(|(name, desc, _)| {
let name = *name;
let desc = *desc;
view! {
@@ -276,6 +322,10 @@ fn ChatShell(room: Memo<String>, user: User) -> impl IntoView {
</div>
</header>
<Show when=move || room.get() == "lobby">
<CallPanel room=room me=call_me.clone()/>
</Show>
<div class="stream" node_ref=list_ref>
<Show when=move || messages.with(|m| m.is_empty())>
<div class="stream-empty">
@@ -357,6 +407,159 @@ fn open_event_source(
Some(es)
}
/// Minimal mesh call entry point, scoped to the `lobby` room only (see
/// `ChatShell`'s `<Show when=move || room.get() == "lobby">`) - the
/// signaling itself (`call.rs`, `/call-sse/{room}`) is already generic
/// per-room, so widening this later is a one-line UI change, not an
/// architectural one. `CallState` (browser-only: it holds `web_sys`
/// types) can't exist in the `ssr` build at all, so the two targets get
/// entirely separate bodies rather than sharing signals across the gate.
#[component]
fn CallPanel(room: Memo<String>, me: String) -> impl IntoView {
#[cfg(feature = "hydrate")]
{
use crate::webrtc::CallState;
let call_state = StoredValue::new_local(CallState::new(room.get_untracked(), me.clone()));
let in_call = call_state.get_value().in_call;
let local_video_ref = NodeRef::<leptos::html::Video>::new();
let es_handle = StoredValue::new_local(None::<web_sys::EventSource>);
Effect::new(move |_| {
let room_name = room.get();
es_handle.update_value(|es| {
if let Some(es) = es.take() {
es.close();
}
});
es_handle.set_value(open_call_event_source(&room_name, call_state.get_value()));
});
on_cleanup(move || {
es_handle.update_value(|es| {
if let Some(es) = es.take() {
es.close();
}
});
let cs = call_state.get_value();
if cs.in_call.get_untracked() {
leptos::task::spawn_local(async move {
cs.leave().await;
});
}
});
// Mirror the local MediaStream into the preview <video> element -
// `srcObject` has no HTML attribute form, has to be set via JS.
Effect::new(move |_| {
let stream = call_state.get_value().local_stream().get();
if let Some(el) = local_video_ref.get() {
el.set_src_object(stream.as_ref());
}
});
let on_join = move |_| {
let cs = call_state.get_value();
leptos::task::spawn_local(async move {
cs.join().await;
});
};
let on_leave = move |_| {
let cs = call_state.get_value();
leptos::task::spawn_local(async move {
cs.leave().await;
});
};
view! {
<div class="call-panel">
<Show
when=move || in_call.get()
fallback=move || {
view! {
<button class="call-join" on:click=on_join>
"☎ join call"
</button>
}
}
>
<div class="call-active">
<div class="video-grid">
<video
class="video-tile video-tile-local"
node_ref=local_video_ref
autoplay=true
muted=true
playsinline=true
></video>
<For
each=move || call_state.get_value().peer_streams()
key=|(id, _)| id.clone()
children=move |(_id, stream)| {
view! { <PeerVideoTile stream=stream/> }
}
/>
</div>
<button class="call-leave" on:click=on_leave>
"⏏ leave call"
</button>
</div>
</Show>
</div>
}
}
#[cfg(not(feature = "hydrate"))]
{
let _ = (room, me);
view! { <div class="call-panel"></div> }
}
}
/// One remote participant's video tile - a plain child component so each
/// tile gets its own `NodeRef`/effect pair instead of trying to juggle a
/// `Vec` of node refs by hand in the parent.
#[cfg(feature = "hydrate")]
#[component]
fn PeerVideoTile(stream: RwSignal<Option<web_sys::MediaStream>>) -> impl IntoView {
let video_ref = NodeRef::<leptos::html::Video>::new();
Effect::new(move |_| {
let s = stream.get();
if let Some(el) = video_ref.get() {
el.set_src_object(s.as_ref());
}
});
view! { <video class="video-tile" node_ref=video_ref autoplay=true playsinline=true></video> }
}
/// Bridges `/call-sse/{room}` into `CallState::handle_signal`. A separate
/// function from `open_event_source` (rather than a shared generic) since
/// the event name differs: `sse::call_events` emits a custom `"signal"`
/// SSE event, not the default unnamed one, so this needs
/// `add_event_listener_with_callback` instead of `set_onmessage` (which
/// only fires for the default event type).
#[cfg(feature = "hydrate")]
fn open_call_event_source(
room: &str,
call_state: crate::webrtc::CallState,
) -> Option<web_sys::EventSource> {
use wasm_bindgen::{prelude::Closure, JsCast};
use web_sys::{EventSource, MessageEvent};
let es = EventSource::new(&format!("/call-sse/{room}")).ok()?;
let on_signal = Closure::<dyn FnMut(MessageEvent)>::new(move |ev: MessageEvent| {
if let Some(data) = ev.data().as_string() {
if let Ok(signal) = serde_json::from_str::<crate::call::CallSignal>(&data) {
call_state.handle_signal(signal.from, signal.to, signal.kind);
}
}
});
es.add_event_listener_with_callback("signal", on_signal.as_ref().unchecked_ref())
.ok()?;
on_signal.forget();
Some(es)
}
#[component]
fn NotFound() -> impl IntoView {
view! {