Files
zep/src/auth/AuthGate.svelte
T
2026-04-17 03:36:52 -04:00

460 lines
13 KiB
Svelte

<script lang="ts">
import { onMount, untrack } from "svelte";
import { auth } from "./auth.svelte";
import { HOST_KEY, DB_NAME_KEY } from "../env";
import { TokenStore, getStdbHost, getStdbDbName } from "../config";
import SpacetimeProvider from "../SpacetimeProvider.svelte";
import ComboBoxInput from "../chat/components/ComboBoxInput.svelte";
let { children, showServerSettings, onToggleServerSettings: _onToggleServerSettings } = $props<{
children: any,
showServerSettings: boolean,
onToggleServerSettings: (_val: boolean) => void
}>();
let authError = $state<string | null>(null);
let isGuest = $state(false);
let stdbHost = $state("");
let stdbDbName = $state("");
let storedConnections = $state<string[]>([]);
let combinedConnection = $state("");
let userWantsToConnect = $state(false);
onMount(() => {
stdbHost = getStdbHost();
stdbDbName = getStdbDbName();
storedConnections = TokenStore.listStoredConnections();
combinedConnection = `${stdbHost.replace(/^(https?|wss?):\/\//, "")}:${stdbDbName}`;
const isChanging = localStorage.getItem("zep_changing_server") === "true";
const isInCallback = window.location.search.includes("code=") && window.location.search.includes("state=");
if (isChanging) {
userWantsToConnect = false;
} else if (TokenStore.get(stdbHost, stdbDbName) || isInCallback) {
// Auto-connect ONLY if we have a SpacetimeDB token OR are in a redirect callback.
// We don't auto-connect just because of auth.isAuthenticated to prevent logout loops.
userWantsToConnect = true;
}
});
// Ensure we transition to connecting if we just finished an OIDC redirect
$effect(() => {
if (auth.isAuthenticated && !auth.isLoading) {
const isInCallback = window.location.search.includes("code=") && window.location.search.includes("state=");
if (isInCallback) {
untrack(() => {
userWantsToConnect = true;
});
}
}
});
// Split combined connection if it changes (Only when on login screen)
$effect(() => {
if (!userWantsToConnect && combinedConnection.includes(":")) {
const lastColon = combinedConnection.lastIndexOf(":");
const host = combinedConnection.substring(0, lastColon);
const db = combinedConnection.substring(lastColon + 1);
if (host && db && (host !== stdbHost || db !== stdbDbName)) {
untrack(() => {
stdbHost = host;
stdbDbName = db;
});
}
}
});
// 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);
}
});
$effect(() => {
if (stdbHost && localStorage.getItem(HOST_KEY) !== stdbHost) {
localStorage.setItem(HOST_KEY, stdbHost);
}
});
$effect(() => {
if (stdbDbName && localStorage.getItem(DB_NAME_KEY) !== stdbDbName) {
localStorage.setItem(DB_NAME_KEY, stdbDbName);
}
});
const isBypassEnabled =
import.meta.env.VITE_BYPASS_AUTH === "true" ||
new URLSearchParams(window.location.search).has("bypass_auth");
const isAuthenticated = $derived(auth.isAuthenticated || hasStoredToken || isGuest || isBypassEnabled);
const shouldShowContent = $derived(isAuthenticated && !showServerSettings && userWantsToConnect);
$effect(() => {
console.log("AuthGate: auth.isLoading:", auth.isLoading);
console.log("AuthGate: auth.isAuthenticated:", auth.isAuthenticated);
console.log("AuthGate: hasStoredToken:", hasStoredToken);
console.log("AuthGate: isGuest:", isGuest);
console.log("AuthGate: isBypassEnabled:", isBypassEnabled);
});
</script>
{#if auth.isLoading && !isBypassEnabled}
<div class="login-screen">
<h1>Loading Authentication...</h1>
</div>
{:else if shouldShowContent}
<SpacetimeProvider
onCancel={() => {
userWantsToConnect = false;
isGuest = false;
}}
>
{@render children()}
</SpacetimeProvider>
{:else}
<div class="login-screen">
<div class="login-card">
<div class="hero-header">
<div class="logo-container">
<svg viewBox="0 0 100 100" class="logo-svg">
<defs>
<linearGradient id="logoGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="var(--brand)" />
<stop offset="100%" stop-color="var(--brand-hover)" />
</linearGradient>
</defs>
<g class="zep-blimp">
<!-- Fins / Comic Action Lines -->
<path d="M15 40 L5 35 M15 50 L2 50 M15 60 L5 65" stroke="currentColor" stroke-width="4" stroke-linecap="round" opacity="0.4" />
<!-- The Chat Bubble Zeppelin Sandwich -->
<!-- Top Bun -->
<path d="M25 50 C25 30 45 25 60 25 C85 25 95 35 95 50" class="blimp-top" />
<!-- Energy Filling -->
<path d="M22 50 C22 50 50 50 98 50" class="blimp-filling" />
<!-- Bottom Bun + Chat Pointer (Gondola) -->
<path d="M95 50 C95 65 85 75 60 75 C55 75 50 75 45 85 L38 75 C30 75 25 65 25 50" class="blimp-bottom" />
<!-- Internal "Z" detail -->
<path d="M55 40 L70 40 L55 60 L70 60" stroke="currentColor" stroke-width="3" fill="none" stroke-linecap="round" opacity="0.3" />
</g>
</svg>
</div>
<h1>Zep</h1>
<p class="tagline">Decentralized. Private. Fast.</p>
</div>
{#if authError}
<div class="login-error">{authError}</div>
{/if}
<div style="display: flex; flex-direction: column; gap: 12px; width: 100%">
<button
onclick={() => {
userWantsToConnect = true;
auth.signinRedirect();
}}
disabled={auth.isLoading}
class="btn-primary"
style="width: 100%"
>
{auth.isLoading ? "Loading..." : "Login with OIDC"}
</button>
<button
onclick={() => {
console.log("AuthGate: Reconnect/Guest clicked. hasStoredToken:", hasStoredToken);
userWantsToConnect = true;
if (!hasStoredToken) {
isGuest = true;
}
_onToggleServerSettings(false);
}}
class="btn-secondary"
style="width: 100%"
>
{hasStoredToken ? "Reconnect as last user" : "Connect as guest"}
</button>
</div>
<div class="server-settings-section" style="margin-top: 24px; width: 100%; border-top: 1px solid var(--background-modifier-accent);">
<div style="padding-top: 16px; display: flex; flex-direction: column; gap: 16px;">
<div class="form-group" style="display: flex; flex-direction: column; gap: 4px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px;">
<label for="stdb-connection" style="font-size: 0.7rem; font-weight: bold; color: var(--text-muted); text-transform: uppercase; text-align: left; width: 100%;">
Instance hostname:database
</label>
</div>
<ComboBoxInput
id="stdb-connection"
bind:value={combinedConnection}
placeholder="connect.zep.chat:zep"
options={storedConnections}
/>
</div>
</div>
</div>
<div class="specs-section">
<h3>Technical Specifications</h3>
<div class="specs-grid">
<div class="spec-tag" title="Reactive state management with Svelte 5 Runes">
<i class="fab fa-js-square"></i>
<span>Svelte 5</span>
</div>
<div class="spec-tag" title="High-performance relational database backend">
<i class="fas fa-database"></i>
<span>SpacetimeDB</span>
</div>
<div class="spec-tag" title="Secure, efficient logic compiled to WebAssembly">
<i class="fab fa-rust"></i>
<span>Rust / WASM</span>
</div>
<div class="spec-tag" title="Real-time peer-to-peer voice and screen sharing">
<i class="fas fa-network-wired"></i>
<span>WebRTC Mesh</span>
</div>
<div class="spec-tag" title="End-to-end encryption via OpenPGP">
<i class="fas fa-lock"></i>
<span>GPG E2EE</span>
</div>
<div class="spec-tag" title="Cross-platform desktop app foundation">
<i class="fas fa-desktop"></i>
<span>Tauri 2.0</span>
</div>
</div>
</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;
}
.login-error {
color: var(--status-danger);
background-color: rgba(250, 119, 122, 0.1);
border: 1px solid rgba(250, 119, 122, 0.2);
padding: 8px;
border-radius: 4px;
margin-bottom: 16px;
width: 100%;
font-size: 0.9rem;
}
.specs-section {
margin-top: 32px;
width: 100%;
border-top: 1px solid var(--background-modifier-accent);
padding-top: 24px;
}
.specs-section h3 {
font-size: 0.75rem;
font-weight: 800;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 16px;
}
.specs-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
}
.spec-tag {
background-color: var(--background-secondary);
border: 1px solid var(--background-modifier-accent);
padding: 10px 12px;
border-radius: 6px;
display: flex;
align-items: center;
gap: 10px;
color: var(--text-normal);
font-size: 0.95rem; /* Bigger text */
font-weight: 600;
transition: all 0.2s ease;
cursor: default;
}
.spec-tag i {
font-size: 1.1rem;
color: var(--brand);
opacity: 0.8;
}
.spec-tag:hover {
background-color: var(--background-modifier-hover);
border-color: var(--brand);
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
color: var(--interactive-hover);
}
.spec-tag:hover i {
opacity: 1;
}
.ios-switch {
position: relative;
display: inline-block;
width: 34px;
height: 20px;
}
.ios-switch input {
opacity: 0;
width: 0;
height: 0;
}
.slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: var(--background-accent);
transition: .4s;
border-radius: 20px;
}
.slider:before {
position: absolute;
content: "";
height: 16px;
width: 16px;
left: 2px;
bottom: 2px;
background-color: var(--header-primary);
transition: .4s;
border-radius: 50%;
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
}
input:checked + .slider {
background-color: var(--status-positive);
}
input:checked + .slider:before {
transform: translateX(14px);
}
.hero-header {
margin-bottom: 32px;
display: flex;
flex-direction: column;
align-items: center;
}
.logo-container {
width: 100px;
height: 100px;
margin-bottom: 16px;
filter: drop-shadow(0 0 10px var(--brand));
}
.logo-svg {
width: 100%;
height: 100%;
color: var(--brand);
}
.zep-blimp {
animation: float 4s infinite ease-in-out;
transform-origin: center;
}
.blimp-top, .blimp-bottom {
fill: none;
stroke: currentColor;
stroke-width: 6;
stroke-linecap: round;
opacity: 0.8;
}
.blimp-filling {
fill: none;
stroke: url(#logoGrad);
stroke-width: 8;
stroke-linecap: round;
filter: drop-shadow(0 0 8px var(--brand));
animation: energy-pulse 2s infinite ease-in-out;
}
.tagline {
font-size: 0.9rem;
color: var(--text-muted);
margin-top: 4px;
letter-spacing: 1px;
text-transform: uppercase;
font-weight: 700;
}
@keyframes float {
0%, 100% { transform: translateY(0) rotate(-2deg); }
50% { transform: translateY(-10px) rotate(2deg); }
}
@keyframes energy-pulse {
0%, 100% { opacity: 0.6; stroke-width: 8; }
50% { opacity: 1; stroke-width: 10; filter: drop-shadow(0 0 12px var(--brand)); }
}
</style>