cnats: NATS-native chat with Leptos SSR and Kanidm SSO
Initial import plus deployment packaging: multi-stage Dockerfile (cargo-leptos build -> debian-slim runtime), .dockerignore, and a dev-only docker-compose (app + local NATS). Production deployment lives in the infrastructure repo. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,4 @@
|
|||||||
|
target/
|
||||||
|
.env
|
||||||
|
kanidm-data/
|
||||||
|
.git/
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# Copy to .env and fill in. `dotenvy` loads this at startup.
|
||||||
|
|
||||||
|
# NATS server the chat server publishes/subscribes on.
|
||||||
|
NATS_URL=nats://127.0.0.1:4222
|
||||||
|
|
||||||
|
# Base URL of your Kanidm instance (no trailing slash).
|
||||||
|
KANIDM_URL=https://idm.example.com
|
||||||
|
|
||||||
|
# OAuth2 client registered in Kanidm (see README for the kanidm commands).
|
||||||
|
OAUTH2_CLIENT_ID=cnats
|
||||||
|
OAUTH2_CLIENT_SECRET=change-me
|
||||||
|
|
||||||
|
# Where THIS app is reachable by browsers; the OIDC redirect is
|
||||||
|
# ${PUBLIC_URL}/auth/callback and must match the Kanidm client config.
|
||||||
|
PUBLIC_URL=http://localhost:3000
|
||||||
|
|
||||||
|
# Set to true when serving over HTTPS so session cookies are Secure-only.
|
||||||
|
COOKIE_SECURE=false
|
||||||
|
|
||||||
|
# Optional: log filter
|
||||||
|
# RUST_LOG=info,cnats=debug
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
target/
|
||||||
|
.env
|
||||||
|
kanidm-data/
|
||||||
Generated
+4237
File diff suppressed because it is too large
Load Diff
+90
@@ -0,0 +1,90 @@
|
|||||||
|
[package]
|
||||||
|
name = "cnats"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
crate-type = ["cdylib", "rlib"]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
leptos = { version = "0.8" }
|
||||||
|
leptos_meta = { version = "0.8" }
|
||||||
|
leptos_router = { version = "0.8" }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
|
||||||
|
# --- server only ---
|
||||||
|
leptos_axum = { version = "0.8", optional = true }
|
||||||
|
axum = { version = "0.8", optional = true }
|
||||||
|
tokio = { version = "1", features = ["rt-multi-thread", "macros", "signal"], optional = true }
|
||||||
|
tower = { version = "0.5", optional = true }
|
||||||
|
tower-http = { version = "0.6", features = ["fs", "trace"], optional = true }
|
||||||
|
tower-sessions = { version = "0.14", optional = true }
|
||||||
|
async-nats = { version = "0.38", optional = true }
|
||||||
|
openidconnect = { version = "4", optional = true }
|
||||||
|
futures = { version = "0.3", optional = true }
|
||||||
|
chrono = { version = "0.4", features = ["serde"], optional = true }
|
||||||
|
uuid = { version = "1", features = ["v4"], optional = true }
|
||||||
|
dotenvy = { version = "0.15", optional = true }
|
||||||
|
anyhow = { version = "1", optional = true }
|
||||||
|
tracing = { version = "0.1", optional = true }
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter"], optional = true }
|
||||||
|
|
||||||
|
# --- browser only ---
|
||||||
|
wasm-bindgen = { version = "0.2", optional = true }
|
||||||
|
console_error_panic_hook = { version = "0.1", optional = true }
|
||||||
|
web-sys = { version = "0.3", features = [
|
||||||
|
"EventSource",
|
||||||
|
"MessageEvent",
|
||||||
|
"HtmlElement",
|
||||||
|
"Element",
|
||||||
|
], optional = true }
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = []
|
||||||
|
hydrate = [
|
||||||
|
"leptos/hydrate",
|
||||||
|
"dep:wasm-bindgen",
|
||||||
|
"dep:console_error_panic_hook",
|
||||||
|
"dep:web-sys",
|
||||||
|
]
|
||||||
|
ssr = [
|
||||||
|
"leptos/ssr",
|
||||||
|
"leptos_meta/ssr",
|
||||||
|
"leptos_router/ssr",
|
||||||
|
"dep:leptos_axum",
|
||||||
|
"dep:axum",
|
||||||
|
"dep:tokio",
|
||||||
|
"dep:tower",
|
||||||
|
"dep:tower-http",
|
||||||
|
"dep:tower-sessions",
|
||||||
|
"dep:async-nats",
|
||||||
|
"dep:openidconnect",
|
||||||
|
"dep:futures",
|
||||||
|
"dep:chrono",
|
||||||
|
"dep:uuid",
|
||||||
|
"dep:dotenvy",
|
||||||
|
"dep:anyhow",
|
||||||
|
"dep:tracing",
|
||||||
|
"dep:tracing-subscriber",
|
||||||
|
]
|
||||||
|
|
||||||
|
[profile.wasm-release]
|
||||||
|
inherits = "release"
|
||||||
|
opt-level = 'z'
|
||||||
|
lto = true
|
||||||
|
codegen-units = 1
|
||||||
|
|
||||||
|
[package.metadata.leptos]
|
||||||
|
output-name = "cnats"
|
||||||
|
site-root = "target/site"
|
||||||
|
site-pkg-dir = "pkg"
|
||||||
|
style-file = "style/main.css"
|
||||||
|
assets-dir = "public"
|
||||||
|
site-addr = "127.0.0.1:3000"
|
||||||
|
reload-port = 3001
|
||||||
|
bin-features = ["ssr"]
|
||||||
|
bin-default-features = false
|
||||||
|
lib-features = ["hydrate"]
|
||||||
|
lib-default-features = false
|
||||||
|
lib-profile-release = "wasm-release"
|
||||||
+25
@@ -0,0 +1,25 @@
|
|||||||
|
# Multi-stage build for the cnats Leptos (SSR + hydrate) app.
|
||||||
|
# Built by the infrastructure repo via `docker_image.cnats` (or manually:
|
||||||
|
# docker build -t cnats .)
|
||||||
|
|
||||||
|
FROM rust:1-bookworm AS builder
|
||||||
|
RUN rustup target add wasm32-unknown-unknown \
|
||||||
|
&& cargo install cargo-leptos --locked
|
||||||
|
WORKDIR /app
|
||||||
|
COPY . .
|
||||||
|
RUN cargo leptos build --release
|
||||||
|
|
||||||
|
FROM debian:bookworm-slim
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends ca-certificates \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=builder /app/target/release/cnats /usr/local/bin/cnats
|
||||||
|
COPY --from=builder /app/target/site /app/site
|
||||||
|
|
||||||
|
ENV LEPTOS_SITE_ROOT=/app/site \
|
||||||
|
LEPTOS_SITE_ADDR=0.0.0.0:3000 \
|
||||||
|
RUST_LOG=info
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
CMD ["cnats"]
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
# cnats — chat over the bus
|
||||||
|
|
||||||
|
A realtime web chat built with [Leptos](https://leptos.dev) (SSR + hydration on
|
||||||
|
Axum) where every room is a **NATS subject** and sign-in is **Kanidm SSO**
|
||||||
|
(OIDC authorization-code + PKCE).
|
||||||
|
|
||||||
|
```
|
||||||
|
browser ──(server fn POST)──▶ axum ──publish──▶ NATS chat.room.<room>
|
||||||
|
browser ◀──(SSE /sse/<room>)── axum ◀─subscribe── NATS chat.room.<room>
|
||||||
|
browser ◀──(302 /auth/*)────── axum ◀──OIDC──▶ Kanidm
|
||||||
|
```
|
||||||
|
|
||||||
|
The browser never talks to NATS directly: the server publishes on behalf of the
|
||||||
|
signed-in session (sender identity comes from the session, never the client)
|
||||||
|
and fans messages out to browsers over Server-Sent Events. Any other NATS
|
||||||
|
client on the bus can publish/subscribe to `chat.room.*` and participate.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Rust (stable) with the `wasm32-unknown-unknown` target
|
||||||
|
- [`cargo-leptos`](https://github.com/leptos-rs/cargo-leptos): `cargo install cargo-leptos --locked`
|
||||||
|
- A NATS server: `docker compose up -d nats` (or `nats-server` locally)
|
||||||
|
- A running Kanidm instance you administer
|
||||||
|
|
||||||
|
## Kanidm setup
|
||||||
|
|
||||||
|
Register the app as an OAuth2 client (confidential, with PKCE — Kanidm's
|
||||||
|
default). Replace the URLs with yours:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kanidm login --name idm_admin
|
||||||
|
|
||||||
|
# create the client
|
||||||
|
kanidm system oauth2 create cnats "cnats chat" http://localhost:3000
|
||||||
|
|
||||||
|
# register the redirect URL used by this app
|
||||||
|
kanidm system oauth2 add-redirect-url cnats http://localhost:3000/auth/callback
|
||||||
|
|
||||||
|
# map which Kanidm groups may sign in, and grant the scopes the app requests
|
||||||
|
kanidm group create cnats_users
|
||||||
|
kanidm group add-members cnats_users your_username
|
||||||
|
kanidm system oauth2 update-scope-map cnats cnats_users openid profile email
|
||||||
|
|
||||||
|
# if you serve the app over plain http in dev, allow insecure redirect urls
|
||||||
|
kanidm system oauth2 enable-localhost-redirects cnats
|
||||||
|
|
||||||
|
# read back the client secret for .env
|
||||||
|
kanidm system oauth2 show-basic-secret cnats
|
||||||
|
```
|
||||||
|
|
||||||
|
> Kanidm's OIDC discovery endpoint is per-client:
|
||||||
|
> `https://<kanidm>/oauth2/openid/<client_id>/.well-known/openid-configuration`.
|
||||||
|
> The app derives this from `KANIDM_URL` + `OAUTH2_CLIENT_ID` automatically.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env # then edit
|
||||||
|
```
|
||||||
|
|
||||||
|
| Variable | Meaning |
|
||||||
|
| --- | --- |
|
||||||
|
| `NATS_URL` | NATS server, default `nats://127.0.0.1:4222` |
|
||||||
|
| `KANIDM_URL` | Base URL of Kanidm, e.g. `https://idm.example.com` |
|
||||||
|
| `OAUTH2_CLIENT_ID` | The Kanidm oauth2 client name (`cnats` above) |
|
||||||
|
| `OAUTH2_CLIENT_SECRET` | Output of `show-basic-secret` |
|
||||||
|
| `PUBLIC_URL` | Where browsers reach this app; `${PUBLIC_URL}/auth/callback` must be a registered redirect URL |
|
||||||
|
| `COOKIE_SECURE` | `true` behind HTTPS (production) |
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d nats
|
||||||
|
cargo leptos watch # dev, auto-reload on http://127.0.0.1:3000
|
||||||
|
cargo leptos build --release # production build (binary + target/site)
|
||||||
|
```
|
||||||
|
|
||||||
|
Open two browser windows, sign in, and chat — or join from the CLI:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nats sub 'chat.room.*'
|
||||||
|
nats pub chat.room.lobby '{"id":"cli-1","room":"lobby","username":"cli","display_name":"CLI","text":"hello from the bus","time":"12:00:00"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
main.rs axum entrypoint: routes, sessions, NATS + OIDC bootstrap
|
||||||
|
lib.rs hydrate entrypoint (wasm)
|
||||||
|
app.rs Leptos UI (login gate + chat console)
|
||||||
|
auth.rs shared User type + current_user server fn
|
||||||
|
chat.rs shared ChatMessage/rooms + send_message server fn (publishes to NATS)
|
||||||
|
server/
|
||||||
|
oidc.rs Kanidm OIDC login/callback/logout handlers
|
||||||
|
sse.rs NATS → browser SSE bridge (one subscription per client)
|
||||||
|
style/main.css the console theme
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes & production hardening
|
||||||
|
|
||||||
|
- Sessions are in-memory (`tower-sessions` `MemoryStore`): restart logs
|
||||||
|
everyone out, and it is single-instance. Swap in a Redis/SQL store for
|
||||||
|
multiple replicas.
|
||||||
|
- Messages are not persisted; you only see traffic while connected. NATS
|
||||||
|
JetStream (the compose file starts NATS with `-js`) would give history via a
|
||||||
|
durable stream over `chat.room.*`.
|
||||||
|
- The OIDC client verifies ID-token signature, nonce, and CSRF state, and
|
||||||
|
requires PKCE — but there is no token refresh; the app session lives
|
||||||
|
independently of the Kanidm token lifetime.
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# Local development stack. In production the app is deployed by the
|
||||||
|
# infrastructure repo (terraform), which supplies NATS and Kanidm — this
|
||||||
|
# compose file only exists for standalone hacking.
|
||||||
|
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
build: .
|
||||||
|
env_file: .env
|
||||||
|
environment:
|
||||||
|
NATS_URL: nats://nats:4222
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
depends_on:
|
||||||
|
- nats
|
||||||
|
|
||||||
|
nats:
|
||||||
|
image: nats:2.10-alpine
|
||||||
|
command: ["-js", "-m", "8222"]
|
||||||
|
ports:
|
||||||
|
- "4222:4222" # client connections
|
||||||
|
- "8222:8222" # monitoring UI (http://localhost:8222)
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||||
|
<rect width="32" height="32" fill="#060a09"/>
|
||||||
|
<path d="M8 22V10l10 12V10" fill="none" stroke="#4ef0b1" stroke-width="3" stroke-linecap="square"/>
|
||||||
|
<rect x="22" y="19" width="3" height="3" fill="#ffb454"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 278 B |
+349
@@ -0,0 +1,349 @@
|
|||||||
|
use leptos::prelude::*;
|
||||||
|
use leptos_meta::{provide_meta_context, MetaTags, Stylesheet, Title};
|
||||||
|
use leptos_router::{
|
||||||
|
components::{Route, Router, Routes, A},
|
||||||
|
hooks::use_params_map,
|
||||||
|
path,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::auth::{current_user, User};
|
||||||
|
use crate::chat::{is_valid_room, room_subject, ChatMessage, SendMessage, DEFAULT_ROOM, ROOMS};
|
||||||
|
|
||||||
|
pub fn shell(options: LeptosOptions) -> impl IntoView {
|
||||||
|
view! {
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8"/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||||
|
<link rel="icon" href="/favicon.svg" type="image/svg+xml"/>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com"/>
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin=""/>
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Archivo:wght@400;500;600;700;900&family=IBM+Plex+Mono:ital,wght@0,400;0,500;0,600;1,400&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
<AutoReload options=options.clone()/>
|
||||||
|
<HydrationScripts options/>
|
||||||
|
<MetaTags/>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<App/>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn App() -> impl IntoView {
|
||||||
|
provide_meta_context();
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<Stylesheet id="leptos" href="/pkg/cnats.css"/>
|
||||||
|
<Title text="cnats — chat over the bus"/>
|
||||||
|
<Router>
|
||||||
|
<Routes fallback=|| view! { <NotFound/> }>
|
||||||
|
<Route path=path!("") view=ChatPage/>
|
||||||
|
<Route path=path!("/r/:room") view=ChatPage/>
|
||||||
|
</Routes>
|
||||||
|
</Router>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
fn ChatPage() -> impl IntoView {
|
||||||
|
let params = use_params_map();
|
||||||
|
let room = Memo::new(move |_| {
|
||||||
|
params.with(|p| {
|
||||||
|
p.get("room")
|
||||||
|
.filter(|r| is_valid_room(r))
|
||||||
|
.unwrap_or_else(|| DEFAULT_ROOM.to_string())
|
||||||
|
})
|
||||||
|
});
|
||||||
|
let user = Resource::new(|| (), |_| current_user());
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<Suspense fallback=move || {
|
||||||
|
view! {
|
||||||
|
<main class="gate">
|
||||||
|
<p class="gate-loading">"handshaking with the bus…"</p>
|
||||||
|
</main>
|
||||||
|
}
|
||||||
|
}>
|
||||||
|
{move || {
|
||||||
|
user.get()
|
||||||
|
.map(|res| match res {
|
||||||
|
Ok(Some(u)) => view! { <ChatShell room user=u/> }.into_any(),
|
||||||
|
_ => view! { <LoginGate/> }.into_any(),
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</Suspense>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// unauthenticated: the gate
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
fn LoginGate() -> impl IntoView {
|
||||||
|
view! {
|
||||||
|
<main class="gate">
|
||||||
|
<div class="gate-card">
|
||||||
|
<div class="gate-badge">"MESSAGE BUS · AUTH REQUIRED"</div>
|
||||||
|
<h1 class="gate-title">
|
||||||
|
"CN" <span class="gate-title-accent">"ATS"</span>
|
||||||
|
</h1>
|
||||||
|
<p class="gate-sub">
|
||||||
|
"Realtime chat carried on NATS subjects. Identity issued by your Kanidm realm — no separate passwords, no local accounts."
|
||||||
|
</p>
|
||||||
|
<a class="gate-btn" href="/auth/login" rel="external">
|
||||||
|
<span class="gate-btn-glyph">"⏻"</span>
|
||||||
|
"Sign in with Kanidm"
|
||||||
|
</a>
|
||||||
|
<dl class="gate-meta">
|
||||||
|
<div><dt>"subjects"</dt><dd><code>"chat.room.*"</code></dd></div>
|
||||||
|
<div><dt>"downlink"</dt><dd><code>"SSE"</code></dd></div>
|
||||||
|
<div><dt>"identity"</dt><dd><code>"OIDC + PKCE"</code></dd></div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// authenticated: the console
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum BusStatus {
|
||||||
|
Connecting,
|
||||||
|
Live,
|
||||||
|
Offline,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BusStatus {
|
||||||
|
fn label(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
BusStatus::Connecting => "SYN…",
|
||||||
|
BusStatus::Live => "LIVE",
|
||||||
|
BusStatus::Offline => "RETRY",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
fn ChatShell(room: Memo<String>, user: User) -> impl IntoView {
|
||||||
|
let messages = RwSignal::new(Vec::<ChatMessage>::new());
|
||||||
|
let status = RwSignal::new(BusStatus::Connecting);
|
||||||
|
let list_ref = NodeRef::<leptos::html::Div>::new();
|
||||||
|
|
||||||
|
// Subscribe to the room's SSE feed (browser only). Reconnects whenever
|
||||||
|
// the room changes; closes the previous stream first.
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
{
|
||||||
|
let es_handle = StoredValue::new_local(None::<web_sys::EventSource>);
|
||||||
|
Effect::new(move |_| {
|
||||||
|
let room = room.get();
|
||||||
|
es_handle.update_value(|es| {
|
||||||
|
if let Some(es) = es.take() {
|
||||||
|
es.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
messages.set(Vec::new());
|
||||||
|
status.set(BusStatus::Connecting);
|
||||||
|
es_handle.set_value(open_event_source(&room, messages, status));
|
||||||
|
});
|
||||||
|
on_cleanup(move || {
|
||||||
|
es_handle.update_value(|es| {
|
||||||
|
if let Some(es) = es.take() {
|
||||||
|
es.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pin the stream to the bottom as messages arrive.
|
||||||
|
Effect::new(move |_| {
|
||||||
|
messages.track();
|
||||||
|
if let Some(el) = list_ref.get() {
|
||||||
|
el.set_scroll_top(el.scroll_height());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let draft = RwSignal::new(String::new());
|
||||||
|
let send = ServerAction::<SendMessage>::new();
|
||||||
|
let on_submit = move |ev: leptos::ev::SubmitEvent| {
|
||||||
|
ev.prevent_default();
|
||||||
|
let text = draft.get();
|
||||||
|
if text.trim().is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
send.dispatch(SendMessage {
|
||||||
|
room: room.get(),
|
||||||
|
text,
|
||||||
|
});
|
||||||
|
draft.set(String::new());
|
||||||
|
};
|
||||||
|
|
||||||
|
let me = user.username.clone();
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div class="console">
|
||||||
|
<nav class="rail">
|
||||||
|
<div class="wordmark">
|
||||||
|
"CN" <span class="wordmark-accent">"ATS"</span>
|
||||||
|
<span class="wordmark-sub">"chat over the bus"</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rail-label">"SUBJECTS"</div>
|
||||||
|
<div class="rooms">
|
||||||
|
{ROOMS
|
||||||
|
.iter()
|
||||||
|
.map(|(name, desc)| {
|
||||||
|
let name = *name;
|
||||||
|
let desc = *desc;
|
||||||
|
view! {
|
||||||
|
<A
|
||||||
|
href=format!("/r/{name}")
|
||||||
|
attr:class=move || {
|
||||||
|
if room.get() == name { "room-link active" } else { "room-link" }
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span class="room-hash">"❯"</span>
|
||||||
|
<span class="room-text">
|
||||||
|
<span class="room-name">{name}</span>
|
||||||
|
<span class="room-desc">{desc}</span>
|
||||||
|
</span>
|
||||||
|
</A>
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect_view()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rail-foot">
|
||||||
|
<div class="user-chip">
|
||||||
|
<span class="user-avatar">
|
||||||
|
{user.display_name.chars().next().unwrap_or('?').to_uppercase().to_string()}
|
||||||
|
</span>
|
||||||
|
<span class="user-names">
|
||||||
|
<span class="user-display">{user.display_name.clone()}</span>
|
||||||
|
<span class="user-handle">{format!("@{}", user.username)}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<a class="logout" href="/auth/logout" rel="external" title="sign out">
|
||||||
|
"EOT ⏏"
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main class="deck">
|
||||||
|
<header class="topbar">
|
||||||
|
<div class="topbar-room">
|
||||||
|
<h1>{move || room.get()}</h1>
|
||||||
|
<code class="subject-code">{move || room_subject(&room.get())}</code>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="topbar-status"
|
||||||
|
class:live=move || status.get() == BusStatus::Live
|
||||||
|
class:offline=move || status.get() == BusStatus::Offline
|
||||||
|
>
|
||||||
|
<span class="led"></span>
|
||||||
|
<span class="status-text">{move || status.get().label()}</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="stream" node_ref=list_ref>
|
||||||
|
<Show when=move || messages.with(|m| m.is_empty())>
|
||||||
|
<div class="stream-empty">
|
||||||
|
<span class="empty-glyph">"[ ∅ ]"</span>
|
||||||
|
<p>"no traffic on this subject yet — say something"</p>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
<For
|
||||||
|
each=move || messages.get()
|
||||||
|
key=|m| m.id.clone()
|
||||||
|
children=move |m: ChatMessage| {
|
||||||
|
let mine = m.username == me;
|
||||||
|
view! {
|
||||||
|
<article class="msg" class:mine=mine>
|
||||||
|
<span class="msg-time">{m.time}</span>
|
||||||
|
<span class="msg-user">{m.display_name}</span>
|
||||||
|
<span class="msg-text">{m.text}</span>
|
||||||
|
</article>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form class="composer" on:submit=on_submit>
|
||||||
|
<span class="composer-prompt">"❯"</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="composer-input"
|
||||||
|
placeholder=move || format!("PUB {} …", room_subject(&room.get()))
|
||||||
|
prop:value=move || draft.get()
|
||||||
|
on:input=move |ev| draft.set(event_target_value(&ev))
|
||||||
|
autocomplete="off"
|
||||||
|
maxlength="2000"
|
||||||
|
/>
|
||||||
|
<button type="submit" class="composer-send" disabled=move || send.pending().get()>
|
||||||
|
"PUB ↵"
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
fn open_event_source(
|
||||||
|
room: &str,
|
||||||
|
messages: RwSignal<Vec<ChatMessage>>,
|
||||||
|
status: RwSignal<BusStatus>,
|
||||||
|
) -> Option<web_sys::EventSource> {
|
||||||
|
use wasm_bindgen::{prelude::Closure, JsCast};
|
||||||
|
use web_sys::{EventSource, MessageEvent};
|
||||||
|
|
||||||
|
let es = EventSource::new(&format!("/sse/{room}")).ok()?;
|
||||||
|
|
||||||
|
let on_open = Closure::<dyn FnMut()>::new(move || status.set(BusStatus::Live));
|
||||||
|
es.set_onopen(Some(on_open.as_ref().unchecked_ref()));
|
||||||
|
on_open.forget();
|
||||||
|
|
||||||
|
let on_error = Closure::<dyn FnMut()>::new(move || status.set(BusStatus::Offline));
|
||||||
|
es.set_onerror(Some(on_error.as_ref().unchecked_ref()));
|
||||||
|
on_error.forget();
|
||||||
|
|
||||||
|
let on_message = Closure::<dyn FnMut(MessageEvent)>::new(move |ev: MessageEvent| {
|
||||||
|
if let Some(data) = ev.data().as_string() {
|
||||||
|
if let Ok(msg) = serde_json::from_str::<ChatMessage>(&data) {
|
||||||
|
messages.update(|m| {
|
||||||
|
m.push(msg);
|
||||||
|
// keep the DOM bounded on long-lived tabs
|
||||||
|
if m.len() > 500 {
|
||||||
|
m.remove(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
es.set_onmessage(Some(on_message.as_ref().unchecked_ref()));
|
||||||
|
on_message.forget();
|
||||||
|
|
||||||
|
Some(es)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
fn NotFound() -> impl IntoView {
|
||||||
|
view! {
|
||||||
|
<main class="gate">
|
||||||
|
<div class="gate-card">
|
||||||
|
<div class="gate-badge">"ERR · NO RESPONDERS"</div>
|
||||||
|
<h1 class="gate-title">"404"</h1>
|
||||||
|
<p class="gate-sub">"No subscribers on this subject."</p>
|
||||||
|
<a class="gate-btn" href="/">"⟵ back to the lobby"</a>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
}
|
||||||
|
}
|
||||||
+24
@@ -0,0 +1,24 @@
|
|||||||
|
use leptos::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// The authenticated user, as established by the Kanidm OIDC flow and
|
||||||
|
/// stored in the server-side session.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct User {
|
||||||
|
pub sub: String,
|
||||||
|
pub username: String,
|
||||||
|
pub display_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const SESSION_USER_KEY: &str = "user";
|
||||||
|
|
||||||
|
/// Returns the currently signed-in user, if any.
|
||||||
|
#[server]
|
||||||
|
pub async fn current_user() -> Result<Option<User>, ServerFnError> {
|
||||||
|
let session: tower_sessions::Session = leptos_axum::extract().await?;
|
||||||
|
let user = session
|
||||||
|
.get::<User>(SESSION_USER_KEY)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||||
|
Ok(user)
|
||||||
|
}
|
||||||
+76
@@ -0,0 +1,76 @@
|
|||||||
|
use leptos::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// Rooms available in the UI. Each maps to the NATS subject
|
||||||
|
/// `chat.room.<name>`, so any other NATS client on the bus can join in.
|
||||||
|
pub const ROOMS: &[(&str, &str)] = &[
|
||||||
|
("lobby", "general traffic"),
|
||||||
|
("dev", "build & ship"),
|
||||||
|
("ops", "incidents & infra"),
|
||||||
|
("random", "off the record"),
|
||||||
|
];
|
||||||
|
|
||||||
|
pub const DEFAULT_ROOM: &str = "lobby";
|
||||||
|
|
||||||
|
pub fn is_valid_room(room: &str) -> bool {
|
||||||
|
ROOMS.iter().any(|(name, _)| *name == room)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn room_subject(room: &str) -> String {
|
||||||
|
format!("chat.room.{room}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A single chat message as it travels over NATS (JSON-encoded payload).
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct ChatMessage {
|
||||||
|
pub id: String,
|
||||||
|
pub room: String,
|
||||||
|
pub username: String,
|
||||||
|
pub display_name: String,
|
||||||
|
pub text: String,
|
||||||
|
/// Pre-formatted UTC wall-clock time, e.g. "14:03:27".
|
||||||
|
pub time: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Publishes a message to the room's NATS subject. Requires a signed-in
|
||||||
|
/// session; the sender identity always comes from the session, never from
|
||||||
|
/// the client.
|
||||||
|
#[server]
|
||||||
|
pub async fn send_message(room: String, text: String) -> Result<(), ServerFnError> {
|
||||||
|
use crate::auth::{User, SESSION_USER_KEY};
|
||||||
|
use crate::server::AppState;
|
||||||
|
|
||||||
|
let text = text.trim().to_string();
|
||||||
|
if text.is_empty() || text.len() > 2000 {
|
||||||
|
return Err(ServerFnError::new("message must be 1..=2000 characters"));
|
||||||
|
}
|
||||||
|
if !is_valid_room(&room) {
|
||||||
|
return Err(ServerFnError::new("unknown room"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let session: tower_sessions::Session = leptos_axum::extract().await?;
|
||||||
|
let Some(user) = session
|
||||||
|
.get::<User>(SESSION_USER_KEY)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(e.to_string()))?
|
||||||
|
else {
|
||||||
|
return Err(ServerFnError::new("not signed in"));
|
||||||
|
};
|
||||||
|
|
||||||
|
let state = expect_context::<AppState>();
|
||||||
|
let msg = ChatMessage {
|
||||||
|
id: uuid::Uuid::new_v4().to_string(),
|
||||||
|
room: room.clone(),
|
||||||
|
username: user.username,
|
||||||
|
display_name: user.display_name,
|
||||||
|
text,
|
||||||
|
time: chrono::Utc::now().format("%H:%M:%S").to_string(),
|
||||||
|
};
|
||||||
|
let payload = serde_json::to_vec(&msg).map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||||
|
state
|
||||||
|
.nats
|
||||||
|
.publish(room_subject(&room), payload.into())
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(format!("nats publish failed: {e}")))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
pub mod app;
|
||||||
|
pub mod auth;
|
||||||
|
pub mod chat;
|
||||||
|
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
pub mod server;
|
||||||
|
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
#[wasm_bindgen::prelude::wasm_bindgen]
|
||||||
|
pub fn hydrate() {
|
||||||
|
console_error_panic_hook::set_once();
|
||||||
|
leptos::mount::hydrate_body(app::App);
|
||||||
|
}
|
||||||
+97
@@ -0,0 +1,97 @@
|
|||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> anyhow::Result<()> {
|
||||||
|
use axum::{
|
||||||
|
body::Body,
|
||||||
|
extract::State,
|
||||||
|
http::Request,
|
||||||
|
response::IntoResponse,
|
||||||
|
routing::{any, get},
|
||||||
|
Router,
|
||||||
|
};
|
||||||
|
use cnats::app::{shell, App};
|
||||||
|
use cnats::server::{oidc, sse, AppState};
|
||||||
|
use leptos::prelude::*;
|
||||||
|
use leptos_axum::{generate_route_list, LeptosRoutes};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tower_sessions::{MemoryStore, SessionManagerLayer};
|
||||||
|
|
||||||
|
dotenvy::dotenv().ok();
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_env_filter(
|
||||||
|
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||||
|
.unwrap_or_else(|_| "info,cnats=debug".into()),
|
||||||
|
)
|
||||||
|
.init();
|
||||||
|
|
||||||
|
let conf = get_configuration(None)?;
|
||||||
|
let leptos_options = conf.leptos_options;
|
||||||
|
let addr = leptos_options.site_addr;
|
||||||
|
let routes = generate_route_list(App);
|
||||||
|
|
||||||
|
let nats_url =
|
||||||
|
std::env::var("NATS_URL").unwrap_or_else(|_| "nats://127.0.0.1:4222".to_string());
|
||||||
|
tracing::info!(%nats_url, "connecting to NATS");
|
||||||
|
let nats = async_nats::connect(&nats_url).await?;
|
||||||
|
|
||||||
|
let oidc_state = Arc::new(oidc::Oidc::from_env().await?);
|
||||||
|
|
||||||
|
let state = AppState {
|
||||||
|
leptos_options: leptos_options.clone(),
|
||||||
|
nats,
|
||||||
|
oidc: oidc_state,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Dev-friendly defaults: in-memory sessions, secure cookies only when
|
||||||
|
// COOKIE_SECURE=true (set it behind TLS in production).
|
||||||
|
let cookie_secure = std::env::var("COOKIE_SECURE")
|
||||||
|
.map(|v| v == "true" || v == "1")
|
||||||
|
.unwrap_or(false);
|
||||||
|
let session_layer = SessionManagerLayer::new(MemoryStore::default())
|
||||||
|
.with_secure(cookie_secure)
|
||||||
|
.with_name("cnats_session");
|
||||||
|
|
||||||
|
async fn server_fn_handler(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
request: Request<Body>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
leptos_axum::handle_server_fns_with_context(
|
||||||
|
move || provide_context(state.clone()),
|
||||||
|
request,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
let app = Router::new()
|
||||||
|
.route("/auth/login", get(oidc::login))
|
||||||
|
.route("/auth/callback", get(oidc::callback))
|
||||||
|
.route("/auth/logout", get(oidc::logout))
|
||||||
|
.route("/sse/{room}", get(sse::room_events))
|
||||||
|
.route("/api/{*fn_name}", any(server_fn_handler))
|
||||||
|
.leptos_routes_with_context(
|
||||||
|
&state,
|
||||||
|
routes,
|
||||||
|
{
|
||||||
|
let state = state.clone();
|
||||||
|
move || provide_context(state.clone())
|
||||||
|
},
|
||||||
|
{
|
||||||
|
let leptos_options = leptos_options.clone();
|
||||||
|
move || shell(leptos_options.clone())
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.fallback(leptos_axum::file_and_error_handler::<AppState, _>(shell))
|
||||||
|
.layer(session_layer)
|
||||||
|
.with_state(state);
|
||||||
|
|
||||||
|
tracing::info!("listening on http://{addr}");
|
||||||
|
let listener = tokio::net::TcpListener::bind(&addr).await?;
|
||||||
|
axum::serve(listener, app.into_make_service()).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(feature = "ssr"))]
|
||||||
|
fn main() {
|
||||||
|
// The browser build is a cdylib; this stub only exists so `cargo check`
|
||||||
|
// without features still succeeds.
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
pub mod oidc;
|
||||||
|
pub mod sse;
|
||||||
|
|
||||||
|
use axum::extract::FromRef;
|
||||||
|
use leptos::prelude::LeptosOptions;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AppState {
|
||||||
|
pub leptos_options: LeptosOptions,
|
||||||
|
pub nats: async_nats::Client,
|
||||||
|
pub oidc: Arc<oidc::Oidc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromRef<AppState> for LeptosOptions {
|
||||||
|
fn from_ref(state: &AppState) -> Self {
|
||||||
|
state.leptos_options.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
//! OIDC authorization-code + PKCE flow against Kanidm.
|
||||||
|
//!
|
||||||
|
//! Kanidm serves per-client OIDC discovery documents at
|
||||||
|
//! `<KANIDM_URL>/oauth2/openid/<client_id>/.well-known/openid-configuration`,
|
||||||
|
//! and enforces PKCE, so this module always sends a S256 challenge.
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
extract::{Query, State},
|
||||||
|
http::StatusCode,
|
||||||
|
response::Redirect,
|
||||||
|
};
|
||||||
|
use openidconnect::{
|
||||||
|
core::{CoreAuthenticationFlow, CoreClient, CoreProviderMetadata},
|
||||||
|
AuthorizationCode, ClientId, ClientSecret, CsrfToken, EndpointMaybeSet, EndpointNotSet,
|
||||||
|
EndpointSet, IssuerUrl, Nonce, PkceCodeChallenge, PkceCodeVerifier, RedirectUrl, Scope,
|
||||||
|
TokenResponse,
|
||||||
|
};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use tower_sessions::Session;
|
||||||
|
|
||||||
|
use crate::auth::{User, SESSION_USER_KEY};
|
||||||
|
|
||||||
|
use super::AppState;
|
||||||
|
|
||||||
|
type OidcClient = CoreClient<
|
||||||
|
EndpointSet, // auth endpoint
|
||||||
|
EndpointNotSet, // device auth
|
||||||
|
EndpointNotSet, // introspection
|
||||||
|
EndpointNotSet, // revocation
|
||||||
|
EndpointMaybeSet, // token endpoint (from discovery)
|
||||||
|
EndpointMaybeSet, // userinfo endpoint (from discovery)
|
||||||
|
>;
|
||||||
|
|
||||||
|
pub struct Oidc {
|
||||||
|
client: OidcClient,
|
||||||
|
http: openidconnect::reqwest::Client,
|
||||||
|
}
|
||||||
|
|
||||||
|
const PKCE_KEY: &str = "oidc_pkce_verifier";
|
||||||
|
const CSRF_KEY: &str = "oidc_csrf_state";
|
||||||
|
const NONCE_KEY: &str = "oidc_nonce";
|
||||||
|
|
||||||
|
impl Oidc {
|
||||||
|
/// Discovers the provider and builds the client from environment:
|
||||||
|
/// `KANIDM_URL`, `OAUTH2_CLIENT_ID`, `OAUTH2_CLIENT_SECRET`, `PUBLIC_URL`.
|
||||||
|
pub async fn from_env() -> anyhow::Result<Self> {
|
||||||
|
let kanidm_url = require_env("KANIDM_URL")?;
|
||||||
|
let client_id = require_env("OAUTH2_CLIENT_ID")?;
|
||||||
|
let client_secret = require_env("OAUTH2_CLIENT_SECRET")?;
|
||||||
|
let public_url = require_env("PUBLIC_URL")?;
|
||||||
|
|
||||||
|
let issuer = IssuerUrl::new(format!(
|
||||||
|
"{}/oauth2/openid/{}",
|
||||||
|
kanidm_url.trim_end_matches('/'),
|
||||||
|
client_id
|
||||||
|
))?;
|
||||||
|
let redirect = RedirectUrl::new(format!(
|
||||||
|
"{}/auth/callback",
|
||||||
|
public_url.trim_end_matches('/')
|
||||||
|
))?;
|
||||||
|
|
||||||
|
// Never follow redirects when talking to the IdP (SSRF hygiene).
|
||||||
|
let http = openidconnect::reqwest::ClientBuilder::new()
|
||||||
|
.redirect(openidconnect::reqwest::redirect::Policy::none())
|
||||||
|
.build()?;
|
||||||
|
|
||||||
|
tracing::info!(issuer = %issuer.as_str(), "discovering OIDC provider");
|
||||||
|
let metadata = CoreProviderMetadata::discover_async(issuer, &http).await?;
|
||||||
|
let client = CoreClient::from_provider_metadata(
|
||||||
|
metadata,
|
||||||
|
ClientId::new(client_id),
|
||||||
|
Some(ClientSecret::new(client_secret)),
|
||||||
|
)
|
||||||
|
.set_redirect_uri(redirect);
|
||||||
|
|
||||||
|
Ok(Self { client, http })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn require_env(name: &str) -> anyhow::Result<String> {
|
||||||
|
std::env::var(name).map_err(|_| anyhow::anyhow!("missing required env var {name}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
type HandlerError = (StatusCode, String);
|
||||||
|
|
||||||
|
fn internal(err: impl std::fmt::Display) -> HandlerError {
|
||||||
|
tracing::error!("oidc error: {err}");
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"authentication failed; see server logs".to_string(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /auth/login — stash PKCE/state/nonce in the session and bounce to Kanidm.
|
||||||
|
pub async fn login(State(state): State<AppState>, session: Session) -> Result<Redirect, HandlerError> {
|
||||||
|
let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();
|
||||||
|
|
||||||
|
let (auth_url, csrf_state, nonce) = state
|
||||||
|
.oidc
|
||||||
|
.client
|
||||||
|
.authorize_url(
|
||||||
|
CoreAuthenticationFlow::AuthorizationCode,
|
||||||
|
CsrfToken::new_random,
|
||||||
|
Nonce::new_random,
|
||||||
|
)
|
||||||
|
.add_scope(Scope::new("openid".to_string()))
|
||||||
|
.add_scope(Scope::new("profile".to_string()))
|
||||||
|
.add_scope(Scope::new("email".to_string()))
|
||||||
|
.set_pkce_challenge(pkce_challenge)
|
||||||
|
.url();
|
||||||
|
|
||||||
|
session
|
||||||
|
.insert(PKCE_KEY, pkce_verifier.secret())
|
||||||
|
.await
|
||||||
|
.map_err(internal)?;
|
||||||
|
session
|
||||||
|
.insert(CSRF_KEY, csrf_state.secret())
|
||||||
|
.await
|
||||||
|
.map_err(internal)?;
|
||||||
|
session
|
||||||
|
.insert(NONCE_KEY, nonce.secret())
|
||||||
|
.await
|
||||||
|
.map_err(internal)?;
|
||||||
|
|
||||||
|
Ok(Redirect::to(auth_url.as_str()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct CallbackParams {
|
||||||
|
code: String,
|
||||||
|
state: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /auth/callback — verify state, exchange the code, verify the ID token,
|
||||||
|
/// and store the user in the session.
|
||||||
|
pub async fn callback(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
session: Session,
|
||||||
|
Query(params): Query<CallbackParams>,
|
||||||
|
) -> Result<Redirect, HandlerError> {
|
||||||
|
let stored_csrf: Option<String> = session.remove(CSRF_KEY).await.map_err(internal)?;
|
||||||
|
let pkce_verifier: Option<String> = session.remove(PKCE_KEY).await.map_err(internal)?;
|
||||||
|
let nonce: Option<String> = session.remove(NONCE_KEY).await.map_err(internal)?;
|
||||||
|
|
||||||
|
let (Some(stored_csrf), Some(pkce_verifier), Some(nonce)) =
|
||||||
|
(stored_csrf, pkce_verifier, nonce)
|
||||||
|
else {
|
||||||
|
return Err((
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"no login in progress; start again at /auth/login".to_string(),
|
||||||
|
));
|
||||||
|
};
|
||||||
|
if params.state != stored_csrf {
|
||||||
|
return Err((
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"state mismatch; start again at /auth/login".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let oidc = &state.oidc;
|
||||||
|
let token_response = oidc
|
||||||
|
.client
|
||||||
|
.exchange_code(AuthorizationCode::new(params.code))
|
||||||
|
.map_err(internal)?
|
||||||
|
.set_pkce_verifier(PkceCodeVerifier::new(pkce_verifier))
|
||||||
|
.request_async(&oidc.http)
|
||||||
|
.await
|
||||||
|
.map_err(internal)?;
|
||||||
|
|
||||||
|
let id_token = token_response
|
||||||
|
.id_token()
|
||||||
|
.ok_or_else(|| internal("provider returned no ID token"))?;
|
||||||
|
let claims = id_token
|
||||||
|
.claims(&oidc.client.id_token_verifier(), &Nonce::new(nonce))
|
||||||
|
.map_err(internal)?;
|
||||||
|
|
||||||
|
let username = claims
|
||||||
|
.preferred_username()
|
||||||
|
.map(|u| u.as_str().to_string())
|
||||||
|
.or_else(|| claims.email().map(|e| e.as_str().to_string()))
|
||||||
|
.unwrap_or_else(|| claims.subject().as_str().to_string());
|
||||||
|
let display_name = claims
|
||||||
|
.name()
|
||||||
|
.and_then(|n| n.get(None))
|
||||||
|
.map(|n| n.as_str().to_string())
|
||||||
|
.unwrap_or_else(|| username.clone());
|
||||||
|
|
||||||
|
let user = User {
|
||||||
|
sub: claims.subject().as_str().to_string(),
|
||||||
|
username,
|
||||||
|
display_name,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Rotate the session id on privilege change, then store the user.
|
||||||
|
session.cycle_id().await.map_err(internal)?;
|
||||||
|
session
|
||||||
|
.insert(SESSION_USER_KEY, &user)
|
||||||
|
.await
|
||||||
|
.map_err(internal)?;
|
||||||
|
|
||||||
|
tracing::info!(user = %user.username, "signed in");
|
||||||
|
Ok(Redirect::to("/"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /auth/logout — drop the session.
|
||||||
|
pub async fn logout(session: Session) -> Result<Redirect, HandlerError> {
|
||||||
|
session.flush().await.map_err(internal)?;
|
||||||
|
Ok(Redirect::to("/"))
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
//! Server-Sent Events bridge: one NATS subscription per connected browser,
|
||||||
|
//! scoped to a single room subject.
|
||||||
|
|
||||||
|
use std::{convert::Infallible, time::Duration};
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
extract::{Path, State},
|
||||||
|
http::StatusCode,
|
||||||
|
response::sse::{Event, KeepAlive, Sse},
|
||||||
|
};
|
||||||
|
use futures::{Stream, StreamExt};
|
||||||
|
use tower_sessions::Session;
|
||||||
|
|
||||||
|
use crate::auth::{User, SESSION_USER_KEY};
|
||||||
|
use crate::chat::{is_valid_room, room_subject};
|
||||||
|
|
||||||
|
use super::AppState;
|
||||||
|
|
||||||
|
/// GET /sse/{room} — stream the room's NATS subject to the browser.
|
||||||
|
pub async fn room_events(
|
||||||
|
Path(room): Path<String>,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
session: Session,
|
||||||
|
) -> Result<Sse<impl Stream<Item = Result<Event, Infallible>>>, (StatusCode, &'static str)> {
|
||||||
|
let signed_in = session
|
||||||
|
.get::<User>(SESSION_USER_KEY)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.is_some();
|
||||||
|
if !signed_in {
|
||||||
|
return Err((StatusCode::UNAUTHORIZED, "sign in first"));
|
||||||
|
}
|
||||||
|
if !is_valid_room(&room) {
|
||||||
|
return Err((StatusCode::NOT_FOUND, "unknown room"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let subscriber = state
|
||||||
|
.nats
|
||||||
|
.subscribe(room_subject(&room))
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
tracing::error!("nats subscribe failed: {e}");
|
||||||
|
(StatusCode::BAD_GATEWAY, "message bus unavailable")
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let stream = subscriber.map(|msg| {
|
||||||
|
Ok(Event::default()
|
||||||
|
.event("message")
|
||||||
|
.data(String::from_utf8_lossy(&msg.payload).into_owned()))
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(Sse::new(stream).keep_alive(
|
||||||
|
KeepAlive::new()
|
||||||
|
.interval(Duration::from_secs(15))
|
||||||
|
.text("ping"),
|
||||||
|
))
|
||||||
|
}
|
||||||
+523
@@ -0,0 +1,523 @@
|
|||||||
|
/* ── cnats · message-bus console ─────────────────────────────────────────
|
||||||
|
dark phosphor terminal: deep green-black ground, mint signal, amber id.
|
||||||
|
type: Archivo (UI voice) + IBM Plex Mono (wire voice). */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--ink-0: #060a09;
|
||||||
|
--ink-1: #0b1210;
|
||||||
|
--ink-2: #101a17;
|
||||||
|
--ink-3: #16241f;
|
||||||
|
--line: #1e332c;
|
||||||
|
--line-hot: #2c4a3f;
|
||||||
|
--text: #d7e6df;
|
||||||
|
--text-dim: #7d968c;
|
||||||
|
--text-faint: #4d6159;
|
||||||
|
--signal: #4ef0b1;
|
||||||
|
--signal-dim: #2a8f6c;
|
||||||
|
--amber: #ffb454;
|
||||||
|
--alarm: #ff6b6b;
|
||||||
|
--mono: "IBM Plex Mono", ui-monospace, monospace;
|
||||||
|
--sans: "Archivo", system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
|
||||||
|
html, body { height: 100%; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--sans);
|
||||||
|
background: var(--ink-0);
|
||||||
|
color: var(--text);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* phosphor atmosphere: faint scanlines + vignette over everything */
|
||||||
|
body::before {
|
||||||
|
content: "";
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 999;
|
||||||
|
background:
|
||||||
|
repeating-linear-gradient(0deg, rgba(255, 255, 255, 0.015) 0 1px, transparent 1px 3px),
|
||||||
|
radial-gradient(ellipse 120% 90% at 50% 40%, transparent 55%, rgba(0, 0, 0, 0.45));
|
||||||
|
}
|
||||||
|
|
||||||
|
::selection { background: var(--signal); color: var(--ink-0); }
|
||||||
|
|
||||||
|
/* ── gate (login / 404 / loading) ──────────────────────────────────────── */
|
||||||
|
|
||||||
|
.gate {
|
||||||
|
height: 100vh;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 2rem;
|
||||||
|
background:
|
||||||
|
radial-gradient(ellipse 60% 45% at 50% 0%, rgba(78, 240, 177, 0.07), transparent 70%),
|
||||||
|
linear-gradient(var(--ink-0), var(--ink-1));
|
||||||
|
}
|
||||||
|
|
||||||
|
.gate-loading {
|
||||||
|
font-family: var(--mono);
|
||||||
|
color: var(--signal-dim);
|
||||||
|
animation: blink 1.1s steps(2) infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gate-card {
|
||||||
|
width: min(30rem, 100%);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: linear-gradient(160deg, var(--ink-2), var(--ink-1) 60%);
|
||||||
|
padding: 3rem 2.75rem 2.5rem;
|
||||||
|
position: relative;
|
||||||
|
box-shadow: 0 40px 80px rgba(0, 0, 0, 0.55);
|
||||||
|
animation: rise 0.5s cubic-bezier(0.2, 0.9, 0.3, 1) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* corner ticks, oscilloscope style */
|
||||||
|
.gate-card::before,
|
||||||
|
.gate-card::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border: 1px solid var(--signal);
|
||||||
|
}
|
||||||
|
.gate-card::before { top: -1px; left: -1px; border-right: 0; border-bottom: 0; }
|
||||||
|
.gate-card::after { bottom: -1px; right: -1px; border-left: 0; border-top: 0; }
|
||||||
|
|
||||||
|
.gate-badge {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 0.65rem;
|
||||||
|
letter-spacing: 0.28em;
|
||||||
|
color: var(--signal-dim);
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gate-title {
|
||||||
|
font-size: clamp(3.2rem, 9vw, 4.5rem);
|
||||||
|
font-weight: 900;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
line-height: 0.95;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gate-title-accent { color: var(--signal); }
|
||||||
|
|
||||||
|
.gate-sub {
|
||||||
|
margin: 1.1rem 0 2rem;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
max-width: 24rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gate-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--ink-0);
|
||||||
|
background: var(--signal);
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 0.85rem 1.4rem;
|
||||||
|
border: 1px solid var(--signal);
|
||||||
|
transition: background 120ms, color 120ms, box-shadow 120ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gate-btn:hover {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--signal);
|
||||||
|
box-shadow: 0 0 24px rgba(78, 240, 177, 0.25), inset 0 0 12px rgba(78, 240, 177, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gate-btn-glyph { font-size: 1.05rem; }
|
||||||
|
|
||||||
|
.gate-meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 1.6rem;
|
||||||
|
margin-top: 2.4rem;
|
||||||
|
padding-top: 1.2rem;
|
||||||
|
border-top: 1px dashed var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gate-meta dt {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 0.6rem;
|
||||||
|
letter-spacing: 0.2em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-faint);
|
||||||
|
margin-bottom: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gate-meta dd code {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--amber);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── console layout ────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.console {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 17rem 1fr;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* rail */
|
||||||
|
|
||||||
|
.rail {
|
||||||
|
background: var(--ink-1);
|
||||||
|
border-right: 1px solid var(--line);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 1.5rem 0 1rem;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wordmark {
|
||||||
|
font-size: 1.6rem;
|
||||||
|
font-weight: 900;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
padding: 0 1.4rem 1.4rem;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wordmark-accent { color: var(--signal); }
|
||||||
|
|
||||||
|
.wordmark-sub {
|
||||||
|
display: block;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 0.62rem;
|
||||||
|
font-weight: 400;
|
||||||
|
letter-spacing: 0.22em;
|
||||||
|
color: var(--text-faint);
|
||||||
|
margin-top: 0.35rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rail-label {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 0.6rem;
|
||||||
|
letter-spacing: 0.3em;
|
||||||
|
color: var(--text-faint);
|
||||||
|
padding: 1.4rem 1.4rem 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rooms { display: flex; flex-direction: column; gap: 2px; padding: 0 0.7rem; }
|
||||||
|
|
||||||
|
.room-link {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.7rem;
|
||||||
|
align-items: baseline;
|
||||||
|
padding: 0.6rem 0.7rem;
|
||||||
|
text-decoration: none;
|
||||||
|
border-left: 2px solid transparent;
|
||||||
|
transition: background 100ms, border-color 100ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-link:hover { background: var(--ink-2); }
|
||||||
|
|
||||||
|
.room-link.active {
|
||||||
|
background: var(--ink-3);
|
||||||
|
border-left-color: var(--signal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-hash {
|
||||||
|
font-family: var(--mono);
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-link.active .room-hash { color: var(--signal); }
|
||||||
|
|
||||||
|
.room-text { display: flex; flex-direction: column; gap: 0.15rem; }
|
||||||
|
|
||||||
|
.room-name {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-link.active .room-name { color: var(--signal); }
|
||||||
|
|
||||||
|
.room-desc { font-size: 0.72rem; color: var(--text-faint); }
|
||||||
|
|
||||||
|
.rail-foot {
|
||||||
|
margin-top: auto;
|
||||||
|
padding: 1rem 1.4rem 0.4rem;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-chip { display: flex; align-items: center; gap: 0.7rem; min-width: 0; flex: 1; }
|
||||||
|
|
||||||
|
.user-avatar {
|
||||||
|
width: 2.1rem;
|
||||||
|
height: 2.1rem;
|
||||||
|
flex: none;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ink-0);
|
||||||
|
background: var(--amber);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-names { display: flex; flex-direction: column; min-width: 0; }
|
||||||
|
|
||||||
|
.user-display {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-handle {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 0.68rem;
|
||||||
|
color: var(--text-faint);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 0.65rem;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
color: var(--text-faint);
|
||||||
|
text-decoration: none;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
padding: 0.4rem 0.55rem;
|
||||||
|
transition: color 120ms, border-color 120ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout:hover { color: var(--alarm); border-color: var(--alarm); }
|
||||||
|
|
||||||
|
/* deck */
|
||||||
|
|
||||||
|
.deck { display: flex; flex-direction: column; min-width: 0; min-height: 0; }
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 1rem 1.6rem;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
background: linear-gradient(180deg, var(--ink-1), transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-room { display: flex; align-items: baseline; gap: 1rem; }
|
||||||
|
|
||||||
|
.topbar-room h1 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subject-code {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--signal-dim);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
background: var(--ink-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.55rem;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
color: var(--text-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.led {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--text-faint);
|
||||||
|
transition: background 200ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-status.live .led {
|
||||||
|
background: var(--signal);
|
||||||
|
box-shadow: 0 0 8px var(--signal);
|
||||||
|
animation: pulse 2.2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-status.live .status-text { color: var(--signal); }
|
||||||
|
|
||||||
|
.topbar-status.offline .led { background: var(--alarm); box-shadow: 0 0 8px var(--alarm); }
|
||||||
|
.topbar-status.offline .status-text { color: var(--alarm); }
|
||||||
|
|
||||||
|
/* stream */
|
||||||
|
|
||||||
|
.stream {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 1.4rem 1.6rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.15rem;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--line-hot) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stream-empty {
|
||||||
|
margin: auto;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-glyph {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 1.6rem;
|
||||||
|
color: var(--signal-dim);
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stream-empty p { font-size: 0.85rem; }
|
||||||
|
|
||||||
|
.msg {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 4.6rem 9rem 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: baseline;
|
||||||
|
padding: 0.42rem 0.6rem;
|
||||||
|
border-left: 2px solid transparent;
|
||||||
|
animation: msg-in 220ms cubic-bezier(0.2, 0.9, 0.3, 1) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg:hover { background: var(--ink-1); }
|
||||||
|
|
||||||
|
.msg.mine { border-left-color: var(--amber); }
|
||||||
|
|
||||||
|
.msg-time {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 0.68rem;
|
||||||
|
color: var(--text-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg-user {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--signal);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg.mine .msg-user { color: var(--amber); }
|
||||||
|
|
||||||
|
.msg-text {
|
||||||
|
font-size: 0.92rem;
|
||||||
|
line-height: 1.55;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* composer */
|
||||||
|
|
||||||
|
.composer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.9rem;
|
||||||
|
margin: 0 1.6rem 1.4rem;
|
||||||
|
padding: 0.4rem 0.4rem 0.4rem 1rem;
|
||||||
|
border: 1px solid var(--line-hot);
|
||||||
|
background: var(--ink-1);
|
||||||
|
transition: border-color 120ms, box-shadow 120ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer:focus-within {
|
||||||
|
border-color: var(--signal-dim);
|
||||||
|
box-shadow: 0 0 0 1px var(--signal-dim), 0 0 30px rgba(78, 240, 177, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-prompt {
|
||||||
|
font-family: var(--mono);
|
||||||
|
color: var(--signal);
|
||||||
|
animation: blink 1.4s steps(2) infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-input {
|
||||||
|
flex: 1;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
padding: 0.6rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-input::placeholder { color: var(--text-faint); }
|
||||||
|
|
||||||
|
.composer-send {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--ink-0);
|
||||||
|
background: var(--signal);
|
||||||
|
border: none;
|
||||||
|
padding: 0.7rem 1.1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: filter 120ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-send:hover { filter: brightness(1.15); }
|
||||||
|
.composer-send:disabled { filter: grayscale(0.6) brightness(0.7); cursor: wait; }
|
||||||
|
|
||||||
|
/* motion */
|
||||||
|
|
||||||
|
@keyframes rise {
|
||||||
|
from { opacity: 0; transform: translateY(14px); }
|
||||||
|
to { opacity: 1; transform: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes msg-in {
|
||||||
|
from { opacity: 0; transform: translateX(-6px); }
|
||||||
|
to { opacity: 1; transform: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes blink { 50% { opacity: 0.15; } }
|
||||||
|
|
||||||
|
@keyframes pulse { 50% { box-shadow: 0 0 14px var(--signal); } }
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
* { animation: none !important; transition: none !important; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* small screens: collapse the rail into a top strip */
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.console { grid-template-columns: 1fr; grid-template-rows: auto 1fr; }
|
||||||
|
.rail {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding: 0.6rem 1rem;
|
||||||
|
border-right: 0;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.wordmark { border: 0; padding: 0 1rem 0 0; font-size: 1.1rem; }
|
||||||
|
.wordmark-sub, .rail-label, .room-desc, .rail-foot { display: none; }
|
||||||
|
.rooms { flex-direction: row; padding: 0; }
|
||||||
|
.room-link { border-left: 0; border-bottom: 2px solid transparent; }
|
||||||
|
.room-link.active { border-bottom-color: var(--signal); }
|
||||||
|
.msg { grid-template-columns: auto 1fr; grid-template-rows: auto auto; }
|
||||||
|
.msg-time { grid-row: 1; order: 2; justify-self: end; }
|
||||||
|
.msg-text { grid-column: 1 / -1; }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user