mirror of
https://github.com/HeyPuter/puter.git
synced 2026-08-26 07:57:10 +00:00
feat: make uninstall also revoke + dashboard css changes (#3469)
* feat: make uninstall also revoke * fix: wording for free trial
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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%',
|
||||
|
||||
Reference in New Issue
Block a user