diff --git a/packages/host/app/components/operator-mode/archived-realm-state.gts b/packages/host/app/components/operator-mode/archived-realm-state.gts new file mode 100644 index 0000000000..95ebd92da5 --- /dev/null +++ b/packages/host/app/components/operator-mode/archived-realm-state.gts @@ -0,0 +1,80 @@ +import type { TemplateOnlyComponent } from '@ember/component/template-only'; + +import { CardHeader } from '@cardstack/boxel-ui/components'; +import type { MenuItem } from '@cardstack/boxel-ui/helpers'; +import { Lock } from '@cardstack/boxel-ui/icons'; + +import type { CardErrorJSONAPI } from '@cardstack/host/services/store'; + +interface Signature { + Args: { + error: CardErrorJSONAPI; + headerOptions?: { + isTopCard?: boolean; + moreOptionsMenuItems?: MenuItem[]; + onClose?: () => void; + }; + }; + Element: HTMLElement; +} + +// Shown in place of card chrome when a realm responds 403 (archived). An +// archived realm is sealed for everyone, so there is no read-only browsing — +// the only way back in is to restore it. +const ArchivedRealmState: TemplateOnlyComponent = ; + +export default ArchivedRealmState; diff --git a/packages/host/app/components/operator-mode/stack-item.gts b/packages/host/app/components/operator-mode/stack-item.gts index 004279269b..2b858727b9 100644 --- a/packages/host/app/components/operator-mode/stack-item.gts +++ b/packages/host/app/components/operator-mode/stack-item.gts @@ -82,6 +82,7 @@ import ElementTracker, { } from '../../resources/element-tracker'; import CardRenderer from '../card-renderer'; +import ArchivedRealmState from './archived-realm-state'; import CardError from './card-error'; import DeleteModal from './delete-modal'; @@ -715,6 +716,18 @@ export default class OperatorModeStackItem extends Component { return this.cardResource?.cardError; } + // A realm that responds 403 (archived) carries the X-Boxel-Realm-Archived + // marker (exposed to cross-origin clients via Access-Control-Expose-Headers). + // The realm only returns this to callers it has already authorized, so its + // presence is an unambiguous signal to render the sealed state rather than a + // generic card error. + private get isArchivedRealmError() { + return ( + this.cardError?.meta?.responseHeaders?.['x-boxel-realm-archived'] === + 'true' + ); + } + private get isWideFormat() { if (!this.card) { return false; @@ -986,36 +999,66 @@ export default class OperatorModeStackItem extends Component { {{else if this.showError}} {{! this is for types--this.cardError is always true in this case !}} {{#if this.cardError}} - + {{#if this.isArchivedRealmError}} + + {{else}} + + {{/if}} {{/if}} {{else if this.card}} {{this.setWindowTitle}} diff --git a/packages/host/tests/integration/components/operator-mode-test.gts b/packages/host/tests/integration/components/operator-mode-test.gts index 05d1e363c5..c0fcc52556 100644 --- a/packages/host/tests/integration/components/operator-mode-test.gts +++ b/packages/host/tests/integration/components/operator-mode-test.gts @@ -95,6 +95,62 @@ module('Integration | operator-mode | basics', function (hooks) { ); }); + test('navigating to an archived realm shows the sealed state, not card chrome or a generic error', async function (assert) { + // A realm sealed by the archive flag answers content requests with 403 and + // the X-Boxel-Realm-Archived marker. Intercept the card fetch to reproduce + // that response and assert the host renders the sealed state. + let networkService = getService('network'); + networkService.virtualNetwork.mount( + async (req: Request) => { + if (req.method === 'GET' && req.url.includes('Person/fadhlan')) { + return new Response( + JSON.stringify({ + errors: [ + { + status: '403', + code: 'archived', + title: 'Realm Archived', + detail: `Realm ${testRealmURL} is archived`, + }, + ], + }), + { + status: 403, + headers: { + 'Content-Type': 'application/vnd.api+json', + 'X-Boxel-Realm-Archived': 'true', + 'X-Boxel-Realm-Url': testRealmURL, + }, + }, + ); + } + return null; + }, + { prepend: true }, + ); + + ctx.setCardInOperatorModeState(`${testRealmURL}Person/fadhlan`); + await renderComponent( + class TestDriver extends GlimmerComponent { + + }, + ); + + await waitFor('[data-test-archived-realm-state]'); + assert + .dom('[data-test-archived-realm-state]') + .containsText( + 'This workspace is archived', + 'the sealed/archived state is shown', + ); + assert + .dom('[data-test-boxel-card-header-title]') + .containsText('Workspace Archived', 'the archived header is shown'); + assert + .dom('[data-test-card-error]') + .doesNotExist('a generic card error is not shown for an archived realm'); + }); + module( 'card with an error that has a last known good state', function (hooks) {