Enhance Kiosk Mode: auto-enable logic and magic link generation

This improves the UX for setting up unattended displays by:1. Automatically enabling visual Kiosk mode when a token has monitoring-only scope (unless explicitly disabled).2. Providing a ready-to-use 'Magic Link' (with ?token=...&kiosk=1) upon token creation.
This commit is contained in:
rcourtman 2026-02-03 10:09:49 +00:00
parent 35eedcb5ac
commit 1be9e6a024
3 changed files with 129 additions and 66 deletions

View file

@ -24,6 +24,7 @@ import { logger } from './utils/logger';
import { POLLING_INTERVALS } from './constants';
import { STORAGE_KEYS } from '@/utils/localStorage';
import { layoutStore } from '@/utils/layout';
import { MONITORING_READ_SCOPE } from '@/constants/apiScopes';
import { UpdatesAPI } from './api/updates';
import type { VersionInfo } from './api/updates';
import { apiFetch } from './utils/apiClient';
@ -55,7 +56,7 @@ import { AIChat } from './components/AI/Chat';
import { aiChatStore } from './stores/aiChat';
import { useResourcesAsLegacy } from './hooks/useResources';
import { updateSystemSettingsFromResponse, markSystemSettingsLoadedWithDefaults } from './stores/systemSettings';
import { initKioskMode, isKioskMode, setKioskMode, subscribeToKioskMode } from './utils/url';
import { initKioskMode, isKioskMode, setKioskMode, subscribeToKioskMode, getKioskModePreference } from './utils/url';
const Dashboard = lazy(() =>
@ -229,6 +230,15 @@ function App() {
// This must happen before any renders so kiosk state is available everywhere
initKioskMode();
// Reactive kiosk state for App-level components (banners, etc.)
const [kioskMode, setKioskModeSignal] = createSignal(isKioskMode());
onMount(() => {
const unsubscribe = subscribeToKioskMode((enabled) => {
setKioskModeSignal(enabled);
});
onCleanup(unsubscribe);
});
const TooltipRoot = createTooltipSystem();
const owner = getOwner();
const acquireWsStore = (): EnhancedStore => {
@ -883,10 +893,12 @@ function App() {
}>
<WebSocketContext.Provider value={enhancedStore()!}>
<DarkModeContext.Provider value={darkMode}>
<SecurityWarning />
<DemoBanner />
<UpdateBanner />
<GlobalUpdateProgressWatcher />
<Show when={!kioskMode()}>
<SecurityWarning />
<DemoBanner />
<UpdateBanner />
<GlobalUpdateProgressWatcher />
</Show>
{/* Main layout container - flexbox to allow AI panel to push content */}
<div class="flex h-screen overflow-hidden">
{/* Main content area - shrinks when AI panel is open, scrolls independently */}
@ -911,30 +923,7 @@ function App() {
<AIChat onClose={() => aiChatStore.close()} />
</div>
<TokenRevealDialog />
{/* Fixed AI Assistant Button - only shows when chat is CLOSED */}
<Show when={aiChatStore.enabled === true && !aiChatStore.isOpenSignal()}>
<button
type="button"
onClick={() => aiChatStore.toggle()}
class="fixed right-0 top-1/2 -translate-y-1/2 z-40 flex items-center gap-1.5 pl-2 pr-1.5 py-3 rounded-l-xl bg-blue-600 text-white shadow-lg hover:bg-blue-700 transition-colors duration-200 group sm:top-1/2 sm:translate-y-[-50%] top-auto bottom-20 translate-y-0"
title={aiChatStore.context.context?.name ? `Pulse Assistant - ${aiChatStore.context.context.name}` : 'Pulse Assistant (⌘K)'}
aria-label="Expand Pulse Assistant"
>
<svg
class="h-5 w-5 flex-shrink-0"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456zM16.894 20.567L16.5 21.75l-.394-1.183a2.25 2.25 0 00-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 001.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 001.423 1.423l1.183.394-1.183.394a2.25 2.25 0 00-1.423 1.423z"
/>
</svg>
</button>
</Show>
{/* AI Assistant Button moved to AppLayout to access kioskMode state */}
<TooltipRoot />
</DarkModeContext.Provider>
</WebSocketContext.Provider>
@ -1070,6 +1059,20 @@ function AppLayout(props: {
onCleanup(unsubscribe);
});
// Auto-enable kiosk mode for monitoring-only tokens (if no user preference is set)
createEffect(() => {
const scopes = props.tokenScopes();
// Only proceed if scopes are loaded and equal exactly ['monitoring:read']
if (scopes && scopes.length === 1 && scopes[0] === MONITORING_READ_SCOPE) {
// Check if user has an explicit preference
const pref = getKioskModePreference();
// If preference is unset (null), default to Kiosk Mode
if (pref === null) {
setKioskMode(true);
}
}
});
const toggleKioskMode = () => {
const newValue = !kioskMode();
setKioskMode(newValue);
@ -1289,7 +1292,7 @@ function AppLayout(props: {
};
return (
<div class={`pulse-shell ${layoutStore.isFullWidth() ? 'pulse-shell--full-width' : ''}`}>
<div class={`pulse-shell ${layoutStore.isFullWidth() || kioskMode() ? 'pulse-shell--full-width' : ''}`}>
{/* Header - simplified in kiosk mode */}
<div class={`header mb-3 flex items-center gap-2 ${kioskMode() ? 'justify-end' : 'justify-between sm:grid sm:grid-cols-[1fr_auto_1fr] sm:items-center sm:gap-0'}`}>
<Show when={!kioskMode()}>
@ -1338,7 +1341,7 @@ function AppLayout(props: {
<button
type="button"
onClick={toggleKioskMode}
class={`group relative flex h-7 items-center justify-center gap-1 rounded-full px-2 text-xs transition-all duration-500 ease-in-out focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500 ${kioskMode()
class={`group relative flex h-7 w-7 items-center justify-center rounded-full text-xs transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500 ${kioskMode()
? 'bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900 dark:text-blue-300 dark:hover:bg-blue-800'
: 'bg-gray-200 text-gray-700 hover:bg-gray-300 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600'
}`}
@ -1349,11 +1352,6 @@ function AppLayout(props: {
<Show when={kioskMode()} fallback={<Maximize2Icon class="h-3 w-3 flex-shrink-0" />}>
<Minimize2Icon class="h-3 w-3 flex-shrink-0" />
</Show>
<span
class="max-w-0 overflow-hidden whitespace-nowrap opacity-0 transition-all duration-500 ease-in-out group-hover:ml-1 group-hover:max-w-[80px] group-hover:opacity-100 group-focus-visible:ml-1 group-focus-visible:max-w-[80px] group-focus-visible:opacity-100"
>
{kioskMode() ? 'Exit' : 'Kiosk'}
</span>
</button>
<Show when={props.proxyAuthInfo()?.username}>
<span class="text-xs px-2 py-1 text-gray-600 dark:text-gray-400">
@ -1363,7 +1361,7 @@ function AppLayout(props: {
<button
type="button"
onClick={props.handleLogout}
class="group relative flex h-7 items-center justify-center gap-1 rounded-full bg-gray-200 px-2 text-xs text-gray-700 transition-all duration-500 ease-in-out hover:bg-gray-300 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
class="group relative flex h-7 w-7 items-center justify-center rounded-full bg-gray-200 text-xs text-gray-700 transition hover:bg-gray-300 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
title="Logout"
aria-label="Logout"
>
@ -1381,11 +1379,6 @@ function AppLayout(props: {
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
/>
</svg>
<span
class="max-w-0 overflow-hidden whitespace-nowrap opacity-0 transition-all duration-500 ease-in-out group-hover:ml-1 group-hover:max-w-[80px] group-hover:opacity-100 group-focus-visible:ml-1 group-focus-visible:max-w-[80px] group-focus-visible:opacity-100"
>
Logout
</span>
</button>
</div>
</Show>
@ -1543,6 +1536,31 @@ function AppLayout(props: {
</Show>
</footer>
</Show>
{/* Fixed AI Assistant Button - only shows when chat is CLOSED and NOT in kiosk mode */}
<Show when={aiChatStore.enabled === true && !aiChatStore.isOpenSignal() && !kioskMode()}>
<button
type="button"
onClick={() => aiChatStore.toggle()}
class="fixed right-0 top-1/2 -translate-y-1/2 z-40 flex items-center gap-1.5 pl-2 pr-1.5 py-3 rounded-l-xl bg-blue-600 text-white shadow-lg hover:bg-blue-700 transition-colors duration-200 group sm:top-1/2 sm:translate-y-[-50%] top-auto bottom-20 translate-y-0"
title={aiChatStore.context.context?.name ? `Pulse Assistant - ${aiChatStore.context.context.name}` : 'Pulse Assistant (⌘K)'}
aria-label="Expand Pulse Assistant"
>
<svg
class="h-5 w-5 flex-shrink-0"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456zM16.894 20.567L16.5 21.75l-.394-1.183a2.25 2.25 0 00-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 001.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 001.423 1.423l1.183.394-1.183.394a2.25 2.25 0 00-1.423 1.423z"
/>
</svg>
</button>
</Show>
</div>
);
}

View file

@ -4,6 +4,7 @@ import { notificationStore } from '@/stores/notifications';
import { formatRelativeTime } from '@/utils/format';
import { useWebSocket } from '@/App';
import type { DockerHost, Host } from '@/types/api';
import { getPulseBaseUrl } from '@/utils/url';
import { showTokenReveal, useTokenRevealState } from '@/stores/tokenReveal';
import { logger } from '@/utils/logger';
import { Card } from '@/components/shared/Card';
@ -478,29 +479,56 @@ export const APITokenManager: Component<APITokenManagerProps> = (props) => {
</Show>
<Show when={newTokenValue() && !isRevealActiveForCurrentToken()}>
<Card
tone="success"
padding="sm"
class="flex flex-wrap items-center justify-between gap-3 border border-green-300/70 text-sm text-green-800 dark:border-green-700/70 dark:text-green-200"
>
<span>
✓ Token generated: <strong>{newTokenRecord()?.name || 'Untitled'}</strong> ({tokenHint(newTokenRecord())})
</span>
<div class="flex items-center gap-3 text-xs">
<button onClick={reopenTokenDialog} class="font-medium underline decoration-green-500/50 underline-offset-2 hover:text-green-900 dark:hover:text-green-100">
Show
</button>
<button
onClick={() => {
setNewTokenValue(null);
setNewTokenRecord(null);
}}
class="font-medium underline decoration-green-500/50 underline-offset-2 hover:text-green-900 dark:hover:text-green-100"
>
Dismiss
</button>
</div>
</Card>
<div class="space-y-3">
<Card
tone="success"
padding="sm"
class="flex flex-wrap items-center justify-between gap-3 border border-green-300/70 text-sm text-green-800 dark:border-green-700/70 dark:text-green-200"
>
<span>
✓ Token generated: <strong>{newTokenRecord()?.name || 'Untitled'}</strong> ({tokenHint(newTokenRecord())})
</span>
<div class="flex items-center gap-3 text-xs">
<button onClick={reopenTokenDialog} class="font-medium underline decoration-green-500/50 underline-offset-2 hover:text-green-900 dark:hover:text-green-100">
Show
</button>
<button
onClick={() => {
setNewTokenValue(null);
setNewTokenRecord(null);
}}
class="font-medium underline decoration-green-500/50 underline-offset-2 hover:text-green-900 dark:hover:text-green-100"
>
Dismiss
</button>
</div>
</Card>
<Show when={newTokenRecord()?.scopes?.length === 1 && newTokenRecord()?.scopes?.[0] === MONITORING_READ_SCOPE}>
<div class="rounded-lg border border-blue-200 bg-blue-50/50 p-4 text-sm text-blue-900 shadow-sm dark:border-blue-800/30 dark:bg-blue-900/10 dark:text-blue-100">
<div class="mb-2 font-semibold">Magic Kiosk Link</div>
<p class="mb-3 text-xs text-blue-700 dark:text-blue-300">
Use this link to open Pulse directly in Kiosk mode without logging in. Perfect for wall displays and digital signage.
</p>
<div class="flex items-center gap-2">
<code class="flex-1 rounded border border-blue-200 bg-white px-3 py-2 font-mono text-xs text-blue-800 dark:border-blue-800 dark:bg-black/20 dark:text-blue-200 break-all">
{getPulseBaseUrl()}/?token={newTokenValue()}&kiosk=1
</code>
<button
type="button"
onClick={() => {
const url = `${getPulseBaseUrl()}/?token=${newTokenValue()}&kiosk=1`;
navigator.clipboard.writeText(url);
notificationStore.success('Link copied to clipboard');
}}
class="flex-shrink-0 rounded-md bg-blue-600 px-3 py-2 text-xs font-semibold text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1 dark:bg-blue-600 dark:hover:bg-blue-500"
>
Copy Link
</button>
</div>
</div>
</Show>
</div>
</Show>
<Show

View file

@ -56,6 +56,7 @@ export function isKioskMode(): boolean {
// Check sessionStorage first (persists across navigation)
const stored = window.sessionStorage.getItem(KIOSK_MODE_KEY);
if (stored === 'true') return true;
if (stored === 'false') return false; // Explicitly disabled
// Fall back to URL check (for initial page load)
const params = new URLSearchParams(window.location.search);
@ -66,6 +67,22 @@ export function isKioskMode(): boolean {
}
}
/**
* Get the explicit kiosk mode preference from storage.
* Returns true/false if set, or null if unset.
*/
export function getKioskModePreference(): boolean | null {
if (typeof window === 'undefined') return null;
try {
const stored = window.sessionStorage.getItem(KIOSK_MODE_KEY);
if (stored === 'true') return true;
if (stored === 'false') return false;
return null;
} catch {
return null;
}
}
/**
* Set kiosk mode on or off.
* Updates sessionStorage and notifies reactive listeners.
@ -77,7 +94,7 @@ export function setKioskMode(enabled: boolean): void {
if (enabled) {
window.sessionStorage.setItem(KIOSK_MODE_KEY, 'true');
} else {
window.sessionStorage.removeItem(KIOSK_MODE_KEY);
window.sessionStorage.setItem(KIOSK_MODE_KEY, 'false');
}
notifyKioskListeners(enabled);
} catch {