oidc/guest working
This commit is contained in:
+11
-4
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
};
|
||||
|
||||
@@ -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
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user