cookie and pwa stuff
This commit is contained in:
@@ -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:
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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": []
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
59
src/components/ios-install-prompt.tsx
Normal file
59
src/components/ios-install-prompt.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
15
src/hooks/use-is-pwa.ts
Normal 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
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user