From 6e9e014fcc2167c70476c204893abbb7534d411d Mon Sep 17 00:00:00 2001 From: yohlo Date: Sat, 14 Feb 2026 12:59:01 -0600 Subject: [PATCH] cookie and pwa stuff --- k8s/base/app-deployment.yaml | 5 ++ k8s/overlays/dev/configmap.yaml | 1 + k8s/overlays/prod/configmap.yaml | 1 + public/site.webmanifest | 19 +++++-- src/app/routes/__root.tsx | 8 +++ src/components/ios-install-prompt.tsx | 59 ++++++++++++++++++++ src/components/session-monitor.tsx | 6 +- src/hooks/use-is-pwa.ts | 15 +++++ src/lib/logger/index.ts | 55 +++++++++++------- src/lib/supertokens/recipes/start-session.ts | 34 +++++++++++ src/lib/supertokens/server.ts | 1 + 11 files changed, 180 insertions(+), 24 deletions(-) create mode 100644 src/components/ios-install-prompt.tsx create mode 100644 src/hooks/use-is-pwa.ts diff --git a/k8s/base/app-deployment.yaml b/k8s/base/app-deployment.yaml index 455e093..f3fec21 100644 --- a/k8s/base/app-deployment.yaml +++ b/k8s/base/app-deployment.yaml @@ -96,6 +96,11 @@ spec: configMapKeyRef: name: flxn-config key: vite_spotify_redirect_uri + - name: COOKIE_DOMAIN + valueFrom: + configMapKeyRef: + name: flxn-config + key: cookie_domain resources: requests: diff --git a/k8s/overlays/dev/configmap.yaml b/k8s/overlays/dev/configmap.yaml index 4a45b99..04db0ba 100644 --- a/k8s/overlays/dev/configmap.yaml +++ b/k8s/overlays/dev/configmap.yaml @@ -12,3 +12,4 @@ data: vite_spotify_redirect_uri: "https://dev.flexxon.app/api/spotify/callback" s3_endpoint: "https://s3.yohler.net" s3_bucket: "flxn-dev" + cookie_domain: "dev.flexxon.app" diff --git a/k8s/overlays/prod/configmap.yaml b/k8s/overlays/prod/configmap.yaml index faf5cb5..1aa7fc3 100644 --- a/k8s/overlays/prod/configmap.yaml +++ b/k8s/overlays/prod/configmap.yaml @@ -12,3 +12,4 @@ data: vite_spotify_redirect_uri: "https://flexxon.app/api/spotify/callback" s3_endpoint: "https://s3.yohler.net" s3_bucket: "flxn-prod" + cookie_domain: "flexxon.app" diff --git a/public/site.webmanifest b/public/site.webmanifest index 5b6c80f..642a4c5 100644 --- a/public/site.webmanifest +++ b/public/site.webmanifest @@ -1,17 +1,28 @@ { - "name": "FLXN IX", "short_name": "FLXN", + "name": "FLXN", + "description": "Register for FLXN and view FLXN stats", "icons": [ { "src": "/favicon.png", + "type": "image/png", "sizes": "192x192", - "type": "image/png" + "purpose": "any maskable" }, { "src": "/favicon.png", + "type": "image/png", "sizes": "512x512", - "type": "image/png" + "purpose": "any maskable" } ], - "display": "standalone" + "start_url": "/", + "display": "standalone", + "theme_color": "#1e293b", + "background_color": "#0f172a", + "orientation": "portrait-primary", + "scope": "/", + "categories": ["games", "social", "beer pong"], + "prefer_related_applications": false, + "shortcuts": [] } diff --git a/src/app/routes/__root.tsx b/src/app/routes/__root.tsx index 03b49f4..64f63ff 100644 --- a/src/app/routes/__root.tsx +++ b/src/app/routes/__root.tsx @@ -12,6 +12,7 @@ import { ensureSuperTokensFrontend } from "@/lib/supertokens/client"; import { AuthContextType } from "@/contexts/auth-context"; import Providers from "@/features/core/components/providers"; import { SessionMonitor } from "@/components/session-monitor"; +import { IOSInstallPrompt } from "@/components/ios-install-prompt"; import { ColorSchemeScript, mantineHtmlProps } from "@mantine/core"; import { HeaderConfig } from "@/features/core/types/header-config"; import { playerQueries } from "@/features/players/queries"; @@ -47,6 +48,12 @@ export const Route = createRootRouteWithContext<{ { property: 'og:type', content: 'website' }, { property: 'og:site_name', content: 'FLXN IX' }, { property: 'og:image', content: 'https://flexxon.app/favicon.png' }, + { property: 'og:image:width', content: '512' }, + { property: 'og:image:height', content: '512' }, + { name: 'mobile-web-app-capable', content: 'yes' }, + { name: 'apple-mobile-web-app-capable', content: 'yes' }, + { name: 'apple-mobile-web-app-status-bar-style', content: 'black-translucent' }, + { name: 'apple-mobile-web-app-title', content: 'FLXN' }, ], links: [ { @@ -128,6 +135,7 @@ function RootComponent() { + diff --git a/src/components/ios-install-prompt.tsx b/src/components/ios-install-prompt.tsx new file mode 100644 index 0000000..7d4a140 --- /dev/null +++ b/src/components/ios-install-prompt.tsx @@ -0,0 +1,59 @@ +import { useEffect, useState } from 'react' +import { Box, Paper, Group, Text, ActionIcon } from '@mantine/core' +import { DownloadIcon, XIcon } from '@phosphor-icons/react' + +export function IOSInstallPrompt() { + const [show, setShow] = useState(false) + const [platform, setPlatform] = useState<'ios' | 'android' | null>(null) + + useEffect(() => { + const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) + const isAndroid = /Android/.test(navigator.userAgent) + + const isInStandaloneMode = + window.matchMedia('(display-mode: standalone)').matches || + ('standalone' in window.navigator && (window.navigator as any).standalone) + + const hasBeenDismissed = localStorage.getItem('pwa-install-prompt-dismissed') === 'true' + + if ((isIOS || isAndroid) && !isInStandaloneMode && !hasBeenDismissed) { + setPlatform(isIOS ? 'ios' : 'android') + const timer = setTimeout(() => setShow(true), 3000) + return () => clearTimeout(timer) + } + }, []) + + const handleDismiss = () => { + localStorage.setItem('pwa-install-prompt-dismissed', 'true') + setShow(false) + } + + if (!show || !platform) return null + + const instructions = platform === 'ios' + ? 'Tap Share → Add to Home Screen' + : 'Tap Menu (⋮) → Add to Home screen' + + return ( + + + + + + + + Please install FLXN • This will save me Twilio credits as you won't be signed out! + + + {instructions} + + + + + + + + + + ) +} diff --git a/src/components/session-monitor.tsx b/src/components/session-monitor.tsx index ee4e3ec..9fc8963 100644 --- a/src/components/session-monitor.tsx +++ b/src/components/session-monitor.tsx @@ -1,10 +1,12 @@ import { useEffect, useRef } from 'react'; +import { useNavigate } from '@tanstack/react-router'; import { doesSessionExist } from 'supertokens-web-js/recipe/session'; import { getOrCreateRefreshPromise } from '@/lib/supertokens/client'; import { attemptRefreshingSession } from 'supertokens-web-js/recipe/session'; import { logger } from '@/lib/supertokens'; export function SessionMonitor() { + const navigate = useNavigate(); const lastRefreshTimeRef = useRef(0); const REFRESH_COOLDOWN = 30 * 1000; @@ -49,12 +51,14 @@ export function SessionMonitor() { } }; + handleVisibilityChange(); + document.addEventListener('visibilitychange', handleVisibilityChange); return () => { document.removeEventListener('visibilitychange', handleVisibilityChange); }; - }, []); + }, [navigate]); return null; } diff --git a/src/hooks/use-is-pwa.ts b/src/hooks/use-is-pwa.ts new file mode 100644 index 0000000..a46d545 --- /dev/null +++ b/src/hooks/use-is-pwa.ts @@ -0,0 +1,15 @@ +import { useEffect, useState } from 'react' + +export function useIsPWA(): boolean { + const [isPWA, setIsPWA] = useState(false) + + useEffect(() => { + const isStandalone = window.matchMedia('(display-mode: standalone)').matches + + const isIOSStandalone = 'standalone' in window.navigator && (window.navigator as any).standalone + + setIsPWA(isStandalone || isIOSStandalone) + }, []) + + return isPWA +} diff --git a/src/lib/logger/index.ts b/src/lib/logger/index.ts index f9067fe..d9453f2 100644 --- a/src/lib/logger/index.ts +++ b/src/lib/logger/index.ts @@ -46,7 +46,7 @@ class Logger { constructor(context?: string, options: LoggerOptions = {}) { this.context = context; this.options = { - enabled: import.meta.env.NODE_ENV !== "production", + enabled: true, showTimestamp: true, collapsed: true, colors: true, @@ -75,27 +75,44 @@ class Logger { const groupLabel = `${timestamp}${style.label}${context} │ ${label}`; - const group = this.options.collapsed - ? console.groupCollapsed - : console.group; + // In server environment (no window), use simple console.log instead of groups + const isServer = typeof window === "undefined"; - if (this.options.colors && typeof window !== "undefined") { - group(`%c${groupLabel}`, `color: ${style.color}; font-weight: bold;`); - } else { - group(groupLabel); - } - - if (data !== undefined) { - console.log(data); - } - - if (rest.length > 0) { - for (const item of rest) { - console.log(item); + if (isServer) { + // Server-side: Simple formatted output (no console.group in Node.js) + console.log(groupLabel); + if (data !== undefined) { + console.log(JSON.stringify(data, null, 2)); } - } + if (rest.length > 0) { + for (const item of rest) { + console.log(JSON.stringify(item, null, 2)); + } + } + } else { + // Browser: Use console.group with colors + const group = this.options.collapsed + ? console.groupCollapsed + : console.group; - console.groupEnd(); + if (this.options.colors) { + group(`%c${groupLabel}`, `color: ${style.color}; font-weight: bold;`); + } else { + group(groupLabel); + } + + if (data !== undefined) { + console.log(data); + } + + if (rest.length > 0) { + for (const item of rest) { + console.log(item); + } + } + + console.groupEnd(); + } } info(label: string, data?: any, ...rest: any[]): void { diff --git a/src/lib/supertokens/recipes/start-session.ts b/src/lib/supertokens/recipes/start-session.ts index f2c6bf1..66d7b5b 100644 --- a/src/lib/supertokens/recipes/start-session.ts +++ b/src/lib/supertokens/recipes/start-session.ts @@ -5,6 +5,40 @@ import SuperTokens from "supertokens-node"; export async function getSessionForStart(request: Request, options?: { sessionRequired?: boolean }) { ensureSuperTokensBackend(); + + const cookieHeader = request.headers.get('cookie'); + if (cookieHeader) { + const tokens = cookieHeader.match(/sAccessToken=([^;]+)/g); + if (tokens && tokens.length > 1) { + logger.warn(`Detected ${tokens.length} duplicate sAccessToken cookies, cleaning up`); + + const parsedTokens = tokens.map(tokenStr => { + const token = tokenStr.replace('sAccessToken=', ''); + try { + const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString()); + return { token, exp: payload.exp, iat: payload.iat }; + } catch (e) { + logger.error('Failed to parse token', e); + return { token, exp: 0, iat: 0 }; + } + }); + + parsedTokens.sort((a, b) => b.exp - a.exp); + const freshestToken = parsedTokens[0]; + + logger.info(`Using freshest token: exp=${freshestToken.exp}, iat=${freshestToken.iat}`); + + const cleanedCookie = cookieHeader + .split(';') + .filter(c => !c.trim().startsWith('sAccessToken=')) + .join(';') + `; sAccessToken=${freshestToken.token}`; + + const cleanedHeaders = new Headers(request.headers); + cleanedHeaders.set('cookie', cleanedCookie); + request = new Request(request, { headers: cleanedHeaders }); + } + } + try { const session = await getSessionForSSR(request); diff --git a/src/lib/supertokens/server.ts b/src/lib/supertokens/server.ts index be2d736..f5119ab 100644 --- a/src/lib/supertokens/server.ts +++ b/src/lib/supertokens/server.ts @@ -25,6 +25,7 @@ export const backendConfig = (): TypeInput => { cookieSameSite: "lax", cookieSecure: process.env.NODE_ENV === "production", cookieDomain: process.env.COOKIE_DOMAIN || undefined, + olderCookieDomain: undefined, antiCsrf: process.env.NODE_ENV === "production" ? "VIA_TOKEN" : "NONE", // Debug only