diff --git a/apps/calendar/apps/web/src/routes/(app)/+layout.svelte b/apps/calendar/apps/web/src/routes/(app)/+layout.svelte index d2d0ccbfe..64c4c99f0 100644 --- a/apps/calendar/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/calendar/apps/web/src/routes/(app)/+layout.svelte @@ -41,6 +41,12 @@ import { searchStore } from '$lib/stores/search.svelte'; import { format } from 'date-fns'; import { de } from 'date-fns/locale'; + import type { CreatePreview } from '@manacore/shared-ui'; + import { + parseEventInput, + resolveEventIds, + formatParsedEventPreview, + } from '$lib/utils/event-parser'; import UnifiedBar from '$lib/components/calendar/UnifiedBar.svelte'; import SettingsModal from '$lib/components/settings/SettingsModal.svelte'; import VoiceRecordButton from '$lib/components/voice/VoiceRecordButton.svelte'; @@ -48,6 +54,7 @@ import { voiceRecordingStore } from '$lib/stores/voice-recording.svelte'; import { calendarOnboarding } from '$lib/stores/app-onboarding.svelte'; import { MiniOnboardingModal } from '@manacore/shared-app-onboarding'; + import { SessionExpiredBanner } from '@manacore/shared-auth-ui'; // App switcher items const appItems = getPillAppItems('calendar'); @@ -93,6 +100,58 @@ } } + // Quick-Create: parse input for preview + function handleParseCreate(query: string): CreatePreview | null { + if (!query.trim()) return null; + + const parsed = parseEventInput(query); + if (!parsed.title && !parsed.startDate) return null; + + const preview = formatParsedEventPreview(parsed); + return { + title: `"${parsed.title || query.trim()}" erstellen`, + subtitle: preview || 'Neuer Termin', + }; + } + + // Quick-Create: create event from parsed input + async function handleCreate(query: string): Promise { + if (!query.trim()) return; + + const parsed = parseEventInput(query); + if (!parsed.title) return; + + const defaultCalendarId = + calendarsStore.calendars.find((c) => c.isDefault)?.id || calendarsStore.calendars[0]?.id; + + const resolved = resolveEventIds( + parsed, + calendarsStore.calendars.map((c) => ({ id: c.id, name: c.name })), + eventTagsStore.tags.map((t) => ({ id: t.id, name: t.name })), + defaultCalendarId + ); + + if (!resolved.startTime) { + // No date/time parsed - default to now + 1h + const now = new Date(); + now.setMinutes(0, 0, 0); + now.setHours(now.getHours() + 1); + resolved.startTime = now.toISOString(); + const end = new Date(now.getTime() + 60 * 60_000); + resolved.endTime = end.toISOString(); + } + + await eventsStore.createEvent({ + title: resolved.title, + startTime: resolved.startTime, + endTime: resolved.endTime!, + isAllDay: resolved.isAllDay, + calendarId: resolved.calendarId, + location: resolved.location, + tagIds: resolved.tagIds, + }); + } + // Mobile detection for responsive layout let isMobile = $state(false); @@ -467,6 +526,8 @@ {/if} + {/if} diff --git a/packages/shared-auth-ui/src/index.ts b/packages/shared-auth-ui/src/index.ts index efae4c516..cc2636a0c 100644 --- a/packages/shared-auth-ui/src/index.ts +++ b/packages/shared-auth-ui/src/index.ts @@ -8,6 +8,7 @@ export { default as GoogleSignInButton } from './components/GoogleSignInButton.s export { default as AppleSignInButton } from './components/AppleSignInButton.svelte'; export { default as GuestWelcomeModal } from './components/GuestWelcomeModal.svelte'; export { default as AuthGateModal } from './components/AuthGateModal.svelte'; +export { default as SessionExpiredBanner } from './components/SessionExpiredBanner.svelte'; // Utilities export { diff --git a/packages/shared-auth/src/core/tokenManager.ts b/packages/shared-auth/src/core/tokenManager.ts index ce4872183..8eee975e0 100644 --- a/packages/shared-auth/src/core/tokenManager.ts +++ b/packages/shared-auth/src/core/tokenManager.ts @@ -7,6 +7,7 @@ import type { import { TokenState as TokenStateEnum } from '../types'; import { isDeviceConnected, hasStableConnection } from '../adapters/network'; import type { AuthService } from './authService'; +import { emitSessionExpired } from '../events/sessionExpired'; /** * Configuration for the token manager @@ -110,6 +111,7 @@ export function createTokenManager(authService: AuthService, config?: TokenManag try { await authService.clearAuthStorage(); setState(TokenStateEnum.EXPIRED); + emitSessionExpired(); } catch (error) { console.debug('Error in handleRefreshFailure:', error); } diff --git a/packages/shared-auth/src/events/sessionExpired.ts b/packages/shared-auth/src/events/sessionExpired.ts new file mode 100644 index 000000000..4ccdf139d --- /dev/null +++ b/packages/shared-auth/src/events/sessionExpired.ts @@ -0,0 +1,68 @@ +/** + * Session expired event system + * + * Provides a simple pub/sub mechanism for notifying UI layers + * when a user's session has permanently expired (token refresh failed). + * + * This is intentionally kept framework-agnostic so it can be consumed + * by Svelte, React, or plain JS consumers. + */ + +type SessionExpiredListener = () => void; + +const listeners = new Set(); + +let _sessionExpired = false; + +/** + * Subscribe to session expired events. + * Returns an unsubscribe function. + */ +export function onSessionExpired(listener: SessionExpiredListener): () => void { + listeners.add(listener); + + // If session is already expired, notify immediately + if (_sessionExpired) { + try { + listener(); + } catch { + // Ignore listener errors + } + } + + return () => { + listeners.delete(listener); + }; +} + +/** + * Emit a session expired event. + * Called internally by the token manager when refresh fails permanently. + */ +export function emitSessionExpired(): void { + if (_sessionExpired) return; // Only emit once + _sessionExpired = true; + + listeners.forEach((listener) => { + try { + listener(); + } catch { + // Ignore listener errors + } + }); +} + +/** + * Reset the session expired state. + * Should be called when the user logs in again. + */ +export function resetSessionExpired(): void { + _sessionExpired = false; +} + +/** + * Check if the session is currently marked as expired. + */ +export function isSessionExpired(): boolean { + return _sessionExpired; +} diff --git a/packages/shared-auth/src/index.ts b/packages/shared-auth/src/index.ts index 5d0eaa1dd..15db5c939 100644 --- a/packages/shared-auth/src/index.ts +++ b/packages/shared-auth/src/index.ts @@ -74,6 +74,16 @@ export type { FetchInterceptorConfig } from './interceptors/fetchInterceptor'; export { ContactsClient, createContactsClient } from './clients/contactsClient'; export type { ContactsClientConfig, ContactSearchOptions } from './clients/contactsClient'; +// Session expired events +import { resetSessionExpired as _resetSessionExpired } from './events/sessionExpired'; +import { TokenState as _TokenStateEnum } from './types'; +export { + onSessionExpired, + emitSessionExpired, + resetSessionExpired, + isSessionExpired, +} from './events/sessionExpired'; + /** * Initialize auth service with all adapters for web * @@ -112,5 +122,12 @@ export function initializeWebAuth(config: { if (config.backendUrl) urls.push(config.backendUrl); _setupFetchInterceptor(authService, tokenManager, { urls }); + // Reset session expired state when token becomes valid again (e.g., after re-login) + tokenManager.subscribe((state) => { + if (state === _TokenStateEnum.VALID) { + _resetSessionExpired(); + } + }); + return { authService, tokenManager }; }