Let guests try a demo project without an account (#428)
feuerwerk / gate (push) Successful in 4m51s
feuerwerk / suite (push) Successful in 19m23s

The editor was only open to somebody with an account. A guest now picks
a demo project on the sign-in screen and gets the whole editor on a copy
held in the tab. The server writes nothing for a guest, so there is no
guest session, no guest folder and no quota to keep.

- Demo copies are frozen copies like a release, in data/demos/. An
  administrator makes, updates and removes one with "Demo copy..." in
  the project's main menu. GET demos and GET demos/{token} need no
  session. An empty project is just a demo that is empty.
- Demo material: an administrator offers an uploaded file with "Offer
  as demo" in the pickers (PATCH {demo}, data/demo-files.json). Every
  list carries it with demo: true, for accounts and guests alike.
  Signed out, a list holds the demo material alone, without the owner.
- An offered file is only an administrator's to delete. A file a demo
  copy names counts as used, like one a release names.
- The guest's project lives in MemoryProjectStore. Every store that
  would write refuses in the tab before a request is made, and the
  autosave is not attached at all.
- The main menu drops projects, snapshots and release for a guest. The
  pickers drop Upload and Delete, a dropped or pasted file is refused,
  and a recording is saved as a download.
- The toolbar says the work is not saved. The Account menu holds
  "Sign in..." instead of Logout and the password.
- A guest's demo never goes into the address: a reload brings the
  sign-in screen back, not the copy. Found by e2e 70.
- GET plugins needs no session any more, since a demo may switch one
  on. It only reads the server's own modules.

Tests:
- e2e 70 (atx): ALL PASSED, 13 checks. Counter-test with the guest
  autosave sent to the server: 70f and 70g red on the PUT.
- tests/core/guest.test.ts: 9 tests. PHP: demos 20, files 46,
  rights 80 checks (the matrix asks every new route).
- Gate (atx): CHECK EXIT 0, 314 files, 4852 tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-16 06:58:30 +02:00
1 parent ce1f25f2eb
commit 925a39a276
30 files changed
+1397 -128

No files matched your search

+42 -14
View File
@@ -4,6 +4,7 @@ import { findAsset, findObjectHolder, ROUTING_ID } from '../core/model/Project';
import { assetTitle } from '../core/music/embedded';
import { mixOf, videoBusesOf } from '../core/music/mixer';
import type { Role } from '../core/store/AuthStore';
import type { DemoStore } from '../core/store/DemoStore';
import type { ProjectStore } from '../core/store/ProjectStore';
import { pluginsOf } from '../core/store/projectPlugins';
import type { FontStore } from '../core/store/FontStore';
@@ -158,7 +159,11 @@ export interface EditorAccount {
user: string;
/** What the editor offers: an administrator gets the user management (#180, #179). */
role: Role;
/** Somebody trying a demo copy without an account (#428): nothing they do is kept. */
guest?: boolean;
users: UsersStore;
/** An administrator's: making the demo copy of the open project (#428). */
demos?: DemoStore;
logOut: () => void;
/** Rejects with `WrongCredentialsError` when the current password does not match (#182). */
changePassword: (current: string, next: string) => Promise<void>;
@@ -365,6 +370,7 @@ export class Editor implements PlayHost {
private readonly cleanups: (() => void)[] = [];
private readonly autosave: { flush: () => Promise<void>; stop: () => void };
private disposed = false;
private readonly guest: boolean;
// Each starts where its divider was left in this browser (`panelSizes.ts`), else at the number here.
private leftWidth = panelSize('left', 250, MIN_LEFT_WIDTH, MAX_LEFT_WIDTH);
private rightWidth = panelSize('right', 350, MIN_RIGHT_WIDTH, MAX_RIGHT_WIDTH);
@@ -448,11 +454,13 @@ export class Editor implements PlayHost {
() => {
if (this.play.isPlaying) this.play.stop();
},
{ snapshots, releases, flush: () => this.autosave.flush() },
{ snapshots, releases, demos: account.demos, flush: () => this.autosave.flush() },
{ guest: account.guest === true, role: account.role },
// Every project brings its own plugins (#119); until `startPlugins` has run there is no
// manager, and a project with plugins simply has none of them.
async (raw) => void (await this.plugins?.syncTo(pluginsOf(raw))),
);
this.guest = account.guest === true;
this.graphEditor.canvas.traceProvider = (assetId) => this.play.traceFor(assetId);
this.graphEditor.canvas.timeProvider = () => this.play.time;
@@ -476,16 +484,20 @@ export class Editor implements PlayHost {
: null;
this.managementPane = new ManagementPane(this.usersPane, this.pluginsPane);
this.toolbar = new Toolbar(state, {
openMainMenu: (anchor) =>
this.projects.openMainMenu(anchor, this.managementEntries(), [
{ label: 'Master routing', onSelect: () => this.openDocument('routing') },
]),
togglePlay: () => (state.mode === 'edit' ? this.play.play() : this.play.stop()),
togglePause: () => this.play.togglePause(),
step: () => this.play.step(),
openAccountMenu: (anchor) => this.openAccountMenu(anchor, account),
});
this.toolbar = new Toolbar(
state,
{
openMainMenu: (anchor) =>
this.projects.openMainMenu(anchor, this.managementEntries(), [
{ label: 'Master routing', onSelect: () => this.openDocument('routing') },
]),
togglePlay: () => (state.mode === 'edit' ? this.play.play() : this.play.stop()),
togglePause: () => this.play.togglePause(),
step: () => this.play.step(),
openAccountMenu: (anchor) => this.openAccountMenu(anchor, account),
},
account.guest === true,
);
root.add(this.toolbar);
this.workspace = new HStack();
@@ -614,7 +626,10 @@ export class Editor implements PlayHost {
// A press in the editor takes the keys back from a window (#371).
this.workspace.on('pointerdowncapture', () => (this.pressedWindow = null));
this.autosave = attachAutosave(state, store);
// A guest's project lives in the tab alone (#428): there is nowhere to save it to.
this.autosave = this.guest
? { flush: () => Promise.resolve(), stop: () => {} }
: attachAutosave(state, store);
this.cleanups.push(
installShortcuts(state, {
play: () => this.play.play(),
@@ -863,6 +878,14 @@ export class Editor implements PlayHost {
* picked: it says who is signed in without costing the crowded toolbar another label.
*/
private openAccountMenu(anchor: Widget, account: EditorAccount): void {
if (this.guest) {
new Menu([
{ label: 'Guest – nothing is saved', disabled: true, onSelect: () => {} },
{ separator: true },
{ label: 'Sign in…', onSelect: () => account.logOut() },
]).openBelow(anchor);
return;
}
new Menu([
{ label: `Signed in as ${account.user}`, disabled: true, onSelect: () => {} },
{ separator: true },
@@ -1348,8 +1371,13 @@ export class Editor implements PlayHost {
};
}
boot(): Promise<void> {
return this.projects.boot();
async boot(): Promise<void> {
await this.projects.boot();
if (this.guest) {
this.state.setNotice(
'You are trying a demo as a guest: change anything, play it, record it – nothing is saved. Sign in from the menu at the far right to keep your work.',
);
}
}
/**