feat(desktop): runtime-configurable backend server address
Build & push images / images (push) Successful in 3m19s
Release desktop / bundle (push) Successful in 4m57s

The desktop shell is one generic .deb/.AppImage distributed via
mca/public_releases, not built per-booth, but the backend origin was baked
in at build time (VITE_API_BASE, hardcoded to http://127.0.0.1:3000) — the
same installer could never point at a different appliance without a
rebuild.

Adds ConnectScreen (shown before Login in Tauri when no backend is saved),
backed by tauri-plugin-store persisting the operator-entered URL across
restarts. CSP's connect-src tightens to 'self' only — all backend traffic
already routes through tauri-plugin-http/websocket, which run Rust-side
and are outside connect-src's reach anyway — and the real access boundary
moves to capabilities/default.json's http:default scope, wildcarded so an
operator-chosen host is actually reachable. Adds a "Change server" control
in Setup (desktop-only) to repoint an already-configured install.

While tracing the desktop auth path for this: tauri-plugin-http's fetch()
runs through Rust's reqwest, which keeps its own cookie jar separate from
the webview, so document.cookie on tauri://localhost never sees the
parking_csrf cookie the server sets (open upstream bug,
tauri-apps/tauri#13045/#11518). This means the desktop app has likely been
silently sending no CSRF header on every mutation since the shell was
first built — pre-existing, independent of this change. Fixed by having
sessionView() (routes/auth.ts) also echo the CSRF value in the login/me
JSON body; the desktop client stashes it in memory and echoes that instead
of reading document.cookie. assertCsrf() itself is untouched.

Verified end-to-end against a real LAN-bound dev server: login returns a
csrfToken matching the cookie, a mutation using the body-sourced token in
X-CSRF-Token succeeds (200), and the same mutation without it still
correctly 403s.
This commit is contained in:
2026-09-04 10:32:03 +02:00
parent 969bf2b191
commit 5c6a21e2c3
21 changed files with 575 additions and 47 deletions
+33 -3
View File
@@ -3,25 +3,42 @@ import { QueryClientProvider } from "@tanstack/react-query";
import { RouterProvider } from "@tanstack/react-router";
import { fetchMe, type SessionUser } from "./api.js";
import { Login } from "./Login.js";
import { ConnectScreen } from "./ConnectScreen.js";
import { queryClient } from "./lib/query.js";
import { setLanguage } from "./lib/i18n/index.js";
import { applyTheme, applyFontScale } from "./lib/theme.js";
import { router } from "./router.js";
import { initApiBase, inTauri } from "./lib/origin.js";
// App root: bootstraps the session (cookie-based, from /api/auth/me), then hands
// off to TanStack Router inside the QueryClient provider. The router renders the
// terminal chrome + screens; auth gating stays here (Login until signed in), and
// the signed-in user flows into the router context for role-based route guards.
// See wiki/entities/react-vite-spa.md and local-jwt-auth.md.
//
// Desktop shell only: BEFORE any of that, the backend origin itself must be
// known — the same installer is used at every booth (see lib/origin.ts /
// backend-config.ts), so on first launch (or after the operator clears it)
// there is no server to call fetchMe() against yet. ConnectScreen gates that;
// a browser build always has a same-origin backend, so `needsConnect` is
// always false there and this is skipped entirely.
export function App() {
const [user, setUser] = useState<SessionUser | null>(null);
const [loading, setLoading] = useState(true);
const [needsConnect, setNeedsConnect] = useState(false);
useEffect(() => {
fetchMe()
.then(setUser)
.finally(() => setLoading(false));
initApiBase().then((saved) => {
if (inTauri() && !saved) {
setNeedsConnect(true);
setLoading(false);
return;
}
fetchMe()
.then(setUser)
.finally(() => setLoading(false));
});
}, []);
// Apply the signed-in user's preferred language + theme + font scale whenever they
@@ -41,6 +58,19 @@ export function App() {
if (loading) {
return <div className="flex h-screen items-center justify-center text-term-muted">loading…</div>;
}
if (needsConnect) {
return (
<ConnectScreen
onConnected={() => {
setNeedsConnect(false);
setLoading(true);
fetchMe()
.then(setUser)
.finally(() => setLoading(false));
}}
/>
);
}
if (!user) {
return (
<QueryClientProvider client={queryClient}>
+116
View File
@@ -0,0 +1,116 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { setApiBase } from "./lib/origin.js";
// Desktop-only gate shown BEFORE Login whenever no backend has been
// configured yet (first launch of a generic .deb/.AppImage install, or after
// the operator clears it from Settings). Same installer works at any booth —
// see backend-config.ts for why this can't be a build-time value.
//
// backend-config.ts is imported dynamically (not at module top-level) purely
// to keep bundling consistent with origin.ts/router.tsx's other Tauri-only
// imports — this component itself only ever renders inside Tauri anyway, so
// it's not a functional requirement, just avoids an INEFFECTIVE_DYNAMIC_IMPORT
// warning from Vite (a static import here would defeat those other dynamic
// imports' chunk-splitting intent).
function normalizeHost(raw: string): string {
const trimmed = raw.trim();
if (!trimmed) return trimmed;
return /^https?:\/\//i.test(trimmed) ? trimmed : `http://${trimmed}`;
}
export function ConnectScreen({ onConnected }: { onConnected: () => void }) {
const { t } = useTranslation();
const [host, setHost] = useState("");
const [testing, setTesting] = useState(false);
const [saving, setSaving] = useState(false);
const [result, setResult] = useState<"ok" | "unreachable" | "bad_response" | null>(null);
const [detail, setDetail] = useState<string | undefined>(undefined);
const url = normalizeHost(host);
const canSubmit = url.length > 0 && !testing && !saving;
async function handleTest(e: React.FormEvent) {
e.preventDefault();
if (!canSubmit) return;
setTesting(true);
setResult(null);
setDetail(undefined);
try {
const { testBackendUrl } = await import("./lib/backend-config.js");
const check = await testBackendUrl(url);
setResult(check.ok ? "ok" : (check.reason ?? "unreachable"));
setDetail(check.detail);
} finally {
setTesting(false);
}
}
async function handleSave() {
setSaving(true);
try {
const { saveBackendUrl } = await import("./lib/backend-config.js");
await saveBackendUrl(url);
setApiBase(url);
onConnected();
} finally {
setSaving(false);
}
}
return (
<main className="flex min-h-screen items-center justify-center bg-term-bg px-4">
<form onSubmit={handleTest} className="card w-full max-w-sm p-6">
<h1 className="mb-1 text-h5 font-semibold uppercase tracking-widest text-term-amber">
{t("connect.title")}
</h1>
<p className="mb-5 text-[0.75rem] text-term-muted">{t("connect.hint")}</p>
<div className="field mb-3">
<label className="label">{t("connect.serverAddress")}</label>
<input
className="input"
value={host}
onChange={(e) => {
setHost(e.target.value);
setResult(null);
}}
placeholder="192.168.1.50:3000"
autoFocus
autoCapitalize="off"
autoCorrect="off"
spellCheck={false}
/>
</div>
{result === "ok" && (
<p className="mb-3 text-[0.75rem] text-term-green">{t("connect.testOk")}</p>
)}
{result === "unreachable" && (
<p className="mb-3 text-[0.75rem] text-term-red">
{t("connect.testUnreachable")}
{detail ? ` (${detail})` : ""}
</p>
)}
{result === "bad_response" && (
<p className="mb-3 text-[0.75rem] text-term-red">{t("connect.testBadResponse")}</p>
)}
<div className="flex gap-2">
<button type="submit" className="btn flex-1" disabled={!canSubmit}>
{testing ? t("connect.testing") : t("connect.test")}
</button>
<button
type="button"
className="btn btn-primary flex-1"
disabled={!canSubmit || result !== "ok"}
onClick={handleSave}
>
{saving ? t("connect.saving") : t("connect.save")}
</button>
</div>
</form>
</main>
);
}
+44 -9
View File
@@ -1,12 +1,25 @@
// Thin API client for the operator/admin UI.
//
// Auth is cookie-based: the JWT lives in an HttpOnly cookie the browser sends
// automatically (credentials: 'include'). For mutations we echo the readable
// CSRF cookie back in the X-CSRF-Token header (double-submit). See
// Auth is cookie-based: the JWT lives in an HttpOnly cookie sent automatically
// (credentials: 'include'). For mutations we echo the readable CSRF cookie
// back in the X-CSRF-Token header (double-submit). See
// wiki/entities/local-jwt-auth.md.
//
// Desktop shell exception: tauri-plugin-http's fetch() runs through Rust's
// reqwest, which keeps its OWN cookie jar separate from the webview —
// document.cookie on the tauri://localhost page never sees a cookie set on a
// plugin-routed response (open upstream bug, tauri-apps/tauri#13045). The
// cookie itself IS still sent back to the server by reqwest on later
// requests (only the client-side *read* is broken), so the server also
// echoes the token in the login/me response BODY (sessionView's csrfToken —
// see routes/auth.ts) purely as a second channel for the desktop client to
// learn the value; desktopCsrfToken below stashes it in memory and
// setSessionUser() (called wherever a SessionUser is received) keeps it
// current. The browser path is untouched — it still reads document.cookie.
import { logFailedRequest } from "./lib/logger.js";
import { apiUrl, platformFetch } from "./lib/origin.js";
import { inTauri } from "./lib/tauri-env.js";
import type { AppLogRecord, ValidationLine, ValidationMode } from "@parking/shared";
const CSRF_COOKIE = "parking_csrf";
@@ -17,6 +30,18 @@ function readCookie(name: string): string | null {
return m ? decodeURIComponent(m[1]!) : null;
}
/** Desktop-only in-memory CSRF stash — see file header. Never persisted (a
* fresh app launch always logs in again, or bootstraps via /api/auth/me
* which re-supplies it). */
let desktopCsrfToken: string | null = null;
/** Update the desktop CSRF stash. Called wherever a SessionUser is received
* (login, fetchMe). No-op / cheap in the browser (the value just goes
* unused there — reads still come from document.cookie). */
function setSessionUser(user: SessionUser): void {
if (user.csrfToken) desktopCsrfToken = user.csrfToken;
}
/** fetch wrapper: sends cookies, adds CSRF header on mutations, parses errors. */
export async function apiFetch<T>(path: string, init: RequestInit = {}): Promise<T> {
const method = (init.method ?? "GET").toUpperCase();
@@ -25,7 +50,7 @@ export async function apiFetch<T>(path: string, init: RequestInit = {}): Promise
headers.set("content-type", "application/json");
}
if (method !== "GET" && method !== "HEAD") {
const csrf = readCookie(CSRF_COOKIE);
const csrf = inTauri() ? desktopCsrfToken : readCookie(CSRF_COOKIE);
if (csrf) headers.set(CSRF_HEADER, csrf);
}
const res = await platformFetch(apiUrl(path), { ...init, headers, credentials: "include" });
@@ -82,6 +107,10 @@ export interface SessionUser {
fullName: string | null;
/** Optional contact email (profile metadata); null if unset. */
email: string | null;
/** Desktop-only: the CSRF token also echoed via the (JS-unreadable, on
* desktop) parking_csrf cookie — see the file header. Absent/unused in the
* browser build, which reads the cookie directly instead. */
csrfToken?: string;
}
/** Does this session grant the permission? Central authz check for the SPA. */
@@ -89,15 +118,19 @@ export function can(user: SessionUser | null, perm: Permission): boolean {
return !!user && user.permissions.includes(perm);
}
export function login(username: string, password: string): Promise<SessionUser> {
return apiFetch<SessionUser>("/api/auth/login", {
export async function login(username: string, password: string): Promise<SessionUser> {
const user = await apiFetch<SessionUser>("/api/auth/login", {
method: "POST",
body: JSON.stringify({ username, password }),
});
setSessionUser(user);
return user;
}
export function logout(): Promise<{ ok: boolean }> {
return apiFetch("/api/auth/logout", { method: "POST" });
export async function logout(): Promise<{ ok: boolean }> {
const res = await apiFetch<{ ok: boolean }>("/api/auth/logout", { method: "POST" });
desktopCsrfToken = null;
return res;
}
/** Persist the current user's UI language preference (restored on next login). */
@@ -146,7 +179,9 @@ export function changeMyPassword(
/** Returns the current user, or null if not authenticated. */
export async function fetchMe(): Promise<SessionUser | null> {
try {
return await apiFetch<SessionUser>("/api/auth/me");
const user = await apiFetch<SessionUser>("/api/auth/me");
setSessionUser(user);
return user;
} catch (e) {
if (e instanceof ApiError && (e.status === 401 || e.status === 403)) return null;
throw e;
+88
View File
@@ -0,0 +1,88 @@
// Desktop-only: the operator-configured backend origin (host:port of the
// Fastify server this install talks to), persisted across restarts.
//
// The desktop shell is a generic .deb/.AppImage — it is NOT built for one
// specific booth, so the backend address can't be baked in at build time
// (that was the old VITE_API_BASE approach; a rebuild was needed to point the
// same installer at a different appliance). Instead the operator enters it
// once in the ConnectScreen (shown before login whenever nothing usable is
// stored yet) and it's saved to a JSON file in the OS config dir via
// tauri-plugin-store, read back on every launch before any API call.
//
// Browser build: this module is never reached (inTauri() gates every call
// site — see origin.ts), so there is no browser equivalent or fallback here.
import type { Store } from "@tauri-apps/plugin-store";
const STORE_FILE = "backend-config.json";
const KEY = "backendUrl";
let storeHandle: Store | null = null;
async function getStore(): Promise<Store> {
if (!storeHandle) {
const { load } = await import("@tauri-apps/plugin-store");
storeHandle = await load(STORE_FILE, { autoSave: true });
}
return storeHandle;
}
/** The saved backend origin (no trailing slash), or null if never configured.
* Desktop only — throws if called from a browser build. */
export async function loadBackendUrl(): Promise<string | null> {
const store = await getStore();
const v = await store.get<string>(KEY);
return typeof v === "string" && v.length > 0 ? v.replace(/\/$/, "") : null;
}
/** Persist a new backend origin (validated + reachable — call testBackendUrl
* first). Takes effect immediately for future platformFetch/wsUrl calls. */
export async function saveBackendUrl(url: string): Promise<void> {
const store = await getStore();
await store.set(KEY, url.replace(/\/$/, ""));
await store.save();
}
/** Clear the saved backend (forces the ConnectScreen back up next launch). */
export async function clearBackendUrl(): Promise<void> {
const store = await getStore();
await store.delete(KEY);
await store.save();
}
export interface BackendCheck {
ok: boolean;
/** "unreachable" (network/DNS/refused) | "bad_response" (reachable, not our API). */
reason?: "unreachable" | "bad_response";
detail?: string;
}
/** Probe a candidate origin by hitting /api/version. That route is behind
* requirePermission("site:read") (session cookie + site:read — see
* apps/server/src/routes/site.ts), so a pre-login probe can never get a 2xx;
* we're not checking "is this reachable and mine to use", only "is something
* that speaks our Fastify auth protocol listening here" — a 401 (missing/bad
* JWT) or 403 (valid session, wrong permission) from THIS specific route is
* as strong a signal of that as a 200 would be, and both are expected outcomes
* pre-login. Uses the same tauri-plugin-http path platformFetch does (raw
* fetch from the webview can't reach an arbitrary LAN host — mixed content,
* see origin.ts). */
export async function testBackendUrl(url: string): Promise<BackendCheck> {
const origin = url.replace(/\/$/, "");
try {
const { fetch: tauriFetch } = await import("@tauri-apps/plugin-http");
const res = await tauriFetch(`${origin}/api/version`, {
method: "GET",
signal: AbortSignal.timeout(5000),
});
if (!res.ok && res.status !== 401 && res.status !== 403) {
return { ok: false, reason: "bad_response", detail: `HTTP ${res.status}` };
}
return { ok: true };
} catch (err) {
return {
ok: false,
reason: "unreachable",
detail: err instanceof Error ? err.message : String(err),
};
}
}
+1 -5
View File
@@ -20,11 +20,7 @@
// LogsViewer.tsx without needing a terminal or devtools at all.
import { logClient } from "./logger.js";
/** True when running inside the Tauri webview (not a normal browser). */
function inTauri(): boolean {
return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window;
}
import { inTauri } from "./tauri-env.js";
export interface UpdatePrompt {
/** Newer version string offered by the server. */
+14
View File
@@ -42,6 +42,20 @@ export const en: Catalog = {
signIn: "Sign in",
signingIn: "Signing in…",
},
connect: {
title: "Connect to server",
hint: "Enter the address of the parking system server for this booth.",
serverAddress: "Server address",
test: "Test",
testing: "Testing…",
save: "Save & continue",
saving: "Saving…",
testOk: "Reachable — this looks like a Parking System server.",
testUnreachable: "Could not reach this address.",
testBadResponse: "Reachable, but this doesn't look like a Parking System server.",
changeServer: "Change server",
changeServerConfirm: "This signs you out and asks for a new server address on next launch. Continue?",
},
update: {
available: "Update available",
prompt: "Version {{version}} is available. Install now and restart?",
+14
View File
@@ -45,6 +45,20 @@ export const sq = {
signIn: "Hyr",
signingIn: "Duke hyrë…",
},
connect: {
title: "Lidhu me serverin",
hint: "Vendos adresën e serverit të sistemit të parkimit për këtë kabinë.",
serverAddress: "Adresa e serverit",
test: "Testo",
testing: "Duke testuar…",
save: "Ruaj & vazhdo",
saving: "Duke ruajtur…",
testOk: "I arritshëm — duket si server i Sistemit të Parkimit.",
testUnreachable: "Nuk u arrit kjo adresë.",
testBadResponse: "I arritshëm, por nuk duket si server i Sistemit të Parkimit.",
changeServer: "Ndrysho serverin",
changeServerConfirm: "Kjo do t'ju dalë nga sesioni dhe do kërkojë adresë të re serveri në hapjen tjetër. Vazhdo?",
},
update: {
available: "Përditësim i disponueshëm",
prompt: "Versioni {{version}} është i disponueshëm. Ta instaloj tani dhe ta rinis?",
+32 -11
View File
@@ -3,16 +3,19 @@
// In a browser (dev via the Vite proxy, or prod where Fastify serves the built
// SPA) this is EMPTY — requests stay relative (`/api/...`) and same-origin, so
// nothing changes. The Tauri desktop shell (apps/desktop) serves the bundled
// SPA from `tauri://localhost`, which has no backend and no proxy; there we set
// VITE_API_BASE to the appliance's Fastify origin (e.g. http://127.0.0.1:3000)
// at build time so /api and the live WS feed resolve to the real server.
// SPA from `tauri://localhost`, which has no backend and no proxy; there the
// operator enters the appliance's Fastify origin (e.g. http://192.168.1.50:3000)
// once in the ConnectScreen and it's persisted via tauri-plugin-store (see
// backend-config.ts) — a RUNTIME value, not a build-time one, since the same
// installer is used across every booth and the backend can move (new box, new
// IP) without a rebuild. main.tsx calls initApiBase() before the app mounts.
//
// Keep this the SINGLE source for the backend origin — api.ts and the live-feed
// WebSocket both read it, so the web app and the desktop shell stay identical
// except for this one build-time value.
// except for this one runtime value.
//
// platformFetch(): WebKitGTK treats tauri://localhost as a SECURE origin, so a
// plain http://127.0.0.1:3000 fetch() from inside it is blocked as mixed
// plain http://192.168.1.50:3000 fetch() from inside it is blocked as mixed
// content (a WebKit limitation — CSP's connect-src does NOT override this;
// found 2026-09-03 as "Load failed" on every desktop request). Inside Tauri we
// dynamically import @tauri-apps/plugin-http's fetch, which routes the request
@@ -20,8 +23,29 @@
// the check entirely. Browser build never imports the plugin (dynamic import,
// same pattern as desktop-updater.ts).
/** Backend HTTP origin, no trailing slash. Empty string = same-origin/relative. */
export const API_BASE: string = (import.meta.env.VITE_API_BASE ?? "").replace(/\/$/, "");
import { inTauri } from "./tauri-env.js";
/** Backend HTTP origin, no trailing slash. Empty string = same-origin/relative
* (browser) or not-yet-configured (desktop, before the ConnectScreen runs). */
export let API_BASE: string = "";
/** Desktop only: load the persisted backend URL (if any) before the app
* mounts, so the very first fetchMe() call already has the right origin.
* No-op in the browser. Returns the loaded value (null = not configured yet,
* meaning main.tsx should show the ConnectScreen instead of the normal app). */
export async function initApiBase(): Promise<string | null> {
if (!inTauri()) return null;
const { loadBackendUrl } = await import("./backend-config.js");
const saved = await loadBackendUrl();
if (saved) API_BASE = saved;
return saved;
}
/** Desktop only: change the backend origin at runtime (after the operator
* saves a new one in Settings) without requiring a full app restart. */
export function setApiBase(url: string): void {
API_BASE = url.replace(/\/$/, "");
}
/** Resolve an API path to a full URL (or a relative path when API_BASE is empty). */
export function apiUrl(path: string): string {
@@ -38,10 +62,7 @@ export function wsUrl(path: string): string {
return `${proto}//${window.location.host}${path}`;
}
/** True when running inside the Tauri webview (not a normal browser). */
export function inTauri(): boolean {
return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window;
}
export { inTauri };
/**
* fetch(), but routed through @tauri-apps/plugin-http inside the desktop
+2 -5
View File
@@ -17,6 +17,8 @@
// Browser build: plain pass-through to the real WebSocket (this file's
// createPlatformSocket is only called from inside inTauri() callers).
import { inTauri } from "./tauri-env.js";
export interface PlatformSocket {
onopen: (() => void) | null;
onmessage: ((ev: { data: string }) => void) | null;
@@ -97,11 +99,6 @@ class TauriSocketAdapter implements PlatformSocket {
}
}
/** True when running inside the Tauri webview (not a normal browser). */
function inTauri(): boolean {
return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window;
}
/** Open a live-feed socket, routed through the Tauri WebSocket plugin inside the
* desktop shell (mixed-content workaround), or the native WebSocket in a browser. */
export function createPlatformSocket(url: string): PlatformSocket {
+6
View File
@@ -0,0 +1,6 @@
/** True when running inside the Tauri webview (not a normal browser). Single
* source for this check — origin.ts, platform-ws.ts, desktop-updater.ts, and
* backend-config.ts all gate their Tauri-only code paths on it. */
export function inTauri(): boolean {
return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window;
}
+50
View File
@@ -130,6 +130,55 @@ function DesktopVersionBadge() {
return <span className="ml-auto shrink-0 pl-3 text-[0.7rem] text-term-muted">app v{version}</span>;
}
/** Desktop-only "change which server this install talks to" control. No-op /
* renders nothing in a browser (the concept doesn't apply — same-origin).
* Simplest correct action: clear the saved backend URL and reload, which
* drops the app back to ConnectScreen (see App.tsx) to re-enter it — this
* mirrors clearing the session (logout → back to Login), not an inline
* editor, since repointing the app is a rare, deliberate admin action. */
function DesktopServerButton() {
const { t } = useTranslation();
const [confirming, setConfirming] = useState(false);
const [busy, setBusy] = useState(false);
if (!inTauri()) return null;
return (
<>
<button
type="button"
className="btn btn-ghost btn-sm ml-2"
onClick={() => setConfirming(true)}
>
{t("connect.changeServer")}
</button>
{confirming && (
<Modal open onClose={() => setConfirming(false)} title={t("connect.changeServer")} width="max-w-sm">
<div className="text-[0.8125rem]">
<p className="text-term-muted">{t("connect.changeServerConfirm")}</p>
<div className="mt-3 flex justify-end gap-2">
<button type="button" className="btn btn-sm" onClick={() => setConfirming(false)} disabled={busy}>
{t("subs.cancel")}
</button>
<button
type="button"
className="btn btn-sm btn-danger"
disabled={busy}
onClick={async () => {
setBusy(true);
const { clearBackendUrl } = await import("./lib/backend-config.js");
await clearBackendUrl();
window.location.reload();
}}
>
{busy ? <Spinner /> : t("connect.changeServer")}
</button>
</div>
</div>
</Modal>
)}
</>
);
}
/** Setup layout — the config hub. Renders a permission-gated tab bar and the active
* tab's screen via <Outlet>. Each tab is a child route (its own URL + guard), so
* deep links and the back button work and a denied tab redirects to the booth. */
@@ -150,6 +199,7 @@ function SetupLayout() {
{show("backup:read") && <SetupTab to="/setup/backup" label={t("nav.backup")} />}
{show("site:read") && <VersionBadge />}
<DesktopVersionBadge />
<DesktopServerButton />
</nav>
<Outlet />
</div>