mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-19 02:28:59 +00:00
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:
parent
35eedcb5ac
commit
1be9e6a024
3 changed files with 129 additions and 66 deletions
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue