From 019930924da9a8ff10c8c187c113c420407dacaa Mon Sep 17 00:00:00 2001 From: Dmitriy Petrov Date: Thu, 13 Aug 2026 15:06:13 +0300 Subject: [PATCH] Handle expired subscriptions in client profile UI --- src/shared/versions.ts | 4 +- src/web/components/ClientOverviewPage.tsx | 9 +++- .../subscription/SubscriptionFeature.tsx | 44 +++++++++++-------- src/web/styles/features/servers.css | 2 +- src/web/styles/features/subscription.css | 14 +++++- test/web/server-picker.test.js | 2 +- test/web/style-boundaries.test.js | 22 +++++----- .../web/subscription-feature-contract.test.js | 10 ++++- 8 files changed, 68 insertions(+), 39 deletions(-) diff --git a/src/shared/versions.ts b/src/shared/versions.ts index df140c1..4c82850 100644 --- a/src/shared/versions.ts +++ b/src/shared/versions.ts @@ -1,6 +1,6 @@ export const HARBOR_VERSIONS = Object.freeze({ - macClient: '0.25.8', - gatewayClient: '0.26.7', + macClient: '0.25.9', + gatewayClient: '0.26.8', gatewayBackend: '0.26.5', }); diff --git a/src/web/components/ClientOverviewPage.tsx b/src/web/components/ClientOverviewPage.tsx index f0d4ad4..a24db81 100644 --- a/src/web/components/ClientOverviewPage.tsx +++ b/src/web/components/ClientOverviewPage.tsx @@ -482,12 +482,17 @@ export function ClientOverviewPage({ && (canonicalSwitch || localSwitch) && Boolean(operationProfile && operationServer) && (operationProfile?.id !== appliedProfile?.id || operationServer?.id !== appliedServer?.id); + const subscriptionError = error?.context === 'subscription' + && profiles.some((profile) => profile.id === error.profileId + && profile.subscription.errorCode === 'SUBSCRIPTION_EXPIRED') + ? null + : error; const subscriptionFeature = useSubscriptionFeature({ profiles, selection: state.selection, connected, operations: visibleOperations, - error, + error: subscriptionError, isGateway, gatewayDirect, onAdd: onAddProfile, @@ -716,7 +721,7 @@ export function ClientOverviewPage({ - + } renderServerPicker={(profile, pickerState) => ReactNode; }) { - const expanded = feature.expanded.includes(profile.id); + const expired = profile.subscription.errorCode === 'SUBSCRIPTION_EXPIRED'; + const expanded = !expired && feature.expanded.includes(profile.id); const closing = feature.closing.includes(profile.id); const visited = feature.visited.includes(profile.id); const applied = feature.connected @@ -401,18 +402,29 @@ function ProfileGroup({ const visibleServerLocation = [visibleServer?.city, visibleServer?.country] .filter((value): value is string => typeof value === 'string' && Boolean(value)) .join(' · '); - const localStatus = profileError?.message || (profile.subscription.status === 'stale' + const localStatus = !expired && (profileError?.message || (profile.subscription.status === 'stale' ? `Последнее обновление не удалось. Данные от ${profile.subscription.fetchedAt ? new Date(profile.subscription.fetchedAt).toLocaleString('ru-RU', { dateStyle: 'short', timeStyle: 'short' }) : 'предыдущей загрузки'}.` - : ''); + : '')); const profileDomain = subscriptionDomain(profile.subscription.host); - const profileDetails = `Серверов: ${profile.servers.length}${profile.subscription.fetchedAt - ? ` · обновлено ${new Date(profile.subscription.fetchedAt).toLocaleDateString('ru-RU')}` - : ''}`; - return
+ const profileDetails = expired + ? 'Срок действия подписки истёк' + : `Серверов: ${profile.servers.length}${profile.subscription.fetchedAt + ? ` · обновлено ${new Date(profile.subscription.fetchedAt).toLocaleDateString('ru-RU')}` + : ''}`; + const profileTitle = + + {profileDomain} + {(applied || desired) && {applied ? 'АКТИВНА' : 'ВЫБРАНА'}} + + {profileDetails} + ; + return
-
: - {(applied || desired) && } + {profileTitle} + } + {!expired && (applied || desired) && } } - {visited &&
{ const witnesses = readStyleWitnesses(root); - assert.equal(witnesses.length, 805); + assert.equal(witnesses.length, 811); assert.equal(witnesses.filter((witness) => witness.unknown || witness.ancestorUnknown).length, 0); const ledger = createStyleLedger(readStyleSource(root), { witnesses }); assert.deepEqual(ledger.counts, acceptedLedger.counts); @@ -405,8 +405,8 @@ test('main owns one public stylesheet and the regrouped production CSS is determ assert.equal((main.match(/import ['"][^'"]+\.css['"]/g) || []).length, 1); const assets = fs.readdirSync(path.join(root, 'dist/assets')).filter((file) => file.endsWith('.css')); - assert.deepEqual(assets, ['index-9maKdwRg.css']); + assert.deepEqual(assets, ['index-DbgBuebh.css']); const built = fs.readFileSync(path.join(root, 'dist/assets', assets[0])); - assert.equal(built.byteLength, 127604); - assert.equal(sha256(built), '0ba84e80e387ee62c33379b3674b1849ba65857cf45dac1257f4d87605008d04'); + assert.equal(built.byteLength, 127802); + assert.equal(sha256(built), '19995a47de1ae634f29b26fb4e484f6d9fb048519cc42ad0a186d11904e2bfb6'); }); diff --git a/test/web/subscription-feature-contract.test.js b/test/web/subscription-feature-contract.test.js index 0a62443..3d928ca 100644 --- a/test/web/subscription-feature-contract.test.js +++ b/test/web/subscription-feature-contract.test.js @@ -107,11 +107,16 @@ test('feature keeps exact slots, truthy closes and subscription DOM order', () = test('profiles render as flat accordion groups with scoped controls', () => { assert.match(feature, /feature\.profiles\.map\(\(profile\) => [\s\S]*: { assert.doesNotMatch(page, /onActivateProfile/); assert.match(feature, /const desired = !feature\.connected[\s\S]*&& !feature\.gatewayDirect/); assert.match(feature, /const profileDomain = subscriptionDomain\(profile\.subscription\.host\)/); - assert.match(feature, /const profileDetails = `Серверов: \$\{profile\.servers\.length\}/); assert.doesNotMatch(feature, /\{profile\.label\}<\/strong>|Переименовать|client-profile-menu/); assert.match(styles, /\.client-profile-group/); assert.match(styles, /\.client-profile-body/); assert.match(styles, /\.client-profile-body \{[\s\S]*grid-template-rows: 0fr[\s\S]*\.client-profile-body\.is-open \{[\s\S]*grid-template-rows: 1fr/); + assert.match(styles, /\.client-profile-body-inner \{[\s\S]*padding: 0 0 4px 24px/); + assert.match(styles, /\.client-profile-group\.is-expired \.client-profile-title small \{[\s\S]*color: oklch\(0\.68 0\.14 72\)/); assert.match(styles, /@media \(prefers-reduced-motion: reduce\)[\s\S]*\.client-profile-body \*/); assert.match(styles, /\.client-profile-refresh\.is-refreshing/); assert.match(styles, /\.client-profile-refresh,[\s\S]*\.client-profile-delete\s*\{[\s\S]*width:\s*36px;[\s\S]*height:\s*36px;[\s\S]*display:\s*grid;[\s\S]*place-items:\s*center/);