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
let mut initial_name = None;
let mut is_anon = true;
if let Some(jwt) = ctx.sender_auth().jwt() {
let sub = jwt.subject();
let issuer = jwt.issuer();
// 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() });
is_anon = issuer.contains("localhost");
}
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() {
user.name = initial_name;
}
user.anonymous = is_anon;
ctx.db.user().identity().update(user);
} else {
ctx.db.user().insert(User {
@@ -112,7 +116,7 @@ pub fn on_connect(ctx: &ReducerContext) {
online: true,
issuer: None,
subject: None,
anonymous: true,
anonymous: is_anon,
avatar_id: None,
banner_id: 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(jwt) = ctx.sender_auth().jwt() {
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.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
if user.name.is_none() {
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);
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}
+18 -12
View File
@@ -1,23 +1,29 @@
<script lang="ts">
import { onDestroy } from "svelte";
import { stopActiveConnection } from "./config";
import InnerSpacetimeProvider from "./InnerSpacetimeProvider.svelte";
import { auth } from "./auth/auth.svelte";
import { getStdbHost, getStdbDbName, connectionBuilder } from "./config";
import InnerSpacetimeDBProvider from "./InnerSpacetimeDBProvider.svelte";
let { children, onCancel } = $props<{
children: any,
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(() => {
console.log("SpacetimeProvider: Destroying, stopping connection...");
stopActiveConnection();
});
// Reactive labels for the loading screen
const host = getStdbHost();
const dbName = getStdbDbName();
</script>
{#key reconnectKey}
<InnerSpacetimeProvider {onCancel}>
<InnerSpacetimeDBProvider
{builder}
{onCancel}
{host}
{dbName}
oidcToken={auth.user?.id_token}
>
{@render children()}
</InnerSpacetimeProvider>
{/key}
</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(() => {
// Only parse if we are NOT currently connecting and there is something to parse
if (!userWantsToConnect && combinedConnection.includes(":")) {
const lastColon = combinedConnection.lastIndexOf(":");
const host = combinedConnection.substring(0, lastColon);
const db = combinedConnection.substring(lastColon + 1);
// Update internal state only if it actually changed to prevent loops
if (host && db && (host !== stdbHost || db !== stdbDbName)) {
untrack(() => {
stdbHost = host;
@@ -68,24 +71,7 @@
}
});
// Update combined connection if individual fields change (e.g. on mount)
$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);
}
});
// 2. State persistence
$effect(() => {
if (stdbHost && localStorage.getItem(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 =
import.meta.env.VITE_BYPASS_AUTH === "true" ||
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/"),
client_id: getEnv("VITE_OIDC_CLIENT_ID", "Omb5jIeYY2rJqkxK52qZgLRGejOBCK5Km2BcA5RO"),
redirect_uri: window.location.origin,
scope: "openid profile email",
scope: "openid profile email offline_access",
response_type: "code",
automaticSilentRenew: true,
loadUserInfo: true,
};
class AuthStore {
@@ -89,13 +91,6 @@ class AuthStore {
try {
const user = await this.#userManager.signinCallback();
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);
} catch (error) {
console.error("Signin callback error:", error);
@@ -118,15 +113,33 @@ class AuthStore {
async logout() {
this.#isLoading = true;
try {
// Clear all potential SpacetimeDB tokens from local storage
const keysToRemove: string[] = [];
console.log("AuthStore: Initiating full session purge...");
// 1. Purge LocalStorage
const localKeysToRemove: string[] = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && (key.includes("stdb_token:") || key.includes("auth_token") || key === "zep_oidc_staging_token")) {
keysToRemove.push(key);
if (key && (key.includes("stdb_token:") || key.includes("auth_token") || key.startsWith("oidc."))) {
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) {
await this.#userManager.signoutRedirect();
+179 -95
View File
@@ -8,138 +8,222 @@
const chat = getContext<ChatService>("chat");
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(
chat.userStates.filter((s) => s.channelId === webrtc.connectedChannelId),
);
const results = chat.userStates.filter(s => s.channelId === channelId);
console.log(`[VideoGrid] Rendering ${results.length} participants for channel ${channelId}`);
return results;
});
const localSharing = $derived(!!webrtc.localScreenStream);
const remoteSharerVs = $derived(
participants.find((s) => {
if (s.identity.isEqual(webrtc.identity!)) return false;
return s.isSharingScreen;
}),
);
const sharer = $derived(participants.find(s => s.isSharingScreen));
const localSharing = $derived(webrtc.isSharingScreen);
const defaultSharerIdentity = $derived(
localSharing ? webrtc.identity : remoteSharerVs?.identity,
);
// Explicit check for local user existence in participants
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) {
const s = participants.find(p => p.identity.toHexString() === peerIdHex);
// 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())) {
function toggleWatch(identity: Identity) {
if (chat.currentVoiceState?.watching?.isEqual(identity)) {
webrtc.stopWatching();
} 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>
<div class="video-grid {primarySharerIdentity ? 'has-sharer' : ''}">
<div class="video-grid" class:has-sharer={!!effectiveSharer}>
{#if participants.length === 0}
<div class="empty-channel-state">
<i class="fas fa-microphone-alt-slash"></i>
<p>Synchronizing channel participants...</p>
</div>
{:else}
<div class="video-grid-content">
{#if primarySharerIdentity}
{#if heroVs}
<div
class="video-tile-container is-hero"
onclick={() => (focusedIdentity = heroVs.identity)}
role="button"
tabindex="0"
onkeydown={(e) => e.key === "Enter" && (focusedIdentity = heroVs.identity)}
style="cursor: pointer;"
>
{#if effectiveSharer}
<div class="hero-container">
<VideoTile
identity={heroVs.identity}
stream={heroVs.identity.isEqual(webrtc.identity!)
? webrtc.localScreenStream || undefined
: webrtc.peers.get(heroVs.identity.toHexString())?.videoStream}
isLocal={heroVs.identity.isEqual(webrtc.identity!)}
isTalking={heroVs.isTalking}
isWatching={isWatchingPeer(heroVs.identity.toHexString())}
isSharing={heroVs.identity.isEqual(webrtc.identity!)
? localSharing
: heroVs.isSharingScreen}
onToggleWatch={() => toggleWatch(heroVs.identity)}
identity={effectiveSharer.identity!}
isLocal={effectiveSharer.identity!.isEqual(chat.identity!)}
stream={effectiveSharer.identity!.isEqual(chat.identity!)
? webrtc.localMedia.screenStream
: webrtc.getRemoteStream(effectiveSharer.identity!.toHexString(), 'screen')}
isSharing={true}
isWatching={true}
onToggleWatch={() => toggleWatch(effectiveSharer.identity!)}
isHero={true}
users={chat.users}
isTalking={participants.find(p => p.identity.isEqual(effectiveSharer.identity!))?.isTalking}
/>
</div>
{/if}
{#if rowParticipants.length > 0}
<div class="video-participants-row">
{#each rowParticipants as s (s.identity.toHexString())}
<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;"
>
<div class="participants-row">
{#each participants.filter(s => !effectiveSharer.identity!.isEqual(s.identity)) as s (s.identity.toHexString())}
<div class="row-tile-wrapper">
<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}
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 !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="video-tile-container is-grid"
onclick={() => (focusedIdentity = s.identity)}
role="button"
tabindex="0"
onkeydown={(e) => e.key === "Enter" && (focusedIdentity = s.identity)}
style="cursor: pointer;"
>
<div class="grid-tile-wrapper">
<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}
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>
{/if}
</div>
{/if}
</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>
<div
@@ -128,7 +129,7 @@
{:else}
<div class="avatar-placeholder-container">
<Avatar user={users.find(u => u.identity.isEqual(identity))} size="large" />
{#if !isLocal && isSharing}
{#if showWatchButton}
<button
class="watch-btn"
onclick={(e) => {
@@ -199,7 +200,7 @@
}
.video-tile.talking {
border-color: #23a559;
border-color: var(--status-positive);
}
video {
+37 -2
View File
@@ -583,10 +583,45 @@ export class ChatService {
// Derived Helpers
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() {
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() {
+16 -7
View File
@@ -1,6 +1,7 @@
import { DatabaseService } from "./database.svelte";
import { Identity } from "spacetimedb";
import { untrack } from "svelte";
import * as Types from "../../module_bindings/types";
export class NavigationService {
activeServerId = $state<bigint | null>(null);
@@ -134,16 +135,24 @@ export class NavigationService {
const channelId = this.activeChannelId;
if (!channelId) return undefined;
const channel = this.#db.channels.find(c => c.id === channelId);
if (!channel) return undefined;
// 1. Try to find in the synchronized channel rows
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 {
...channel,
serverId: channel.serverId
};
id: meta.id,
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) => {
// 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();
this.#joinVoiceReducer({ channelId });
};
+15
View File
@@ -268,4 +268,19 @@ export class WebRTCService {
toggleDeafen = () => this.localMedia.toggleDeafen();
setPeerAudioPreference = (peerIdHex: string, pref: any) =>
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;
}
};
}
+33 -128
View File
@@ -7,19 +7,12 @@ export { HOST_KEY, DB_NAME_KEY, getEnv };
/**
* 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) => {
let normalized = host.trim().replace(/\/+$/, "");
// Remove any existing protocol (http, https, ws, 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 protocol = isLocal ? "http://" : "https://";
return `${protocol}${normalized}`;
};
@@ -40,19 +33,15 @@ export const TokenStore = {
listStoredConnections: () => {
const connections: string[] = [];
const seen = new Set<string>();
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (!key) continue;
if (key.startsWith("stdb_token:")) {
if (key?.startsWith("stdb_token:")) {
const parts = key.split(":");
if (parts.length >= 4) {
const dbName = parts[parts.length - 1];
const hostParts = parts.slice(2, parts.length - 1);
const host = hostParts.join(":").replace(/^\/\//, "");
const connStr = `${host}:${dbName}`;
if (!seen.has(connStr)) {
connections.push(connStr);
seen.add(connStr);
@@ -74,170 +63,86 @@ export const getStdbDbName = () =>
let _connection: DbConnection | null = null;
export const getConnection = () => _connection;
let activeManager: ConnectionManager | null = null;
export const stopActiveConnection = () => {
if (activeManager) {
activeManager.stop();
activeManager = null;
}
if (_connection) {
_connection.disconnect();
_connection = null;
}
};
let lastSyncedIdentity: string | null = null;
export const handleConnect = (conn: DbConnection, identity: any, token: string) => {
const host = getStdbHost();
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;
if (token) {
console.log("handleConnect: Persisting token to TokenStore...");
TokenStore.set(host, dbName, token);
// Clear staging token now that we have a permanent one
localStorage.removeItem("zep_oidc_staging_token");
} else {
console.warn("handleConnect: No token received from SpacetimeDB!");
if (token) {
console.log(`[Handshake] Persisting SpacetimeDB token (len: ${token.length}) to local storage.`);
TokenStore.set(host, dbName, token);
}
connectionState.status = "connected";
connectionState.hasConnectedOnce = true;
connectionState.error = null;
// Call the auth update reducer to ensure OIDC info is synced
if (identityHex !== lastSyncedIdentity) {
console.log("[Handshake] New identity detected, syncing server-side auth metadata...");
lastSyncedIdentity = identityHex;
setTimeout(() => {
if (_connection) {
console.log("handleConnect: Requesting auth info update...");
// Use the typed reducer call if available
if ((_connection.reducers as any).updateAuthInfo) {
if (_connection && (_connection.reducers as any).updateAuthInfo) {
(_connection.reducers as any).updateAuthInfo({});
}
}
}, 100);
}
};
class ConnectionManager {
#retryCount = 0;
#reconnectTimeout: any = null;
#host: string;
#dbName: string;
#isStopped = false;
export const handleConnectError = (err: Error) => {
const host = getStdbHost();
const dbName = getStdbDbName();
console.log("[Handshake] Error connecting to SpacetimeDB:", err);
constructor(host: string, dbName: string) {
this.#host = host;
this.#dbName = dbName;
}
const errStr = err.message || "";
if (errStr.includes("401") || errStr.toLowerCase().includes("unauthorized")) {
console.warn(`[Handshake] Unauthorized (401) for ${host}:${dbName}! Purging host session...`);
TokenStore.clear(host, 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();
// Trigger full application logout (which now purges sessionStorage too)
import("./auth/auth.svelte").then(({ auth }) => {
auth.logout();
});
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(() => {
if (_connection) {
console.log("ConnectionManager: Requesting auth info update...");
(_connection.reducers as any).updateAuthInfo({});
}
}, 100);
this.#retryCount = 0;
if (this.#reconnectTimeout) {
clearTimeout(this.#reconnectTimeout);
this.#reconnectTimeout = null;
}
};
onDisconnect = () => {
if (this.#isStopped) return;
console.log("Disconnected from SpacetimeDB");
_connection = null;
connectionState.status = "disconnected";
this.#scheduleReconnect();
};
onConnectError = (_ctx: any, err: Error) => {
if (this.#isStopped) return;
console.log("Error connecting to SpacetimeDB:", err);
connectionState.error = err.message;
this.#scheduleReconnect();
};
#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) => {
const rawHost = getStdbHost();
const host = normalizeHost(rawHost);
const host = normalizeHost(getStdbHost());
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()
.withUri(host)
.withDatabaseName(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) {
console.log("[Builder] Using OIDC token for handshake (overriding stored token)");
builder.withToken(oidcToken);
} else if (stagingToken) {
console.log("connectionBuilder: Using staged OIDC token");
builder.withToken(stagingToken);
} else if (storedToken) {
console.log("[Builder] Using existing stored token for handshake");
builder.withToken(storedToken);
}
builder.onConnect((conn: any, identity: any, token: string) => {
handleConnect(conn, identity, token);
});
builder.onConnect((conn, identity, token) => handleConnect(conn as any, identity, token));
builder.onConnectError((_ctx, err) => handleConnectError(err));
return builder;
};