diff --git a/src/gui/src/UI/Dashboard/TabApps.js b/src/gui/src/UI/Dashboard/TabApps.js index b8fb99b75..ac9c62693 100644 --- a/src/gui/src/UI/Dashboard/TabApps.js +++ b/src/gui/src/UI/Dashboard/TabApps.js @@ -1,6 +1,7 @@ import UIContextMenu from '../UIContextMenu.js'; import UIAlert from '../UIAlert.js'; import launch_app from '../../helpers/launch_app.js'; +import revokeAppSessions from '../../helpers/revoke_app_sessions.js'; import { begin_dashboard_tile_launch, settle_dashboard_tile_launch } from '../UIWindow.js'; import { isTouchPrimaryDevice } from './ContextMenu/ContextMenu.js'; import { reconcileAppOrder, serializeAppOrder, mergeSavedOrder, APPS_ORDER_KV_KEY } from './appOrder.js'; @@ -252,7 +253,27 @@ function showUninstallModal ({ appName, appTitle, appUid, self, $el_window }) { finishRemoval(); } - puter.perms.revokeApp(appUid, '*').catch(async err => { + puter.perms.revokeApp(appUid, '*').then(async () => { + // Clearing the grants is only half of it. An app the user already + // opened holds a token that authenticates against a session row, + // and that row outlives the permission rows — so without this an + // uninstalled app keeps calling with the credential it has. The + // revoke endpoint deliberately doesn't do this itself: dropping + // grants without ending the app's sign-in is a valid thing to ask + // for on its own, so uninstall asks for both. + // + // The grants are already gone at this point, so the app is + // uninstalled either way and the tile stays removed. A failure here + // is worth saying out loud rather than swallowing — it's the + // difference between "revoked" and "revoked but still signed in", + // and Manage Sessions is where the user can finish the job. + try { + await revokeAppSessions(appUid); + } catch ( e ) { + console.error('Uninstalled the app but could not end its sessions:', e); + UIAlert(i18n('uninstall_sessions_failed', [displayName])); + } + }).catch(async err => { console.error('Failed to uninstall app:', err); await removalSettled; self._invalidateInFlightLoads(); diff --git a/src/gui/src/UI/Dashboard/TabHome.js b/src/gui/src/UI/Dashboard/TabHome.js index 81f1c48bc..825951b69 100644 --- a/src/gui/src/UI/Dashboard/TabHome.js +++ b/src/gui/src/UI/Dashboard/TabHome.js @@ -468,8 +468,8 @@ const TabHome = { ) .show(); } else if (trialing) { - // A trial grants the full tier but lapses to Free unless a - // payment method is added, so say when it ends. + // A trial grants the full tier and then continues as a paid + // plan, so say when that happens and where to opt out. $badge .text( trialEnds @@ -480,8 +480,8 @@ const TabHome = { $warning .text( trialEnds - ? `Your free trial ends on ${trialEnds.long}. Add a payment method before then to keep this plan.` - : 'You are on a free trial. Add a payment method to keep this plan when it ends.', + ? `Your free trial ends on ${trialEnds.long}, after which the plan continues at the usual monthly price. Cancel any time before then under Billing.` + : 'When your free trial ends the plan continues at the usual monthly price. Cancel any time before then under Billing.', ) .addClass('info') .show(); diff --git a/src/gui/src/css/dashboard.css b/src/gui/src/css/dashboard.css index a8d1925ab..89a707ecd 100644 --- a/src/gui/src/css/dashboard.css +++ b/src/gui/src/css/dashboard.css @@ -51,6 +51,12 @@ --dashboard-warning-hover-border: #d97706; --dashboard-warning-hover-text: #78350f; + /* Advisory notices (trial ending, renewal scheduled). Deliberately not the + warning ramp — nothing is wrong, so amber overstates it. */ + --dashboard-notice-background: #eff6ff; + --dashboard-notice-border: #bfdbfe; + --dashboard-notice-text: #1d4ed8; + --dashboard-success-background: #e6ffed; --dashboard-success-border: #08bf4e; --dashboard-success-text: #03933a; @@ -156,6 +162,12 @@ body { --dashboard-warning-hover-border: #d97706; --dashboard-warning-hover-text: #fde68a; + /* Muted slate-blue rather than the amber ramp, which glares on a dark + surface. Low-chroma fill, visible-but-quiet border, legible text. */ + --dashboard-notice-background: #17202f; + --dashboard-notice-border: #2f4058; + --dashboard-notice-text: #a8c4e8; + --dashboard-success-background: #052e16; --dashboard-success-border: #16a34a; --dashboard-success-text: #4ade80; @@ -3459,6 +3471,19 @@ body.myapps-reordering .myapps-tile { gap: 24px; } +/* Three even columns need real width. Below that the plan column lands around + 230px, which wraps its badge and renewal notice into a tall ribbon — so give + the plan its own row and let the two usage meters share the next one. */ +@media (max-width: 1200px) { + .dashboard .bento-usage-grid { + grid-template-columns: 1fr 1fr; + } + + .dashboard .bento-plan-section { + grid-column: 1 / -1; + } +} + .dashboard .bento-usage-section { display: flex; flex-direction: column; @@ -3505,9 +3530,9 @@ body.myapps-reordering .myapps-tile { border-radius: 999px; font-size: 12px; font-weight: 500; - color: var(--dashboard-warning-text); - background: var(--dashboard-warning-background); - border: 1px solid var(--dashboard-warning-border); + color: var(--dashboard-notice-text); + background: var(--dashboard-notice-background); + border: 1px solid var(--dashboard-notice-border); } .dashboard .bento-plan-warning { @@ -3522,11 +3547,11 @@ body.myapps-reordering .myapps-tile { } /* Time-boxed but not broken — a trial notice reads as advisory, not as the - red dunning banner. */ + red dunning banner and not as an amber warning either. */ .dashboard .bento-plan-warning.info { - color: var(--dashboard-warning-text); - background: var(--dashboard-warning-background); - border-color: var(--dashboard-warning-border); + color: var(--dashboard-notice-text); + background: var(--dashboard-notice-background); + border-color: var(--dashboard-notice-border); } .dashboard .bento-plan-upgrade { diff --git a/src/gui/src/helpers/revoke_app_sessions.js b/src/gui/src/helpers/revoke_app_sessions.js new file mode 100644 index 000000000..e5aa4af03 --- /dev/null +++ b/src/gui/src/helpers/revoke_app_sessions.js @@ -0,0 +1,103 @@ +/** + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify it under the + * terms of the GNU Affero General Public License as published by the Free + * Software Foundation, either version 3 of the License, or (at your option) any + * later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more + * details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see + * [https://www.gnu.org/licenses/](https://www.gnu.org/licenses/). + */ + +/** + * Pick the session rows that belong to one app, out of a /auth/list-sessions + * response. + * + * Only `kind === 'app'` rows: `worker` rows also carry an `app_uid` but are + * deployment credentials rather than this user's grant to the app, and access + * tokens the app issued follow on their own through the server-side cascade. + * + * @param {{ uuid?: string; kind?: string; app_uid?: string | null }[]} sessions + * @param {string} appUid + * @returns {string[]} Uuids to revoke, in list order + */ +export const appSessionUuids = (sessions, appUid) => { + if (!Array.isArray(sessions) || !appUid) return []; + return sessions + .filter((s) => s?.kind === 'app' && s?.app_uid === appUid && s?.uuid) + .map((s) => s.uuid); +}; + +/** + * Revoke every session the current user holds for one app, so a token the app + * already has stops authenticating. + * + * Withdrawing an app's permissions is deliberately separate from this on the + * server: `/auth/revoke-user-app` clears granted permissions and nothing else, + * because revoking grants without ending the app's sign-in is a real use case. + * Uninstall is the case that wants both, so it asks for both. + * + * Best-effort per row: a 404 means the row already went away, which is the + * outcome we wanted, and one failed row shouldn't strand the others. Returns + * the count actually revoked so a caller can tell "nothing to do" from "we + * tried and the server said no". + * + * @param {string} appUid + * @param {object} [deps] Injectable seams for tests. + * @param {typeof fetch} [deps.fetchImpl] + * @param {() => Promise} [deps.antiCsrfToken] + * @param {string} [deps.apiOrigin] + * @param {string} [deps.authToken] + * @returns {Promise} How many sessions were revoked + */ +export const revokeAppSessions = async ( + appUid, + { + fetchImpl = globalThis.fetch?.bind(globalThis), + antiCsrfToken = () => globalThis.services.get('anti-csrf').token(), + apiOrigin = window.api_origin, + authToken = undefined, + } = {}, +) => { + if (!appUid) return 0; + const token = authToken ?? (puter.authToken || window.auth_token); + const authHeaders = { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }; + + const listResp = await fetchImpl(`${apiOrigin}/auth/list-sessions`, { + method: 'GET', + headers: { Authorization: `Bearer ${token}` }, + }); + if (!listResp.ok) { + throw new Error(`Failed to list sessions (${listResp.status})`); + } + const uuids = appSessionUuids(await listResp.json(), appUid); + if (uuids.length === 0) return 0; + + // One anti-CSRF token per row: the service issues single-use tokens, so a + // token reused across the loop would be rejected from the second row on. + let revoked = 0; + for (const uuid of uuids) { + const anti_csrf = await antiCsrfToken(); + const resp = await fetchImpl(`${apiOrigin}/auth/revoke-session`, { + method: 'POST', + headers: authHeaders, + body: JSON.stringify({ uuid, anti_csrf }), + }); + if (resp.ok || resp.status === 404) revoked++; + } + return revoked; +}; + +export default revokeAppSessions; diff --git a/src/gui/src/helpers/revoke_app_sessions.test.js b/src/gui/src/helpers/revoke_app_sessions.test.js new file mode 100644 index 000000000..fe3f287fa --- /dev/null +++ b/src/gui/src/helpers/revoke_app_sessions.test.js @@ -0,0 +1,129 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { describe, it, expect } from 'vitest'; +import { appSessionUuids, revokeAppSessions } from './revoke_app_sessions.js'; + +const APP = 'app-1111'; + +// A list-sessions payload covering every row kind that carries an app_uid. +const sessions = [ + { uuid: 'web-1', kind: 'web', app_uid: null }, + { uuid: 'app-a', kind: 'app', app_uid: APP }, + { uuid: 'app-b', kind: 'app', app_uid: APP }, + { uuid: 'other', kind: 'app', app_uid: 'app-2222' }, + { uuid: 'wrk-1', kind: 'worker', app_uid: APP }, + { uuid: 'tok-1', kind: 'access_token', app_uid: APP }, +]; + +describe('appSessionUuids', () => { + it("picks only the app's own app-kind rows", () => { + expect(appSessionUuids(sessions, APP)).toEqual(['app-a', 'app-b']); + }); + + it('leaves worker rows alone — they are deployment credentials', () => { + expect(appSessionUuids(sessions, APP)).not.toContain('wrk-1'); + }); + + it('returns empty for a missing uid or a non-array payload', () => { + expect(appSessionUuids(sessions, '')).toEqual([]); + expect(appSessionUuids(null, APP)).toEqual([]); + expect(appSessionUuids({ error: 'nope' }, APP)).toEqual([]); + }); +}); + +// Minimal fetch double: records calls and answers by URL. +const makeFetch = ({ list = sessions, listOk = true, revokeStatus = 200 } = {}) => { + const calls = []; + const fetchImpl = async (url, opts = {}) => { + calls.push({ url, method: opts.method, body: opts.body ? JSON.parse(opts.body) : null }); + if ( url.endsWith('/auth/list-sessions') ) { + return { ok: listOk, status: listOk ? 200 : 500, json: async () => list }; + } + const status = typeof revokeStatus === 'function' ? revokeStatus(calls.length) : revokeStatus; + return { ok: status >= 200 && status < 300, status }; + }; + return { fetchImpl, calls }; +}; + +const deps = (over = {}) => ({ + antiCsrfToken: async () => 'csrf-token', + apiOrigin: 'https://api.test', + authToken: 'auth-token', + ...over, +}); + +describe('revokeAppSessions', () => { + it("revokes each of the app's sessions and reports the count", async () => { + const { fetchImpl, calls } = makeFetch(); + const revoked = await revokeAppSessions(APP, deps({ fetchImpl })); + + expect(revoked).toBe(2); + const revokes = calls.filter(c => c.url.endsWith('/auth/revoke-session')); + expect(revokes.map(c => c.body.uuid)).toEqual(['app-a', 'app-b']); + }); + + it('sends an anti-csrf token with every revoke — they are single-use', async () => { + const { fetchImpl, calls } = makeFetch(); + let issued = 0; + await revokeAppSessions(APP, deps({ + fetchImpl, + antiCsrfToken: async () => `csrf-${++issued}`, + })); + + const tokens = calls + .filter(c => c.url.endsWith('/auth/revoke-session')) + .map(c => c.body.anti_csrf); + expect(tokens).toEqual(['csrf-1', 'csrf-2']); + }); + + it('makes no revoke call when the app has no sessions', async () => { + const { fetchImpl, calls } = makeFetch({ list: [{ uuid: 'web-1', kind: 'web' }] }); + expect(await revokeAppSessions(APP, deps({ fetchImpl }))).toBe(0); + expect(calls.some(c => c.url.endsWith('/auth/revoke-session'))).toBe(false); + }); + + it('counts a 404 as done — the row already went away', async () => { + const { fetchImpl } = makeFetch({ revokeStatus: 404 }); + expect(await revokeAppSessions(APP, deps({ fetchImpl }))).toBe(2); + }); + + it('keeps going after one row fails, and reports the shortfall', async () => { + // First revoke 500s, second succeeds. + let n = 0; + const { fetchImpl, calls } = makeFetch({ revokeStatus: () => (++n === 1 ? 500 : 200) }); + const revoked = await revokeAppSessions(APP, deps({ fetchImpl })); + + expect(revoked).toBe(1); + expect(calls.filter(c => c.url.endsWith('/auth/revoke-session'))).toHaveLength(2); + }); + + it('throws when the session list cannot be read', async () => { + const { fetchImpl } = makeFetch({ listOk: false }); + await expect(revokeAppSessions(APP, deps({ fetchImpl }))).rejects.toThrow( + /Failed to list sessions/, + ); + }); + + it('does nothing without an app uid', async () => { + const { fetchImpl, calls } = makeFetch(); + expect(await revokeAppSessions('', deps({ fetchImpl }))).toBe(0); + expect(calls).toHaveLength(0); + }); +}); diff --git a/src/gui/src/i18n/translations/en.js b/src/gui/src/i18n/translations/en.js index ad786450e..088cf2196 100644 --- a/src/gui/src/i18n/translations/en.js +++ b/src/gui/src/i18n/translations/en.js @@ -405,6 +405,7 @@ const en = { ui_session_last_active: 'Last active', ui_toggle_session_children: 'Toggle child sessions', undo: 'Undo', + uninstall_sessions_failed: '%% was uninstalled, but its existing sign-in could not be ended. You can revoke it under Settings → Security → Manage Sessions.', unlimited: 'Unlimited', unzip: 'Unzip', unzipping: 'Unzipping %strong%',