feat: protect private work before sign out (Closes #1128)
This commit is contained in:
parent
3b4f6b39a5
commit
c2526969b1
|
|
@ -57,7 +57,7 @@ jobs:
|
||||||
pip install -r requirements-e2e.txt
|
pip install -r requirements-e2e.txt
|
||||||
python3 -m playwright install --with-deps chromium
|
python3 -m playwright install --with-deps chromium
|
||||||
- name: Exercise packaged mobile work journeys
|
- name: Exercise packaged mobile work journeys
|
||||||
run: python3 -m pytest tests/e2e/test_mobile_offline_issue_release.py tests/e2e/test_mobile_search_preview_navigation.py tests/e2e/test_mobile_find_work_release.py tests/e2e/test_mobile_home_bootstrap_release.py tests/e2e/test_mobile_today_handoff_release.py tests/e2e/test_mobile_today_wrap_up_release.py tests/e2e/test_mobile_today_summary_release.py tests/e2e/test_mobile_wrap_up_handoff_release.py -q
|
run: python3 -m pytest tests/e2e/test_mobile_offline_issue_release.py tests/e2e/test_mobile_search_preview_navigation.py tests/e2e/test_mobile_find_work_release.py tests/e2e/test_mobile_home_bootstrap_release.py tests/e2e/test_mobile_sign_out_release.py tests/e2e/test_mobile_today_handoff_release.py tests/e2e/test_mobile_today_wrap_up_release.py tests/e2e/test_mobile_today_summary_release.py tests/e2e/test_mobile_wrap_up_handoff_release.py -q
|
||||||
|
|
||||||
release-candidate:
|
release-candidate:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,15 @@ header { position: sticky; top: 0; z-index: 20; padding: 12px 16px; display:flex
|
||||||
.toolbar { display:flex; gap:10px; align-items:center; flex-wrap:wrap; }
|
.toolbar { display:flex; gap:10px; align-items:center; flex-wrap:wrap; }
|
||||||
.app-brand { display:flex; align-items:center; gap:6px; white-space:nowrap; }
|
.app-brand { display:flex; align-items:center; gap:6px; white-space:nowrap; }
|
||||||
.app-live-status { display:inline-flex; gap:6px; align-items:center; min-height:44px; padding:6px 10px; background:transparent; border-color:transparent; }
|
.app-live-status { display:inline-flex; gap:6px; align-items:center; min-height:44px; padding:6px 10px; background:transparent; border-color:transparent; }
|
||||||
|
.sign-out-review-sheet { position:fixed; inset:0; z-index:112; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
|
||||||
|
.sign-out-review-sheet[hidden] { display:none; }
|
||||||
|
.sign-out-review-panel { box-sizing:border-box; width:min(560px,100%); max-height:100dvh; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #b45309; border-radius:18px 18px 0 0; background:#0b1526; overflow-wrap:anywhere; }
|
||||||
|
.sign-out-review-panel h2 { margin:.25rem 0; }
|
||||||
|
.sign-out-review-warning { color:#fbbf24; }
|
||||||
|
.sign-out-review-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; margin-top:16px; }
|
||||||
|
.sign-out-review-actions button { min-height:44px; width:100%; }
|
||||||
|
#confirm-sign-out { border-color:#b45309; }
|
||||||
|
@media (max-width:359px) { .sign-out-review-actions { grid-template-columns:1fr; } }
|
||||||
.live-data-status-sheet { position:fixed; inset:0; z-index:96; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
|
.live-data-status-sheet { position:fixed; inset:0; z-index:96; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
|
||||||
.live-data-status-sheet[hidden] { display:none; }
|
.live-data-status-sheet[hidden] { display:none; }
|
||||||
.live-data-status-panel { box-sizing:border-box; width:min(560px,100%); max-height:100dvh; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #31577f; border-radius:18px 18px 0 0; background:#0b1526; }
|
.live-data-status-panel { box-sizing:border-box; width:min(560px,100%); max-height:100dvh; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #31577f; border-radius:18px 18px 0 0; background:#0b1526; }
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,17 @@
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
</header>
|
</header>
|
||||||
|
<div id="sign-out-review-sheet" class="sign-out-review-sheet" hidden>
|
||||||
|
<section class="sign-out-review-panel" role="dialog" aria-modal="true" aria-labelledby="sign-out-review-heading">
|
||||||
|
<h2 id="sign-out-review-heading">Review sign out</h2>
|
||||||
|
<p id="sign-out-review-summary" class="small" role="status" aria-live="polite">Checking private work…</p>
|
||||||
|
<p id="sign-out-review-warning" class="sign-out-review-warning"></p>
|
||||||
|
<div class="sign-out-review-actions">
|
||||||
|
<button id="cancel-sign-out" type="button">Cancel</button>
|
||||||
|
<button id="confirm-sign-out" type="button">Sign out</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
<div id="live-data-status-sheet" class="live-data-status-sheet" hidden>
|
<div id="live-data-status-sheet" class="live-data-status-sheet" hidden>
|
||||||
<section class="live-data-status-panel" role="dialog" aria-modal="true" aria-labelledby="live-data-status-heading">
|
<section class="live-data-status-panel" role="dialog" aria-modal="true" aria-labelledby="live-data-status-heading">
|
||||||
<div class="live-data-status-header">
|
<div class="live-data-status-header">
|
||||||
|
|
@ -1816,6 +1827,8 @@
|
||||||
<div class="footer">Creative AI-imbued UI • stackchain-dashboard</div>
|
<div class="footer">Creative AI-imbued UI • stackchain-dashboard</div>
|
||||||
|
|
||||||
<script src="static/private-data-registry.js"></script>
|
<script src="static/private-data-registry.js"></script>
|
||||||
|
<script src="static/private-data-inventory.js"></script>
|
||||||
|
<script src="static/sign-out-review.js"></script>
|
||||||
<script src="static/session.js"></script>
|
<script src="static/session.js"></script>
|
||||||
<script src="static/feature-loader.js"></script>
|
<script src="static/feature-loader.js"></script>
|
||||||
<script src="static/conversation-action-hydrator.js"></script>
|
<script src="static/conversation-action-hydrator.js"></script>
|
||||||
|
|
@ -1923,7 +1936,6 @@
|
||||||
<script src="static/mobile-insights.js"></script>
|
<script src="static/mobile-insights.js"></script>
|
||||||
<script src="static/mobile-app-shortcuts.js"></script>
|
<script src="static/mobile-app-shortcuts.js"></script>
|
||||||
<script src="static/install-app.js"></script>
|
<script src="static/install-app.js"></script>
|
||||||
<script src="static/private-data-inventory.js"></script>
|
|
||||||
<script src="static/private-device-data.js"></script>
|
<script src="static/private-device-data.js"></script>
|
||||||
<script src="static/device-storage.js"></script>
|
<script src="static/device-storage.js"></script>
|
||||||
<script src="static/mobile-device-setup.js"></script>
|
<script src="static/mobile-device-setup.js"></script>
|
||||||
|
|
|
||||||
|
|
@ -81,7 +81,8 @@
|
||||||
revoke.type = 'button';
|
revoke.type = 'button';
|
||||||
revoke.textContent = device.current ? 'Sign out' : 'Revoke';
|
revoke.textContent = device.current ? 'Sign out' : 'Revoke';
|
||||||
revoke.addEventListener('click', async () => {
|
revoke.addEventListener('click', async () => {
|
||||||
if (device.current) await boundary.signOut();
|
if (device.current && boundary.requestSignOut) await boundary.requestSignOut(revoke);
|
||||||
|
else if (device.current) await boundary.signOut();
|
||||||
else if (await boundary.revokeActiveDevice(device)) {
|
else if (await boundary.revokeActiveDevice(device)) {
|
||||||
await Promise.all([renderDevices(), renderSecurityActivity()]);
|
await Promise.all([renderDevices(), renderSecurityActivity()]);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ const SHELL = [
|
||||||
BASE + 'static/icons/stackchain-192.png',
|
BASE + 'static/icons/stackchain-192.png',
|
||||||
BASE + 'static/icons/stackchain-512.png',
|
BASE + 'static/icons/stackchain-512.png',
|
||||||
BASE + 'static/session.js',
|
BASE + 'static/session.js',
|
||||||
|
BASE + 'static/sign-out-review.js',
|
||||||
BASE + 'static/feature-loader.js',
|
BASE + 'static/feature-loader.js',
|
||||||
BASE + 'static/workspace-bootstrap.js',
|
BASE + 'static/workspace-bootstrap.js',
|
||||||
BASE + 'static/conversation-action-hydrator.js',
|
BASE + 'static/conversation-action-hydrator.js',
|
||||||
|
|
|
||||||
|
|
@ -38,9 +38,25 @@
|
||||||
boundary.startActivityHeartbeat();
|
boundary.startActivityHeartbeat();
|
||||||
boundary.startReconnectResume();
|
boundary.startReconnectResume();
|
||||||
const button = root.document.getElementById('sign-out');
|
const button = root.document.getElementById('sign-out');
|
||||||
if (button) button.addEventListener('click', () => boundary.signOut());
|
const allButton = root.document.getElementById('sign-out-all');
|
||||||
const allDevicesButton = root.document.getElementById('sign-out-all');
|
let review;
|
||||||
if (allDevicesButton) allDevicesButton.addEventListener('click', () => boundary.signOutAllDevices());
|
const features = root.createFeatureLoader({
|
||||||
|
document: root.document,
|
||||||
|
urls: {
|
||||||
|
'sign-out': root.document.querySelector('meta[name="stackchain-feature-sign-out"]')?.content || '',
|
||||||
|
'security-center': root.document.querySelector('meta[name="stackchain-feature-security-center"]')?.content || '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const openReview = (mode, source) => features.run(
|
||||||
|
'sign-out', { trigger: source }, () => {
|
||||||
|
if (!review) review = root.createSignOutReview.mount(boundary);
|
||||||
|
review.show(mode, source);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
[[button, 'current'], [allButton, 'all']].forEach(([source, mode]) =>
|
||||||
|
source?.addEventListener('click', () => openReview(mode, source))
|
||||||
|
);
|
||||||
|
boundary.requestSignOut = source => openReview('current', source || button);
|
||||||
const devicesButton = root.document.getElementById('active-devices');
|
const devicesButton = root.document.getElementById('active-devices');
|
||||||
const devicesSheet = root.document.getElementById('active-devices-sheet');
|
const devicesSheet = root.document.getElementById('active-devices-sheet');
|
||||||
const devicesStatus = root.document.getElementById('active-devices-status');
|
const devicesStatus = root.document.getElementById('active-devices-status');
|
||||||
|
|
@ -51,14 +67,6 @@
|
||||||
root.stackchainTodayTimerView?.beginDetour?.('security-center');
|
root.stackchainTodayTimerView?.beginDetour?.('security-center');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const securityFeatures = root.createFeatureLoader({
|
|
||||||
document: root.document,
|
|
||||||
urls: {
|
|
||||||
'security-center': root.document.querySelector(
|
|
||||||
'meta[name="stackchain-feature-security-center"]'
|
|
||||||
)?.content || '',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
let loadingSecurityCenter = false;
|
let loadingSecurityCenter = false;
|
||||||
const openSecurityCenter = async () => {
|
const openSecurityCenter = async () => {
|
||||||
if (loadingSecurityCenter) return;
|
if (loadingSecurityCenter) return;
|
||||||
|
|
@ -67,7 +75,7 @@
|
||||||
devicesSheet.hidden = false;
|
devicesSheet.hidden = false;
|
||||||
closeDevices?.focus();
|
closeDevices?.focus();
|
||||||
try {
|
try {
|
||||||
await securityFeatures.run('security-center', {
|
await features.run('security-center', {
|
||||||
trigger: devicesButton,
|
trigger: devicesButton,
|
||||||
status: devicesStatus,
|
status: devicesStatus,
|
||||||
retryLabel: 'Tap Active devices to retry.',
|
retryLabel: 'Tap Active devices to retry.',
|
||||||
|
|
@ -607,8 +615,8 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function signOutAllDevices() {
|
async function signOutAllDevices({ reviewed = false } = {}) {
|
||||||
const confirmed = confirmAction?.('Sign out every device? You will need to sign in again everywhere.');
|
const confirmed = reviewed || confirmAction?.('Sign out every device? You will need to sign in again everywhere.');
|
||||||
if (!confirmed) return false;
|
if (!confirmed) return false;
|
||||||
const response = await sessionFetch(base + 'api/v1/sessions', { method: 'DELETE' });
|
const response = await sessionFetch(base + 'api/v1/sessions', { method: 'DELETE' });
|
||||||
if (!response.ok) throw new Error('Could not sign out all devices');
|
if (!response.ok) throw new Error('Could not sign out all devices');
|
||||||
|
|
|
||||||
131
frontend/sign-out-review.js
Normal file
131
frontend/sign-out-review.js
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
(function (root, factory) {
|
||||||
|
if (typeof module === 'object' && module.exports) module.exports = factory;
|
||||||
|
else {
|
||||||
|
root.createSignOutReview = factory;
|
||||||
|
root.createSignOutReview.mount = boundary => {
|
||||||
|
const document = root.document;
|
||||||
|
const review = factory({
|
||||||
|
sheet: document.getElementById('sign-out-review-sheet'),
|
||||||
|
heading: document.getElementById('sign-out-review-heading'),
|
||||||
|
summary: document.getElementById('sign-out-review-summary'),
|
||||||
|
warning: document.getElementById('sign-out-review-warning'),
|
||||||
|
cancelButton: document.getElementById('cancel-sign-out'),
|
||||||
|
confirmButton: document.getElementById('confirm-sign-out'),
|
||||||
|
escapeTarget: document,
|
||||||
|
historyTarget: root,
|
||||||
|
history: root.history,
|
||||||
|
backgroundTargets: Array.from(document.querySelectorAll('body > :not(#sign-out-review-sheet)')),
|
||||||
|
getActiveElement: () => document.activeElement,
|
||||||
|
localStorage: root.localStorage,
|
||||||
|
sessionStorage: root.sessionStorage,
|
||||||
|
privateDatabases: root.stackchainPrivateDatabases,
|
||||||
|
inspectPrivateDatabases: root.inspectStackchainPrivateDatabases,
|
||||||
|
onConfirm: mode => mode === 'all'
|
||||||
|
? boundary.signOutAllDevices({ reviewed: true })
|
||||||
|
: boundary.signOut(),
|
||||||
|
});
|
||||||
|
review.start();
|
||||||
|
return review;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
})(typeof globalThis !== 'undefined' ? globalThis : this, function createSignOutReview(options) {
|
||||||
|
let mode = 'current';
|
||||||
|
let launcher = null;
|
||||||
|
let open = false;
|
||||||
|
let historyEntry = false;
|
||||||
|
|
||||||
|
function ownedItemCount(storage) {
|
||||||
|
if (!storage) return 0;
|
||||||
|
let count = 0;
|
||||||
|
for (let index = 0; index < storage.length; index += 1) {
|
||||||
|
if (storage.key(index)?.startsWith('stackchain.')) count += 1;
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function show(nextMode, source) {
|
||||||
|
mode = nextMode;
|
||||||
|
launcher = source;
|
||||||
|
const itemCount = ownedItemCount(options.localStorage)
|
||||||
|
+ (options.sessionStorage === options.localStorage ? 0 : ownedItemCount(options.sessionStorage));
|
||||||
|
let inventory = { recordCount: 0, unavailable: true };
|
||||||
|
try {
|
||||||
|
inventory = await options.inspectPrivateDatabases?.(options.privateDatabases) || inventory;
|
||||||
|
} catch (_error) { /* Unknown private work must use the guarded confirmation path. */ }
|
||||||
|
const recordCount = Math.max(0, Number(inventory.recordCount) || 0);
|
||||||
|
options.heading.textContent = mode === 'all' ? 'Review sign out on every device' : 'Review sign out';
|
||||||
|
options.summary.textContent = inventory.unavailable
|
||||||
|
? `This device has ${itemCount} private browser item${itemCount === 1 ? '' : 's'}; private work status is unknown.`
|
||||||
|
: `This device has ${itemCount} private browser item${itemCount === 1 ? '' : 's'} and ${recordCount} private work record${recordCount === 1 ? '' : 's'}.`;
|
||||||
|
const atRisk = itemCount > 0 || recordCount > 0 || inventory.unavailable;
|
||||||
|
if (mode === 'all') {
|
||||||
|
options.warning.textContent = atRisk
|
||||||
|
? 'Every device will need to sign in again. Private drafts or queued work on this device may not be synced and will be erased.'
|
||||||
|
: 'Every device will need to sign in again. This device will be cleared after its session is revoked.';
|
||||||
|
} else {
|
||||||
|
options.warning.textContent = atRisk
|
||||||
|
? 'Private drafts or queued work may not be synced. Signing out erases them from this device.'
|
||||||
|
: 'Signing out clears this device after the session is revoked.';
|
||||||
|
}
|
||||||
|
options.confirmButton.textContent = atRisk
|
||||||
|
? 'Sign out and erase private work'
|
||||||
|
: (mode === 'all' ? 'Sign out all devices' : 'Sign out');
|
||||||
|
options.sheet.hidden = false;
|
||||||
|
(options.backgroundTargets || []).forEach(target => { target.inert = true; });
|
||||||
|
open = true;
|
||||||
|
options.history?.pushState?.({ stackchainSignOutReview: true }, '');
|
||||||
|
historyEntry = Boolean(options.history?.back);
|
||||||
|
options.cancelButton.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function close({ restoreFocus = true } = {}) {
|
||||||
|
if (!open) return;
|
||||||
|
options.sheet.hidden = true;
|
||||||
|
(options.backgroundTargets || []).forEach(target => { target.inert = false; });
|
||||||
|
open = false;
|
||||||
|
if (restoreFocus) launcher?.focus?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
function dismiss() {
|
||||||
|
if (historyEntry) {
|
||||||
|
historyEntry = false;
|
||||||
|
options.history.back();
|
||||||
|
} else close();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirm() {
|
||||||
|
options.confirmButton.disabled = true;
|
||||||
|
try {
|
||||||
|
await options.onConfirm(mode);
|
||||||
|
close({ restoreFocus: false });
|
||||||
|
} catch (_error) {
|
||||||
|
options.warning.textContent = 'Sign out could not finish clearing private work. Close other Stackchain tabs, then retry.';
|
||||||
|
} finally {
|
||||||
|
options.confirmButton.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function start() {
|
||||||
|
options.launcher?.addEventListener('click', event => show('current', event.currentTarget || options.launcher));
|
||||||
|
options.allLauncher?.addEventListener('click', event => show('all', event.currentTarget || options.allLauncher));
|
||||||
|
options.cancelButton?.addEventListener('click', dismiss);
|
||||||
|
options.confirmButton?.addEventListener('click', confirm);
|
||||||
|
options.escapeTarget?.addEventListener('keydown', event => {
|
||||||
|
if (!open) return;
|
||||||
|
if (event.key === 'Escape') { event.preventDefault(); dismiss(); return; }
|
||||||
|
if (event.key !== 'Tab') return;
|
||||||
|
const focusable = Array.from(options.sheet.querySelectorAll('button:not([disabled])'));
|
||||||
|
const first = focusable[0];
|
||||||
|
const last = focusable[focusable.length - 1];
|
||||||
|
const active = options.getActiveElement?.();
|
||||||
|
if (!event.shiftKey && active === last) { event.preventDefault(); first?.focus(); }
|
||||||
|
if (event.shiftKey && active === first) { event.preventDefault(); last?.focus(); }
|
||||||
|
});
|
||||||
|
options.historyTarget?.addEventListener('popstate', () => {
|
||||||
|
historyEntry = false;
|
||||||
|
close();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { start, show, close };
|
||||||
|
});
|
||||||
|
|
@ -27,13 +27,14 @@ FEATURE_SOURCES = {
|
||||||
),
|
),
|
||||||
"pull-workflow": ("static/pull-sheet.js", "static/review-sheet.js", "static/release-receipt.js"),
|
"pull-workflow": ("static/pull-sheet.js", "static/review-sheet.js", "static/release-receipt.js"),
|
||||||
"push-notifications": ("static/push-notifications.js",),
|
"push-notifications": ("static/push-notifications.js",),
|
||||||
|
"sign-out": ("static/private-data-inventory.js", "static/sign-out-review.js"),
|
||||||
"device-setup": (
|
"device-setup": (
|
||||||
"static/install-app.js", "static/private-data-inventory.js", "static/private-device-data.js",
|
"static/install-app.js", "static/private-data-inventory.js", "static/private-device-data.js",
|
||||||
"static/device-storage.js", "static/mobile-device-setup.js",
|
"static/device-storage.js", "static/mobile-device-setup.js",
|
||||||
),
|
),
|
||||||
"security-center": ("static/security-center.js",),
|
"security-center": ("static/security-center.js",),
|
||||||
"today-timer": (
|
"today-timer": (
|
||||||
"static/conversation.js", "static/voice-transcript-store.js", "static/voice-conversation-capture.js", "static/mobile-launch.js", "static/mobile-insights.js", "static/mobile-app-shortcuts.js", "static/mobile-plan-today-nav.js", "static/mobile-find-work-nav.js", "static/mobile-pull-refresh.js", "static/live-data-status.js",
|
"static/conversation.js", "static/widgets.js", "static/voice-transcript-store.js", "static/voice-conversation-capture.js", "static/mobile-launch.js", "static/mobile-insights.js", "static/mobile-app-shortcuts.js", "static/mobile-plan-today-nav.js", "static/mobile-find-work-nav.js", "static/mobile-pull-refresh.js", "static/live-data-status.js",
|
||||||
"static/today-completion.js", "static/card-planning.js", "static/work-detail-position.js", "static/work-route.js", "static/commands.js", "static/saved-searches.js", "static/task-overlay-history.js", "static/search-preview.js", "static/mobile-search-preview-nav.js", "static/search-reply-draft-store.js", "static/conversation-reply-draft-store.js", "static/conversation-photo-drafts.js", "static/search-defer.js", "static/mobile-search-viewport.js", "static/agenda-replan.js", "static/agenda-calendar.js", "static/my-work.js", "static/protect-today.js", "static/mobile-today-command-bar.js", "static/mobile-task-dock.js", "static/mobile-work-entry.js", "static/mobile-queue-launcher.js", "static/mobile-delivery-recovery.js", "static/mobile-start-day.js", "static/update-triage-session.js", "static/update-review-handoff.js", "static/update-triage-launcher.js", "static/update-triage-gesture.js", "static/notification-undo.js", "static/today-timer.js", "static/today-break.js", "static/today-progress.js", "static/today-lock-screen.js", "static/today-session-sync.js", "static/today-recap.js", "static/today-wrap-up.js", "static/today-summary.js", "static/today-handoff.js",
|
"static/today-completion.js", "static/card-planning.js", "static/work-detail-position.js", "static/work-route.js", "static/commands.js", "static/saved-searches.js", "static/task-overlay-history.js", "static/search-preview.js", "static/mobile-search-preview-nav.js", "static/search-reply-draft-store.js", "static/conversation-reply-draft-store.js", "static/conversation-photo-drafts.js", "static/search-defer.js", "static/mobile-search-viewport.js", "static/agenda-replan.js", "static/agenda-calendar.js", "static/my-work.js", "static/protect-today.js", "static/mobile-today-command-bar.js", "static/mobile-task-dock.js", "static/mobile-work-entry.js", "static/mobile-queue-launcher.js", "static/mobile-delivery-recovery.js", "static/mobile-start-day.js", "static/update-triage-session.js", "static/update-review-handoff.js", "static/update-triage-launcher.js", "static/update-triage-gesture.js", "static/notification-undo.js", "static/today-timer.js", "static/today-break.js", "static/today-progress.js", "static/today-lock-screen.js", "static/today-session-sync.js", "static/today-recap.js", "static/today-wrap-up.js", "static/today-summary.js", "static/today-handoff.js",
|
||||||
"static/today-rollover.js", "static/later-work.js", "static/detail-defer.js", "static/later-picker.js", "static/drafts.js", "static/unfiled-captures.js", "static/unfiled-draft-sync.js",
|
"static/today-rollover.js", "static/later-work.js", "static/detail-defer.js", "static/later-picker.js", "static/drafts.js", "static/unfiled-captures.js", "static/unfiled-draft-sync.js",
|
||||||
"static/assign-and-start.js", "static/filed-claim.js", "static/queue-today.js", "static/create-and-start.js",
|
"static/assign-and-start.js", "static/filed-claim.js", "static/queue-today.js", "static/create-and-start.js",
|
||||||
|
|
|
||||||
76
tests/e2e/test_mobile_sign_out_release.py
Normal file
76
tests/e2e/test_mobile_sign_out_release.py
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1":
|
||||||
|
pytest.skip("packaged mobile sign-out journey runs only in its gated CI job", allow_module_level=True)
|
||||||
|
pytest.importorskip("playwright.sync_api")
|
||||||
|
from playwright.sync_api import expect, sync_playwright
|
||||||
|
|
||||||
|
from fake_gitea import FakeGiteaServer
|
||||||
|
from test_mobile_offline_issue_release import ACCESS_TOKEN, ROOT, release_server
|
||||||
|
|
||||||
|
|
||||||
|
def test_release_artifact_reviews_private_work_before_mobile_sign_out(tmp_path: Path):
|
||||||
|
archives = sorted((ROOT / "dist").glob("stackchain-dashboard-*.tar.gz"))
|
||||||
|
assert len(archives) == 1, "browser job must download exactly one assembled release archive"
|
||||||
|
|
||||||
|
fake = FakeGiteaServer(("127.0.0.1", 0))
|
||||||
|
thread = threading.Thread(target=fake.serve_forever, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
delete_requests: list[str] = []
|
||||||
|
browser_errors: list[str] = []
|
||||||
|
try:
|
||||||
|
with release_server(archives[0], tmp_path, f"http://127.0.0.1:{fake.server_port}") as origin, sync_playwright() as playwright:
|
||||||
|
browser = playwright.chromium.launch(args=["--ignore-certificate-errors"])
|
||||||
|
context = browser.new_context(
|
||||||
|
viewport={"width": 390, "height": 844}, ignore_https_errors=True
|
||||||
|
)
|
||||||
|
page = context.new_page()
|
||||||
|
page.on("pageerror", lambda error: browser_errors.append(error.stack or str(error)))
|
||||||
|
page.on(
|
||||||
|
"request",
|
||||||
|
lambda request: delete_requests.append(request.url)
|
||||||
|
if request.method == "DELETE" and "/api/v1/session" in request.url
|
||||||
|
else None,
|
||||||
|
)
|
||||||
|
page.goto(origin + "/", wait_until="networkidle")
|
||||||
|
page.locator('input[name="device_label"]').fill("Sign-out review phone")
|
||||||
|
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
|
||||||
|
page.locator("#submit-sign-in").click()
|
||||||
|
page.wait_for_url(origin + "/", wait_until="networkidle")
|
||||||
|
page.evaluate("localStorage.setItem('stackchain.release-test-draft', 'unsynced')")
|
||||||
|
|
||||||
|
launcher = page.locator("#sign-out")
|
||||||
|
page.locator("#app-menu-toggle").click()
|
||||||
|
launcher.click()
|
||||||
|
sheet = page.locator("#sign-out-review-sheet")
|
||||||
|
expect(sheet).to_be_visible()
|
||||||
|
expect(page.locator("#sign-out-review-summary")).to_contain_text("private browser item")
|
||||||
|
expect(page.locator("#sign-out-review-warning")).to_contain_text("may not be synced")
|
||||||
|
expect(page.locator("#confirm-sign-out")).to_have_text("Sign out and erase private work")
|
||||||
|
assert page.evaluate("document.querySelector('main').inert") is True
|
||||||
|
panel = page.locator(".sign-out-review-panel").bounding_box()
|
||||||
|
assert panel and panel["height"] <= 844
|
||||||
|
for selector in ("#cancel-sign-out", "#confirm-sign-out"):
|
||||||
|
bounds = page.locator(selector).bounding_box()
|
||||||
|
assert bounds and bounds["height"] >= 44
|
||||||
|
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||||
|
assert delete_requests == []
|
||||||
|
|
||||||
|
page.keyboard.press("Escape")
|
||||||
|
expect(sheet).to_be_hidden()
|
||||||
|
expect(launcher).to_be_focused()
|
||||||
|
assert page.evaluate("document.querySelector('main').inert") is False
|
||||||
|
assert page.evaluate("localStorage.getItem('stackchain.release-test-draft')") == "unsynced"
|
||||||
|
assert delete_requests == []
|
||||||
|
assert browser_errors == []
|
||||||
|
browser.close()
|
||||||
|
finally:
|
||||||
|
fake.shutdown()
|
||||||
|
fake.server_close()
|
||||||
|
thread.join(timeout=5)
|
||||||
|
|
@ -63,6 +63,7 @@ def test_release_promotion_waits_for_artifact_mobile_offline_journey():
|
||||||
"tests/e2e/test_mobile_search_preview_navigation.py "
|
"tests/e2e/test_mobile_search_preview_navigation.py "
|
||||||
"tests/e2e/test_mobile_find_work_release.py "
|
"tests/e2e/test_mobile_find_work_release.py "
|
||||||
"tests/e2e/test_mobile_home_bootstrap_release.py "
|
"tests/e2e/test_mobile_home_bootstrap_release.py "
|
||||||
|
"tests/e2e/test_mobile_sign_out_release.py "
|
||||||
"tests/e2e/test_mobile_today_handoff_release.py "
|
"tests/e2e/test_mobile_today_handoff_release.py "
|
||||||
"tests/e2e/test_mobile_today_wrap_up_release.py "
|
"tests/e2e/test_mobile_today_wrap_up_release.py "
|
||||||
"tests/e2e/test_mobile_today_summary_release.py "
|
"tests/e2e/test_mobile_today_summary_release.py "
|
||||||
|
|
|
||||||
|
|
@ -36,8 +36,8 @@ def test_page_runtime_is_one_deterministic_content_addressed_bundle(tmp_path):
|
||||||
|
|
||||||
changed_frontend = tmp_path / "frontend"
|
changed_frontend = tmp_path / "frontend"
|
||||||
shutil.copytree(FRONTEND, changed_frontend)
|
shutil.copytree(FRONTEND, changed_frontend)
|
||||||
(changed_frontend / "widgets.js").write_text(
|
(changed_frontend / "session.js").write_text(
|
||||||
(changed_frontend / "widgets.js").read_text() + "\n// changed\n"
|
(changed_frontend / "session.js").read_text() + "\n// changed\n"
|
||||||
)
|
)
|
||||||
|
|
||||||
changed = build_frontend(changed_frontend)
|
changed = build_frontend(changed_frontend)
|
||||||
|
|
@ -49,7 +49,7 @@ def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path):
|
||||||
first = build_frontend(FRONTEND)
|
first = build_frontend(FRONTEND)
|
||||||
|
|
||||||
assert set(first.feature_bundles) == {
|
assert set(first.feature_bundles) == {
|
||||||
"comment-actions", "issue-capture", "pull-workflow", "push-notifications", "device-setup",
|
"comment-actions", "issue-capture", "pull-workflow", "push-notifications", "sign-out", "device-setup",
|
||||||
"today-timer", "security-center",
|
"today-timer", "security-center",
|
||||||
}
|
}
|
||||||
assert first.dashboard_html.count("<script src=") == 1
|
assert first.dashboard_html.count("<script src=") == 1
|
||||||
|
|
@ -75,6 +75,8 @@ def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path):
|
||||||
assert b"function createConversationPager" not in first.feature_bundles["comment-actions"].runtime_bytes
|
assert b"function createConversationPager" not in first.feature_bundles["comment-actions"].runtime_bytes
|
||||||
assert b"function createMobileDeliveryRecovery" not in first.runtime_bytes
|
assert b"function createMobileDeliveryRecovery" not in first.runtime_bytes
|
||||||
assert b"function createMobileDeliveryRecovery" in first.feature_bundles["today-timer"].runtime_bytes
|
assert b"function createMobileDeliveryRecovery" in first.feature_bundles["today-timer"].runtime_bytes
|
||||||
|
assert b"function createSignOutReview" not in first.runtime_bytes
|
||||||
|
assert b"function createSignOutReview" in first.feature_bundles["sign-out"].runtime_bytes
|
||||||
assert b"function createDetailDefer" not in first.runtime_bytes
|
assert b"function createDetailDefer" not in first.runtime_bytes
|
||||||
assert b"function createDetailDefer" in first.feature_bundles["today-timer"].runtime_bytes
|
assert b"function createDetailDefer" in first.feature_bundles["today-timer"].runtime_bytes
|
||||||
assert b"gitea_time_logged" not in first.runtime_bytes
|
assert b"gitea_time_logged" not in first.runtime_bytes
|
||||||
|
|
|
||||||
|
|
@ -1159,6 +1159,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||||
"/dashboard/static/icons/stackchain-192.png",
|
"/dashboard/static/icons/stackchain-192.png",
|
||||||
"/dashboard/static/icons/stackchain-512.png",
|
"/dashboard/static/icons/stackchain-512.png",
|
||||||
"/dashboard/static/session.js",
|
"/dashboard/static/session.js",
|
||||||
|
"/dashboard/static/sign-out-review.js",
|
||||||
"/dashboard/static/feature-loader.js",
|
"/dashboard/static/feature-loader.js",
|
||||||
"/dashboard/static/workspace-bootstrap.js",
|
"/dashboard/static/workspace-bootstrap.js",
|
||||||
"/dashboard/static/conversation-action-hydrator.js",
|
"/dashboard/static/conversation-action-hydrator.js",
|
||||||
|
|
|
||||||
193
tests/test_sign_out_review.py
Normal file
193
tests/test_sign_out_review.py
Normal file
|
|
@ -0,0 +1,193 @@
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
MODULE = Path(__file__).parents[1] / "frontend" / "sign-out-review.js"
|
||||||
|
|
||||||
|
|
||||||
|
def run_scenario(scenario: str, *, inventory: str = "{recordCount:3, unavailable:false}") -> dict:
|
||||||
|
harness = r"""
|
||||||
|
const createSignOutReview = require(__MODULE__);
|
||||||
|
class Target {
|
||||||
|
constructor() { this.listeners={}; this.hidden=false; this.textContent=''; this.disabled=false; this.dataset={}; }
|
||||||
|
addEventListener(name, callback) { (this.listeners[name] ||= []).push(callback); }
|
||||||
|
async dispatch(name, event={}) { for (const callback of this.listeners[name] || []) await callback({preventDefault() { state.prevented = true; }, shiftKey:false, currentTarget:this, ...event}); }
|
||||||
|
focus() { state.focused=this; }
|
||||||
|
contains(target) { return target === this; }
|
||||||
|
querySelectorAll() { return [cancelButton, confirmButton]; }
|
||||||
|
}
|
||||||
|
const state={signedOut:[], focused:null, historyPushes:0, historyBacks:0};
|
||||||
|
const launcher=new Target();
|
||||||
|
const allLauncher=new Target();
|
||||||
|
const sheet=new Target(); sheet.hidden=true;
|
||||||
|
const heading=new Target();
|
||||||
|
const summary=new Target();
|
||||||
|
const warning=new Target();
|
||||||
|
const cancelButton=new Target();
|
||||||
|
const confirmButton=new Target();
|
||||||
|
const escapeTarget=new Target();
|
||||||
|
const historyTarget=new Target();
|
||||||
|
const background=new Target(); background.inert=false;
|
||||||
|
const storage={
|
||||||
|
values:new Map([['stackchain.private','secret'],['stackchain.draft','draft'],['gitea.preference','keep']]),
|
||||||
|
get length() { return this.values.size; },
|
||||||
|
key(index) { return Array.from(this.values.keys())[index] || null; },
|
||||||
|
};
|
||||||
|
const history={
|
||||||
|
pushState() { state.historyPushes += 1; },
|
||||||
|
back() { state.historyBacks += 1; historyTarget.dispatch('popstate'); },
|
||||||
|
};
|
||||||
|
const review=createSignOutReview({
|
||||||
|
launcher, allLauncher, sheet, heading, summary, warning, cancelButton, confirmButton,
|
||||||
|
escapeTarget, historyTarget, history, localStorage:storage, sessionStorage:storage,
|
||||||
|
backgroundTargets:[background], getActiveElement:() => state.focused,
|
||||||
|
privateDatabases:['private-work'], inspectPrivateDatabases:async () => (__INVENTORY__),
|
||||||
|
onConfirm:async mode => { if (state.failConfirm) throw new Error('blocked purge'); state.signedOut.push(mode); },
|
||||||
|
});
|
||||||
|
(async () => { review.start(); __SCENARIO__ })().catch(error => { console.error(error); process.exit(1); });
|
||||||
|
""".replace("__MODULE__", json.dumps(str(MODULE))).replace("__INVENTORY__", inventory).replace("__SCENARIO__", scenario)
|
||||||
|
completed = subprocess.run(["node", "-e", harness], capture_output=True, text=True)
|
||||||
|
assert completed.returncode == 0, completed.stderr
|
||||||
|
return json.loads(completed.stdout)
|
||||||
|
|
||||||
|
|
||||||
|
def test_private_work_is_counted_before_sign_out_and_requires_sheet_confirmation():
|
||||||
|
result = run_scenario("""
|
||||||
|
await launcher.dispatch('click');
|
||||||
|
const before=[...state.signedOut];
|
||||||
|
await confirmButton.dispatch('click');
|
||||||
|
process.stdout.write(JSON.stringify({
|
||||||
|
before, after:state.signedOut, hidden:sheet.hidden, heading:heading.textContent,
|
||||||
|
summary:summary.textContent, warning:warning.textContent, action:confirmButton.textContent,
|
||||||
|
}));
|
||||||
|
""")
|
||||||
|
|
||||||
|
assert result == {
|
||||||
|
"before": [],
|
||||||
|
"after": ["current"],
|
||||||
|
"hidden": True,
|
||||||
|
"heading": "Review sign out",
|
||||||
|
"summary": "This device has 2 private browser items and 3 private work records.",
|
||||||
|
"warning": "Private drafts or queued work may not be synced. Signing out erases them from this device.",
|
||||||
|
"action": "Sign out and erase private work",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_keyboard_focus_is_contained_inside_open_review():
|
||||||
|
result = run_scenario("""
|
||||||
|
await launcher.dispatch('click');
|
||||||
|
state.focused=confirmButton;
|
||||||
|
await escapeTarget.dispatch('keydown', {key:'Tab'});
|
||||||
|
const wrappedForward=state.focused === cancelButton;
|
||||||
|
state.focused=cancelButton;
|
||||||
|
await escapeTarget.dispatch('keydown', {key:'Tab', shiftKey:true});
|
||||||
|
process.stdout.write(JSON.stringify({wrappedForward, wrappedBackward:state.focused === confirmButton, prevented:state.prevented || false}));
|
||||||
|
""")
|
||||||
|
|
||||||
|
assert result == {"wrappedForward": True, "wrappedBackward": True, "prevented": True}
|
||||||
|
|
||||||
|
|
||||||
|
def test_cancel_removes_review_history_and_preserves_session_and_private_work():
|
||||||
|
result = run_scenario("""
|
||||||
|
await launcher.dispatch('click');
|
||||||
|
await cancelButton.dispatch('click');
|
||||||
|
process.stdout.write(JSON.stringify({
|
||||||
|
signedOut:state.signedOut, hidden:sheet.hidden, historyPushes:state.historyPushes,
|
||||||
|
historyBacks:state.historyBacks, launcherFocused:state.focused === launcher,
|
||||||
|
}));
|
||||||
|
""")
|
||||||
|
|
||||||
|
assert result == {
|
||||||
|
"signedOut": [], "hidden": True, "historyPushes": 1,
|
||||||
|
"historyBacks": 1, "launcherFocused": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_open_review_makes_background_inert_until_cancelled():
|
||||||
|
result = run_scenario("""
|
||||||
|
await launcher.dispatch('click');
|
||||||
|
const inertWhileOpen=background.inert;
|
||||||
|
await cancelButton.dispatch('click');
|
||||||
|
process.stdout.write(JSON.stringify({inertWhileOpen, inertAfterCancel:background.inert}));
|
||||||
|
""")
|
||||||
|
|
||||||
|
assert result == {"inertWhileOpen": True, "inertAfterCancel": False}
|
||||||
|
|
||||||
|
|
||||||
|
def test_sign_out_all_review_combines_global_session_and_local_work_warning():
|
||||||
|
result = run_scenario("""
|
||||||
|
await allLauncher.dispatch('click');
|
||||||
|
const before=[...state.signedOut];
|
||||||
|
await confirmButton.dispatch('click');
|
||||||
|
process.stdout.write(JSON.stringify({
|
||||||
|
before, after:state.signedOut, heading:heading.textContent,
|
||||||
|
warning:warning.textContent, action:confirmButton.textContent,
|
||||||
|
}));
|
||||||
|
""")
|
||||||
|
|
||||||
|
assert result == {
|
||||||
|
"before": [], "after": ["all"],
|
||||||
|
"heading": "Review sign out on every device",
|
||||||
|
"warning": "Every device will need to sign in again. Private drafts or queued work on this device may not be synced and will be erased.",
|
||||||
|
"action": "Sign out and erase private work",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_failed_inventory_never_claims_the_device_is_empty():
|
||||||
|
result = run_scenario("""
|
||||||
|
await launcher.dispatch('click');
|
||||||
|
process.stdout.write(JSON.stringify({
|
||||||
|
signedOut:state.signedOut, hidden:sheet.hidden,
|
||||||
|
summary:summary.textContent, warning:warning.textContent, action:confirmButton.textContent,
|
||||||
|
}));
|
||||||
|
""", inventory="Promise.reject(new Error('blocked'))")
|
||||||
|
|
||||||
|
assert result == {
|
||||||
|
"signedOut": [], "hidden": False,
|
||||||
|
"summary": "This device has 2 private browser items; private work status is unknown.",
|
||||||
|
"warning": "Private drafts or queued work may not be synced. Signing out erases them from this device.",
|
||||||
|
"action": "Sign out and erase private work",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_failed_purge_keeps_review_open_with_recovery_guidance():
|
||||||
|
result = run_scenario("""
|
||||||
|
await launcher.dispatch('click');
|
||||||
|
state.failConfirm=true;
|
||||||
|
try { await confirmButton.dispatch('click'); } catch (_error) {}
|
||||||
|
process.stdout.write(JSON.stringify({
|
||||||
|
signedOut:state.signedOut, hidden:sheet.hidden, disabled:confirmButton.disabled,
|
||||||
|
warning:warning.textContent,
|
||||||
|
}));
|
||||||
|
""")
|
||||||
|
|
||||||
|
assert result == {
|
||||||
|
"signedOut": [], "hidden": False, "disabled": False,
|
||||||
|
"warning": "Sign out could not finish clearing private work. Close other Stackchain tabs, then retry.",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_renders_and_wires_phone_safe_sign_out_review():
|
||||||
|
root = MODULE.parents[1]
|
||||||
|
html = (root / "frontend" / "index.html").read_text()
|
||||||
|
css = (root / "frontend" / "dashboard.css").read_text()
|
||||||
|
session = (root / "frontend" / "session.js").read_text()
|
||||||
|
review = MODULE.read_text()
|
||||||
|
worker = (root / "frontend" / "service-worker.js").read_text()
|
||||||
|
|
||||||
|
assert 'id="sign-out-review-sheet"' in html
|
||||||
|
assert 'role="dialog" aria-modal="true" aria-labelledby="sign-out-review-heading"' in html
|
||||||
|
assert 'id="sign-out-review-summary"' in html
|
||||||
|
assert 'id="sign-out-review-warning"' in html
|
||||||
|
assert 'id="cancel-sign-out"' in html
|
||||||
|
assert 'id="confirm-sign-out"' in html
|
||||||
|
assert html.index('static/private-data-inventory.js') < html.index('static/session.js')
|
||||||
|
assert html.index('static/sign-out-review.js') < html.index('static/session.js')
|
||||||
|
assert "root.createSignOutReview" in session
|
||||||
|
assert "inspectPrivateDatabases: root.inspectStackchainPrivateDatabases" in review
|
||||||
|
assert "stackchain-feature-sign-out" in session
|
||||||
|
assert "max-height:100dvh" in css
|
||||||
|
assert ".sign-out-review-actions button { min-height:44px" in css
|
||||||
|
assert "env(safe-area-inset-bottom)" in css
|
||||||
|
assert "BASE + 'static/sign-out-review.js'" in worker
|
||||||
Loading…
Reference in New Issue
Block a user