From 3996c20629880cf1321e08ed160e30340cde286f Mon Sep 17 00:00:00 2001
From: Mathieu Audebert
Date: Tue, 13 Oct 2026 15:27:25 +0200
Subject: [PATCH 1/9] feat(console): the setup on one screen: your account,
your site (FRU-239)
The commits of the branch as one, on main after #259 and #260: the setup is one screen of three
steps, the workspace takes the name of the domain and is renamed in place, and the script tag and
the browser of a site move to install-options.tsx.
Co-Authored-By: Claude Opus 5.5
---
apps/console/app/api-answers.test.ts | 7 +-
apps/console/app/api.ts | 25 +-
apps/console/app/install-options.tsx | 102 ++
apps/console/app/messages.fr.ts | 80 +-
apps/console/app/messages.test.ts | 2 +
apps/console/app/routes/connector-parts.tsx | 3 +-
apps/console/app/routes/home.tsx | 15 +-
apps/console/app/routes/setup.tsx | 974 ++++++++++----------
apps/console/app/sign-in-form.tsx | 99 +-
apps/console/app/workspace-name.test.ts | 48 +
apps/console/app/workspace-name.ts | 58 ++
docs/decisions/console.md | 27 +
e2e/console-cookie-session.spec.ts | 69 +-
e2e/console-invitation.spec.ts | 33 +-
e2e/console-kit.spec.ts | 23 +-
e2e/console-narrow.spec.ts | 90 +-
e2e/console-no-sign-in.spec.ts | 4 +-
e2e/console-setup-sources.spec.ts | 68 --
e2e/console-setup.spec.ts | 655 +++++++++++++
e2e/console-sign-in.spec.ts | 31 +-
e2e/console-site-address.spec.ts | 4 +-
21 files changed, 1606 insertions(+), 811 deletions(-)
create mode 100644 apps/console/app/install-options.tsx
create mode 100644 apps/console/app/workspace-name.test.ts
create mode 100644 apps/console/app/workspace-name.ts
delete mode 100644 e2e/console-setup-sources.spec.ts
create mode 100644 e2e/console-setup.spec.ts
diff --git a/apps/console/app/api-answers.test.ts b/apps/console/app/api-answers.test.ts
index c7c87f96..8240319b 100644
--- a/apps/console/app/api-answers.test.ts
+++ b/apps/console/app/api-answers.test.ts
@@ -148,8 +148,9 @@ describe('what the console keeps of a sign-in (FRU-148, FRU-216)', () => {
assert.equal(redeemed.ok, true);
assert.deepEqual(written, []);
assert.deepEqual(sent, ['POST /console/auth/email/redeem', 'GET /console/me', 'POST /console/auth/sign-out']);
- // The control: the storage is there and it is the one `api.ts` writes in, for the one thing it keeps.
- api.rememberWorkspaceName('Acme');
- assert.deepEqual(written, ['localStorage.setItem(fruitback:pending-workspace, Acme)']);
+ // The control: the storage is there, and a write in it is seen. `api.ts` keeps nothing in it
+ // since the name of a workspace comes from the address of its person (FRU-239).
+ globalThis.localStorage.setItem('a-key', 'a-value');
+ assert.deepEqual(written, ['localStorage.setItem(a-key, a-value)']);
});
});
diff --git a/apps/console/app/api.ts b/apps/console/app/api.ts
index fa302363..9fc09867 100644
--- a/apps/console/app/api.ts
+++ b/apps/console/app/api.ts
@@ -471,28 +471,11 @@ export function pairingLink(code: string): string {
}
/**
- * The name typed before the link was sent, kept for the moment the link is opened. A convenience: when
- * storage is refused, the person types it again.
+ * Renames a workspace (FRU-236): its owner or an admin. The worker answers the workspace as
+ * `/console/me` lists it, with the role of the person.
*/
-const PENDING = 'fruitback:pending-workspace';
-
-export function rememberWorkspaceName(name: string): void {
- try {
- localStorage.setItem(PENDING, name);
- } catch {
- // Private browsing can refuse storage. The name is asked again after the sign-in.
- }
-}
-
-export function takeWorkspaceName(): string | undefined {
- try {
- const name = localStorage.getItem(PENDING) ?? undefined;
- localStorage.removeItem(PENDING);
-
- return name === '' ? undefined : name;
- } catch {
- return undefined;
- }
+export function renameWorkspace(workspace: Workspace, name: string): Promise> {
+ return call('POST', `/console/workspaces/${workspace.id}`, { name });
}
/**
diff --git a/apps/console/app/install-options.tsx b/apps/console/app/install-options.tsx
new file mode 100644
index 00000000..f0be0655
--- /dev/null
+++ b/apps/console/app/install-options.tsx
@@ -0,0 +1,102 @@
+import { useState } from 'react';
+import { API, type Site, type Workspace, call, pairingLink } from './api';
+import { msg, t } from './i18n';
+import { copyText, problemOf } from './problems';
+import { Button, Problem, Snippet } from './ui';
+import { useLocale } from './use-locale';
+
+/**
+ * The two ways to put Fruitback on a site, as the Sites screen shows them for a site: the script
+ * tag, and the browser of the person. The setup has its own drawing of them since FRU-239.
+ */
+
+/** The script tag a site embeds, pointing at this console's copy of the widget. */
+export function snippetFor(site: Site): string {
+ return [
+ '',
+ ].join('\n');
+}
+
+// Every role connects a browser, so no code of the worker has a sentence of its own here.
+const CONNECT_PROBLEMS: Readonly> = {};
+const BROWSER_NOT_CONNECTED = msg('This browser was not connected. Try again.');
+
+export function InstallOptions({ workspace, site }: { workspace: Workspace; site: Site }) {
+ useLocale();
+ const [copied, setCopied] = useState<'no' | 'yes' | 'refused'>('no');
+ const [link, setLink] = useState();
+ const [problem, setProblem] = useState();
+ const snippet = snippetFor(site);
+
+ async function connect() {
+ // Open the tab in the click, before the await: a browser blocks a window opened after one.
+ // The tab loses its `opener` before it loads the pairing page.
+ const tab = window.open('', '_blank');
+ setProblem(undefined);
+ const minted = await call<{ code: string }>('POST', `/console/workspaces/${workspace.id}/connect`, {});
+ if (!minted.ok) {
+ // The tab that just opened closes: the reason is said under the button (FRU-164).
+ tab?.close();
+ return setProblem(problemOf(minted, CONNECT_PROBLEMS, BROWSER_NOT_CONNECTED));
+ }
+ const target = pairingLink(minted.data.code);
+ setLink(target);
+ if (tab !== null) {
+ tab.opener = null;
+ tab.location.href = target;
+ }
+ }
+
+ return (
+
+
+
{t('Script')}
+
{t('Paste it before the closing body tag of the site.')}
+ {/* The box of the script takes focus, so a keyboard scrolls its long lines (axe, FRU-239). */}
+ {snippet}
+
+ {copied === 'refused' ? (
+
+ {t('The browser refused the copy. Select the script above and copy it by hand.')}
+
+ ) : null}
+
+
+
{t('Extension')}
+
+ {t(
+ 'Nothing to change on the site. Connect this browser, then click the Fruitback icon on the page that opens.',
+ )}
+
+
+ {problem === undefined ? null : (
+
+ {t(problem)}
+
+ )}
+ {link === undefined ? null : (
+
+ {t('A page opened in a new tab. If it did not, open')}{' '}
+
+ {t('this link')}
+
+ {t(': it works once, for fifteen minutes.')}
+
+ )}
+
+
+ );
+}
diff --git a/apps/console/app/messages.fr.ts b/apps/console/app/messages.fr.ts
index 8ffcdd6b..21ee18b7 100644
--- a/apps/console/app/messages.fr.ts
+++ b/apps/console/app/messages.fr.ts
@@ -6,7 +6,6 @@
export const FRENCH: Readonly> = {
Fruitback: 'Fruitback',
Linear: 'Linear',
- 'Fruitback Cloud': 'Fruitback Cloud',
'Fruitback cannot reach its server. Check your connection.':
'Fruitback ne joint pas son serveur. Vérifiez votre connexion.',
@@ -102,8 +101,6 @@ export const FRENCH: Readonly> = {
'Google n’a pas d’adresse vérifiée pour ce compte. Utilisez un lien par e-mail.',
'The sign-in with Google did not finish. Try again, or use an email link.':
'La connexion avec Google n’a pas abouti. Réessayez, ou utilisez un lien par e-mail.',
- 'Google sign-in is not set up on this Fruitback': 'La connexion Google n’est pas configurée sur ce Fruitback',
- 'GitHub sign-in is not set up on this Fruitback': 'La connexion GitHub n’est pas configurée sur ce Fruitback',
'Not set up': 'Non configuré',
'Could not check': 'Vérification impossible',
'Where you are signed in': 'Où vous êtes connecté',
@@ -248,20 +245,7 @@ export const FRENCH: Readonly> = {
'Seulement les sites partagés avec lui. Gratuit, et ne voit jamais le tracker.',
Workspace: 'Workspace',
- 'Sign in, name it': 'Se connecter, le nommer',
- Source: 'Source',
- Sources: 'Sources',
- 'Where feedback goes': 'Où vont les retours',
- Site: 'Site',
- 'The address you review': 'L’adresse que vous relisez',
Install: 'Installer',
- 'Script or extension': 'Script ou extension',
- 'Set up': 'Mise en place',
- 'How to run Fruitback': 'Comment faire tourner Fruitback',
- 'Self-hosted': 'Auto-hébergé',
- Steps: 'Étapes',
- 'Step {step} of {total}': 'Étape {step} sur {total}',
- 'Create your workspace': 'Créez votre workspace',
'That is not an e-mail address. Check it and send again.':
'Ce n’est pas une adresse e-mail. Vérifiez-la et renvoyez.',
'Several links went to this address already. Use the last one, or wait fifteen minutes.':
@@ -286,45 +270,19 @@ export const FRENCH: Readonly> = {
"La connexion n'a pas abouti. Réessayez, ou utilisez un lien par e-mail.",
'The link could not be sent just now. Try again in a minute.':
'Le lien n’a pas pu être envoyé. Réessayez dans une minute.',
- 'One workspace for your team, your sites and your sources. Hosted in Europe.':
- 'Un workspace pour votre équipe, vos sites et vos sources. Hébergé en Europe.',
'Sending…': 'Envoi…',
Continue: 'Continuer',
'Continue with Google': 'Continuer avec Google',
'Continue with GitHub': 'Continuer avec GitHub',
- 'or with an email link': 'ou avec un lien par e-mail',
'Work email': 'E-mail professionnel',
'Workspace name': 'Nom du workspace',
'Check your inbox': 'Regardez votre boîte de réception',
- 'A sign-in link is on its way to {email}. It works once, for fifteen minutes.':
- 'Un lien de connexion est en route vers {email}. Il fonctionne une fois, pendant quinze minutes.',
'Use another address': 'Utiliser une autre adresse',
- 'Open it in this browser. Nothing in it asks for a password, and ignoring it is safe.':
- 'Ouvrez-le dans ce navigateur. Rien n’y demande un mot de passe, et l’ignorer est sans risque.',
'A workspace needs a name of one line, up to 80 characters.':
'Un workspace a besoin d’un nom d’une ligne, de 80 caractères au plus.',
- 'Name your workspace': 'Nommez votre workspace',
- 'You are signed in. One more word, and the workspace exists.':
- 'Vous êtes connecté. Encore un mot, et le workspace existe.',
- 'Notes stay in this workspace. Nothing to connect.': 'Les notes restent dans ce workspace. Rien à connecter.',
- 'Where should feedback go?': 'Où doivent aller les retours ?',
- Selected: 'Sélectionné',
- 'In Connectors, after the set-up': 'Dans Connecteurs, après la mise en place',
- 'For now the notes stay in this workspace. Linear and a REST API connect after the set-up, in Connectors.':
- 'Pour l’instant, les notes restent dans ce workspace. Linear et une API REST se connectent après la mise en place, dans Connecteurs.',
'Paste the full address of the site, starting with https://.':
'Collez l’adresse complète du site, en commençant par https://.',
- 'Which site do you review?': 'Quel site relisez-vous ?',
- 'Paste its address. Any page of it will do.': 'Collez son adresse. N’importe laquelle de ses pages convient.',
'Site address': 'Adresse du site',
- 'Who sees the feedback?': 'Qui voit les retours ?',
- 'The members of {workspace}': 'Les membres de {workspace}',
- 'Visitors of the site see no note.': 'Les visiteurs du site ne voient aucune note.',
- 'Everyone who visits the site': 'Tous ceux qui visitent le site',
- 'For a public « report a problem ».': 'Pour un « signaler un problème » public.',
- 'Install it': 'Installez-le',
- 'Two ways to put Fruitback on {site}. Use one, or both.':
- 'Deux façons de mettre Fruitback sur {site}. Utilisez l’une, ou les deux.',
Finish: 'Terminer',
Script: 'Script',
'Paste it before the closing body tag of the site.': 'Collez-le avant la balise body fermante du site.',
@@ -501,4 +459,42 @@ export const FRENCH: Readonly> = {
'A link works once, for fifteen minutes. This one was already used or ran out of time.':
'Un lien fonctionne une fois, pendant quinze minutes. Celui-ci a déjà servi, ou son délai est passé.',
'Send me a new link': 'M’envoyer un nouveau lien',
+ // The setup on one screen (FRU-239).
+ 'Set up Fruitback': 'Mettre en place Fruitback',
+ 'Current step:': 'Étape en cours :',
+ 'Running your own server?': 'Vous hébergez votre propre serveur ?',
+ 'Self-hosting guide': 'Guide d’auto-hébergement',
+ 'Three steps to your first note on a live page. Hosted in Europe.':
+ 'Trois étapes jusqu’à votre première note sur une page en ligne. Hébergé en Europe.',
+ 'Your account': 'Votre compte',
+ 'Your site': 'Votre site',
+ 'The address you review.': 'L’adresse que vous relisez.',
+ 'Put it on the site': 'Le mettre sur le site',
+ 'The extension, or a script tag.': 'L’extension, ou une balise de script.',
+ 'Preview of your site': 'Aperçu de votre site',
+ 'your site': 'votre site',
+ 'Your site shows here once you add it, with its pins.': 'Votre site s’affiche ici une fois ajouté, avec ses pins.',
+ 'A preview of your site. Pins show the same way on the real page.':
+ 'Un aperçu de votre site. Les pins s’affichent de la même façon sur la vraie page.',
+ 'We send you a sign-in link. No password to make up.':
+ 'Nous vous envoyons un lien de connexion. Aucun mot de passe à inventer.',
+ 'Your workspace takes the name of your domain. Rename it whenever you like.':
+ 'Votre workspace prend le nom de votre domaine. Renommez-le quand vous voulez.',
+ 'A sign-in link is on its way to {email}. Open it in this browser: this page carries on by itself.':
+ 'Un lien de connexion est en route vers {email}. Ouvrez-le dans ce navigateur : cette page continue toute seule.',
+ 'The link works once, for fifteen minutes. Nothing in it asks for a password.':
+ 'Le lien fonctionne une fois, pendant quinze minutes. Rien n’y demande un mot de passe.',
+ 'Signed in as {email}': 'Connecté en tant que {email}',
+ 'The workspace was not renamed. Try again.': 'Le workspace n’a pas été renommé. Réessayez.',
+ 'Only an owner or an admin of the workspace renames it.': 'Seul un propriétaire ou un admin du workspace le renomme.',
+ Save: 'Enregistrer',
+ Rename: 'Renommer',
+ 'Any page of it will do.': 'N’importe laquelle de ses pages convient.',
+ 'Members of {workspace} see the notes': 'Les membres de {workspace} voient les notes',
+ 'Everyone who visits sees the notes': 'Tous les visiteurs voient les notes',
+ 'Fruitback did not answer. Your sites are not lost.': 'Fruitback n’a pas répondu. Vos sites ne sont pas perdus.',
+ Site: 'Site',
+ Change: 'Modifier',
+ '{site} is still in your sites, with its notes. Remove it in Sites if you no longer review it.':
+ '{site} est toujours dans vos sites, avec ses notes. Retirez-le dans Sites si vous ne le relisez plus.',
};
diff --git a/apps/console/app/messages.test.ts b/apps/console/app/messages.test.ts
index 86e37c31..fd85047d 100644
--- a/apps/console/app/messages.test.ts
+++ b/apps/console/app/messages.test.ts
@@ -15,6 +15,7 @@ const APP = new URL('./', import.meta.url);
// (FRU-164): a sentence marked there is shown by a screen, so the guard reads them too.
// `invitation-message.ts` holds the three notices of the members screen (FRU-209).
// `sign-in-form.tsx` holds the ways to sign in, for the sign-in page and the setup (FRU-235).
+// `install-options.tsx` holds the script tag and the browser of a site, for the Sites screen (FRU-239).
const screens = [
...readdirSync(new URL('routes/', APP))
.map((name) => `routes/${name}`)
@@ -26,6 +27,7 @@ const screens = [
'problems.ts',
'invitation-message.ts',
'sign-in-form.tsx',
+ 'install-options.tsx',
];
const sources = new Map(screens.map((name) => [name, readFileSync(new URL(name, APP), 'utf8')]));
diff --git a/apps/console/app/routes/connector-parts.tsx b/apps/console/app/routes/connector-parts.tsx
index 8fa58bda..9528dedb 100644
--- a/apps/console/app/routes/connector-parts.tsx
+++ b/apps/console/app/routes/connector-parts.tsx
@@ -14,7 +14,8 @@ import { useLocale } from '../use-locale';
/** How each kind of source is drawn. The mark is a letter of the mockup, not a logo. */
export const KINDS = {
linear: { mark: 'L', name: 'Linear' },
- rest: { mark: '{ }', name: 'REST API' },
+ // « REST API » is said « API REST » in French: `msg` marks it for the guard of the words.
+ rest: { mark: '{ }', name: msg('REST API') },
} as const;
/** What a refused key means for the person, and what to do. */
diff --git a/apps/console/app/routes/home.tsx b/apps/console/app/routes/home.tsx
index 65c833a3..b00e59e8 100644
--- a/apps/console/app/routes/home.tsx
+++ b/apps/console/app/routes/home.tsx
@@ -1,12 +1,12 @@
import { useEffect } from 'react';
import { useNavigate } from 'react-router';
-import { type Me, callUntilAnswered, myInvitations } from '../api';
+import { type Me, type Site, call, callUntilAnswered, myInvitations } from '../api';
import { t } from '../i18n';
import { useLocale } from '../use-locale';
/**
* The front door: the invitations that wait for a signed-in person, then their first workspace, or
- * the setup.
+ * the setup, which a workspace with no site goes back to.
*
* Somebody who was invited (FRU-104) signs in from the link of the message, and lands here: they see
* the invitation before the setup offers to make a workspace of their own.
@@ -25,7 +25,16 @@ export default function Home() {
// An invitation that could not be read does not hold the front door: it is on its own page.
if (invitations.ok && invitations.data.length > 0) return navigate('/invitations', { replace: true });
const workspace = me.data.workspaces[0];
- navigate(workspace === undefined ? '/setup' : `/w/${workspace.id}/sites`, { replace: true });
+ if (workspace === undefined) return navigate('/setup', { replace: true });
+ const sites = `/w/${workspace.id}/sites`;
+ // Only somebody who adds a site is sent back to the step that adds one.
+ if (workspace.role !== 'owner' && workspace.role !== 'admin') return navigate(sites, { replace: true });
+ void call<{ sites: Site[] }>('GET', `/console/workspaces/${workspace.id}/sites`).then((listed) => {
+ if (stopped) return;
+ // A workspace with no site is a setup that stopped at its second step (FRU-239). A list
+ // that could not be read is not an empty list: the Sites screen says so itself.
+ navigate(listed.ok && listed.data.sites.length === 0 ? '/setup' : sites, { replace: true });
+ });
});
});
diff --git a/apps/console/app/routes/setup.tsx b/apps/console/app/routes/setup.tsx
index d5da895a..5fbb08be 100644
--- a/apps/console/app/routes/setup.tsx
+++ b/apps/console/app/routes/setup.tsx
@@ -1,468 +1,550 @@
-import { type FormEvent, type ReactNode, useEffect, useState } from 'react';
+import { type FormEvent, useEffect, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router';
import {
- API,
- providerStart,
+ type Account,
type Me,
type Site,
type Visibility,
type Workspace,
call,
- pairingLink,
- rememberWorkspaceName,
- requestLink,
+ myInvitations,
+ renameWorkspace,
saveLanguage,
- takeWorkspaceName,
} from '../api';
-import { NAME_PROBLEMS, SITE_NOT_ADDED, SITE_PROBLEMS, WORKSPACE_NOT_CREATED, copyText, problemOf } from '../problems';
-import { Button, Field, Mark, Pick, Problem, WideButton } from '../ui';
-import { adoptLanguage, locale, msg, t } from '../i18n';
-import { useLocale } from '../use-locale';
+import { adoptLanguage, msg, t } from '../i18n';
+import { InstallOptions } from '../install-options';
+import { NAME_PROBLEMS, SITE_NOT_ADDED, SITE_PROBLEMS, WORKSPACE_NOT_CREATED, problemOf } from '../problems';
import {
- LINK_NOT_SENT,
- SEND_PROBLEMS,
+ AnotherAddress,
+ EmailForm,
+ ProviderButtons,
SignInClosed,
providerProblem,
useCarryOn,
+ useSendAgain,
useSignInWays,
+ withStrong,
} from '../sign-in-form';
import { siteAddress } from '../site-address';
+import {
+ BrowserFrame,
+ Button,
+ Callout,
+ EntryPage,
+ Field,
+ Lead,
+ LinkButton,
+ Pick,
+ Problem,
+ Step,
+ type StepState,
+ Steps,
+ Title,
+} from '../ui';
+import { useLocale } from '../use-locale';
+import { workspaceNameFrom } from '../workspace-name';
/**
- * « Create your workspace », the four steps of the setup (design/boards/2-onboarding.png).
+ * The setup, on one screen (FRU-239, screen 01 of the mockup of 2026-10-13): « Your account »,
+ * « Your site », « Put it on the site ». The three steps show together; one that is done is a
+ * summary, and opens again.
*
- * The steps are the design's. What this beta has not built yet is shown and disabled, with the reason
- * in words: Google sign-in, and the sources other than the workspace's own (FRU-102).
+ * Nobody names a workspace here: it takes its name from the address of the person
+ * (`workspace-name.ts`), and « Rename » changes it. The choice of a tracker is not a step any more:
+ * it is in Connectors, after.
*/
-const STEPS = [
- { title: msg('Workspace'), hint: msg('Sign in, name it') },
- { title: msg('Source'), hint: msg('Where feedback goes') },
- { title: msg('Site'), hint: msg('The address you review') },
- { title: msg('Install'), hint: msg('Script or extension') },
-] as const;
+// `InstallOptions` moved to its own module. The Sites screen still reads it from here.
+export { InstallOptions };
-const DOCS = 'https://fruitback.com';
+const SITE = 'https://fruitback.com';
type Stage =
| { kind: 'loading' }
- | { kind: 'sign-in' }
+ | { kind: 'email' }
| { kind: 'sent'; email: string }
- | { kind: 'name' }
- | { kind: 'source'; workspace: Workspace }
- | { kind: 'site'; workspace: Workspace }
- | { kind: 'install'; workspace: Workspace; site: Site };
+ /** Signed in, and the workspace could not be made: the person tries again, with the name they like. */
+ | { kind: 'unnamed'; account: Account }
+ /** The sites of the workspace could not be read. That is not « no site »: nothing is asked again. */
+ | { kind: 'unread'; account: Account; workspace: Workspace }
+ /** `replaces`: the site that « Change » opened again, with its values in the form. */
+ | { kind: 'site'; account: Account; workspace: Workspace; replaces?: Added }
+ | ({ kind: 'install'; account: Account; workspace: Workspace } & Added)
+ /** An invitation waits for this person: they came to join a workspace, not to make one. */
+ | { kind: 'invited' };
+
+/**
+ * The site of the setup. `fresh`: it was added in this visit, so nothing was noted on it yet.
+ * `left`: a site that « Change » replaced and did not remove, which the Sites screen still lists.
+ */
+type Added = { site: Site; fresh?: boolean; left?: Site };
-function stepOf(stage: Stage): number {
- if (stage.kind === 'source') return 2;
- if (stage.kind === 'site') return 3;
- if (stage.kind === 'install') return 4;
+/** The state of a step whose step before is in `before`: the one to do now, or one to come. */
+function stepAfter(before: StepState): StepState {
+ return before === 'done' ? 'current' : 'pending';
+}
- return 1;
+/** Who changes the name of a workspace and adds a site: `can` of the worker says the same. */
+function manages(workspace: Workspace): boolean {
+ return workspace.role === 'owner' || workspace.role === 'admin';
}
+/**
+ * The first workspace of a person, named after their address.
+ *
+ * WARNING: one at a time, in every tab of the browser. The tab that waited for the link and the tab
+ * of the link can both arrive here in the same second, and each would make a workspace. The lock is
+ * of the browser (`navigator.locks`); the tab that gets it second reads `/console/me` again and
+ * finds the workspace of the first.
+ */
+async function firstWorkspace(account: Account, name?: string): Promise {
+ const make = async (): Promise => {
+ const again = await call('GET', '/console/me', undefined, { quiet: true });
+ const made = again.ok ? again.data.workspaces[0] : undefined;
+ if (made !== undefined) return { workspace: made };
+ const created = await call('POST', '/console/workspaces', {
+ name: name ?? workspaceNameFrom(account.email),
+ });
+
+ return created.ok
+ ? { workspace: created.data }
+ : { problem: problemOf(created, NAME_PROBLEMS, WORKSPACE_NOT_CREATED) };
+ };
+ if (typeof navigator === 'undefined' || navigator.locks === undefined) return make();
+
+ return navigator.locks.request('fruitback:first-workspace', make);
+}
+
+type Made = { workspace: Workspace; problem?: undefined } | { workspace?: undefined; problem: string };
+
let resolving: Promise | undefined;
/**
- * WARNING: one resolution at a time. It can create the workspace named before the link was sent, and
- * two resolutions in flight would create it twice, or let the second ask for a name the first took.
+ * WARNING: one resolution at a time in this tab. It can make the first workspace, and two
+ * resolutions in flight would ask for it twice.
*/
-function resolveOnce(step: string | null): Promise {
- resolving ??= resolveStage(step).finally(() => {
+function resolveOnce(): Promise {
+ resolving ??= resolveStage().finally(() => {
resolving = undefined;
});
return resolving;
}
-async function resolveStage(step: string | null): Promise {
- // Quiet: a person who is not signed in gets the sign-in screen, not the notice of a session that ended.
+/** Where the setup of this person is: read from the worker at each visit, so it resumes anywhere. */
+async function resolveStage(): Promise {
+ // Quiet: a person who is not signed in gets the first step, not the notice of a session that ended.
const me = await call('GET', '/console/me', undefined, { quiet: true });
- if (!me.ok) return { kind: 'sign-in' };
+ if (!me.ok) return { kind: 'email' };
adoptLanguage(me.data.account, saveLanguage);
+ const { account } = me.data;
+
+ let workspace = me.data.workspaces[0];
+ if (workspace === undefined) {
+ // Somebody who was invited joins a workspace: none is made for them behind their back.
+ const invitations = await myInvitations();
+ if (invitations.ok && invitations.data.length > 0) return { kind: 'invited' };
+ workspace = (await firstWorkspace(account)).workspace;
+ if (workspace === undefined) return { kind: 'unnamed', account };
+ }
- const workspace = me.data.workspaces[0];
- if (workspace !== undefined) return step === 'site' ? { kind: 'site', workspace } : { kind: 'source', workspace };
+ return siteStage(account, workspace);
+}
- // The name typed before the link was sent: the workspace is made without asking again.
- const pending = takeWorkspaceName();
- if (pending === undefined) return { kind: 'name' };
- const created = await call('POST', '/console/workspaces', { name: pending });
+/** The step of a person who has a workspace: its first site, or the form that adds one. */
+async function siteStage(account: Account, workspace: Workspace): Promise {
+ const listed = await call<{ sites: Site[] }>('GET', `/console/workspaces/${workspace.id}/sites`);
+ if (!listed.ok) return { kind: 'unread', account, workspace };
+ const site = listed.data.sites[0];
- return created.ok ? { kind: 'source', workspace: created.data } : { kind: 'name' };
+ return site === undefined ? { kind: 'site', account, workspace } : { kind: 'install', account, workspace, site };
}
export default function Setup() {
useLocale();
+ const navigate = useNavigate();
const [stage, setStage] = useState({ kind: 'loading' });
- const [search] = useSearchParams();
+ // Kept above the stages: « Use another address » gives the form back with the address in it.
+ const [email, setEmail] = useState('');
useEffect(() => {
let live = true;
- void resolveOnce(search.get('step')).then((resolved) => {
+ void resolveOnce().then((resolved) => {
if (live) setStage(resolved);
});
return () => {
live = false;
};
- }, [search]);
+ }, []);
- return (
-
-
-
- {/* On a phone each label stays on one line (FRU-160): « Set up » is left to the screen
- reader, which the card's title repeats, and the tabs are tighter. Under 360 px the name
- goes too, and the mark stays. */}
-
-
- {t('Fruitback')}
- {t('Set up')}
-
-
-
-
-
- {/* Under md the steps are a row of numbers, with the title of the current one, so the card and its
- action come up the screen (FRU-160). */}
-
-
- {STEPS.map((step, index) => {
- const number = index + 1;
- const current = number === stepOf(stage);
-
- return (
-
- );
-}
+ // The title of the tab, as the mockup names it. The cleanup gives back the name of the product.
+ useEffect(() => {
+ const before = document.title;
+ document.title = t('Set up Fruitback');
-function StageCard({ stage, onStage }: { stage: Stage; onStage: (stage: Stage) => void }) {
- useLocale();
- if (stage.kind === 'loading') return ;
- if (stage.kind === 'sign-in') return onStage({ kind: 'sent', email })} />;
-
- if (stage.kind === 'sent') {
- return (
- onStage({ kind: 'sign-in' })}
- // Signed in from another tab: the same resolution as a visit, which makes the workspace
- // named before the link was sent.
- onSignedIn={() => void resolveOnce(null).then(onStage)}
- />
- );
- }
- if (stage.kind === 'name') return onStage({ kind: 'source', workspace })} />;
- if (stage.kind === 'source') return onStage({ kind: 'site', workspace: stage.workspace })} />;
- if (stage.kind === 'site') {
- return (
- onStage({ kind: 'install', workspace: stage.workspace, site })}
- />
- );
- }
+ return () => {
+ document.title = before;
+ };
+ });
- return ;
-}
+ const account = 'account' in stage ? stage.account : undefined;
+ const workspace = 'workspace' in stage ? stage.workspace : undefined;
+ // The first step is to do until somebody is signed in and has a workspace; the last one is the
+ // current one once a site is there.
+ const signedIn = account !== undefined && stage.kind !== 'unnamed';
+ const installing = stage.kind === 'install';
+ const first: StepState = signedIn ? 'done' : 'current';
+ const second: StepState = installing ? 'done' : stepAfter(first);
+ const third: StepState = installing ? 'current' : 'pending';
+ const prefix = t('Current step:');
-/** The card of a step, with the footer of the board: the step number and the one action. */
-function Frame({
- step,
- title,
- lead,
- children,
- action,
- onSubmit,
-}: {
- step: number;
- title: string;
- lead?: string;
- children?: ReactNode;
- action?: ReactNode;
- onSubmit?: (event: FormEvent) => void;
-}) {
- useLocale();
return (
-
);
}
+/** Step 1, the link is on its way: this page goes on by itself when it is opened (FRU-235). */
function Sent({ email, onBack, onSignedIn }: { email: string; onBack: () => void; onSignedIn: () => void }) {
useLocale();
- // The link is opened in another tab: this one goes on to the next step by itself (FRU-235).
useCarryOn(onSignedIn);
+ const again = useSendAgain(email);
return (
-
- {t('Use another address')}
-
- }
- >
-
- {t('Open it in this browser. Nothing in it asks for a password, and ignoring it is safe.')}
+
+
{t('Check your inbox')}
+
+ {withStrong(
+ msg('A sign-in link is on its way to {email}. Open it in this browser: this page carries on by itself.'),
+ 'email',
+ email,
+ )}
+
+
+ {t('The link works once, for fifteen minutes. Nothing in it asks for a password.')}
-
+
+
+
+
+ {again.problem === undefined ? null : {t(again.problem)}}
+
);
}
-function NameIt({ onCreated }: { onCreated: (workspace: Workspace) => void }) {
+/**
+ * Signed in, and the worker did not make the workspace: the name it would have taken is in a field,
+ * and the person sends it again, as it is or changed.
+ */
+function Unnamed({ account, onCreated }: { account: Account; onCreated: (workspace: Workspace) => void }) {
useLocale();
- const [name, setName] = useState('');
- const [problem, setProblem] = useState();
+ const [name, setName] = useState(workspaceNameFrom(account.email));
+ const [busy, setBusy] = useState(false);
+ const [problem, setProblem] = useState(WORKSPACE_NOT_CREATED);
async function submit(event: FormEvent) {
event.preventDefault();
- const created = await call('POST', '/console/workspaces', { name });
- if (created.ok) return onCreated(created.data);
- setProblem(problemOf(created, NAME_PROBLEMS, WORKSPACE_NOT_CREATED));
+ setBusy(true);
+ setProblem(undefined);
+ const made = await firstWorkspace(account, name);
+ setBusy(false);
+ if (made.workspace !== undefined) return onCreated(made.workspace);
+ setProblem(made.problem);
}
return (
-
- {t('Continue')}
-
- }
- >
+
);
}
-/**
- * Where the notes go, as it is at the end of the set-up (FRU-166). The step chooses nothing: Fruitback
- * keeps the notes, and the sources that the Connectors screen connects say so, rather than « After the
- * beta ». When the step connects a source itself, it becomes a choice again.
- */
-const SOURCES = [
- {
- key: 'fruitback',
- name: 'Fruitback',
- detail: msg('Notes stay in this workspace. Nothing to connect.'),
- state: msg('Selected'),
- },
- {
- key: 'linear',
- name: 'Linear',
- detail: msg('An issue per note, in the team you choose.'),
- state: msg('In Connectors, after the set-up'),
- },
- {
- key: 'rest',
- name: msg('REST API'),
- detail: msg('POST each note to your own endpoint.'),
- state: msg('In Connectors, after the set-up'),
- },
- {
- key: 'github',
- name: 'GitHub Issues',
- detail: msg('An issue per note, in one repository.'),
- state: msg('After the beta'),
- },
-] as const;
-
-function Source({ onNext }: { onNext: () => void }) {
+const WORKSPACE_NOT_RENAMED = msg('The workspace was not renamed. Try again.');
+/** What the worker refuses when a workspace is renamed: the rule of its name, and who may. */
+const RENAME_PROBLEMS: Readonly> = {
+ ...NAME_PROBLEMS,
+ forbidden: msg('Only an owner or an admin of the workspace renames it.'),
+};
+
+/** Step 1, done: who is signed in, and the workspace, which « Rename » renames in place. */
+function Account({
+ account,
+ workspace,
+ onRenamed,
+}: {
+ account: Account;
+ workspace: Workspace;
+ onRenamed: (workspace: Workspace) => void;
+}) {
useLocale();
+ const [renaming, setRenaming] = useState(false);
+ const [name, setName] = useState(workspace.name);
+ const [busy, setBusy] = useState(false);
+ const [problem, setProblem] = useState();
+ // Set once the form closed: « Rename » then takes focus again, where the keyboard left it.
+ const [back, setBack] = useState(false);
+
+ function close() {
+ setRenaming(false);
+ setProblem(undefined);
+ setBack(true);
+ }
+
+ async function submit(event: FormEvent) {
+ event.preventDefault();
+ if (name.trim() === workspace.name) return close();
+ setBusy(true);
+ setProblem(undefined);
+ const renamed = await renameWorkspace(workspace, name);
+ setBusy(false);
+ // A refusal keeps the form open, with the name as it was typed.
+ if (!renamed.ok) return setProblem(problemOf(renamed, RENAME_PROBLEMS, WORKSPACE_NOT_RENAMED));
+ onRenamed(renamed.data);
+ close();
+ }
+
return (
- {t('Continue')}}
- >
- {/* A list to read, not a choice: nothing here is chosen, so it is no radio group (FRU-166). */}
-
- {SOURCES.map((source) => (
-
-
+
+ {withStrong(msg('Signed in as {email}'), 'email', account.email, 'text-ink')}
+
+ )}
+
);
}
-function SiteStep({ workspace, onAdded }: { workspace: Workspace; onAdded: (site: Site) => void }) {
+type SiteFormProps = {
+ workspace: Workspace;
+ /** The site this form changes: its values are in the form, and « Cancel » keeps it. */
+ replaces?: Added | undefined;
+ onDone: (added: Added) => void;
+};
+
+/**
+ * Step 2: the address of the site, and who sees its notes.
+ *
+ * WARNING: the worker has no route that changes a site, and a site that is removed takes its notes
+ * with it. So « Change » adds the site again with the new values, and removes the one it replaces
+ * only when that one was added in this visit: nothing was noted on it yet. A site of a visit before
+ * stays, the summary says so, and the Sites screen removes it with its two presses (FRU-165).
+ */
+function SiteForm({ workspace, replaces, onDone }: SiteFormProps) {
useLocale();
- const [url, setUrl] = useState('');
- const [visibility, setVisibility] = useState('members');
+ const before = replaces?.site;
+ const [url, setUrl] = useState(before === undefined ? '' : new URL(before.origin).host);
+ const [visibility, setVisibility] = useState(before?.visibility ?? 'members');
+ const [busy, setBusy] = useState(false);
const [problem, setProblem] = useState();
async function submit(event: FormEvent) {
@@ -471,155 +553,105 @@ function SiteStep({ workspace, onAdded }: { workspace: Workspace; onAdded: (site
// The same reading as the Sites screen: a host with no scheme is https (FRU-167).
const address = siteAddress(url);
if (address === undefined) return setProblem(SITE_PROBLEMS['invalid-url'] ?? SITE_NOT_ADDED);
- const added = await call('POST', `/console/workspaces/${workspace.id}/sites`, { url: address, visibility });
- if (added.ok) return onAdded(added.data);
- setProblem(problemOf(added, SITE_PROBLEMS, SITE_NOT_ADDED));
+ // Nothing changed: the site stays as it is, and nothing is asked of the worker.
+ if (replaces !== undefined && new URL(address).origin === before?.origin && visibility === before.visibility) {
+ return onDone(replaces);
+ }
+ setBusy(true);
+ const base = `/console/workspaces/${workspace.id}/sites`;
+ const added = await call('POST', base, { url: address, visibility });
+ if (!added.ok) {
+ setBusy(false);
+
+ // No failure clears the field: the address stays as it was typed.
+ return setProblem(problemOf(added, SITE_PROBLEMS, SITE_NOT_ADDED));
+ }
+ const removed =
+ before !== undefined && replaces?.fresh === true && (await call('DELETE', `${base}/${before.id}`)).ok;
+ setBusy(false);
+ onDone({ site: added.data, fresh: true, ...(before === undefined || removed ? {} : { left: before }) });
}
return (
-
- {t('Continue')}
-
- }
- >
+
);
}
-// Every role connects a browser, so no code of the worker has a sentence of its own here.
-const CONNECT_PROBLEMS: Readonly> = {};
-const BROWSER_NOT_CONNECTED = msg('This browser was not connected. Try again.');
-
-export function InstallOptions({ workspace, site }: { workspace: Workspace; site: Site }) {
+/** Step 2, done: the site, who sees its notes, and « Change ». */
+function SiteSummary({
+ workspace,
+ site,
+ left,
+ onChange,
+}: {
+ workspace: Workspace;
+ site: Site;
+ left: Site | undefined;
+ onChange: () => void;
+}) {
useLocale();
- const [copied, setCopied] = useState<'no' | 'yes' | 'refused'>('no');
- const [link, setLink] = useState();
- const [problem, setProblem] = useState();
- const snippet = snippetFor(site);
-
- async function connect() {
- // Open the tab in the click, before the await: a browser blocks a window opened after one.
- // The tab loses its `opener` before it loads the pairing page.
- const tab = window.open('', '_blank');
- setProblem(undefined);
- const minted = await call<{ code: string }>('POST', `/console/workspaces/${workspace.id}/connect`, {});
- if (!minted.ok) {
- // The tab that just opened closes: the reason is said under the button (FRU-164).
- tab?.close();
- return setProblem(problemOf(minted, CONNECT_PROBLEMS, BROWSER_NOT_CONNECTED));
- }
- const target = pairingLink(minted.data.code);
- setLink(target);
- if (tab !== null) {
- tab.opener = null;
- tab.location.href = target;
- }
- }
return (
-
-
-
{t('Script')}
-
{t('Paste it before the closing body tag of the site.')}
-
{snippet}
-
- {copied === 'refused' ? (
-
- {t('The browser refused the copy. Select the script above and copy it by hand.')}
-
+
+ {/* A long host takes the lines it needs: the page does not scroll sideways (FRU-159). */}
+
{new URL(site.origin).host}
+
+ {site.visibility === 'members'
+ ? t('Members of {workspace} see the notes', { workspace: workspace.name })
+ : t('Everyone who visits sees the notes')}
+ {manages(workspace) ? (
+
+ {t('Change')}
+
) : null}
-
-
-
{t('Extension')}
-
- {t(
- 'Nothing to change on the site. Connect this browser, then click the Fruitback icon on the page that opens.',
- )}
+
+ {left === undefined ? null : (
+
+ {t('{site} is still in your sites, with its notes. Remove it in Sites if you no longer review it.', {
+ site: new URL(left.origin).host,
+ })}
-
- {problem === undefined ? null : (
-
- {t(problem)}
-
- )}
- {link === undefined ? null : (
-
- {t('A page opened in a new tab. If it did not, open')}{' '}
-
- {t('this link')}
-
- {t(': it works once, for fifteen minutes.')}
-
- )}
-
+ )}
);
}
diff --git a/apps/console/app/sign-in-form.tsx b/apps/console/app/sign-in-form.tsx
index 6d403d27..53a50e63 100644
--- a/apps/console/app/sign-in-form.tsx
+++ b/apps/console/app/sign-in-form.tsx
@@ -2,7 +2,7 @@ import { type FormEvent, type ReactNode, useEffect, useRef, useState } from 'rea
import { type Providers, call, offersSignIn, providerStart, requestLink, signInProviders } from './api';
import { locale, msg, t } from './i18n';
import { awaitSignIn } from './tab-signal';
-import { Button, Divider, Field, LinkButton, Problem, WideButton } from './ui';
+import { AutoGrid, Button, Divider, Field, LinkButton, Problem, WideButton } from './ui';
import { useLocale } from './use-locale';
/**
@@ -117,8 +117,8 @@ type EmailFormProps = {
onSent: (email: string) => void;
/** A refusal that came from elsewhere, as the code a provider sent back, shown until a link is asked for. */
problem?: string | undefined;
- /** Runs before the link is asked for: the setup keeps what it needs after the sign-in. */
- before?: () => void;
+ /** « row »: the field and the button on one line while there is room, as in the setup. */
+ layout?: 'column' | 'row';
};
/**
@@ -127,7 +127,7 @@ type EmailFormProps = {
* WARNING: nothing here clears the field. A link that is refused leaves the address as it was typed,
* with the reason under it (`CLAUDE.md`: no error path clears a field).
*/
-export function EmailForm({ action, email, onEmail, onSent, problem: given, before }: EmailFormProps) {
+export function EmailForm({ action, email, onEmail, onSent, problem: given, layout = 'column' }: EmailFormProps) {
useLocale();
const [busy, setBusy] = useState(false);
const [problem, setProblem] = useState(given);
@@ -138,7 +138,6 @@ export function EmailForm({ action, email, onEmail, onSent, problem: given, befo
if (email.trim() === '') return setProblem(SEND_PROBLEMS['invalid-email']);
setBusy(true);
setProblem(undefined);
- before?.();
const sent = await requestLink(email, locale());
setBusy(false);
if (sent.ok) return onSent(email.trim());
@@ -146,23 +145,36 @@ export function EmailForm({ action, email, onEmail, onSent, problem: given, befo
setProblem(SEND_PROBLEMS[sent.error] ?? LINK_NOT_SENT);
}
+ const row = layout === 'row';
+
return (
// `noValidate`: the browser would stop the form with its own bubble, in its own language, and the
// sentence of the console would never show (FRU-167). The field stays required for a screen reader.
-
@@ -353,348 +236,3 @@ export default function Setup() {
);
}
-
-type AskProps = { email: string; onEmail: (email: string) => void; onSent: (email: string) => void };
-
-/** Step 1, to do: the address a link goes to, or a provider. */
-function Ask({ email, onEmail, onSent }: AskProps) {
- useLocale();
- const [search] = useSearchParams();
- const { providers, closed } = useSignInWays();
-
- return (
-
-
{t('We send you a sign-in link. No password to make up.')}
- {closed ? (
-
- ) : (
- <>
-
-
- {t('Your workspace takes the name of your domain. Rename it whenever you like.')}
-
-
- >
- )}
-
- );
-}
-
-/** Step 1, the link is on its way: this page goes on by itself when it is opened (FRU-235). */
-function Sent({ email, onBack, onSignedIn }: { email: string; onBack: () => void; onSignedIn: () => void }) {
- useLocale();
- useCarryOn(onSignedIn);
- const again = useSendAgain(email);
-
- return (
-
-
{t('Check your inbox')}
-
- {withStrong(
- msg('A sign-in link is on its way to {email}. Open it in this browser: this page carries on by itself.'),
- 'email',
- email,
- )}
-
-
- {t('The link works once, for fifteen minutes. Nothing in it asks for a password.')}
-
-
-
-
-
- {again.problem === undefined ? null : {t(again.problem)}}
-
- );
-}
-
-/**
- * Signed in, and the worker did not make the workspace: the name it would have taken is in a field,
- * and the person sends it again, as it is or changed.
- */
-/**
- * The workspace of a person who signed in, made again by hand. A try was made for them already, so
- * the form opens on what the worker answered to it (`tried`), and the button says « Try again ».
- */
-function Unnamed({
- account,
- tried,
- onCreated,
-}: {
- account: Account;
- tried: string;
- onCreated: (workspace: Workspace) => void;
-}) {
- useLocale();
- const [name, setName] = useState(workspaceNameFrom(account.email));
- const [busy, setBusy] = useState(false);
- const [problem, setProblem] = useState(tried);
-
- async function submit(event: FormEvent) {
- event.preventDefault();
- setBusy(true);
- setProblem(undefined);
- const made = await firstWorkspace(account, name);
- setBusy(false);
- if (made.workspace !== undefined) return onCreated(made.workspace);
- setProblem(made.problem);
- }
-
- return (
-
- );
-}
-
-const WORKSPACE_NOT_RENAMED = msg('The workspace was not renamed. Try again.');
-/** What the worker refuses when a workspace is renamed: the rule of its name, and who may. */
-const RENAME_PROBLEMS: Readonly> = {
- ...NAME_PROBLEMS,
- forbidden: msg('Only an owner or an admin of the workspace renames it.'),
-};
-
-/** Step 1, done: who is signed in, and the workspace, which « Rename » renames in place. */
-function Account({
- account,
- workspace,
- onRenamed,
-}: {
- account: Account;
- workspace: Workspace;
- onRenamed: (workspace: Workspace) => void;
-}) {
- useLocale();
- const [renaming, setRenaming] = useState(false);
- const [name, setName] = useState(workspace.name);
- const [busy, setBusy] = useState(false);
- const [problem, setProblem] = useState();
- // Set once the form closed: « Rename » then takes focus again, where the keyboard left it.
- const [back, setBack] = useState(false);
-
- function close() {
- setRenaming(false);
- setProblem(undefined);
- setBack(true);
- }
-
- async function submit(event: FormEvent) {
- event.preventDefault();
- if (name.trim() === workspace.name) return close();
- setBusy(true);
- setProblem(undefined);
- const renamed = await renameWorkspace(workspace, name);
- setBusy(false);
- // A refusal keeps the form open, with the name as it was typed.
- if (!renamed.ok) return setProblem(problemOf(renamed, RENAME_PROBLEMS, WORKSPACE_NOT_RENAMED));
- onRenamed(renamed.data);
- close();
- }
-
- return (
-
-
- {withStrong(msg('Signed in as {email}'), 'email', account.email, 'text-ink')}
-
- );
-}
-
-type SiteFormProps = {
- workspace: Workspace;
- /** The site this form changes: its values are in the form, and « Cancel » keeps it. */
- replaces?: Added | undefined;
- onDone: (added: Added) => void;
-};
-
-/**
- * Step 2: the address of the site, and who sees its notes.
- *
- * WARNING: the worker has no route that changes a site, and a site that is removed takes its notes
- * with it. So « Change » adds the site again with the new values, and removes the one it replaces
- * only when that one was added in this visit: nothing was noted on it yet. A site of a visit before
- * stays, the summary says so, and the Sites screen removes it with its two presses (FRU-165).
- */
-function SiteForm({ workspace, replaces, onDone }: SiteFormProps) {
- useLocale();
- const before = replaces?.site;
- const [url, setUrl] = useState(before === undefined ? '' : new URL(before.origin).host);
- const [visibility, setVisibility] = useState(before?.visibility ?? 'members');
- const [busy, setBusy] = useState(false);
- const [problem, setProblem] = useState();
-
- async function submit(event: FormEvent) {
- event.preventDefault();
- setProblem(undefined);
- // The same reading as the Sites screen: a host with no scheme is https (FRU-167).
- const address = siteAddress(url);
- if (address === undefined) return setProblem(SITE_PROBLEMS['invalid-url'] ?? SITE_NOT_ADDED);
- // Nothing changed: the site stays as it is, and nothing is asked of the worker.
- if (replaces !== undefined && new URL(address).origin === before?.origin && visibility === before.visibility) {
- return onDone(replaces);
- }
- setBusy(true);
- const base = `/console/workspaces/${workspace.id}/sites`;
- const added = await call('POST', base, { url: address, visibility });
- if (!added.ok) {
- setBusy(false);
-
- // No failure clears the field: the address stays as it was typed.
- return setProblem(problemOf(added, SITE_PROBLEMS, SITE_NOT_ADDED));
- }
- const removed =
- before !== undefined && replaces?.fresh === true && (await call('DELETE', `${base}/${before.id}`)).ok;
- setBusy(false);
- onDone({ site: added.data, fresh: true, ...(before === undefined || removed ? {} : { left: before }) });
- }
-
- return (
-
- );
-}
-
-/** Step 2, done: the site, who sees its notes, and « Change ». */
-function SiteSummary({
- workspace,
- site,
- left,
- onChange,
-}: {
- workspace: Workspace;
- site: Site;
- left: Site | undefined;
- onChange: () => void;
-}) {
- useLocale();
-
- return (
-
- {/* A long host takes the lines it needs: the page does not scroll sideways (FRU-159). */}
-
{new URL(site.origin).host}
-
- {site.visibility === 'members'
- ? t('Members of {workspace} see the notes', { workspace: workspace.name })
- : t('Everyone who visits sees the notes')}
- {manages(workspace) ? (
-
- {t('Change')}
-
- ) : null}
-
- {left === undefined ? null : (
-
- {t('{site} is still in your sites, with its notes. Remove it in Sites if you no longer review it.', {
- site: new URL(left.origin).host,
- })}
-
- )}
-
- );
-}
diff --git a/apps/console/app/setup-stage.test.ts b/apps/console/app/setup-stage.test.ts
new file mode 100644
index 00000000..9c793f1e
--- /dev/null
+++ b/apps/console/app/setup-stage.test.ts
@@ -0,0 +1,160 @@
+import { describe, it } from 'node:test';
+import assert from 'node:assert/strict';
+import type { Answer, Workspace } from './api.ts';
+import { type SetupCalls, firstWorkspace, leaveFor, resolveStage, siteStage } from './setup-stage.ts';
+
+/**
+ * Where the setup takes a person (FRU-239), with a worker of fakes: each case says what the worker
+ * answers, and the test reads the stage and what was asked of it.
+ */
+const account = { id: 'a1', email: 'ana@acme.dev' };
+const owner: Workspace = { id: 'w1', name: 'Acme', role: 'owner' };
+const member: Workspace = { id: 'w1', name: 'Acme', role: 'member' };
+const site = { id: 's1', origin: 'https://staging.acme.dev', visibility: 'members' as const };
+
+const yes = (data: T): Answer => ({ ok: true, data });
+const no = (status: number, error = 'unavailable'): Answer => ({ ok: false, status, error });
+
+type Worker = {
+ signedIn?: boolean;
+ workspaces?: Workspace[];
+ invitations?: Answer | number;
+ sites?: Answer | number;
+ creates?: Answer;
+};
+
+/** A worker of fakes, and the list of what the setup asked of it. */
+function fake(worker: Worker): { calls: SetupCalls; asked: string[] } {
+ const asked: string[] = [];
+ let workspaces = worker.workspaces ?? [];
+ const calls: SetupCalls = {
+ me: async () => {
+ asked.push('me');
+
+ return worker.signedIn === false ? no(401, 'identity-required') : yes({ account, workspaces });
+ },
+ invitations: async () => {
+ asked.push('invitations');
+ const answer = worker.invitations ?? 0;
+
+ return typeof answer === 'number' ? yes(Array.from({ length: answer }, () => ({}) as never)) : answer;
+ },
+ sites: async () => {
+ asked.push('sites');
+ const answer = worker.sites ?? 0;
+
+ return typeof answer === 'number' ? yes({ sites: answer === 0 ? [] : [site] }) : answer;
+ },
+ createWorkspace: async (name) => {
+ asked.push(`create ${name}`);
+ if (worker.creates !== undefined) return worker.creates;
+ const made = { ...owner, name };
+ workspaces = [made];
+
+ return yes(made);
+ },
+ alone: (task) => {
+ asked.push('lock');
+
+ return task();
+ },
+ };
+
+ return { calls, asked };
+}
+
+describe('where the setup takes a person (FRU-239)', () => {
+ it('asks for an address when nobody is signed in, and asks nothing else', async () => {
+ const { calls, asked } = fake({ signedIn: false });
+
+ assert.deepEqual(await resolveStage(calls), { kind: 'email' });
+ assert.deepEqual(asked, ['me']);
+ });
+
+ it('makes the first workspace under the lock, named after the domain, then asks for a site', async () => {
+ const { calls, asked } = fake({});
+
+ const stage = await resolveStage(calls);
+ assert.equal(stage.kind, 'site');
+ assert.deepEqual(asked, ['me', 'invitations', 'lock', 'me', 'create Acme', 'sites']);
+ });
+
+ it('makes no workspace for somebody an invitation waits for', async () => {
+ const { calls, asked } = fake({ invitations: 1 });
+
+ assert.deepEqual(await resolveStage(calls), { kind: 'invited' });
+ assert.ok(!asked.some((each) => each.startsWith('create')));
+ });
+
+ it('makes no workspace while the invitations cannot be read', async () => {
+ const { calls, asked } = fake({ invitations: no(503) });
+
+ assert.deepEqual(await resolveStage(calls), { kind: 'unchecked', account });
+ assert.ok(!asked.some((each) => each.startsWith('create')));
+ });
+
+ it('goes on when the worker has no invitations at all (404)', async () => {
+ const { calls, asked } = fake({ invitations: no(404, 'not-found') });
+
+ assert.equal((await resolveStage(calls)).kind, 'site');
+ assert.ok(asked.includes('create Acme'));
+ });
+
+ it('says what the worker answered when it did not make the workspace', async () => {
+ const refused = await resolveStage(fake({ creates: no(400, 'invalid-name') }).calls);
+ assert.deepEqual(refused, {
+ kind: 'unnamed',
+ account,
+ problem: 'A workspace needs a name of one line, up to 80 characters.',
+ });
+ const silent = await resolveStage(fake({ creates: no(503) }).calls);
+ assert.equal(silent.kind === 'unnamed' && silent.problem, 'Fruitback did not answer. Try again.');
+ });
+
+ it('finds the workspace a tab before it made, under the lock, and makes none', async () => {
+ const { calls, asked } = fake({});
+ // The other tab made it between the first reading and the lock: the reading under the lock finds it.
+ const original = calls.me;
+ calls.me = async () => {
+ const answer = await original();
+
+ return answer.ok && asked.includes('lock') ? yes({ ...answer.data, workspaces: [owner] }) : answer;
+ };
+
+ assert.deepEqual(await resolveStage(calls), { kind: 'site', account, workspace: owner });
+ assert.ok(!asked.some((each) => each.startsWith('create')));
+ // The control: with no other tab, the same calls make it.
+ const alone = fake({});
+ await firstWorkspace(alone.calls, account);
+ assert.ok(alone.asked.includes('create Acme'));
+ });
+
+ it('takes the name the person typed, when they type one', async () => {
+ const { calls, asked } = fake({});
+
+ await firstWorkspace(calls, account, 'Acme Studio');
+ assert.ok(asked.includes('create Acme Studio'));
+ });
+
+ it('resumes at the last step when the workspace has a site', async () => {
+ const stage = await resolveStage(fake({ workspaces: [owner], sites: 1 }).calls);
+
+ assert.deepEqual(stage, { kind: 'install', account, workspace: owner, site });
+ });
+
+ it('says « unread » when the sites cannot be read, and never « no site »', async () => {
+ const stage = await siteStage(fake({ sites: no(503) }).calls, account, owner);
+
+ assert.deepEqual(stage, { kind: 'unread', account, workspace: owner });
+ });
+
+ it('sends a member of a workspace with no site to its Sites screen, and an invited person to theirs', async () => {
+ const stage = await siteStage(fake({}).calls, account, member);
+
+ assert.deepEqual(stage, { kind: 'waiting', workspace: member });
+ assert.equal(leaveFor(stage), '/w/w1/sites');
+ assert.equal(leaveFor({ kind: 'invited' }), '/invitations');
+ // The control: the owner of the same workspace stays, at the form.
+ assert.equal(leaveFor(await siteStage(fake({}).calls, account, owner)), undefined);
+ });
+});
diff --git a/apps/console/app/setup-stage.ts b/apps/console/app/setup-stage.ts
new file mode 100644
index 00000000..328cfe05
--- /dev/null
+++ b/apps/console/app/setup-stage.ts
@@ -0,0 +1,123 @@
+import type { Account, Answer, Invitation, Me, Site, Workspace } from './api.ts';
+import { NAME_PROBLEMS, WORKSPACE_NOT_CREATED, problemOf } from './problems.ts';
+import { workspaceNameFrom } from './workspace-name.ts';
+
+/**
+ * Where the setup of a person is (FRU-239), decided from what the worker answers. No React and no
+ * network here: the screen gives the calls (`SetupCalls`), and `setup-stage.test.ts` gives fakes.
+ */
+
+/**
+ * The site of the setup. `fresh`: it was added in this visit, so nothing was noted on it yet.
+ * `left`: a site that « Change » replaced and did not remove, which the Sites screen still lists.
+ */
+export type Added = { site: Site; fresh?: boolean; left?: Site };
+
+export type Stage =
+ | { kind: 'loading' }
+ | { kind: 'email' }
+ | { kind: 'sent'; email: string }
+ /**
+ * Signed in, and the workspace could not be made: the person tries again, with the name they like.
+ * `problem`: what the worker said of the try that was made for them.
+ */
+ | { kind: 'unnamed'; account: Account; problem: string }
+ /** The sites of the workspace could not be read. That is not « no site »: nothing is asked again. */
+ | { kind: 'unread'; account: Account; workspace: Workspace }
+ /** `replaces`: the site that « Change » opened again, with its values in the form. */
+ | { kind: 'site'; account: Account; workspace: Workspace; replaces?: Added }
+ | ({ kind: 'install'; account: Account; workspace: Workspace } & Added)
+ /** An invitation waits for this person: they came to join a workspace, not to make one. */
+ | { kind: 'invited' }
+ /**
+ * Signed in, with no workspace, and the invitations could not be read: none is made until they
+ * are, as this person may have come to join one.
+ */
+ | { kind: 'unchecked'; account: Account }
+ /**
+ * A member or a guest of a workspace with no site: only an owner or an admin adds one. The Sites
+ * screen says so, as the front door sends them there too.
+ */
+ | { kind: 'waiting'; workspace: Workspace };
+
+export type Made = { workspace: Workspace; problem?: undefined } | { workspace?: undefined; problem: string };
+
+/** What the setup asks of the worker and of the browser. */
+export type SetupCalls = {
+ /** `GET /console/me`, quiet: a person who is not signed in is no notice. */
+ me: () => Promise>;
+ invitations: () => Promise>;
+ sites: (workspace: Workspace) => Promise>;
+ createWorkspace: (name: string) => Promise>;
+ /** Runs `task` alone in every tab of the browser, when the browser has a lock. */
+ alone: (task: () => Promise) => Promise;
+};
+
+/** Who changes the name of a workspace and adds a site: `can` of the worker says the same. */
+export function manages(workspace: Workspace): boolean {
+ return workspace.role === 'owner' || workspace.role === 'admin';
+}
+
+/**
+ * The first workspace of a person, named after their address.
+ *
+ * WARNING: one at a time, in every tab of the browser. The tab that waited for the link and the tab
+ * of the link can both arrive here in the same second, and each would make a workspace. `alone` is
+ * the lock of the browser (`navigator.locks`); the tab that gets it second reads `/console/me` again
+ * and finds the workspace of the first.
+ */
+export function firstWorkspace(calls: SetupCalls, account: Account, name?: string): Promise {
+ return calls.alone(async (): Promise => {
+ const again = await calls.me();
+ const made = again.ok ? again.data.workspaces[0] : undefined;
+ if (made !== undefined) return { workspace: made };
+ const created = await calls.createWorkspace(name ?? workspaceNameFrom(account.email));
+
+ return created.ok
+ ? { workspace: created.data }
+ : { problem: problemOf(created, NAME_PROBLEMS, WORKSPACE_NOT_CREATED) };
+ });
+}
+
+/**
+ * Where the setup of this person is: read from the worker at each visit, so it resumes anywhere.
+ * `signedIn` hears the account, for the language of the person.
+ */
+export async function resolveStage(calls: SetupCalls, signedIn: (me: Me) => void = () => {}): Promise {
+ const me = await calls.me();
+ if (!me.ok) return { kind: 'email' };
+ signedIn(me.data);
+ const { account } = me.data;
+
+ let workspace = me.data.workspaces[0];
+ if (workspace === undefined) {
+ // Somebody who was invited joins a workspace: none is made for them behind their back.
+ // A worker with no invitations at all answers 404: nobody can have been invited there.
+ const invitations = await calls.invitations();
+ if (!invitations.ok && invitations.status !== 404) return { kind: 'unchecked', account };
+ if (invitations.ok && invitations.data.length > 0) return { kind: 'invited' };
+ const made = await firstWorkspace(calls, account);
+ if (made.workspace === undefined) return { kind: 'unnamed', account, problem: made.problem };
+ workspace = made.workspace;
+ }
+
+ return siteStage(calls, account, workspace);
+}
+
+/** The step of a person who has a workspace: its first site, or the form that adds one. */
+export async function siteStage(calls: SetupCalls, account: Account, workspace: Workspace): Promise {
+ const listed = await calls.sites(workspace);
+ if (!listed.ok) return { kind: 'unread', account, workspace };
+ const site = listed.data.sites[0];
+ if (site !== undefined) return { kind: 'install', account, workspace, site };
+
+ return manages(workspace) ? { kind: 'site', account, workspace } : { kind: 'waiting', workspace };
+}
+
+/** Where a stage sends the person, away from the setup: none for a stage the setup shows. */
+export function leaveFor(stage: Stage): string | undefined {
+ if (stage.kind === 'invited') return '/invitations';
+ if (stage.kind === 'waiting') return `/w/${stage.workspace.id}/sites`;
+
+ return undefined;
+}
diff --git a/docs/decisions/console.md b/docs/decisions/console.md
index 2b1b2396..7bbbc051 100644
--- a/docs/decisions/console.md
+++ b/docs/decisions/console.md
@@ -82,7 +82,9 @@ The console of the Cloud, `apps/console`: its words, its kit, and what it shows
`e2e/console-members.spec.ts` reads the values and the boxes of the mockup at 1440 and 390 px,
the keyboard, the contrasts and axe.
- **The setup is one screen, and resumes where the worker says the person is** (FRU-239, screen 01 of
- the mockup of 2026-10-13). `setup.tsx` shows three steps together: « Your account », « Your site »,
+ the mockup of 2026-10-13). `setup.tsx` shows three steps together (step 1 in `setup-account.tsx`,
+ step 2 in `setup-site.tsx`; where the person is, decided from the answers of the worker, in
+ `setup-stage.ts`, which knows no React and no network and has its own `setup-stage.test.ts`): « Your account », « Your site »,
« Put it on the site ». The step is read from the worker at each visit: nobody signed in, no
workspace, no site, a site. So a visit goes on anywhere, and the address has no `?step=`. **The
front door sends a workspace with no site back to the setup**, for who adds a site (an owner, an
From b45c6442605aa2c6fb1a8b050ddd5842346af2e6 Mon Sep 17 00:00:00 2001
From: Mathieu Audebert
Date: Tue, 13 Oct 2026 17:47:40 +0200
Subject: [PATCH 9/9] fix(console): the first step stays the one to do until a
workspace is known (FRU-239)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
With invitations that could not be read, the setup marked « Your account » done and announced
« Your site » as the current step, empty. The first step is now done only once a workspace is known.
The case of console-setup.spec.ts reads the headings, and fails with the old code. Answers the heavy
review of 9e2ef5c.
Co-Authored-By: Claude Opus 5.5
---
apps/console/app/routes/setup.tsx | 7 ++++---
e2e/console-setup.spec.ts | 3 +++
2 files changed, 7 insertions(+), 3 deletions(-)
diff --git a/apps/console/app/routes/setup.tsx b/apps/console/app/routes/setup.tsx
index e1af8190..6af3d956 100644
--- a/apps/console/app/routes/setup.tsx
+++ b/apps/console/app/routes/setup.tsx
@@ -93,9 +93,10 @@ export default function Setup() {
const account = 'account' in stage ? stage.account : undefined;
const workspace = 'workspace' in stage ? stage.workspace : undefined;
- // The first step is to do until somebody is signed in and has a workspace; the last one is the
- // current one once a site is there.
- const signedIn = account !== undefined && stage.kind !== 'unnamed';
+ // The first step is to do until somebody is signed in and has a workspace: invitations that could
+ // not be read, or a workspace that was not made, keep it current. The last one is the current one
+ // once a site is there.
+ const signedIn = account !== undefined && workspace !== undefined;
const installing = stage.kind === 'install';
const first: StepState = signedIn ? 'done' : 'current';
const second: StepState = installing ? 'done' : stepAfter(first);
diff --git a/e2e/console-setup.spec.ts b/e2e/console-setup.spec.ts
index 754562de..6fff8a4c 100644
--- a/e2e/console-setup.spec.ts
+++ b/e2e/console-setup.spec.ts
@@ -562,6 +562,9 @@ test.describe('the states of the setup, against the mockup (FRU-239)', () => {
await page.goto(`${CONSOLE}/setup`);
await expect(page.getByRole('alert')).toHaveText('Fruitback did not answer. Try again.');
+ // The first step is still the one to do, and the second is to come: a screen reader says so.
+ await expect(page.getByRole('heading', { name: `${CURRENT}Your account` })).toBeVisible();
+ await expect(page.getByRole('heading', { name: 'Your site', exact: true })).toBeVisible();
await expect(page.getByRole('textbox', { name: 'Workspace name' })).toHaveCount(0);
expect(asked.filter((each) => each === 'POST /console/workspaces')).toEqual([]);