feat(auth,a11y): Telegram CloudStorage token recovery + blocking-screen a11y

Telegram CloudStorage token recovery (resilience against WebView localStorage wipes):
- mirror the refresh token to per-user CloudStorage on login, remove it on logout
- restoreRefreshTokenFromCloud() recovers the refresh token in initialize() when
  localStorage is empty, then the normal refresh flow re-establishes the session
- move the initialize() bootstrap call from auth.ts module-load into main.tsx after
  the Telegram SDK init(), since launch params + CloudStorage are unavailable before init
- best-effort: no-ops outside Telegram and on any error -> falls back to prior behavior

Accessibility:
- role=alertdialog + aria-modal + aria-labelledby + focus management (useFocusTrap)
  on Maintenance, Blacklisted, ChannelSubscription and AccountDeleted blocking screens
- aria-label on ColorPicker Hue/Saturation/Lightness range inputs
This commit is contained in:
c0mrade
2026-05-25 23:43:17 +03:00
parent 0caba3dffb
commit f31160208a
8 changed files with 122 additions and 13 deletions

View File

@@ -281,6 +281,7 @@ export function ColorPicker({ value, onChange, label, description, disabled }: C
max="360" max="360"
value={hsl.h} value={hsl.h}
onChange={handleHueChange} onChange={handleHueChange}
aria-label="Hue"
className="h-3 w-full cursor-pointer appearance-none rounded-full" className="h-3 w-full cursor-pointer appearance-none rounded-full"
style={{ style={{
background: background:
@@ -301,6 +302,7 @@ export function ColorPicker({ value, onChange, label, description, disabled }: C
max="100" max="100"
value={hsl.s} value={hsl.s}
onChange={handleSaturationChange} onChange={handleSaturationChange}
aria-label="Saturation"
className="h-3 w-full cursor-pointer appearance-none rounded-full" className="h-3 w-full cursor-pointer appearance-none rounded-full"
style={{ style={{
background: `linear-gradient(to right, hsl(${hsl.h}, 0%, ${hsl.l}%), hsl(${hsl.h}, 100%, ${hsl.l}%))`, background: `linear-gradient(to right, hsl(${hsl.h}, 0%, ${hsl.l}%), hsl(${hsl.h}, 100%, ${hsl.l}%))`,
@@ -320,6 +322,7 @@ export function ColorPicker({ value, onChange, label, description, disabled }: C
max="100" max="100"
value={hsl.l} value={hsl.l}
onChange={handleLightnessChange} onChange={handleLightnessChange}
aria-label="Lightness"
className="h-3 w-full cursor-pointer appearance-none rounded-full" className="h-3 w-full cursor-pointer appearance-none rounded-full"
style={{ style={{
background: `linear-gradient(to right, #000000, hsl(${hsl.h}, ${hsl.s}%, 50%), #ffffff)`, background: `linear-gradient(to right, #000000, hsl(${hsl.h}, ${hsl.s}%, 50%), #ffffff)`,

View File

@@ -1,6 +1,7 @@
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { usePlatform } from '@/platform'; import { usePlatform } from '@/platform';
import { useBlockingStore } from '../../store/blocking'; import { useBlockingStore } from '../../store/blocking';
import { useFocusTrap } from '../../hooks/useFocusTrap';
/** /**
* Full-screen block shown when the backend returns * Full-screen block shown when the backend returns
@@ -21,6 +22,7 @@ export default function AccountDeletedScreen() {
const { t } = useTranslation(); const { t } = useTranslation();
const { openTelegramLink } = usePlatform(); const { openTelegramLink } = usePlatform();
const info = useBlockingStore((state) => state.accountDeletedInfo); const info = useBlockingStore((state) => state.accountDeletedInfo);
const screenRef = useFocusTrap<HTMLDivElement>(true, { lockScroll: false });
const deepLink = info?.telegram_deep_link?.trim() || null; const deepLink = info?.telegram_deep_link?.trim() || null;
// Route through the platform adapter, not raw window.open. Inside the // Route through the platform adapter, not raw window.open. Inside the
@@ -42,7 +44,14 @@ export default function AccountDeletedScreen() {
}; };
return ( return (
<div className="fixed inset-0 z-[100] flex flex-col items-center justify-center bg-dark-950 p-6"> <div
ref={screenRef}
role="alertdialog"
aria-modal="true"
aria-labelledby="account-deleted-title"
tabIndex={-1}
className="fixed inset-0 z-[100] flex flex-col items-center justify-center bg-dark-950 p-6"
>
<div className="w-full max-w-md text-center"> <div className="w-full max-w-md text-center">
<div className="mb-8"> <div className="mb-8">
<div className="mx-auto flex h-24 w-24 items-center justify-center rounded-full bg-dark-800"> <div className="mx-auto flex h-24 w-24 items-center justify-center rounded-full bg-dark-800">
@@ -63,7 +72,9 @@ export default function AccountDeletedScreen() {
</div> </div>
</div> </div>
<h1 className="mb-4 text-2xl font-bold text-white">{t('blocking.accountDeleted.title')}</h1> <h1 id="account-deleted-title" className="mb-4 text-2xl font-bold text-white">
{t('blocking.accountDeleted.title')}
</h1>
<p className="mb-6 text-lg text-gray-400">{t('blocking.accountDeleted.description')}</p> <p className="mb-6 text-lg text-gray-400">{t('blocking.accountDeleted.description')}</p>

View File

@@ -1,12 +1,21 @@
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useBlockingStore } from '../../store/blocking'; import { useBlockingStore } from '../../store/blocking';
import { useFocusTrap } from '../../hooks/useFocusTrap';
export default function BlacklistedScreen() { export default function BlacklistedScreen() {
const { t } = useTranslation(); const { t } = useTranslation();
const blacklistedInfo = useBlockingStore((state) => state.blacklistedInfo); const blacklistedInfo = useBlockingStore((state) => state.blacklistedInfo);
const screenRef = useFocusTrap<HTMLDivElement>(true, { lockScroll: false });
return ( return (
<div className="fixed inset-0 z-[100] flex flex-col items-center justify-center bg-dark-950 p-6"> <div
ref={screenRef}
role="alertdialog"
aria-modal="true"
aria-labelledby="blacklisted-title"
tabIndex={-1}
className="fixed inset-0 z-[100] flex flex-col items-center justify-center bg-dark-950 p-6"
>
<div className="w-full max-w-md text-center"> <div className="w-full max-w-md text-center">
{/* Icon */} {/* Icon */}
<div className="mb-8"> <div className="mb-8">
@@ -28,7 +37,9 @@ export default function BlacklistedScreen() {
</div> </div>
{/* Title */} {/* Title */}
<h1 className="mb-4 text-2xl font-bold text-white">{t('blocking.blacklisted.title')}</h1> <h1 id="blacklisted-title" className="mb-4 text-2xl font-bold text-white">
{t('blocking.blacklisted.title')}
</h1>
{/* Message */} {/* Message */}
<p className="mb-6 text-lg text-gray-400">{t('blocking.blacklisted.defaultMessage')}</p> <p className="mb-6 text-lg text-gray-400">{t('blocking.blacklisted.defaultMessage')}</p>

View File

@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
import { useBlockingStore } from '../../store/blocking'; import { useBlockingStore } from '../../store/blocking';
import { apiClient, isChannelSubscriptionError } from '../../api/client'; import { apiClient, isChannelSubscriptionError } from '../../api/client';
import { usePlatform } from '../../platform'; import { usePlatform } from '../../platform';
import { useFocusTrap } from '../../hooks/useFocusTrap';
const CHECK_COOLDOWN_SECONDS = 5; const CHECK_COOLDOWN_SECONDS = 5;
@@ -80,8 +81,17 @@ export default function ChannelSubscriptionScreen() {
} }
}, [clearBlocking, t]); }, [clearBlocking, t]);
const screenRef = useFocusTrap<HTMLDivElement>(true, { lockScroll: false });
return ( return (
<div className="fixed inset-0 z-[100] flex flex-col items-center justify-center bg-dark-950 p-6"> <div
ref={screenRef}
role="alertdialog"
aria-modal="true"
aria-labelledby="channel-sub-title"
tabIndex={-1}
className="fixed inset-0 z-[100] flex flex-col items-center justify-center bg-dark-950 p-6"
>
<div className="w-full max-w-md text-center"> <div className="w-full max-w-md text-center">
{/* Icon */} {/* Icon */}
<div className="mb-8"> <div className="mb-8">
@@ -93,7 +103,9 @@ export default function ChannelSubscriptionScreen() {
</div> </div>
{/* Title */} {/* Title */}
<h1 className="mb-4 text-2xl font-bold text-white">{t('blocking.channel.title')}</h1> <h1 id="channel-sub-title" className="mb-4 text-2xl font-bold text-white">
{t('blocking.channel.title')}
</h1>
{/* Message */} {/* Message */}
<p className="mb-6 text-lg text-gray-400"> <p className="mb-6 text-lg text-gray-400">

View File

@@ -1,12 +1,21 @@
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useBlockingStore } from '../../store/blocking'; import { useBlockingStore } from '../../store/blocking';
import { useFocusTrap } from '../../hooks/useFocusTrap';
export default function MaintenanceScreen() { export default function MaintenanceScreen() {
const { t } = useTranslation(); const { t } = useTranslation();
const maintenanceInfo = useBlockingStore((state) => state.maintenanceInfo); const maintenanceInfo = useBlockingStore((state) => state.maintenanceInfo);
const screenRef = useFocusTrap<HTMLDivElement>(true, { lockScroll: false });
return ( return (
<div className="fixed inset-0 z-[100] flex flex-col items-center justify-center bg-dark-950 p-6"> <div
ref={screenRef}
role="alertdialog"
aria-modal="true"
aria-labelledby="maintenance-title"
tabIndex={-1}
className="fixed inset-0 z-[100] flex flex-col items-center justify-center bg-dark-950 p-6"
>
<div className="w-full max-w-md text-center"> <div className="w-full max-w-md text-center">
{/* Icon */} {/* Icon */}
<div className="mb-8"> <div className="mb-8">
@@ -28,7 +37,9 @@ export default function MaintenanceScreen() {
</div> </div>
{/* Title */} {/* Title */}
<h1 className="mb-4 text-2xl font-bold text-white">{t('blocking.maintenance.title')}</h1> <h1 id="maintenance-title" className="mb-4 text-2xl font-bold text-white">
{t('blocking.maintenance.title')}
</h1>
{/* Message */} {/* Message */}
<p className="mb-6 text-lg text-gray-400"> <p className="mb-6 text-lg text-gray-400">

View File

@@ -20,6 +20,7 @@ import {
isFullscreen, isFullscreen,
} from '@telegram-apps/sdk-react'; } from '@telegram-apps/sdk-react';
import { clearStaleSessionIfNeeded } from './utils/token'; import { clearStaleSessionIfNeeded } from './utils/token';
import { useAuthStore } from './store/auth';
import { AppWithNavigator } from './AppWithNavigator'; import { AppWithNavigator } from './AppWithNavigator';
import { ErrorBoundary } from './components/ErrorBoundary'; import { ErrorBoundary } from './components/ErrorBoundary';
import { initLogoPreload } from './api/branding'; import { initLogoPreload } from './api/branding';
@@ -100,6 +101,11 @@ if (isTelegramEnv && !alreadyInitialized) {
clearStaleSessionIfNeeded(null); clearStaleSessionIfNeeded(null);
} }
// Bootstrap auth after the Telegram SDK is initialised so CloudStorage-backed
// refresh-token recovery can run inside initialize() (launch params + CloudStorage
// are only available post-init()).
void useAuthStore.getState().initialize();
if ('requestIdleCallback' in window) { if ('requestIdleCallback' in window) {
requestIdleCallback(() => initLogoPreload()); requestIdleCallback(() => initLogoPreload());
} else { } else {

View File

@@ -13,7 +13,12 @@ import {
consumeReferralCode, consumeReferralCode,
getPendingReferralCode, getPendingReferralCode,
} from '../utils/referral'; } from '../utils/referral';
import { tokenStorage, isTokenValid, tokenRefreshManager } from '../utils/token'; import {
tokenStorage,
isTokenValid,
tokenRefreshManager,
restoreRefreshTokenFromCloud,
} from '../utils/token';
import { usePermissionStore } from './permissions'; import { usePermissionStore } from './permissions';
export interface TelegramWidgetData { export interface TelegramWidgetData {
@@ -162,11 +167,16 @@ export const useAuthStore = create<AuthState>()(
tokenStorage.migrateFromLocalStorage(); tokenStorage.migrateFromLocalStorage();
const accessToken = tokenStorage.getAccessToken(); const accessToken = tokenStorage.getAccessToken();
const refreshToken = tokenStorage.getRefreshToken(); let refreshToken = tokenStorage.getRefreshToken();
if (!refreshToken) { if (!refreshToken) {
set({ isLoading: false, isAuthenticated: false }); // localStorage may have been wiped by the Telegram WebView; try to
return; // recover the refresh token from Telegram CloudStorage before giving up.
refreshToken = await restoreRefreshTokenFromCloud();
if (!refreshToken) {
set({ isLoading: false, isAuthenticated: false });
return;
}
} }
if (!isTokenValid(accessToken)) { if (!isTokenValid(accessToken)) {
@@ -384,4 +394,5 @@ export const useAuthStore = create<AuthState>()(
captureCampaignFromUrl(); captureCampaignFromUrl();
captureReferralFromUrl(); captureReferralFromUrl();
useAuthStore.getState().initialize(); // Note: initialize() is kicked off from main.tsx after the Telegram SDK is set up,
// so CloudStorage-backed token recovery can run during bootstrap.

View File

@@ -1,4 +1,10 @@
import axios from 'axios'; import axios from 'axios';
import {
getCloudStorageItem,
setCloudStorageItem,
deleteCloudStorageItem,
} from '@telegram-apps/sdk-react';
import { isInTelegramWebApp } from '../hooks/useTelegramSDK';
const TOKEN_KEYS = { const TOKEN_KEYS = {
ACCESS: 'access_token', ACCESS: 'access_token',
@@ -7,6 +13,42 @@ const TOKEN_KEYS = {
TELEGRAM_INIT: 'telegram_init_data', TELEGRAM_INIT: 'telegram_init_data',
} as const; } as const;
// --- Telegram CloudStorage backup for the refresh token ---
// The Telegram WebView can wipe localStorage between sessions. Mirroring the refresh
// token to per-user CloudStorage lets initialize() recover the session instead of
// dropping the user to the login screen. Best-effort: failures fall back to localStorage.
const CLOUD_REFRESH_KEY = TOKEN_KEYS.REFRESH;
function mirrorRefreshTokenToCloud(refreshToken: string): void {
if (!isInTelegramWebApp()) return;
try {
void setCloudStorageItem(CLOUD_REFRESH_KEY, refreshToken).catch(() => {});
} catch {}
}
function removeRefreshTokenFromCloud(): void {
if (!isInTelegramWebApp()) return;
try {
void deleteCloudStorageItem(CLOUD_REFRESH_KEY).catch(() => {});
} catch {}
}
export async function restoreRefreshTokenFromCloud(): Promise<string | null> {
if (!isInTelegramWebApp()) return null;
try {
const value = await getCloudStorageItem(CLOUD_REFRESH_KEY);
const token = value || null;
if (token) {
try {
localStorage.setItem(TOKEN_KEYS.REFRESH, token);
} catch {}
}
return token;
} catch {
return null;
}
}
interface JWTPayload { interface JWTPayload {
exp?: number; exp?: number;
iat?: number; iat?: number;
@@ -64,6 +106,7 @@ export const tokenStorage = {
sessionStorage.setItem(TOKEN_KEYS.ACCESS, accessToken); sessionStorage.setItem(TOKEN_KEYS.ACCESS, accessToken);
localStorage.setItem(TOKEN_KEYS.REFRESH, refreshToken); localStorage.setItem(TOKEN_KEYS.REFRESH, refreshToken);
sessionStorage.removeItem(TOKEN_KEYS.REFRESH); sessionStorage.removeItem(TOKEN_KEYS.REFRESH);
mirrorRefreshTokenToCloud(refreshToken);
} catch {} } catch {}
}, },
@@ -83,6 +126,7 @@ export const tokenStorage = {
localStorage.removeItem(TOKEN_KEYS.ACCESS); localStorage.removeItem(TOKEN_KEYS.ACCESS);
localStorage.removeItem(TOKEN_KEYS.REFRESH); localStorage.removeItem(TOKEN_KEYS.REFRESH);
localStorage.removeItem(TOKEN_KEYS.USER); localStorage.removeItem(TOKEN_KEYS.USER);
removeRefreshTokenFromCloud();
} catch {} } catch {}
}, },