fix(gui-client): don't say "signed in" without a connlib session (#9477)

With the introduction of the "connect on start" configuration option, we
introduced a bug where the GUI client said "Signed in as ..." even
though we did not have a `connlib` session. The tray-menu handles this
state correctly and clicking sign out and sign in restores Firezone to a
functional state.

This disparity happened because we assumed that having a token means we
must have a session.

To fix this, we introduce a new `SessionViewModel` that combines the
state of the auth session and the `connlib` state. Only if we have both
do we infer that we are "signed in". This also requires us to introduce
an intermediary state where we are "loading". This is represented as a
spinner in the UI.

Last but not least, this also removes the automated hiding of the client
window. In a prior design, the only job of this window was to show the
"Sign in" button so it wasn't useful beyond clicking that. Now that we
show more things in this window, automatically hiding it might confuse
the user.

Here is what this new design looks like:

[Login
flow](https://github.com/user-attachments/assets/276e390b-4837-48e2-aaf1-eea007472816)

As a result of other improvements around "zero-click sign-in", the user
often doesn't even have to switch to the browser window because sign-in
happens in the background. Unfortunately, the tab still remains open but
that is outside of our control (at least on Linux).
This commit is contained in:
Thomas Eizinger
2025-06-09 11:41:18 +02:00
committed by GitHub
parent 04846c5b8a
commit 9210ed2a97
11 changed files with 217 additions and 126 deletions

View File

@@ -20,7 +20,7 @@ import React, { useEffect, useState } from "react";
import { NavLink, Route, Routes } from "react-router";
import { AdvancedSettingsViewModel } from "../generated/AdvancedSettingsViewModel";
import { FileCount } from "../generated/FileCount";
import { Session } from "../generated/Session";
import { SessionViewModel } from "../generated/SessionViewModel";
import About from "./AboutPage";
import AdvancedSettingsPage from "./AdvancedSettingsPage";
import ColorPalette from "./ColorPalettePage";
@@ -30,7 +30,7 @@ import Overview from "./OverviewPage";
import { GeneralSettingsViewModel } from "../generated/GeneralSettingsViewModel";
export default function App() {
let [session, setSession] = useState<Session | null>(null);
let [session, setSession] = useState<SessionViewModel | null>(null);
let [logCount, setLogCount] = useState<FileCount | null>(null);
let [generalSettings, setGeneralSettings] =
useState<GeneralSettingsViewModel | null>(null);
@@ -38,16 +38,12 @@ export default function App() {
useState<AdvancedSettingsViewModel | null>(null);
useEffect(() => {
const signedInUnlisten = listen<Session>("signed_in", (e) => {
const sessionChanged = listen<SessionViewModel>("session_changed", (e) => {
let session = e.payload;
console.log("signed_in", { session });
console.log("session_changed", { session });
setSession(session);
});
const signedOutUnlisten = listen<void>("signed_out", (_e) => {
console.log("signed_out");
setSession(null);
});
const generalSettingsChangedUnlisten = listen<GeneralSettingsViewModel>(
"general_settings_changed",
(e) => {
@@ -78,8 +74,7 @@ export default function App() {
invoke<void>("update_state"); // Let the backend know that we (re)-initialised
return () => {
signedInUnlisten.then((unlistenFn) => unlistenFn());
signedOutUnlisten.then((unlistenFn) => unlistenFn());
sessionChanged.then((unlistenFn) => unlistenFn());
generalSettingsChangedUnlisten.then((unlistenFn) => unlistenFn());
advancedSettingsChangedUnlisten.then((unlistenFn) => unlistenFn());
logsRecountedUnlisten.then((unlistenFn) => unlistenFn());

View File

@@ -1,68 +1,120 @@
import React from "react";
import logo from "../logo.png";
import { Session } from "./App";
import { Button } from "flowbite-react";
import { SessionViewModel } from "../generated/SessionViewModel";
import { Button, Spinner } from "flowbite-react";
interface OverviewPageProps {
session: Session | null;
session: SessionViewModel | null;
signOut: () => void;
signIn: () => void;
}
export default function Overview({
session,
signOut,
signIn,
}: OverviewPageProps) {
export default function Overview(props: OverviewPageProps) {
return (
<div className="flex flex-col items-center justify-center gap-4 min-h-screen">
<img src={logo} alt="Firezone Logo" className="w-40 h-40" />
<h1 className="text-6xl font-bold">Firezone</h1>
{!session ? (
<div id="signed-out">
<div className="flex flex-col items-center gap-4">
<p className="text-center">
You can sign in by clicking the Firezone icon in the taskbar or by
clicking 'Sign in' below.
</p>
<Button id="sign-in" onClick={signIn}>
Sign in
</Button>
<p className="text-xs text-center">
Firezone will continue running after this window is closed.
<br />
It is always available from the taskbar.
</p>
</div>
</div>
) : (
<div id="signed-in">
<div className="flex flex-col items-center gap-4">
<p className="text-center">
You are currently signed into&nbsp;
<span className="font-bold" id="account-slug">
{session.account_slug}
</span>
&nbsp;as&nbsp;
<span className="font-bold" id="actor-name">
{session.actor_name}
</span>
.<br />
Click the Firezone icon in the taskbar to see the list of
Resources.
</p>
<Button id="sign-out" onClick={signOut}>
Sign out
</Button>
<p className="text-xs text-center">
Firezone will continue running in the taskbar after this window is
closed.
</p>
</div>
</div>
)}
<Session {...props} />
</div>
);
}
function Session(props: OverviewPageProps) {
if (!props.session) {
return <SignedOut {...props} />;
}
switch (props.session) {
case "SignedOut": {
return <SignedOut {...props} />;
}
case "Loading": {
return <Loading />;
}
default:
let { account_slug, actor_name } = props.session.SignedIn;
return (
<SignedIn
accountSlug={account_slug}
actorName={actor_name}
signOut={props.signOut}
/>
);
}
}
interface SignedOutProps {
signIn: () => void;
}
function SignedOut({ signIn }: SignedOutProps) {
return (
<div id="signed-out">
<div className="flex flex-col items-center gap-4">
<p className="text-center">
You can sign in by clicking the Firezone icon in the taskbar or by
clicking 'Sign in' below.
</p>
<Button id="sign-in" onClick={signIn}>
Sign in
</Button>
<p className="text-xs text-center">
Firezone will continue running after this window is closed.
<br />
It is always available from the taskbar.
</p>
</div>
</div>
);
}
interface SignedInProps {
accountSlug: string;
actorName: string;
signOut: () => void;
}
function SignedIn({ actorName, accountSlug, signOut }: SignedInProps) {
return (
<div id="signed-in">
<div className="flex flex-col items-center gap-4">
<p className="text-center">
You are currently signed into&nbsp;
<span className="font-bold" id="account-slug">
{accountSlug}
</span>
&nbsp;as&nbsp;
<span className="font-bold" id="actor-name">
{actorName}
</span>
.<br />
Click the Firezone icon in the taskbar to see the list of Resources.
</p>
<Button id="sign-out" onClick={signOut}>
Sign out
</Button>
<p className="text-xs text-center">
Firezone will continue running in the taskbar after this window is
closed.
</p>
</div>
</div>
);
}
function Loading() {
return (
<div id="loading">
<div className="flex flex-col items-center gap-4">
<Spinner />
<p className="text-xs text-center">
Firezone will continue running in the taskbar after this window is
closed.
</p>
</div>
</div>
);
}

View File

@@ -1,4 +0,0 @@
export interface Session {
account_slug: string;
actor_name: string;
}

View File

@@ -0,0 +1,9 @@
export type SessionViewModel =
{
SignedIn: {
account_slug: string;
actor_name: string
}
} |
"Loading" |
"SignedOut";

View File

@@ -34,6 +34,11 @@ const customTheme = createTheme({
},
},
},
spinner: {
color: {
default: "fill-accent-500",
},
},
});
ReactDOM.createRoot(document.getElementById("root") as HTMLElement, {

View File

@@ -98,3 +98,6 @@ tempfile = { workspace = true }
[lints]
workspace = true
[tslink]
enum_representation = "discriminated"

View File

@@ -82,7 +82,6 @@ pub(crate) struct Response {
pub(crate) state: SecretString,
}
#[tslink::tslink(target = "./gui-client/src-frontend/generated/Session.ts")]
#[derive(Default, Clone, Deserialize, Serialize)]
pub struct Session {
pub(crate) account_slug: String,

View File

@@ -6,7 +6,7 @@ use crate::{
service,
settings::{self, AdvancedSettings, GeneralSettings, MdmSettings},
updates, uptime,
view::GeneralSettingsForm,
view::{GeneralSettingsForm, SessionViewModel},
};
use anyhow::{Context, Result, anyhow, bail};
use connlib_model::ResourceView;
@@ -65,8 +65,7 @@ pub struct Controller<I: GuiIntegration> {
}
pub trait GuiIntegration {
fn notify_signed_in(&self, session: &auth::Session) -> Result<()>;
fn notify_signed_out(&self) -> Result<()>;
fn notify_session_changed(&self, session: &SessionViewModel) -> Result<()>;
fn notify_settings_changed(
&self,
mdm_settings: MdmSettings,
@@ -84,7 +83,7 @@ pub trait GuiIntegration {
fn show_update_notification(&self, ctlr_tx: CtlrTx, title: &str, url: url::Url) -> Result<()>;
fn set_window_visible(&self, visible: bool) -> Result<()>;
fn show_overview_page(&self, current_session: Option<&auth::Session>) -> Result<()>;
fn show_overview_page(&self, session: &SessionViewModel) -> Result<()>;
fn show_settings_page(
&self,
mdm_settings: MdmSettings,
@@ -288,10 +287,12 @@ impl<I: GuiIntegration> Controller<I> {
tracing::info!("No token / actor_name on disk, starting in signed-out state");
}
self.refresh_system_tray_menu();
self.refresh_ui_state();
if !ran_before::get().await? || !self.general_settings.start_minimized {
self.integration.show_overview_page(self.auth.session())?;
let (_, session_view_model) = self.build_ui_state();
self.integration.show_overview_page(&session_view_model)?;
}
loop {
@@ -432,13 +433,12 @@ impl<I: GuiIntegration> Controller<I> {
};
let session = self.auth.session().context("Missing session")?;
self.integration.notify_signed_in(session)?;
self.general_settings.account_slug = Some(session.account_slug.clone());
settings::save_general(&self.general_settings).await?;
self.notify_settings_changed()?;
self.refresh_system_tray_menu();
self.refresh_ui_state();
Ok(())
}
@@ -505,7 +505,7 @@ impl<I: GuiIntegration> Controller<I> {
tracing::debug!("Applied new settings. Log level will take effect immediately.");
// Refresh the menu in case the favorites were reset.
self.refresh_system_tray_menu();
self.refresh_ui_state();
self.integration.show_notification("Settings saved", "")?
}
@@ -563,11 +563,10 @@ impl<I: GuiIntegration> Controller<I> {
.context("Couldn't start sign-in flow")?;
let url = req.to_url(&auth_url, account_slug.as_deref());
self.refresh_system_tray_menu();
self.refresh_ui_state();
self.integration
.open_url(url.expose_secret())
.context("Couldn't open auth page")?;
self.integration.set_window_visible(false)?;
}
SystemTrayMenu(system_tray::Event::AddFavorite(resource_id)) => {
self.general_settings.favorite_resources.insert(resource_id);
@@ -648,7 +647,7 @@ impl<I: GuiIntegration> Controller<I> {
tracing::info!("User clicked Quit in the menu");
self.status = Status::Quitting;
self.send_ipc(&service::ClientMsg::Disconnect).await?;
self.refresh_system_tray_menu();
self.refresh_ui_state();
}
UpdateNotificationClicked(download_url) => {
tracing::info!("UpdateNotificationClicked in run_controller!");
@@ -658,13 +657,11 @@ impl<I: GuiIntegration> Controller<I> {
}
UpdateState => {
self.notify_settings_changed()?;
match self.auth.session() {
Some(session) => self.integration.notify_signed_in(session)?,
None => self.integration.notify_signed_out()?,
};
let file_count = logging::count_logs().await?;
self.integration.notify_logs_recounted(&file_count)?;
self.refresh_ui_state();
}
}
Ok(())
@@ -732,7 +729,7 @@ impl<I: GuiIntegration> Controller<I> {
}
tracing::debug!(len = resources.len(), "Got new Resources");
self.status = Status::TunnelReady { resources };
self.refresh_system_tray_menu();
self.refresh_ui_state();
self.update_disabled_resources().await?;
}
@@ -759,7 +756,7 @@ impl<I: GuiIntegration> Controller<I> {
"Firezone connected",
"You are now signed in and able to access resources.",
)?;
self.refresh_system_tray_menu();
self.refresh_ui_state();
}
service::ServerMsg::Hello => {}
}
@@ -790,7 +787,9 @@ impl<I: GuiIntegration> Controller<I> {
}
},
gui::ClientMsg::NewInstance => {
self.integration.show_overview_page(self.auth.session())?;
let (_, session_view_model) = self.build_ui_state();
self.integration.show_overview_page(&session_view_model)?;
}
}
@@ -817,7 +816,7 @@ impl<I: GuiIntegration> Controller<I> {
self.status = Status::WaitingForTunnel {
start_instant: *start_instant,
};
self.refresh_system_tray_menu();
self.refresh_ui_state();
Ok(())
}
Err(service::ConnectError::Io(error)) => {
@@ -830,7 +829,7 @@ impl<I: GuiIntegration> Controller<I> {
self.status = Status::RetryingConnection {
token: token.expose_secret().clone().into(),
};
self.refresh_system_tray_menu();
self.refresh_ui_state();
Ok(())
}
Err(service::ConnectError::Other(error)) => {
@@ -851,13 +850,13 @@ impl<I: GuiIntegration> Controller<I> {
) -> Result<()> {
let Some(notification) = notification else {
self.release = None;
self.refresh_system_tray_menu();
self.refresh_ui_state();
return Ok(());
};
let release = notification.release;
self.release = Some(release.clone());
self.refresh_system_tray_menu();
self.refresh_ui_state();
if notification.tell_user {
let title = format!("Firezone {} available for download", release.version);
@@ -891,7 +890,7 @@ impl<I: GuiIntegration> Controller<I> {
disabled_resources,
))
.await?;
self.refresh_system_tray_menu();
self.refresh_ui_state();
Ok(())
}
@@ -899,41 +898,70 @@ impl<I: GuiIntegration> Controller<I> {
/// Saves the current settings (including favorites) to disk and refreshes the tray menu
async fn refresh_favorite_resources(&mut self) -> Result<()> {
settings::save_general(&self.general_settings).await?;
self.refresh_system_tray_menu();
self.refresh_ui_state();
Ok(())
}
/// Builds a new system tray menu and applies it to the app
fn refresh_system_tray_menu(&mut self) {
fn build_ui_state(&self) -> (system_tray::ConnlibState, SessionViewModel) {
// TODO: Refactor `Controller` and the auth module so that "Are we logged in?"
// doesn't require such complicated control flow to answer.
let connlib = if let Some(auth_session) = self.auth.session() {
if let Some(auth_session) = self.auth.session() {
match &self.status {
Status::Disconnected => {
// If we have an `auth_session` but no connlib session, we are most likely configured to
// _not_ auto-connect on startup. Thus, we treat this the same as being signed out.
system_tray::ConnlibState::SignedOut
(
system_tray::ConnlibState::SignedOut,
SessionViewModel::SignedOut,
)
}
Status::Quitting => system_tray::ConnlibState::Quitting,
Status::RetryingConnection { .. } => system_tray::ConnlibState::RetryingConnection,
Status::TunnelReady { resources } => {
Status::Quitting => (
system_tray::ConnlibState::Quitting,
SessionViewModel::Loading,
),
Status::RetryingConnection { .. } => (
system_tray::ConnlibState::RetryingConnection,
SessionViewModel::Loading,
),
Status::TunnelReady { resources } => (
system_tray::ConnlibState::SignedIn(system_tray::SignedIn {
actor_name: auth_session.actor_name.clone(),
favorite_resources: self.general_settings.favorite_resources.clone(),
internet_resource_enabled: self.general_settings.internet_resource_enabled,
resources: resources.clone(),
})
}
Status::WaitingForPortal { .. } => system_tray::ConnlibState::WaitingForPortal,
Status::WaitingForTunnel { .. } => system_tray::ConnlibState::WaitingForTunnel,
}),
SessionViewModel::SignedIn {
account_slug: auth_session.account_slug.clone(),
actor_name: auth_session.actor_name.clone(),
},
),
Status::WaitingForPortal { .. } => (
system_tray::ConnlibState::WaitingForPortal,
SessionViewModel::Loading,
),
Status::WaitingForTunnel { .. } => (
system_tray::ConnlibState::WaitingForTunnel,
SessionViewModel::Loading,
),
}
} else if self.auth.ongoing_request().is_some() {
// Signing in, waiting on deep link callback
system_tray::ConnlibState::WaitingForBrowser
(
system_tray::ConnlibState::WaitingForBrowser,
SessionViewModel::Loading,
)
} else {
system_tray::ConnlibState::SignedOut
};
(
system_tray::ConnlibState::SignedOut,
SessionViewModel::SignedOut,
)
}
}
/// Refreshes our UI state (i.e. tray-menu and GUI).
fn refresh_ui_state(&mut self) {
let (connlib, session_view_model) = self.build_ui_state();
self.integration.set_tray_menu(system_tray::AppState {
connlib,
@@ -944,6 +972,9 @@ impl<I: GuiIntegration> Controller<I> {
.is_some_and(|hide| hide),
support_url: self.mdm_settings.support_url.clone(),
});
if let Err(e) = self.integration.notify_session_changed(&session_view_model) {
tracing::warn!("Failed to send notify session change: {e:#}")
}
}
/// If we're in the `RetryingConnection` state, use the token to retry the Portal connection
@@ -973,13 +1004,12 @@ impl<I: GuiIntegration> Controller<I> {
| Status::WaitingForTunnel { .. } => {}
}
self.auth.sign_out()?;
self.integration.notify_signed_out()?;
self.status = Status::Disconnected;
tracing::debug!("disconnecting connlib");
// This is redundant if the token is expired, in that case
// connlib already disconnected itself.
self.send_ipc(&service::ClientMsg::Disconnect).await?;
self.refresh_system_tray_menu();
self.refresh_ui_state();
Ok(())
}

View File

@@ -4,7 +4,6 @@
//! The real macOS Client is in `swift/apple`
use crate::{
auth,
controller::{Controller, ControllerRequest, CtlrTx, Failure, GuiIntegration},
deep_link,
ipc::{self, ClientRead, ClientWrite, SocketId},
@@ -14,6 +13,7 @@ use crate::{
GeneralSettingsViewModel, MdmSettings,
},
updates,
view::SessionViewModel,
};
use anyhow::{Context, Result, bail};
use firezone_logging::err_with_src;
@@ -82,20 +82,10 @@ impl Drop for TauriIntegration {
}
impl GuiIntegration for TauriIntegration {
fn notify_signed_in(&self, session: &auth::Session) -> Result<()> {
fn notify_session_changed(&self, session: &SessionViewModel) -> Result<()> {
self.app
.emit("signed_in", session)
.context("Failed to send `signed_in` event")?;
Ok(())
}
fn notify_signed_out(&self) -> Result<()> {
self.app
.emit("signed_out", ())
.context("Failed to send `signed_out` event")?;
Ok(())
.emit("session_changed", session)
.context("Failed to send `session_changed` event")
}
fn notify_settings_changed(
@@ -166,12 +156,9 @@ impl GuiIntegration for TauriIntegration {
Ok(())
}
fn show_overview_page(&self, current_session: Option<&auth::Session>) -> Result<()> {
fn show_overview_page(&self, session: &SessionViewModel) -> Result<()> {
// Ensure state in frontend is up-to-date.
match current_session {
Some(session) => self.notify_signed_in(session)?,
None => self.notify_signed_out()?,
};
self.notify_session_changed(session)?;
self.navigate("overview")?;
self.set_window_visible(true)?;

View File

@@ -20,6 +20,17 @@ pub struct GeneralSettingsForm {
pub account_slug: String,
}
#[tslink::tslink(target = "./gui-client/src-frontend/generated/SessionViewModel.ts")]
#[derive(Clone, serde::Serialize)]
pub enum SessionViewModel {
SignedIn {
account_slug: String,
actor_name: String,
},
Loading,
SignedOut,
}
pub fn generate_handler() -> impl Fn(Invoke<Wry>) -> bool + Send + Sync + 'static {
tauri::generate_handler![
clear_logs,

View File

@@ -19,6 +19,10 @@ export default function GUI({ os }: { os: OS }) {
Fixes an issue where disabling the update checker via MDM would cause
the Client to hang upon sign-in.
</ChangeItem>
<ChangeItem pull="9477">
Fixes an issue where disabling "connect on start" would incorrectly
show the Client as "Signed in" on the next launch.
</ChangeItem>
</Unreleased>
<Entry version="1.5.1" date={new Date("2025-06-05")}>
<ChangeItem pull="9418">