All checks were successful
CD / Build and push images (push) Successful in 2m58s
CI / Lint, typecheck, test (push) Successful in 1m55s
CI / Auth e2e pack (push) Successful in 2m25s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m12s
CD / Promote to Int (push) Successful in 11s
Revoking write access must terminate live sessions and let a user with pending offline edits export them rather than lose them silently. Backend (generic, reused by M5 grants #53): - packages/shared: POND_ACCESS_CHANGED_CHANNEL, the LISTEN/NOTIFY channel shared by api and collab. - api: PondAccessNotifier emits pg_notify(pond_access_changed, pondId) on a permission-relevant change; the single generic seam for revocation. Wired into pond soft-delete as the interim trigger (see==modify until #53). - collab: a dedicated-connection LISTEN listener (LISTEN is connection- bound, not pooled) that, on a notification, closes every open connection to the pond's open pages. Clients then reconnect and the api re-issues a token reflecting current access (downgrade to ro, or 403/404). Reconnects and re-LISTENs if its connection drops. Frontend: - use-collab-provider: a refused token (403/404) on (re)connect sets accessRevoked and stops the reconnect loop; exposes discardLocal. - AccessRevokedDialog: keeps local content visible and offers Markdown copy/download (derived from the live editor doc, so offline edits are included) and an explicit discard that clears IndexedDB. de+en strings. Tests: collab DB-backed integration test proves a direct NOTIFY closes a live session within seconds (AC1) and leaves unrelated ponds untouched; listener unit tests; api test asserts soft-delete fires the notifier; web test for the export Markdown derivation. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PGdhRiwU1WRL4XxJfZYipY
83 lines
2.8 KiB
TypeScript
83 lines
2.8 KiB
TypeScript
import type { Editor } from '@tiptap/react';
|
|
import { useState } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
|
|
import { deriveMarkdownFromEditorJSON } from './derive-markdown';
|
|
|
|
/**
|
|
* Shown when edit access to the open page was revoked while the user had
|
|
* unsynced changes (issue #39, realtime-collaboration.md §Offline). The local
|
|
* content stays visible behind the dialog; the user can export it as Markdown
|
|
* before losing it, and discards the device-local copy only on an explicit
|
|
* choice.
|
|
*/
|
|
export function AccessRevokedDialog({
|
|
editor,
|
|
slug,
|
|
onDiscard,
|
|
}: {
|
|
editor: Editor;
|
|
slug: string;
|
|
onDiscard: () => Promise<void>;
|
|
}): React.JSX.Element {
|
|
const { t } = useTranslation('editor');
|
|
const [copyStatus, setCopyStatus] = useState<'idle' | 'copied' | 'error'>('idle');
|
|
|
|
// Derive Markdown from the live editor doc, so it reflects the local edits
|
|
// the server never received (see derive-markdown.ts for the schema handling).
|
|
function currentMarkdown(): string {
|
|
return deriveMarkdownFromEditorJSON(editor.getJSON());
|
|
}
|
|
|
|
async function copyMarkdown(): Promise<void> {
|
|
try {
|
|
await navigator.clipboard.writeText(currentMarkdown());
|
|
setCopyStatus('copied');
|
|
} catch {
|
|
setCopyStatus('error');
|
|
}
|
|
setTimeout(() => setCopyStatus('idle'), 2000);
|
|
}
|
|
|
|
function downloadMarkdown(): void {
|
|
const blob = new Blob([currentMarkdown()], { type: 'text/markdown;charset=utf-8' });
|
|
const url = URL.createObjectURL(blob);
|
|
const anchor = document.createElement('a');
|
|
anchor.href = url;
|
|
anchor.download = `${slug}.md`;
|
|
document.body.append(anchor);
|
|
anchor.click();
|
|
anchor.remove();
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
|
|
async function discard(): Promise<void> {
|
|
if (!window.confirm(t('accessRevoked.discardConfirm'))) return;
|
|
await onDiscard();
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className="editor-banner editor-banner--error access-revoked"
|
|
role="alertdialog"
|
|
aria-label={t('accessRevoked.title')}
|
|
>
|
|
<p className="access-revoked__title">{t('accessRevoked.title')}</p>
|
|
<p className="access-revoked__description">{t('accessRevoked.description')}</p>
|
|
<div className="access-revoked__actions">
|
|
<button type="button" className="button" onClick={() => void copyMarkdown()}>
|
|
{copyStatus === 'idle' && t('accessRevoked.copy')}
|
|
{copyStatus === 'copied' && t('accessRevoked.copied')}
|
|
{copyStatus === 'error' && t('accessRevoked.copyFailed')}
|
|
</button>
|
|
<button type="button" className="button" onClick={downloadMarkdown}>
|
|
{t('accessRevoked.download')}
|
|
</button>
|
|
<button type="button" className="button" onClick={() => void discard()}>
|
|
{t('accessRevoked.discard')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|