Files
readest/apps/readest-app/src/utils/access.ts
T
Huang Xin d932444b78 fix(sync): cloud-sync settings polish + temporary premium ungate (#4828)
* fix(settings): clamp option-row description to a single line

SettingsRow descriptions wrapped to multiple lines on narrow (mobile)
widths, giving boxed-list rows uneven heights (e.g. "Uploads book files
to your other devices." in the Cloud Sync panel). Clamp the description
to one line with ellipsis in the shared primitive so every option row
stays uniform; the description is a hint, not a paragraph (longer copy
belongs in a Tips block). Codified in DESIGN.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* i18n(settings): shorten sync strategy labels to "Send only" / "Receive only"

Rename the Sync Strategy options (shared by the Cloud Sync and KOReader Sync
forms). Keys renamed in every locale, preserving existing translations.

* feat(sync): temporarily ungate third-party cloud sync from premium

Cloud sync (WebDAV / Google Drive) ships available to every plan, incl. free,
while the feature stabilises. Gated behind a single CLOUD_SYNC_REQUIRES_PREMIUM
flag (off) via isCloudSyncAllowed; the paywall code (CLOUD_SYNC_PLANS /
isCloudSyncInPlan) is intact, so re-gating in an upcoming release is a one-line
flip. Applies to the Settings provider rows and the reader auto-sync gate.

* fix(settings): polish cloud-sync connect buttons

Use btn-contrast for the WebDAV and Google Drive Connect CTAs (theme-neutral,
e-ink correct); rename "Connect Google Drive" to "Connect"; move the Google
Drive sign-in tips below the Connect button.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 19:11:17 +02:00

161 lines
5.5 KiB
TypeScript

import { jwtDecode } from 'jwt-decode';
import { supabase } from '@/utils/supabase';
import { UserPlan } from '@/types/quota';
import { DEFAULT_DAILY_TRANSLATION_QUOTA, DEFAULT_STORAGE_QUOTA } from '@/services/constants';
import { isWebAppPlatform } from '@/services/environment';
import { getDailyUsage } from '@/services/translators/utils';
import { getRuntimeConfig } from '@/services/runtimeConfig';
interface Token {
plan: UserPlan;
storage_usage_bytes: number;
storage_purchased_bytes: number;
[key: string]: string | number;
}
export const getSubscriptionPlan = (token: string): UserPlan => {
const data = jwtDecode<Token>(token) || {};
return data['plan'] || 'free';
};
export const getUserProfilePlan = (token: string): UserPlan => {
const data = jwtDecode<Token>(token) || {};
let plan = data['plan'] || 'free';
if (plan === 'free') {
const purchasedQuota = data['storage_purchased_bytes'] || 0;
if (purchasedQuota > 0) {
plan = 'purchase';
}
}
return plan;
};
/**
* Plans that include the "Send to Readest via email" feature: Plus,
* Pro, and Lifetime (`purchase`). Free users see an upgrade card on
* the client and get a 403 from the server endpoints that allocate /
* rotate the address, plus a bounce from the inbound email Worker.
*
* Other Send channels (in-app `/send` page, mobile share-sheet, browser
* extension) stay open to free users — the gate is the personal email
* inbox only.
*/
export const EMAIL_IN_PLANS: readonly UserPlan[] = ['plus', 'pro', 'purchase'];
export const isEmailInPlan = (plan: UserPlan): boolean =>
(EMAIL_IN_PLANS as readonly UserPlan[]).includes(plan);
/**
* Plans that include third-party cloud sync (WebDAV / Google Drive): any paid
* plan — Plus, Pro, and Lifetime (`purchase`). Free users see an upgrade prompt
* in Settings and the reader's auto-sync stays off, so syncing to a personal
* cloud is a premium feature.
*/
export const CLOUD_SYNC_PLANS: readonly UserPlan[] = ['plus', 'pro', 'purchase'];
export const isCloudSyncInPlan = (plan: UserPlan): boolean =>
(CLOUD_SYNC_PLANS as readonly UserPlan[]).includes(plan);
/**
* Master switch for the third-party cloud-sync premium paywall. Temporarily
* OFF: cloud sync (WebDAV / Google Drive) ships ungated to every plan — including
* free — while the feature stabilises, and will be re-gated to
* {@link CLOUD_SYNC_PLANS} in an upcoming release. Every gate goes through
* {@link isCloudSyncAllowed}, so flipping this back to `true` is the whole toggle.
*/
export const CLOUD_SYNC_REQUIRES_PREMIUM = false;
/**
* Whether third-party cloud sync is available for a plan. While
* {@link CLOUD_SYNC_REQUIRES_PREMIUM} is off this is always true; once it is
* re-enabled it falls back to the {@link isCloudSyncInPlan} paywall.
*/
export const isCloudSyncAllowed = (plan: UserPlan): boolean =>
!CLOUD_SYNC_REQUIRES_PREMIUM || isCloudSyncInPlan(plan);
export const STORAGE_QUOTA_GRACE_BYTES = 10 * 1024 * 1024; // 10 MB grace
export const getStoragePlanData = (token: string) => {
const data = jwtDecode<Token>(token) || {};
const plan = data['plan'] || 'free';
const usage = data['storage_usage_bytes'] || 0;
const purchasedQuota = data['storage_purchased_bytes'] || 0;
const runtimeConfig = getRuntimeConfig();
const fixedQuota =
runtimeConfig?.storageFixedQuota ?? parseInt(process.env['STORAGE_FIXED_QUOTA'] ?? '0');
const planQuota = fixedQuota || DEFAULT_STORAGE_QUOTA[plan] || DEFAULT_STORAGE_QUOTA['free'];
const quota = planQuota + purchasedQuota;
return {
plan,
usage,
quota,
};
};
export const getTranslationQuota = (plan: UserPlan): number => {
const runtimeConfig = getRuntimeConfig();
const fixedQuota =
runtimeConfig?.translationFixedQuota ?? parseInt(process.env['TRANSLATION_FIXED_QUOTA'] ?? '0');
return (
fixedQuota || DEFAULT_DAILY_TRANSLATION_QUOTA[plan] || DEFAULT_DAILY_TRANSLATION_QUOTA['free']
);
};
export const getTranslationPlanData = (token: string) => {
const data = jwtDecode<Token>(token) || {};
const plan: UserPlan = data['plan'] || 'free';
const usage = getDailyUsage() || 0;
const quota = getTranslationQuota(plan);
return {
plan,
usage,
quota,
};
};
export const getDailyTranslationPlanData = (token: string) => {
const data = jwtDecode<Token>(token) || {};
const plan = data['plan'] || 'free';
const quota = getTranslationQuota(plan);
return {
plan,
quota,
};
};
export const getAccessToken = async (): Promise<string | null> => {
// In browser context there might be two instances of supabase one in the app route
// and the other in the pages route, and they might have different sessions
// making the access token invalid for API calls. In that case we should use localStorage.
if (isWebAppPlatform()) {
return localStorage.getItem('token') ?? null;
}
const { data } = await supabase.auth.getSession();
return data?.session?.access_token ?? null;
};
export const getUserID = async (): Promise<string | null> => {
if (isWebAppPlatform()) {
const user = localStorage.getItem('user') ?? '{}';
return JSON.parse(user).id ?? null;
}
const { data } = await supabase.auth.getSession();
return data?.session?.user?.id ?? null;
};
export const validateUserAndToken = async (authHeader: string | null | undefined) => {
if (!authHeader) return {};
const token = authHeader.replace('Bearer ', '');
const {
data: { user },
error,
} = await supabase.auth.getUser(token);
if (error || !user) return {};
return { user, token };
};