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:
+206
-3
@@ -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! {
|
||||
|
||||
Reference in New Issue
Block a user