cookie and pwa stuff

This commit is contained in:
yohlo
2026-02-14 12:59:01 -06:00
parent 236fcda671
commit 6e9e014fcc
11 changed files with 180 additions and 24 deletions

View File

@@ -96,6 +96,11 @@ spec:
configMapKeyRef: configMapKeyRef:
name: flxn-config name: flxn-config
key: vite_spotify_redirect_uri key: vite_spotify_redirect_uri
- name: COOKIE_DOMAIN
valueFrom:
configMapKeyRef:
name: flxn-config
key: cookie_domain
resources: resources:
requests: requests:

View File

@@ -12,3 +12,4 @@ data:
vite_spotify_redirect_uri: "https://dev.flexxon.app/api/spotify/callback" vite_spotify_redirect_uri: "https://dev.flexxon.app/api/spotify/callback"
s3_endpoint: "https://s3.yohler.net" s3_endpoint: "https://s3.yohler.net"
s3_bucket: "flxn-dev" s3_bucket: "flxn-dev"
cookie_domain: "dev.flexxon.app"

View File

@@ -12,3 +12,4 @@ data:
vite_spotify_redirect_uri: "https://flexxon.app/api/spotify/callback" vite_spotify_redirect_uri: "https://flexxon.app/api/spotify/callback"
s3_endpoint: "https://s3.yohler.net" s3_endpoint: "https://s3.yohler.net"
s3_bucket: "flxn-prod" s3_bucket: "flxn-prod"
cookie_domain: "flexxon.app"

View File

@@ -1,17 +1,28 @@
{ {
"name": "FLXN IX",
"short_name": "FLXN", "short_name": "FLXN",
"name": "FLXN",
"description": "Register for FLXN and view FLXN stats",
"icons": [ "icons": [
{ {
"src": "/favicon.png", "src": "/favicon.png",
"type": "image/png",
"sizes": "192x192", "sizes": "192x192",
"type": "image/png" "purpose": "any maskable"
}, },
{ {
"src": "/favicon.png", "src": "/favicon.png",
"type": "image/png",
"sizes": "512x512", "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": []
} }

View File

@@ -12,6 +12,7 @@ import { ensureSuperTokensFrontend } from "@/lib/supertokens/client";
import { AuthContextType } from "@/contexts/auth-context"; import { AuthContextType } from "@/contexts/auth-context";
import Providers from "@/features/core/components/providers"; import Providers from "@/features/core/components/providers";
import { SessionMonitor } from "@/components/session-monitor"; import { SessionMonitor } from "@/components/session-monitor";
import { IOSInstallPrompt } from "@/components/ios-install-prompt";
import { ColorSchemeScript, mantineHtmlProps } from "@mantine/core"; import { ColorSchemeScript, mantineHtmlProps } from "@mantine/core";
import { HeaderConfig } from "@/features/core/types/header-config"; import { HeaderConfig } from "@/features/core/types/header-config";
import { playerQueries } from "@/features/players/queries"; import { playerQueries } from "@/features/players/queries";
@@ -47,6 +48,12 @@ export const Route = createRootRouteWithContext<{
{ property: 'og:type', content: 'website' }, { property: 'og:type', content: 'website' },
{ property: 'og:site_name', content: 'FLXN IX' }, { property: 'og:site_name', content: 'FLXN IX' },
{ property: 'og:image', content: 'https://flexxon.app/favicon.png' }, { 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: [ links: [
{ {
@@ -128,6 +135,7 @@ function RootComponent() {
<RootDocument> <RootDocument>
<Providers> <Providers>
<SessionMonitor /> <SessionMonitor />
<IOSInstallPrompt />
<Outlet /> <Outlet />
</Providers> </Providers>
</RootDocument> </RootDocument>

View File

@@ -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 (
<Box style={{ position: 'fixed', bottom: 0, left: 0, right: 0, zIndex: 1000, padding: '8px' }}>
<Paper shadow="lg" p="sm" style={{ background: 'var(--mantine-color-blue-9)', color: 'white' }}>
<Group justify="space-between" wrap="nowrap">
<Group gap="xs" wrap="nowrap" style={{ flex: 1 }}>
<DownloadIcon size={20} style={{ flexShrink: 0 }} />
<Box style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" fw={500} style={{ lineHeight: 1.3 }}>
Please install FLXN This will save me Twilio credits as you won't be signed out!
</Text>
<Text size="xs" opacity={0.9} style={{ lineHeight: 1.2 }}>
{instructions}
</Text>
</Box>
</Group>
<ActionIcon variant="subtle" color="white" onClick={handleDismiss}>
<XIcon size={18} />
</ActionIcon>
</Group>
</Paper>
</Box>
)
}

View File

@@ -1,10 +1,12 @@
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from 'react';
import { useNavigate } from '@tanstack/react-router';
import { doesSessionExist } from 'supertokens-web-js/recipe/session'; import { doesSessionExist } from 'supertokens-web-js/recipe/session';
import { getOrCreateRefreshPromise } from '@/lib/supertokens/client'; import { getOrCreateRefreshPromise } from '@/lib/supertokens/client';
import { attemptRefreshingSession } from 'supertokens-web-js/recipe/session'; import { attemptRefreshingSession } from 'supertokens-web-js/recipe/session';
import { logger } from '@/lib/supertokens'; import { logger } from '@/lib/supertokens';
export function SessionMonitor() { export function SessionMonitor() {
const navigate = useNavigate();
const lastRefreshTimeRef = useRef<number>(0); const lastRefreshTimeRef = useRef<number>(0);
const REFRESH_COOLDOWN = 30 * 1000; const REFRESH_COOLDOWN = 30 * 1000;
@@ -49,12 +51,14 @@ export function SessionMonitor() {
} }
}; };
handleVisibilityChange();
document.addEventListener('visibilitychange', handleVisibilityChange); document.addEventListener('visibilitychange', handleVisibilityChange);
return () => { return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange); document.removeEventListener('visibilitychange', handleVisibilityChange);
}; };
}, []); }, [navigate]);
return null; return null;
} }

15
src/hooks/use-is-pwa.ts Normal file
View File

@@ -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
}

View File

@@ -46,7 +46,7 @@ class Logger {
constructor(context?: string, options: LoggerOptions = {}) { constructor(context?: string, options: LoggerOptions = {}) {
this.context = context; this.context = context;
this.options = { this.options = {
enabled: import.meta.env.NODE_ENV !== "production", enabled: true,
showTimestamp: true, showTimestamp: true,
collapsed: true, collapsed: true,
colors: true, colors: true,
@@ -75,27 +75,44 @@ class Logger {
const groupLabel = `${timestamp}${style.label}${context}${label}`; const groupLabel = `${timestamp}${style.label}${context}${label}`;
const group = this.options.collapsed // In server environment (no window), use simple console.log instead of groups
? console.groupCollapsed const isServer = typeof window === "undefined";
: console.group;
if (this.options.colors && typeof window !== "undefined") { if (isServer) {
group(`%c${groupLabel}`, `color: ${style.color}; font-weight: bold;`); // Server-side: Simple formatted output (no console.group in Node.js)
} else { console.log(groupLabel);
group(groupLabel); if (data !== undefined) {
} console.log(JSON.stringify(data, null, 2));
if (data !== undefined) {
console.log(data);
}
if (rest.length > 0) {
for (const item of rest) {
console.log(item);
} }
} 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 { info(label: string, data?: any, ...rest: any[]): void {

View File

@@ -5,6 +5,40 @@ import SuperTokens from "supertokens-node";
export async function getSessionForStart(request: Request, options?: { sessionRequired?: boolean }) { export async function getSessionForStart(request: Request, options?: { sessionRequired?: boolean }) {
ensureSuperTokensBackend(); 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 { try {
const session = await getSessionForSSR(request); const session = await getSessionForSSR(request);

View File

@@ -25,6 +25,7 @@ export const backendConfig = (): TypeInput => {
cookieSameSite: "lax", cookieSameSite: "lax",
cookieSecure: process.env.NODE_ENV === "production", cookieSecure: process.env.NODE_ENV === "production",
cookieDomain: process.env.COOKIE_DOMAIN || undefined, cookieDomain: process.env.COOKIE_DOMAIN || undefined,
olderCookieDomain: undefined,
antiCsrf: process.env.NODE_ENV === "production" ? "VIA_TOKEN" : "NONE", antiCsrf: process.env.NODE_ENV === "production" ? "VIA_TOKEN" : "NONE",
// Debug only // Debug only