oidc/guest working

This commit is contained in:
2026-04-17 15:58:26 -04:00
parent ccea3cc9a5
commit 1daf837790
14 changed files with 543 additions and 415 deletions
+11 -4
View File
@@ -92,10 +92,13 @@ pub fn on_connect(ctx: &ReducerContext) {
// Extract potential name from OIDC if available // Extract potential name from OIDC if available
let mut initial_name = None; let mut initial_name = None;
let mut is_anon = true;
if let Some(jwt) = ctx.sender_auth().jwt() { if let Some(jwt) = ctx.sender_auth().jwt() {
let sub = jwt.subject(); let sub = jwt.subject();
let issuer = jwt.issuer();
// Use first 8 chars of sub if it's a long string/UUID // Use first 8 chars of sub if it's a long string/UUID
initial_name = Some(if sub.len() > 12 { sub[..8].to_string() } else { sub.to_string() }); initial_name = Some(if sub.len() > 12 { sub[..8].to_string() } else { sub.to_string() });
is_anon = issuer.contains("localhost");
} }
if let Some(mut user) = ctx.db.user().identity().find(ctx.sender()) { if let Some(mut user) = ctx.db.user().identity().find(ctx.sender()) {
@@ -104,6 +107,7 @@ pub fn on_connect(ctx: &ReducerContext) {
if user.name.is_none() && initial_name.is_some() { if user.name.is_none() && initial_name.is_some() {
user.name = initial_name; user.name = initial_name;
} }
user.anonymous = is_anon;
ctx.db.user().identity().update(user); ctx.db.user().identity().update(user);
} else { } else {
ctx.db.user().insert(User { ctx.db.user().insert(User {
@@ -112,7 +116,7 @@ pub fn on_connect(ctx: &ReducerContext) {
online: true, online: true,
issuer: None, issuer: None,
subject: None, subject: None,
anonymous: true, anonymous: is_anon,
avatar_id: None, avatar_id: None,
banner_id: None, banner_id: None,
biography: None, biography: None,
@@ -172,18 +176,21 @@ pub fn update_auth_info(ctx: &ReducerContext) {
if let Some(mut user) = ctx.db.user().identity().find(ctx.sender()) { if let Some(mut user) = ctx.db.user().identity().find(ctx.sender()) {
if let Some(jwt) = ctx.sender_auth().jwt() { if let Some(jwt) = ctx.sender_auth().jwt() {
let sub = jwt.subject(); let sub = jwt.subject();
user.issuer = Some(jwt.issuer().to_string()); let issuer = jwt.issuer();
user.issuer = Some(issuer.to_string());
user.subject = Some(sub.to_string()); user.subject = Some(sub.to_string());
user.anonymous = false;
// Flag as anonymous if issuer is localhost
user.anonymous = issuer.contains("localhost");
// Also update name if they don't have a custom one yet // Also update name if they don't have a custom one yet
if user.name.is_none() { if user.name.is_none() {
user.name = Some(if sub.len() > 12 { sub[..8].to_string() } else { sub.to_string() }); user.name = Some(if sub.len() > 12 { sub[..8].to_string() } else { sub.to_string() });
} }
log::info!("update_auth_info: updated user with OIDC info (anon={})", user.anonymous);
ctx.db.user().identity().update(user); ctx.db.user().identity().update(user);
sync_server_member_info(&ctx.db, ctx.sender()); sync_server_member_info(&ctx.db, ctx.sender());
log::info!("update_auth_info: updated user with OIDC info");
} }
} }
} }
-71
View File
@@ -1,71 +0,0 @@
<script lang="ts">
import { createSpacetimeDBProvider } from "spacetimedb/svelte";
import type { ConnectionBuilder } from "spacetimedb";
import { untrack } from "svelte";
import { getStdbHost, getStdbDbName, handleConnect } from "./config";
let { builder, children, onCancel }: { builder: ConnectionBuilder<any>, children: any, onCancel?: () => void } = $props();
// Initialize the provider
const db = createSpacetimeDBProvider(builder);
// Handshake latch to prevent infinite reactive loops
let hasSyncedForThisConnection = $state(false);
// Watch for successful connection and persist state
$effect(() => {
// Only trigger when the underlying connection actually changes
const conn = $db.connection;
const isActive = $db.isActive;
const identity = $db.identity;
if (isActive && identity && conn) {
untrack(() => {
if (!hasSyncedForThisConnection) {
console.log("InnerProvider: Initial handshake established, syncing state...");
handleConnect(conn, identity, $db.token || "");
hasSyncedForThisConnection = true;
}
});
} else if (!isActive) {
// Reset latch if connection drops
hasSyncedForThisConnection = false;
}
});
const host = getStdbHost();
const dbName = getStdbDbName();
</script>
{#if $db.identity}
{@render children()}
{:else}
<div class="login-screen">
<div class="login-card" style="text-align: center;">
<i class="fas fa-circle-notch fa-spin" style="font-size: 3rem; color: var(--brand); margin-bottom: 20px;"></i>
<h1>Connecting to SpacetimeDB...</h1>
<p style="color: var(--text-muted); margin-top: 8px; margin-bottom: 24px;">Establishing a secure connection to the chat server.</p>
<div style="background-color: var(--background-tertiary); padding: 16px; border-radius: 8px; margin-bottom: 24px; text-align: left; font-size: 0.8rem; border: 1px solid var(--background-modifier-accent); display: flex; flex-direction: column; gap: 12px; width: 100%; box-sizing: border-box;">
<div class="connection-detail">
<div style="color: var(--text-muted); font-weight: 800; text-transform: uppercase; font-size: 0.65rem; margin-bottom: 4px; letter-spacing: 0.05em;">Host</div>
<div style="color: var(--text-normal); font-family: var(--font-code); word-break: break-all; line-height: 1.4;">{host}</div>
</div>
<div class="connection-detail">
<div style="color: var(--text-muted); font-weight: 800; text-transform: uppercase; font-size: 0.65rem; margin-bottom: 4px; letter-spacing: 0.05em;">Database</div>
<div style="color: var(--text-normal); font-family: var(--font-code); word-break: break-all; line-height: 1.4;">{dbName}</div>
</div>
</div>
{#if onCancel}
<button
onclick={onCancel}
class="btn-secondary"
style="width: 100%;"
>
Cancel
</button>
{/if}
</div>
</div>
{/if}
+166
View File
@@ -0,0 +1,166 @@
<script lang="ts">
import { createSpacetimeDBProvider } from "spacetimedb/svelte";
import { untrack } from "svelte";
import { auth } from "./auth/auth.svelte";
import { connectionState } from "./connection.svelte";
import { handleConnect, handleConnectError } from "./config";
import type { ConnectionBuilder } from "spacetimedb";
let { builder, children, onCancel, host, dbName, oidcToken }: {
builder: ConnectionBuilder<any>,
children: any,
onCancel?: () => void,
host: string,
dbName: string,
oidcToken?: string
} = $props();
// 1. Initialize the provider instance
const db = createSpacetimeDBProvider(builder);
// 2. Handshake Synchronization
// Ensures session persistence happens exactly once per connection
let hasSyncedThisConnection = $state(false);
$effect(() => {
const conn = $db.connection;
const isActive = $db.isActive;
const identity = $db.identity;
if (isActive && identity && conn) {
untrack(() => {
if (!hasSyncedThisConnection && $db.token) {
console.log("[Handshake] Established, syncing state...");
handleConnect(conn as any, identity, $db.token);
hasSyncedThisConnection = true;
}
});
} else if (!isActive) {
hasSyncedThisConnection = false;
}
});
// 3. Late Token Update (OIDC Redirect Recovery)
// If we receive an OIDC token while already connected, upgrade the session
$effect(() => {
if (oidcToken && $db.isActive && $db.connection && !hasSyncedThisConnection) {
console.log("[Handshake] Upgrading session with OIDC token...");
untrack(() => {
// Upgrade the existing connection with the OIDC credentials
($db.connection as any).withToken(oidcToken);
});
}
});
// 4. Error Monitoring
$effect(() => {
if ($db.error) {
untrack(() => handleConnectError(new Error($db.error!)));
}
});
// Update global status
$effect(() => {
if ($db.isActive) {
connectionState.status = "connected";
} else {
connectionState.status = "connecting";
}
});
</script>
{#if $db.identity}
{@render children()}
{:else}
<div class="login-screen">
<div class="login-card" style="text-align: center;">
{#if $db.error}
<i class="fas fa-exclamation-triangle" style="font-size: 3rem; color: var(--status-danger); margin-bottom: 20px;"></i>
<h1>Connection Failed</h1>
<p style="color: var(--status-danger); margin-top: 8px; margin-bottom: 24px;">{$db.error}</p>
{:else if auth.isLoading}
<i class="fas fa-id-card fa-spin" style="font-size: 3rem; color: var(--brand); margin-bottom: 20px;"></i>
<h1>Authenticating...</h1>
<p style="color: var(--text-muted); margin-top: 8px; margin-bottom: 24px;">Verifying your identity with the provider.</p>
{:else}
<i class="fas fa-circle-notch fa-spin" style="font-size: 3rem; color: var(--brand); margin-bottom: 20px;"></i>
<h1>Connecting to SpacetimeDB...</h1>
<p style="color: var(--text-muted); margin-top: 8px; margin-bottom: 24px;">Establishing a secure connection to the chat server.</p>
{/if}
<div style="background-color: var(--background-tertiary); padding: 16px; border-radius: 8px; margin-bottom: 24px; text-align: left; font-size: 0.8rem; border: 1px solid var(--background-modifier-accent); display: flex; flex-direction: column; gap: 12px; width: 100%; box-sizing: border-box;">
<div class="connection-detail">
<div style="color: var(--text-muted); font-weight: 800; text-transform: uppercase; font-size: 0.65rem; margin-bottom: 4px; letter-spacing: 0.05em;">Host</div>
<div style="color: var(--text-normal); font-family: var(--font-code); word-break: break-all; line-height: 1.4;">{host}</div>
</div>
<div class="connection-detail">
<div style="color: var(--text-muted); font-weight: 800; text-transform: uppercase; font-size: 0.65rem; margin-bottom: 4px; letter-spacing: 0.05em;">Database</div>
<div style="color: var(--text-normal); font-family: var(--font-code); word-break: break-all; line-height: 1.4;">{dbName}</div>
</div>
</div>
<div style="display: flex; flex-direction: column; gap: 12px; width: 100%;">
{#if $db.error}
<button
onclick={() => window.location.reload()}
class="btn-primary"
style="width: 100%;"
>
Retry Connection
</button>
{/if}
{#if onCancel}
<button
onclick={onCancel}
class="btn-secondary"
style="width: 100%;"
>
{ $db.error ? "Back to Login" : "Cancel" }
</button>
{/if}
</div>
</div>
</div>
{/if}
<style>
.login-screen {
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
width: 100vw;
background: radial-gradient(
circle at center,
var(--background-secondary) 0%,
var(--background-tertiary) 100%
);
background-color: var(--background-tertiary);
}
.login-card {
background-color: var(--background-primary);
padding: 32px;
border-radius: 8px;
width: 480px;
box-shadow: 0 2px 10px 0 rgba(0, 0, 0, 0.2);
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.login-card h1 {
color: var(--header-primary);
margin-bottom: 8px;
font-size: 24px;
font-weight: 600;
}
.login-card p {
color: var(--text-normal);
margin-bottom: 24px;
font-size: 16px;
}
</style>
-43
View File
@@ -1,43 +0,0 @@
<script lang="ts">
import { connectionBuilder, handleConnect } from "./config";
import { auth } from "./auth/auth.svelte";
import { connectionState } from "./connection.svelte";
import InnerProvider from "./InnerProvider.svelte";
import type { ConnectionBuilder } from "spacetimedb";
let { children, onCancel } = $props<{
children: any,
onCancel?: () => void
}>();
let builder = $state<ConnectionBuilder<any> | null>(null);
let lastUsedToken = $state<string | undefined>(undefined);
$effect(() => {
if (!auth.isLoading) {
const currentToken = auth.user?.id_token;
// Only re-initialize if the token actually changed
if (currentToken !== lastUsedToken || !builder) {
console.log("InnerSpacetimeProvider: Initializing connection...");
connectionState.status = "connecting";
builder = connectionBuilder(currentToken);
lastUsedToken = currentToken;
}
}
});
</script>
{#if builder}
<InnerProvider {builder} {onCancel}>
{@render children()}
</InnerProvider>
{:else}
<div class="login-screen">
<div class="login-card" style="text-align: center;">
<i class="fas fa-circle-notch fa-spin" style="font-size: 3rem; color: var(--brand); margin-bottom: 20px;"></i>
<h1>Authenticating...</h1>
<p style="color: var(--text-muted); margin-top: 8px;">Waiting for identity provider callback.</p>
</div>
</div>
{/if}
+19 -13
View File
@@ -1,23 +1,29 @@
<script lang="ts"> <script lang="ts">
import { onDestroy } from "svelte"; import { auth } from "./auth/auth.svelte";
import { stopActiveConnection } from "./config"; import { getStdbHost, getStdbDbName, connectionBuilder } from "./config";
import InnerSpacetimeProvider from "./InnerSpacetimeProvider.svelte"; import InnerSpacetimeDBProvider from "./InnerSpacetimeDBProvider.svelte";
let { children, onCancel } = $props<{ let { children, onCancel } = $props<{
children: any, children: any,
onCancel?: () => void onCancel?: () => void
}>(); }>();
let reconnectKey = $state(0); // 1. Permanent reactive builder
// We initialize once and the InnerProvider handles the internal SDK lifecycle.
// Check if we already have an OIDC token available (e.g. from a previous sync or current auth state)
const builder = connectionBuilder(auth.user?.id_token);
onDestroy(() => { // Reactive labels for the loading screen
console.log("SpacetimeProvider: Destroying, stopping connection..."); const host = getStdbHost();
stopActiveConnection(); const dbName = getStdbDbName();
});
</script> </script>
{#key reconnectKey} <InnerSpacetimeDBProvider
<InnerSpacetimeProvider {onCancel}> {builder}
{@render children()} {onCancel}
</InnerSpacetimeProvider> {host}
{/key} {dbName}
oidcToken={auth.user?.id_token}
>
{@render children()}
</InnerSpacetimeDBProvider>
+14 -19
View File
@@ -53,12 +53,15 @@
} }
}); });
// Split combined connection if it changes (Only when on login screen) // 1. One-way Sync: Parse combined connection into fields
$effect(() => { $effect(() => {
// Only parse if we are NOT currently connecting and there is something to parse
if (!userWantsToConnect && combinedConnection.includes(":")) { if (!userWantsToConnect && combinedConnection.includes(":")) {
const lastColon = combinedConnection.lastIndexOf(":"); const lastColon = combinedConnection.lastIndexOf(":");
const host = combinedConnection.substring(0, lastColon); const host = combinedConnection.substring(0, lastColon);
const db = combinedConnection.substring(lastColon + 1); const db = combinedConnection.substring(lastColon + 1);
// Update internal state only if it actually changed to prevent loops
if (host && db && (host !== stdbHost || db !== stdbDbName)) { if (host && db && (host !== stdbHost || db !== stdbDbName)) {
untrack(() => { untrack(() => {
stdbHost = host; stdbHost = host;
@@ -68,24 +71,7 @@
} }
}); });
// Update combined connection if individual fields change (e.g. on mount) // 2. State persistence
$effect(() => {
const hostPart = stdbHost.replace(/^(https?|wss?):\/\//, "");
const expected = `${hostPart}:${stdbDbName}`;
if (combinedConnection !== expected) {
combinedConnection = expected;
}
});
let hasStoredToken = $state(false);
$effect(() => {
// Check for token when connection params change
if (stdbHost && stdbDbName) {
hasStoredToken = !!TokenStore.get(stdbHost, stdbDbName);
}
});
$effect(() => { $effect(() => {
if (stdbHost && localStorage.getItem(HOST_KEY) !== stdbHost) { if (stdbHost && localStorage.getItem(HOST_KEY) !== stdbHost) {
localStorage.setItem(HOST_KEY, stdbHost); localStorage.setItem(HOST_KEY, stdbHost);
@@ -98,6 +84,15 @@
} }
}); });
let hasStoredToken = $state(false);
$effect(() => {
// Check for token when connection params change
if (stdbHost && stdbDbName) {
hasStoredToken = !!TokenStore.get(stdbHost, stdbDbName);
}
});
const isBypassEnabled = const isBypassEnabled =
import.meta.env.VITE_BYPASS_AUTH === "true" || import.meta.env.VITE_BYPASS_AUTH === "true" ||
new URLSearchParams(window.location.search).has("bypass_auth"); new URLSearchParams(window.location.search).has("bypass_auth");
+26 -13
View File
@@ -10,8 +10,10 @@ export const oidcConfig: UserManagerSettings = {
authority: getEnv("VITE_OIDC_AUTHORITY", "https://sso.adamlamers.com/application/o/truenas-zep/"), authority: getEnv("VITE_OIDC_AUTHORITY", "https://sso.adamlamers.com/application/o/truenas-zep/"),
client_id: getEnv("VITE_OIDC_CLIENT_ID", "Omb5jIeYY2rJqkxK52qZgLRGejOBCK5Km2BcA5RO"), client_id: getEnv("VITE_OIDC_CLIENT_ID", "Omb5jIeYY2rJqkxK52qZgLRGejOBCK5Km2BcA5RO"),
redirect_uri: window.location.origin, redirect_uri: window.location.origin,
scope: "openid profile email", scope: "openid profile email offline_access",
response_type: "code", response_type: "code",
automaticSilentRenew: true,
loadUserInfo: true,
}; };
class AuthStore { class AuthStore {
@@ -89,13 +91,6 @@ class AuthStore {
try { try {
const user = await this.#userManager.signinCallback(); const user = await this.#userManager.signinCallback();
this.#user = user; this.#user = user;
// Stage the id_token so SpacetimeDB can find it even if OIDC state is cleared
if (user.id_token) {
console.log("AuthStore: Staging OIDC id_token for SpacetimeDB handshake");
localStorage.setItem("zep_oidc_staging_token", user.id_token);
}
window.history.replaceState({}, document.title, window.location.pathname); window.history.replaceState({}, document.title, window.location.pathname);
} catch (error) { } catch (error) {
console.error("Signin callback error:", error); console.error("Signin callback error:", error);
@@ -118,15 +113,33 @@ class AuthStore {
async logout() { async logout() {
this.#isLoading = true; this.#isLoading = true;
try { try {
// Clear all potential SpacetimeDB tokens from local storage console.log("AuthStore: Initiating full session purge...");
const keysToRemove: string[] = [];
// 1. Purge LocalStorage
const localKeysToRemove: string[] = [];
for (let i = 0; i < localStorage.length; i++) { for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i); const key = localStorage.key(i);
if (key && (key.includes("stdb_token:") || key.includes("auth_token") || key === "zep_oidc_staging_token")) { if (key && (key.includes("stdb_token:") || key.includes("auth_token") || key.startsWith("oidc."))) {
keysToRemove.push(key); localKeysToRemove.push(key);
} }
} }
keysToRemove.forEach((key) => localStorage.removeItem(key)); localKeysToRemove.forEach((key) => {
console.log(`AuthStore: Removing LocalStorage key: ${key}`);
localStorage.removeItem(key);
});
// 2. Purge SessionStorage (where oidc-client-ts often hides state)
const sessionKeysToRemove: string[] = [];
for (let i = 0; i < sessionStorage.length; i++) {
const key = sessionStorage.key(i);
if (key && (key.startsWith("oidc.") || key.includes("auth_token"))) {
sessionKeysToRemove.push(key);
}
}
sessionKeysToRemove.forEach((key) => {
console.log(`AuthStore: Removing SessionStorage key: ${key}`);
sessionStorage.removeItem(key);
});
if (this.#user) { if (this.#user) {
await this.#userManager.signoutRedirect(); await this.#userManager.signoutRedirect();
+192 -108
View File
@@ -8,138 +8,222 @@
const chat = getContext<ChatService>("chat"); const chat = getContext<ChatService>("chat");
const webrtc = getContext<WebRTCService>("webrtc"); const webrtc = getContext<WebRTCService>("webrtc");
let focusedIdentity = $state<Identity | null>(null); // Higher reliability derivation
const participants = $derived.by(() => {
const channelId = chat.activeChannelId;
if (!channelId) return [];
const participants = $derived( const results = chat.userStates.filter(s => s.channelId === channelId);
chat.userStates.filter((s) => s.channelId === webrtc.connectedChannelId), console.log(`[VideoGrid] Rendering ${results.length} participants for channel ${channelId}`);
); return results;
});
const localSharing = $derived(!!webrtc.localScreenStream); const sharer = $derived(participants.find(s => s.isSharingScreen));
const remoteSharerVs = $derived( const localSharing = $derived(webrtc.isSharingScreen);
participants.find((s) => {
if (s.identity.isEqual(webrtc.identity!)) return false;
return s.isSharingScreen;
}),
);
const defaultSharerIdentity = $derived( // Explicit check for local user existence in participants
localSharing ? webrtc.identity : remoteSharerVs?.identity, const isMeInChannel = $derived(participants.some(p => p.identity.isEqual(chat.identity!)));
);
const primarySharerIdentity = $derived(focusedIdentity || defaultSharerIdentity); const effectiveSharer = $derived(localSharing ? { identity: chat.identity } : sharer);
function isWatchingPeer(peerIdHex: string) { function toggleWatch(identity: Identity) {
const s = participants.find(p => p.identity.toHexString() === peerIdHex); if (chat.currentVoiceState?.watching?.isEqual(identity)) {
// I am watching the peer if my state's watching field points to them
return chat.currentVoiceState?.watching?.toHexString() === peerIdHex;
}
function toggleWatch(peerIdentity: Identity) {
if (isWatchingPeer(peerIdentity.toHexString())) {
webrtc.stopWatching(); webrtc.stopWatching();
} else { } else {
webrtc.startWatching(peerIdentity); webrtc.startWatching(identity);
} }
} }
const heroVs = $derived(
participants.find((s) =>
s.identity.isEqual(primarySharerIdentity || Identity.zero()),
),
);
const rowParticipants = $derived(
participants.filter(
(s) => !s.identity.isEqual(primarySharerIdentity || Identity.zero()),
),
);
</script> </script>
<div class="video-grid {primarySharerIdentity ? 'has-sharer' : ''}"> <div class="video-grid" class:has-sharer={!!effectiveSharer}>
<div class="video-grid-content"> {#if participants.length === 0}
{#if primarySharerIdentity} <div class="empty-channel-state">
{#if heroVs} <i class="fas fa-microphone-alt-slash"></i>
<div <p>Synchronizing channel participants...</p>
class="video-tile-container is-hero" </div>
onclick={() => (focusedIdentity = heroVs.identity)} {:else}
role="button" <div class="video-grid-content">
tabindex="0" {#if effectiveSharer}
onkeydown={(e) => e.key === "Enter" && (focusedIdentity = heroVs.identity)} <div class="hero-container">
style="cursor: pointer;"
>
<VideoTile <VideoTile
identity={heroVs.identity} identity={effectiveSharer.identity!}
stream={heroVs.identity.isEqual(webrtc.identity!) isLocal={effectiveSharer.identity!.isEqual(chat.identity!)}
? webrtc.localScreenStream || undefined stream={effectiveSharer.identity!.isEqual(chat.identity!)
: webrtc.peers.get(heroVs.identity.toHexString())?.videoStream} ? webrtc.localMedia.screenStream
isLocal={heroVs.identity.isEqual(webrtc.identity!)} : webrtc.getRemoteStream(effectiveSharer.identity!.toHexString(), 'screen')}
isTalking={heroVs.isTalking} isSharing={true}
isWatching={isWatchingPeer(heroVs.identity.toHexString())} isWatching={true}
isSharing={heroVs.identity.isEqual(webrtc.identity!) onToggleWatch={() => toggleWatch(effectiveSharer.identity!)}
? localSharing
: heroVs.isSharingScreen}
onToggleWatch={() => toggleWatch(heroVs.identity)}
isHero={true} isHero={true}
users={chat.users} users={chat.users}
isTalking={participants.find(p => p.identity.isEqual(effectiveSharer.identity!))?.isTalking}
/> />
</div> </div>
{/if}
{#if rowParticipants.length > 0} <div class="participants-row">
<div class="video-participants-row"> {#each participants.filter(s => !effectiveSharer.identity!.isEqual(s.identity)) as s (s.identity.toHexString())}
{#each rowParticipants as s (s.identity.toHexString())} <div class="row-tile-wrapper">
<div
class="video-tile-container is-row"
onclick={() => (focusedIdentity = s.identity)}
role="button"
tabindex="0"
onkeydown={(e) => e.key === "Enter" && (focusedIdentity = s.identity)}
style="cursor: pointer;"
>
<VideoTile <VideoTile
identity={s.identity} identity={s.identity}
stream={s.identity.isEqual(webrtc.identity!) isLocal={s.identity.isEqual(chat.identity!)}
? webrtc.localScreenStream || undefined stream={s.identity.isEqual(chat.identity!)
: webrtc.peers.get(s.identity.toHexString())?.videoStream} ? webrtc.localMedia.stream
isLocal={s.identity.isEqual(webrtc.identity!)} : webrtc.getRemoteStream(s.identity.toHexString(), 'voice')}
isTalking={s.isTalking} isSharing={s.identity.isEqual(chat.identity!) ? localSharing : s.isSharingScreen}
isWatching={isWatchingPeer(s.identity.toHexString())} isWatching={s.identity.isEqual(chat.identity!) ? false : (s.isSharingScreen ? (chat.currentVoiceState?.watching?.isEqual(s.identity) || false) : true)}
isSharing={s.identity.isEqual(webrtc.identity!)
? localSharing
: s.isSharingScreen}
onToggleWatch={() => toggleWatch(s.identity)} onToggleWatch={() => toggleWatch(s.identity)}
isHero={false} isHero={false}
users={chat.users} users={chat.users}
isTalking={s.isTalking}
/> />
</div> </div>
{/each} {/each}
{#if !localSharing && !effectiveSharer.identity!.isEqual(chat.identity!)}
<div class="row-tile-wrapper">
<VideoTile
identity={chat.identity!}
isLocal={true}
stream={webrtc.localMedia.stream}
isSharingScreen={false}
isWatching={true}
onToggleWatch={() => {}}
isHero={false}
users={chat.users}
isTalking={webrtc.localMedia.isTalking}
/>
</div>
{/if}
</div>
{:else}
<div class="standard-grid">
{#each participants as s (s.identity.toHexString())}
<div class="grid-tile-wrapper">
<VideoTile
identity={s.identity}
isLocal={s.identity.isEqual(chat.identity!)}
stream={s.identity.isEqual(chat.identity!)
? webrtc.localMedia.stream
: webrtc.getRemoteStream(s.identity.toHexString(), 'voice')}
isSharing={s.identity.isEqual(chat.identity!) ? localSharing : s.isSharingScreen}
isWatching={s.identity.isEqual(chat.identity!) ? false : (s.isSharingScreen ? (chat.currentVoiceState?.watching?.isEqual(s.identity) || false) : true)}
onToggleWatch={() => toggleWatch(s.identity)}
isHero={false}
users={chat.users}
isTalking={s.isTalking}
/>
</div>
{/each}
{#if !isMeInChannel}
<div class="grid-tile-wrapper">
<VideoTile
identity={chat.identity!}
isLocal={true}
stream={webrtc.localMedia.stream}
isSharingScreen={localSharing}
isWatching={true}
onToggleWatch={() => {}}
isHero={false}
users={chat.users}
isTalking={webrtc.localMedia.isTalking}
/>
</div>
{/if}
</div> </div>
{/if} {/if}
{:else} </div>
{#each participants as s (s.identity.toHexString())} {/if}
<div
class="video-tile-container is-grid"
onclick={() => (focusedIdentity = s.identity)}
role="button"
tabindex="0"
onkeydown={(e) => e.key === "Enter" && (focusedIdentity = s.identity)}
style="cursor: pointer;"
>
<VideoTile
identity={s.identity}
stream={s.identity.isEqual(webrtc.identity!)
? webrtc.localScreenStream || undefined
: webrtc.peers.get(s.identity.toHexString())?.videoStream}
isLocal={s.identity.isEqual(webrtc.identity!)}
isTalking={s.isTalking}
isWatching={isWatchingPeer(s.identity.toHexString())}
isSharing={s.identity.isEqual(webrtc.identity!)
? localSharing
: s.isSharingScreen}
onToggleWatch={() => toggleWatch(s.identity)}
isHero={false}
users={chat.users}
/>
</div>
{/each}
{/if}
</div>
</div> </div>
<style>
.video-grid {
flex: 1;
display: flex;
flex-direction: column;
background-color: var(--background-primary);
overflow: hidden;
min-height: 0;
}
.video-grid-content {
flex: 1;
display: flex;
flex-direction: column;
padding: 16px;
gap: 16px;
min-height: 0;
}
.empty-channel-state {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: var(--text-muted);
gap: 16px;
}
.empty-channel-state i {
font-size: 4rem;
opacity: 0.2;
}
/* Grid Layout (No one sharing) */
.standard-grid {
display: grid;
gap: 16px;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
align-content: center;
justify-content: center;
height: 100%;
width: 100%;
}
.grid-tile-wrapper {
aspect-ratio: 16 / 9;
width: 100%;
min-width: 0;
}
/* Hero Layout (Someone sharing screen) */
.hero-container {
flex: 1;
min-height: 0;
width: 100%;
}
.participants-row {
display: flex;
gap: 12px;
height: 140px;
overflow-x: auto;
overflow-y: hidden;
padding-bottom: 8px;
flex-shrink: 0;
justify-content: center;
}
.row-tile-wrapper {
width: 248px;
height: 100%;
flex-shrink: 0;
}
@media (max-width: 768px) {
.standard-grid {
grid-template-columns: 1fr;
}
.participants-row {
height: 100px;
}
.row-tile-wrapper {
width: 177px;
}
}
</style>
+4 -3
View File
@@ -93,7 +93,8 @@
} }
} }
const showStream = $derived((isLocal || isWatching) && !!stream); const showStream = $derived(isLocal ? !!stream : (isWatching && isSharing && !!stream));
const showWatchButton = $derived(!isLocal && isSharing && !isWatching);
</script> </script>
<div <div
@@ -128,7 +129,7 @@
{:else} {:else}
<div class="avatar-placeholder-container"> <div class="avatar-placeholder-container">
<Avatar user={users.find(u => u.identity.isEqual(identity))} size="large" /> <Avatar user={users.find(u => u.identity.isEqual(identity))} size="large" />
{#if !isLocal && isSharing} {#if showWatchButton}
<button <button
class="watch-btn" class="watch-btn"
onclick={(e) => { onclick={(e) => {
@@ -199,7 +200,7 @@
} }
.video-tile.talking { .video-tile.talking {
border-color: #23a559; border-color: var(--status-positive);
} }
video { video {
+37 -2
View File
@@ -583,10 +583,45 @@ export class ChatService {
// Derived Helpers // Derived Helpers
get isActiveChannelVoice() { get isActiveChannelVoice() {
return this.activeChannel?.kind.tag === "Voice"; const channelId = this.activeChannelId;
if (!channelId) return false;
// Helper to extract tag from various possible object structures
const getTag = (kind: any) => {
if (!kind) return null;
if (typeof kind.tag === 'string') return kind.tag.toLowerCase();
// Fallback for raw enum objects if the tag property is missing
return Object.keys(kind)[0]?.toLowerCase();
};
const dbChannel = this.#db.channels.find(c => c.id === channelId);
if (dbChannel && getTag(dbChannel.kind) === "voice") return true;
const server = this.activeServer;
const meta = server?.channels.find(c => c.id === channelId);
if (meta && getTag(meta.kind) === "voice") return true;
return false;
} }
get isActiveChannelText() { get isActiveChannelText() {
return this.activeChannel?.kind.tag === "Text"; const channelId = this.activeChannelId;
if (!channelId) return false;
const getTag = (kind: any) => {
if (!kind) return null;
if (typeof kind.tag === 'string') return kind.tag.toLowerCase();
return Object.keys(kind)[0]?.toLowerCase();
};
const dbChannel = this.#db.channels.find(c => c.id === channelId);
if (dbChannel && getTag(dbChannel.kind) === "text") return true;
const server = this.activeServer;
const meta = server?.channels.find(c => c.id === channelId);
if (meta && getTag(meta.kind) === "text") return true;
return false;
} }
get textChannels() { get textChannels() {
+16 -7
View File
@@ -1,6 +1,7 @@
import { DatabaseService } from "./database.svelte"; import { DatabaseService } from "./database.svelte";
import { Identity } from "spacetimedb"; import { Identity } from "spacetimedb";
import { untrack } from "svelte"; import { untrack } from "svelte";
import * as Types from "../../module_bindings/types";
export class NavigationService { export class NavigationService {
activeServerId = $state<bigint | null>(null); activeServerId = $state<bigint | null>(null);
@@ -134,16 +135,24 @@ export class NavigationService {
const channelId = this.activeChannelId; const channelId = this.activeChannelId;
if (!channelId) return undefined; if (!channelId) return undefined;
const channel = this.#db.channels.find(c => c.id === channelId); // 1. Try to find in the synchronized channel rows
if (!channel) return undefined; const dbChannel = this.#db.channels.find(c => c.id === channelId);
if (dbChannel) return dbChannel;
if (channel.serverId !== 0n) { // 2. Fallback to active server metadata if available
const server = this.activeServer;
if (server) {
const meta = server.channels.find(c => c.id === channelId);
if (meta) {
return { return {
...channel, id: meta.id,
serverId: channel.serverId serverId: server.id,
}; name: meta.name,
kind: meta.kind
} as Types.VisibleChannelRow;
}
} }
return channel; return undefined;
} }
} }
+6
View File
@@ -34,6 +34,12 @@ export class VoiceService {
} }
handleJoinVoice = (channelId: bigint) => { handleJoinVoice = (channelId: bigint) => {
// Only join if not already in this channel
if (this.currentVoiceState?.channelId === channelId) {
console.log("VoiceService: Already in this channel, skipping join.");
return;
}
sounds.playConnect(); sounds.playConnect();
this.#joinVoiceReducer({ channelId }); this.#joinVoiceReducer({ channelId });
}; };
+15
View File
@@ -268,4 +268,19 @@ export class WebRTCService {
toggleDeafen = () => this.localMedia.toggleDeafen(); toggleDeafen = () => this.localMedia.toggleDeafen();
setPeerAudioPreference = (peerIdHex: string, pref: any) => setPeerAudioPreference = (peerIdHex: string, pref: any) =>
this.voice.peerManager.setPeerAudioPreference(peerIdHex, pref); this.voice.peerManager.setPeerAudioPreference(peerIdHex, pref);
getRemoteStream = (peerIdHex: string, type: 'voice' | 'screen'): MediaStream | undefined => {
if (type === 'voice') {
const peer = this.voice.peerManager.peers.get(peerIdHex);
if (peer?.gainNode) {
// Voice streams are managed via Web Audio API, but we return a MediaStream if needed
// For standard voice participants, the audio is already connected to ctx.destination
return undefined;
}
return undefined;
} else {
const peer = this.screen.peerManager.peers.get(peerIdHex);
return peer?.videoStream;
}
};
} }
+37 -132
View File
@@ -7,19 +7,12 @@ export { HOST_KEY, DB_NAME_KEY, getEnv };
/** /**
* Normalizes the host URL for SpacetimeDB. * Normalizes the host URL for SpacetimeDB.
* This field now takes a hostname without a protocol and assumes https://.
* The SDK automatically handles the upgrade from https:// to wss://.
*/ */
export const normalizeHost = (host: string) => { export const normalizeHost = (host: string) => {
let normalized = host.trim().replace(/\/+$/, ""); let normalized = host.trim().replace(/\/+$/, "");
// Remove any existing protocol (http, https, ws, wss)
normalized = normalized.replace(/^(https?|wss?):\/\//, ""); normalized = normalized.replace(/^(https?|wss?):\/\//, "");
// Default to https:// (or http:// for local dev)
const isLocal = normalized.includes("localhost") || normalized.includes("127.0.0.1"); const isLocal = normalized.includes("localhost") || normalized.includes("127.0.0.1");
const protocol = isLocal ? "http://" : "https://"; const protocol = isLocal ? "http://" : "https://";
return `${protocol}${normalized}`; return `${protocol}${normalized}`;
}; };
@@ -40,19 +33,15 @@ export const TokenStore = {
listStoredConnections: () => { listStoredConnections: () => {
const connections: string[] = []; const connections: string[] = [];
const seen = new Set<string>(); const seen = new Set<string>();
for (let i = 0; i < localStorage.length; i++) { for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i); const key = localStorage.key(i);
if (!key) continue; if (key?.startsWith("stdb_token:")) {
if (key.startsWith("stdb_token:")) {
const parts = key.split(":"); const parts = key.split(":");
if (parts.length >= 4) { if (parts.length >= 4) {
const dbName = parts[parts.length - 1]; const dbName = parts[parts.length - 1];
const hostParts = parts.slice(2, parts.length - 1); const hostParts = parts.slice(2, parts.length - 1);
const host = hostParts.join(":").replace(/^\/\//, ""); const host = hostParts.join(":").replace(/^\/\//, "");
const connStr = `${host}:${dbName}`; const connStr = `${host}:${dbName}`;
if (!seen.has(connStr)) { if (!seen.has(connStr)) {
connections.push(connStr); connections.push(connStr);
seen.add(connStr); seen.add(connStr);
@@ -74,170 +63,86 @@ export const getStdbDbName = () =>
let _connection: DbConnection | null = null; let _connection: DbConnection | null = null;
export const getConnection = () => _connection; export const getConnection = () => _connection;
let activeManager: ConnectionManager | null = null;
export const stopActiveConnection = () => { export const stopActiveConnection = () => {
if (activeManager) {
activeManager.stop();
activeManager = null;
}
if (_connection) { if (_connection) {
_connection.disconnect(); _connection.disconnect();
_connection = null; _connection = null;
} }
}; };
let lastSyncedIdentity: string | null = null;
export const handleConnect = (conn: DbConnection, identity: any, token: string) => { export const handleConnect = (conn: DbConnection, identity: any, token: string) => {
const host = getStdbHost(); const host = getStdbHost();
const dbName = getStdbDbName(); const dbName = getStdbDbName();
const identityHex = identity?.toHexString();
console.log(`handleConnect: Connection established! Token received (len: ${token?.length || 0})`); console.log(`[Handshake] Connection established! Identity: ${identityHex}, Token received: ${!!token}`);
_connection = conn; _connection = conn;
if (token) {
console.log("handleConnect: Persisting token to TokenStore...");
TokenStore.set(host, dbName, token);
// Clear staging token now that we have a permanent one if (token) {
localStorage.removeItem("zep_oidc_staging_token"); console.log(`[Handshake] Persisting SpacetimeDB token (len: ${token.length}) to local storage.`);
} else { TokenStore.set(host, dbName, token);
console.warn("handleConnect: No token received from SpacetimeDB!");
} }
connectionState.status = "connected"; connectionState.status = "connected";
connectionState.hasConnectedOnce = true; connectionState.hasConnectedOnce = true;
connectionState.error = null; connectionState.error = null;
// Call the auth update reducer to ensure OIDC info is synced if (identityHex !== lastSyncedIdentity) {
setTimeout(() => { console.log("[Handshake] New identity detected, syncing server-side auth metadata...");
if (_connection) { lastSyncedIdentity = identityHex;
console.log("handleConnect: Requesting auth info update...");
// Use the typed reducer call if available
if ((_connection.reducers as any).updateAuthInfo) {
(_connection.reducers as any).updateAuthInfo({});
}
}
}, 100);
};
class ConnectionManager {
#retryCount = 0;
#reconnectTimeout: any = null;
#host: string;
#dbName: string;
#isStopped = false;
constructor(host: string, dbName: string) {
this.#host = host;
this.#dbName = dbName;
}
stop = () => {
this.#isStopped = true;
if (this.#reconnectTimeout) {
clearTimeout(this.#reconnectTimeout);
this.#reconnectTimeout = null;
}
};
onConnect = (conn: DbConnection, identity: any, token: string) => {
console.log("ConnectionManager: onConnect called! Identity:", identity?.toHexString(), "Token length:", token?.length);
if (this.#isStopped) {
console.log("ConnectionManager: Manager is stopped, ignoring connect.");
conn.disconnect();
return;
}
_connection = conn;
console.log("ConnectionManager: Storing token for:", this.#host, this.#dbName);
TokenStore.set(this.#host, this.#dbName, token);
console.log(
"Connected to SpacetimeDB with identity:",
identity.toHexString(),
);
connectionState.status = "connected";
connectionState.hasConnectedOnce = true;
connectionState.error = null;
// Call the auth update reducer to ensure OIDC info is synced
// We use setTimeout to ensure the connection is fully processed by the SDK
setTimeout(() => { setTimeout(() => {
if (_connection) { if (_connection && (_connection.reducers as any).updateAuthInfo) {
console.log("ConnectionManager: Requesting auth info update...");
(_connection.reducers as any).updateAuthInfo({}); (_connection.reducers as any).updateAuthInfo({});
} }
}, 100); }, 100);
}
};
this.#retryCount = 0; export const handleConnectError = (err: Error) => {
if (this.#reconnectTimeout) { const host = getStdbHost();
clearTimeout(this.#reconnectTimeout); const dbName = getStdbDbName();
this.#reconnectTimeout = null; console.log("[Handshake] Error connecting to SpacetimeDB:", err);
}
};
onDisconnect = () => { const errStr = err.message || "";
if (this.#isStopped) return; if (errStr.includes("401") || errStr.toLowerCase().includes("unauthorized")) {
console.log("Disconnected from SpacetimeDB"); console.warn(`[Handshake] Unauthorized (401) for ${host}:${dbName}! Purging host session...`);
_connection = null; TokenStore.clear(host, dbName);
connectionState.status = "disconnected";
this.#scheduleReconnect();
};
onConnectError = (_ctx: any, err: Error) => { // Trigger full application logout (which now purges sessionStorage too)
if (this.#isStopped) return; import("./auth/auth.svelte").then(({ auth }) => {
console.log("Error connecting to SpacetimeDB:", err); auth.logout();
connectionState.error = err.message; });
this.#scheduleReconnect(); return;
}; }
connectionState.error = err.message;
#scheduleReconnect = () => { };
if (this.#isStopped || this.#reconnectTimeout) return;
const delay = Math.min(120000, Math.pow(5, this.#retryCount) * 1000);
console.log(
`Scheduling reconnect in ${delay}ms (attempt ${this.#retryCount + 1})`,
);
this.#reconnectTimeout = setTimeout(() => {
this.#reconnectTimeout = null;
if (this.#isStopped) return;
this.#retryCount++;
console.log(
"ConnectionManager: Reconnect delay reached. Reloading window...",
);
window.location.reload();
}, delay);
};
}
export const connectionBuilder = (oidcToken?: string) => { export const connectionBuilder = (oidcToken?: string) => {
const rawHost = getStdbHost(); const host = normalizeHost(getStdbHost());
const host = normalizeHost(rawHost);
const dbName = getStdbDbName(); const dbName = getStdbDbName();
console.log(`connectionBuilder: Creating builder for host: ${host}, database: ${dbName}`); console.log(`[Builder] Creating handshake: host=${host}, database=${dbName}, mode=${oidcToken ? 'OIDC' : 'Token/Guest'}`);
const builder = DbConnection.builder() const builder = DbConnection.builder()
.withUri(host) .withUri(host)
.withDatabaseName(dbName); .withDatabaseName(dbName);
const storedToken = TokenStore.get(host, dbName); const storedToken = TokenStore.get(host, dbName);
const stagingToken = localStorage.getItem("zep_oidc_staging_token");
console.log("connectionBuilder: tokens -> oidc:", oidcToken ? "yes" : "no", "staging:", stagingToken ? "yes" : "no", "stored:", storedToken ? "yes" : "no");
// CRITICAL: OIDC Token MUST take precedence over stored guest tokens
if (oidcToken) { if (oidcToken) {
console.log("[Builder] Using OIDC token for handshake (overriding stored token)");
builder.withToken(oidcToken); builder.withToken(oidcToken);
} else if (stagingToken) {
console.log("connectionBuilder: Using staged OIDC token");
builder.withToken(stagingToken);
} else if (storedToken) { } else if (storedToken) {
console.log("[Builder] Using existing stored token for handshake");
builder.withToken(storedToken); builder.withToken(storedToken);
} }
builder.onConnect((conn: any, identity: any, token: string) => { builder.onConnect((conn, identity, token) => handleConnect(conn as any, identity, token));
handleConnect(conn, identity, token); builder.onConnectError((_ctx, err) => handleConnectError(err));
});
return builder; return builder;
}; };