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
7 changes: 4 additions & 3 deletions apps/console/app/api-answers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)']);
});
});
25 changes: 4 additions & 21 deletions apps/console/app/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Answer<Workspace>> {
return call<Workspace>('POST', `/console/workspaces/${workspace.id}`, { name });
}

/**
Expand Down
102 changes: 102 additions & 0 deletions apps/console/app/install-options.tsx
Original file line number Diff line number Diff line change
@@ -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 [
'<script',
` src="${window.location.origin}/fruitback.iife.js"`,
` data-fruitback-endpoint="${API}"`,
` data-fruitback-client="${site.id}"`,
' defer',
'></script>',
].join('\n');
}

// Every role connects a browser, so no code of the worker has a sentence of its own here.
const CONNECT_PROBLEMS: Readonly<Record<string, string>> = {};
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<string | undefined>();
const [problem, setProblem] = useState<string | undefined>();
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 (
<div className="space-y-5">
<div>
<h2 className="text-sm font-semibold">{t('Script')}</h2>
<p className="mb-2 text-xs text-muted">{t('Paste it before the closing body tag of the site.')}</p>
{/* The box of the script takes focus, so a keyboard scrolls its long lines (axe, FRU-239). */}
<Snippet label={t('Script')}>{snippet}</Snippet>
<Button
tone="outline"
className="mt-2"
onClick={() => void copyText(snippet).then((taken) => setCopied(taken ? 'yes' : 'refused'))}
>
{copied === 'yes' ? t('Copied') : t('Copy the script')}
</Button>
{copied === 'refused' ? (
<p role="alert" className="mt-2 text-xs text-muted">
{t('The browser refused the copy. Select the script above and copy it by hand.')}
</p>
) : null}
</div>
<div>
<h2 className="text-sm font-semibold">{t('Extension')}</h2>
<p className="mb-2 text-xs text-muted">
{t(
'Nothing to change on the site. Connect this browser, then click the Fruitback icon on the page that opens.',
)}
</p>
<Button tone="outline" onClick={() => void connect()}>
{t('Connect this browser')}
</Button>
{problem === undefined ? null : (
<div className="mt-2">
<Problem>{t(problem)}</Problem>
</div>
)}
{link === undefined ? null : (
<p className="mt-2 text-xs text-muted">
{t('A page opened in a new tab. If it did not, open')}{' '}
<a href={link} target="_blank" rel="noopener noreferrer" className="underline">
{t('this link')}
</a>
{t(': it works once, for fifteen minutes.')}
</p>
)}
</div>
</div>
);
}
80 changes: 38 additions & 42 deletions apps/console/app/messages.fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
export const FRENCH: Readonly<Record<string, string>> = {
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.',
Expand Down Expand Up @@ -102,8 +101,6 @@ export const FRENCH: Readonly<Record<string, string>> = {
'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é',
Expand Down Expand Up @@ -248,20 +245,7 @@ export const FRENCH: Readonly<Record<string, string>> = {
'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.':
Expand All @@ -286,45 +270,19 @@ export const FRENCH: Readonly<Record<string, string>> = {
"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.',
Expand Down Expand Up @@ -501,4 +459,42 @@ export const FRENCH: Readonly<Record<string, string>> = {
'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.',
};
2 changes: 2 additions & 0 deletions apps/console/app/messages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`)
Expand All @@ -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')]));

Expand Down
3 changes: 2 additions & 1 deletion apps/console/app/routes/connector-parts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
15 changes: 12 additions & 3 deletions apps/console/app/routes/home.tsx
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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 });
});
});
});

Expand Down
Loading
Loading