mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-30 02:23:47 +00:00
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:
@@ -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)`,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 {}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user