Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<Signature> = <template>
<CardHeader
class='archived-header'
@cardTypeDisplayName='Workspace Archived'
@cardTypeIcon={{Lock}}
@isTopCard={{@headerOptions.isTopCard}}
@moreOptionsMenuItems={{@headerOptions.moreOptionsMenuItems}}
@onClose={{@headerOptions.onClose}}
...attributes
/>
<div class='archived-realm-state' data-test-archived-realm-state>
<Lock class='icon' />
<div class='message'>
<p class='headline'>This workspace is archived</p>
<p class='detail'>
Restore it from the workspace chooser, or ask an owner to restore it.
</p>
</div>
</div>
<style scoped>
.icon {
height: 100px;
width: 100px;
color: var(--boxel-400);
}
.archived-realm-state {
display: flex;
height: 100%;
align-content: center;
justify-content: center;
flex-wrap: wrap;
gap: var(--boxel-sp-xs);
padding: var(--boxel-sp);
}
.message {
width: 100%;
text-align: center;
text-wrap: pretty;
}
.headline {
margin: 0;
font: 600 var(--boxel-font);
}
.detail {
margin: var(--boxel-sp-xxs) 0 0;
color: var(--boxel-450);
font: var(--boxel-font-sm);
}
.archived-header {
min-height: var(--boxel-form-control-height);
background-color: var(--boxel-100);
box-shadow: 0 1px 0 0 rgba(0 0 0 / 15%);
}
</style>
</template>;

export default ArchivedRealmState;
103 changes: 73 additions & 30 deletions packages/host/app/components/operator-mode/stack-item.gts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -715,6 +716,18 @@ export default class OperatorModeStackItem extends Component<Signature> {
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'
);
}
Comment thread
lukemelia marked this conversation as resolved.

private get isWideFormat() {
if (!this.card) {
return false;
Expand Down Expand Up @@ -986,36 +999,66 @@ export default class OperatorModeStackItem extends Component<Signature> {
{{else if this.showError}}
{{! this is for types--this.cardError is always true in this case !}}
{{#if this.cardError}}
<CardError
@error={{this.cardError}}
@viewInCodeMode={{true}}
@headerOptions={{this.cardErrorHeaderOptions}}
class='stack-item-header'
style={{cssVar
boxel-card-header-icon-container-min-width=(if
this.isBuried '50px' '95px'
)
boxel-card-header-actions-min-width=(if
this.isBuried '50px' '95px'
)
boxel-card-header-background-color=this.headerColor
boxel-card-header-text-color=(getContrastColor this.headerColor)
realm-icon-background-color=(getContrastColor
this.headerColor 'transparent'
)
realm-icon-border-color=(getContrastColor
this.headerColor 'transparent' 'rgba(0 0 0 / 15%)'
)
}}
role={{if this.isBuried 'button' 'banner'}}
{{on
'click'
(optional
(if this.isBuried (fn @dismissStackedCardsAbove @index))
)
}}
data-test-stack-card-header
/>
{{#if this.isArchivedRealmError}}
<ArchivedRealmState
@error={{this.cardError}}
@headerOptions={{this.cardErrorHeaderOptions}}
class='stack-item-header'
style={{cssVar
boxel-card-header-icon-container-min-width=(if
this.isBuried '50px' '95px'
)
boxel-card-header-actions-min-width=(if
this.isBuried '50px' '95px'
)
boxel-card-header-background-color=this.headerColor
boxel-card-header-text-color=(getContrastColor
this.headerColor
)
}}
role={{if this.isBuried 'button' 'banner'}}
{{on
'click'
(optional
(if this.isBuried (fn @dismissStackedCardsAbove @index))
)
}}
data-test-stack-card-header
/>
{{else}}
<CardError
@error={{this.cardError}}
@viewInCodeMode={{true}}
@headerOptions={{this.cardErrorHeaderOptions}}
class='stack-item-header'
style={{cssVar
boxel-card-header-icon-container-min-width=(if
this.isBuried '50px' '95px'
)
boxel-card-header-actions-min-width=(if
this.isBuried '50px' '95px'
)
boxel-card-header-background-color=this.headerColor
boxel-card-header-text-color=(getContrastColor
this.headerColor
)
realm-icon-background-color=(getContrastColor
this.headerColor 'transparent'
)
realm-icon-border-color=(getContrastColor
this.headerColor 'transparent' 'rgba(0 0 0 / 15%)'
)
}}
role={{if this.isBuried 'button' 'banner'}}
{{on
'click'
(optional
(if this.isBuried (fn @dismissStackedCardsAbove @index))
)
}}
data-test-stack-card-header
/>
{{/if}}
{{/if}}
{{else if this.card}}
{{this.setWindowTitle}}
Expand Down
56 changes: 56 additions & 0 deletions packages/host/tests/integration/components/operator-mode-test.gts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
<template><OperatorMode @onClose={{noop}} /></template>
},
);

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) {
Expand Down
Loading