feat: make uninstall also revoke + dashboard css changes (#3469)
Maintain Release Merge PR / update-release-pr (push) Canceled after 0s
Notify HeyPuter / notify (push) Canceled after 0s
release-please / release-please (push) Canceled after 0s

* feat: make uninstall also revoke

* fix: wording for free trial
This commit is contained in:
Daniel Salazar
2026-07-29 10:40:43 -07:00
committed by GitHub
parent 92193ff11f
commit f0b1947c54
6 changed files with 291 additions and 12 deletions
+22 -1
View File
@@ -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();
+4 -4
View File
@@ -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();
+32 -7
View File
@@ -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 {
+103
View File
@@ -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<string>} [deps.antiCsrfToken]
* @param {string} [deps.apiOrigin]
* @param {string} [deps.authToken]
* @returns {Promise<number>} 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;
@@ -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 <https://www.gnu.org/licenses/>.
*/
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);
});
});
+1
View File
@@ -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%',