@remit/web-client 0.0.229 → 0.0.230
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/components/config-import/ConfigImportWizard.flow.test.ts +338 -0
- package/src/components/config-import/ConfigImportWizard.tsx +720 -0
- package/src/components/config-import/config-import.fixtures.ts +229 -0
- package/src/components/config-import/config-import.stories.tsx +449 -0
- package/src/components/config-import/steps.tsx +772 -0
- package/src/components/onboarding/OnboardingWizard.tsx +64 -390
- package/src/components/onboarding/credential-steps.tsx +441 -0
- package/src/components/settings/ConfigExportCard.tsx +114 -0
- package/src/components/settings/ConfigTransferPanel.tsx +72 -0
- package/src/lib/config-import.test.ts +306 -0
- package/src/lib/config-import.ts +492 -0
- package/src/lib/json.ts +11 -0
- package/src/routeTree.gen.ts +21 -0
- package/src/routes/import.tsx +51 -0
- package/src/routes/onboarding.tsx +9 -2
- package/src/routes/settings/accounts.tsx +30 -8
- package/src/routes/settings/advanced.tsx +5 -1
- package/src/routing/sections.ts +3 -1
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One exported configuration from a Dutch household setup — a personal domain
|
|
3
|
+
* over IMAP, a work account on Microsoft 365, and a consultancy address —
|
|
4
|
+
* carried into a second instance. Realistic enough that the dry-run report has
|
|
5
|
+
* something to say in every verdict, and shaped exactly as
|
|
6
|
+
* `POST /config/import` answers, so the stories render what the app renders.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type {
|
|
10
|
+
ApiError,
|
|
11
|
+
RemitImapConfigImportItemReport,
|
|
12
|
+
RemitImapConfigImportReport,
|
|
13
|
+
} from "@remit/api-http-client/types.gen.ts";
|
|
14
|
+
import type { ImportedAccount } from "./steps";
|
|
15
|
+
|
|
16
|
+
export const CONFIG_FILE_NAME = "reader-config.ischen.json";
|
|
17
|
+
|
|
18
|
+
const item = (
|
|
19
|
+
section: string,
|
|
20
|
+
key: string,
|
|
21
|
+
verdict: string,
|
|
22
|
+
reason?: string,
|
|
23
|
+
): RemitImapConfigImportItemReport => ({ section, key, verdict, reason });
|
|
24
|
+
|
|
25
|
+
export const mixedItems: RemitImapConfigImportItemReport[] = [
|
|
26
|
+
item("accounts", "matthijs@ischen.nl", "created"),
|
|
27
|
+
item("accounts", "m.vanhenten@gemeente-amstelveen.nl", "created"),
|
|
28
|
+
item("accounts", "post@vanhenten-advies.nl", "updated"),
|
|
29
|
+
item(
|
|
30
|
+
"accounts",
|
|
31
|
+
"matthijs@xs4all.nl",
|
|
32
|
+
"skipped",
|
|
33
|
+
"An account with this address already exists here and its server settings differ. Remove it first, or edit it by hand — importing will not overwrite a working account.",
|
|
34
|
+
),
|
|
35
|
+
item("labels", "Facturen", "created"),
|
|
36
|
+
item("labels", "Raadsstukken", "unchanged"),
|
|
37
|
+
item("filters", "Bonnetjes", "created"),
|
|
38
|
+
item("filters", "Nieuwsbrieven dempen", "created"),
|
|
39
|
+
item(
|
|
40
|
+
"filters",
|
|
41
|
+
"Waternet",
|
|
42
|
+
"skipped",
|
|
43
|
+
"The folder this rule files into is not here yet. The rule is imported switched off and turns itself on once Projecten/Waternet appears.",
|
|
44
|
+
),
|
|
45
|
+
item("filters", "Raadsstukken markeren", "updated"),
|
|
46
|
+
item(
|
|
47
|
+
"filters",
|
|
48
|
+
"Oude spamregel",
|
|
49
|
+
"rejected",
|
|
50
|
+
"This rule uses an action this version does not have (moveToServerRule). Nothing was imported for it. Rebuild the rule in Settings › Filters.",
|
|
51
|
+
),
|
|
52
|
+
item("addressFlags", "anouk@waternet.nl", "created"),
|
|
53
|
+
item("addressFlags", "nieuwsbrief@bol.com", "created"),
|
|
54
|
+
item("addressFlags", "no-reply@promo.example", "unchanged"),
|
|
55
|
+
item("settings", "Density", "updated"),
|
|
56
|
+
item("settings", "Theme", "unchanged"),
|
|
57
|
+
item("settings", "PinnedFolders", "created"),
|
|
58
|
+
item("settings", "FolderRoleAppointment#Archive", "created"),
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
/** A fresh install: nothing here to skip, nothing to reject. */
|
|
62
|
+
export const cleanItems: RemitImapConfigImportItemReport[] = mixedItems
|
|
63
|
+
.filter((entry) => entry.verdict !== "rejected")
|
|
64
|
+
.map((entry) => ({
|
|
65
|
+
section: entry.section,
|
|
66
|
+
key: entry.key,
|
|
67
|
+
verdict: "created",
|
|
68
|
+
}));
|
|
69
|
+
|
|
70
|
+
const warning = (
|
|
71
|
+
code: string,
|
|
72
|
+
message: string,
|
|
73
|
+
details: Record<string, string>,
|
|
74
|
+
): ApiError => ({ code, message, details });
|
|
75
|
+
|
|
76
|
+
export const folderWarnings: ApiError[] = [
|
|
77
|
+
warning(
|
|
78
|
+
"folder_not_found_yet",
|
|
79
|
+
'Filter "Waternet" files mail into "Projecten/Waternet", which this account does not hold yet. It is bound once the folder list has been read.',
|
|
80
|
+
{ filter: "Waternet", folderPath: "Projecten/Waternet" },
|
|
81
|
+
),
|
|
82
|
+
warning(
|
|
83
|
+
"folder_not_found_yet",
|
|
84
|
+
'Filter "Aangifte bewaren" files mail into "Archief/Belasting 2025", which this account does not hold yet. It is bound once the folder list has been read.',
|
|
85
|
+
{ filter: "Aangifte bewaren", folderPath: "Archief/Belasting 2025" },
|
|
86
|
+
),
|
|
87
|
+
warning(
|
|
88
|
+
"folder_not_found_yet",
|
|
89
|
+
'"Raadsstukken" is not a folder this account holds yet. It is bound once the folder list has been read.',
|
|
90
|
+
{ folderPath: "Raadsstukken", accountId: "acct-amstelveen" },
|
|
91
|
+
),
|
|
92
|
+
];
|
|
93
|
+
|
|
94
|
+
const report = (
|
|
95
|
+
overrides: Partial<RemitImapConfigImportReport>,
|
|
96
|
+
): RemitImapConfigImportReport => ({
|
|
97
|
+
valid: true,
|
|
98
|
+
schemaVersion: 1,
|
|
99
|
+
applied: false,
|
|
100
|
+
items: mixedItems,
|
|
101
|
+
errors: [],
|
|
102
|
+
warnings: [],
|
|
103
|
+
accountsNeedingCredentials: [],
|
|
104
|
+
...overrides,
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
export const dryRunReport = report({ warnings: folderWarnings });
|
|
108
|
+
|
|
109
|
+
export const cleanRunReport = report({ items: cleanItems });
|
|
110
|
+
|
|
111
|
+
export const rejectedReports: Record<string, RemitImapConfigImportReport> = {
|
|
112
|
+
wrongKind: report({
|
|
113
|
+
valid: false,
|
|
114
|
+
schemaVersion: 0,
|
|
115
|
+
items: [],
|
|
116
|
+
errors: [
|
|
117
|
+
{
|
|
118
|
+
code: "WrongKind",
|
|
119
|
+
message:
|
|
120
|
+
'Not a reader configuration document: expected kind "reader.config", found "photo-album".',
|
|
121
|
+
},
|
|
122
|
+
],
|
|
123
|
+
}),
|
|
124
|
+
newerSchema: report({
|
|
125
|
+
valid: false,
|
|
126
|
+
schemaVersion: 5,
|
|
127
|
+
items: [],
|
|
128
|
+
errors: [
|
|
129
|
+
{
|
|
130
|
+
code: "UnsupportedVersion",
|
|
131
|
+
message:
|
|
132
|
+
"Document schemaVersion 5 was written by a newer reader; this one reads up to 1. Upgrade before importing.",
|
|
133
|
+
},
|
|
134
|
+
],
|
|
135
|
+
}),
|
|
136
|
+
unknownKey: report({
|
|
137
|
+
valid: false,
|
|
138
|
+
items: [],
|
|
139
|
+
errors: [
|
|
140
|
+
{
|
|
141
|
+
code: "UnknownKeys",
|
|
142
|
+
message:
|
|
143
|
+
"Unknown keys at filters[3]: serverSideCopy, retentionDays. A document this reader does not fully understand is refused rather than partly applied.",
|
|
144
|
+
},
|
|
145
|
+
],
|
|
146
|
+
}),
|
|
147
|
+
credentialField: report({
|
|
148
|
+
valid: false,
|
|
149
|
+
items: [],
|
|
150
|
+
errors: [
|
|
151
|
+
{
|
|
152
|
+
code: "CredentialPresent",
|
|
153
|
+
message:
|
|
154
|
+
"Credential field at accounts[1]: imapPassword. A configuration document declares which credential an account needs; it never carries one.",
|
|
155
|
+
},
|
|
156
|
+
],
|
|
157
|
+
}),
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
export const partialImportReport: RemitImapConfigImportReport = report({
|
|
161
|
+
applied: true,
|
|
162
|
+
items: [
|
|
163
|
+
...mixedItems.filter(
|
|
164
|
+
(entry) => entry.section === "accounts" || entry.section === "labels",
|
|
165
|
+
),
|
|
166
|
+
item(
|
|
167
|
+
"filters",
|
|
168
|
+
"Bonnetjes",
|
|
169
|
+
"rejected",
|
|
170
|
+
"the filter store refused the write",
|
|
171
|
+
),
|
|
172
|
+
],
|
|
173
|
+
errors: [
|
|
174
|
+
{
|
|
175
|
+
code: "import_write_failed",
|
|
176
|
+
message:
|
|
177
|
+
"the filter store refused the write The items above it were written and remain; the import stopped here.",
|
|
178
|
+
details: { section: "filters", key: "Bonnetjes" },
|
|
179
|
+
},
|
|
180
|
+
],
|
|
181
|
+
warnings: folderWarnings,
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
export const importedAccounts: ImportedAccount[] = [
|
|
185
|
+
{
|
|
186
|
+
accountId: "acct-ischen",
|
|
187
|
+
address: "matthijs@ischen.nl",
|
|
188
|
+
displayName: "Matthijs van Henten",
|
|
189
|
+
connector: "imap",
|
|
190
|
+
server: "imap.ischen.nl:993",
|
|
191
|
+
state: "entered",
|
|
192
|
+
},
|
|
193
|
+
{
|
|
194
|
+
accountId: "acct-advies",
|
|
195
|
+
address: "post@vanhenten-advies.nl",
|
|
196
|
+
displayName: "Van Henten Advies",
|
|
197
|
+
connector: "imap",
|
|
198
|
+
server: "mail.antagonist.nl:993",
|
|
199
|
+
state: "needed",
|
|
200
|
+
},
|
|
201
|
+
{
|
|
202
|
+
accountId: "acct-amstelveen",
|
|
203
|
+
address: "m.vanhenten@gemeente-amstelveen.nl",
|
|
204
|
+
displayName: "M. van Henten (Gemeente Amstelveen)",
|
|
205
|
+
connector: "microsoft",
|
|
206
|
+
server: "outlook.office365.com",
|
|
207
|
+
state: "needed",
|
|
208
|
+
},
|
|
209
|
+
];
|
|
210
|
+
|
|
211
|
+
export const accountsAllPending: ImportedAccount[] = importedAccounts.map(
|
|
212
|
+
(account) => ({ ...account, state: "needed" as const }),
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
export const accountsAllConnected: ImportedAccount[] = importedAccounts.map(
|
|
216
|
+
(account) => ({ ...account, state: "entered" as const }),
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
export const accountsOneFailed: ImportedAccount[] = importedAccounts.map(
|
|
220
|
+
(account) =>
|
|
221
|
+
account.accountId === "acct-advies"
|
|
222
|
+
? {
|
|
223
|
+
...account,
|
|
224
|
+
state: "failed" as const,
|
|
225
|
+
failure:
|
|
226
|
+
"535 5.7.8 Authentication credentials invalid — Antagonist wants the mailbox password, not your control-panel login.",
|
|
227
|
+
}
|
|
228
|
+
: account,
|
|
229
|
+
);
|
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import { Server } from "lucide-react";
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { CredentialsStep } from "@/components/onboarding/credential-steps";
|
|
5
|
+
import { StepWelcome } from "@/components/onboarding/OnboardingWizard";
|
|
6
|
+
import { ConfigExportCard } from "@/components/settings/ConfigExportCard";
|
|
7
|
+
import {
|
|
8
|
+
groupReportSections,
|
|
9
|
+
pendingFolders,
|
|
10
|
+
readFailure,
|
|
11
|
+
sectionResults,
|
|
12
|
+
writeFailure,
|
|
13
|
+
} from "@/lib/config-import";
|
|
14
|
+
import {
|
|
15
|
+
accountsAllConnected,
|
|
16
|
+
accountsAllPending,
|
|
17
|
+
accountsOneFailed,
|
|
18
|
+
CONFIG_FILE_NAME,
|
|
19
|
+
cleanRunReport,
|
|
20
|
+
dryRunReport,
|
|
21
|
+
importedAccounts,
|
|
22
|
+
partialImportReport,
|
|
23
|
+
rejectedReports,
|
|
24
|
+
} from "./config-import.fixtures";
|
|
25
|
+
import {
|
|
26
|
+
AccountCredentialsList,
|
|
27
|
+
IMPORT_STEPS,
|
|
28
|
+
StepAccountReconnectMicrosoft,
|
|
29
|
+
StepChooseFile,
|
|
30
|
+
StepCredentialsOverview,
|
|
31
|
+
StepDryRunReport,
|
|
32
|
+
StepFileRejected,
|
|
33
|
+
StepInstanceAlreadyConfigured,
|
|
34
|
+
StepPartialImport,
|
|
35
|
+
StepPendingFolders,
|
|
36
|
+
} from "./steps";
|
|
37
|
+
|
|
38
|
+
const meta: Meta = {
|
|
39
|
+
title: "Flows/Config import",
|
|
40
|
+
parameters: { layout: "fullscreen" },
|
|
41
|
+
};
|
|
42
|
+
export default meta;
|
|
43
|
+
|
|
44
|
+
type Story = StoryObj;
|
|
45
|
+
|
|
46
|
+
const rejectionFor = (key: keyof typeof rejectedReports) => {
|
|
47
|
+
const failure = readFailure(rejectedReports[key]);
|
|
48
|
+
if (!failure) throw new Error(`fixture ${key} carries no blocking error`);
|
|
49
|
+
return failure;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const dryRunSections = groupReportSections(dryRunReport.items);
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Click-through walkthrough of the happy path: pick a file, read the dry run,
|
|
56
|
+
* hand over the credentials the file does not carry, land on the folders that
|
|
57
|
+
* are still waiting. "Go to inbox" loops back so it isn't a dead end.
|
|
58
|
+
*/
|
|
59
|
+
function ConfigImportWalkthrough() {
|
|
60
|
+
const [index, setIndex] = useState(0);
|
|
61
|
+
const back = () => setIndex((i) => Math.max(i - 1, 0));
|
|
62
|
+
|
|
63
|
+
const screens = [
|
|
64
|
+
<StepWelcome
|
|
65
|
+
key="entry"
|
|
66
|
+
onStart={() => undefined}
|
|
67
|
+
onImportConfig={() => setIndex(1)}
|
|
68
|
+
/>,
|
|
69
|
+
<StepChooseFile
|
|
70
|
+
key="file"
|
|
71
|
+
file={{ name: CONFIG_FILE_NAME, size: 18_432 }}
|
|
72
|
+
ready
|
|
73
|
+
onNext={() => setIndex(2)}
|
|
74
|
+
/>,
|
|
75
|
+
<StepDryRunReport
|
|
76
|
+
key="report"
|
|
77
|
+
sections={dryRunSections}
|
|
78
|
+
fileName={CONFIG_FILE_NAME}
|
|
79
|
+
onBack={back}
|
|
80
|
+
onNext={() => setIndex(3)}
|
|
81
|
+
/>,
|
|
82
|
+
<StepCredentialsOverview
|
|
83
|
+
key="credentials"
|
|
84
|
+
accounts={importedAccounts}
|
|
85
|
+
onNext={() => setIndex(4)}
|
|
86
|
+
/>,
|
|
87
|
+
<StepPendingFolders
|
|
88
|
+
key="folders"
|
|
89
|
+
folders={pendingFolders(dryRunReport)}
|
|
90
|
+
onNext={() => setIndex(0)}
|
|
91
|
+
/>,
|
|
92
|
+
];
|
|
93
|
+
|
|
94
|
+
return screens[index];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Full click-through: onboarding entry through to the pending folders. */
|
|
98
|
+
export const Walkthrough: Story = {
|
|
99
|
+
render: () => <ConfigImportWalkthrough />,
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/** Onboarding: importing sits beside adding the first account, not under it. */
|
|
103
|
+
export const OnboardingEntry: Story = {
|
|
104
|
+
render: () => (
|
|
105
|
+
<StepWelcome onStart={() => undefined} onImportConfig={() => undefined} />
|
|
106
|
+
),
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
/** Same entry at phone width — both CTAs share one footer row. */
|
|
110
|
+
export const OnboardingEntryPhone: Story = {
|
|
111
|
+
globals: { viewport: { value: "mobile" } },
|
|
112
|
+
render: () => (
|
|
113
|
+
<StepWelcome onStart={() => undefined} onImportConfig={() => undefined} />
|
|
114
|
+
),
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
/** Drop target at rest. */
|
|
118
|
+
export const ChooseFile: Story = {
|
|
119
|
+
render: () => <StepChooseFile />,
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
/** A file is over the drop target. */
|
|
123
|
+
export const ChooseFileDragging: Story = {
|
|
124
|
+
render: () => <StepChooseFile state="dragging" />,
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
/** A file is chosen and parsed; the check is armed, nothing has been sent. */
|
|
128
|
+
export const ChooseFileChosen: Story = {
|
|
129
|
+
render: () => (
|
|
130
|
+
<StepChooseFile file={{ name: CONFIG_FILE_NAME, size: 18_432 }} ready />
|
|
131
|
+
),
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
/** The browser is still pulling the file off disk — there is nothing to send yet. */
|
|
135
|
+
export const ChooseFileReading: Story = {
|
|
136
|
+
render: () => (
|
|
137
|
+
<StepChooseFile
|
|
138
|
+
state="reading"
|
|
139
|
+
file={{ name: CONFIG_FILE_NAME, size: 18_432 }}
|
|
140
|
+
/>
|
|
141
|
+
),
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
/** The dry run is in flight against the server. */
|
|
145
|
+
export const ChooseFileChecking: Story = {
|
|
146
|
+
render: () => (
|
|
147
|
+
<StepChooseFile
|
|
148
|
+
state="checking"
|
|
149
|
+
file={{ name: CONFIG_FILE_NAME, size: 18_432 }}
|
|
150
|
+
ready
|
|
151
|
+
/>
|
|
152
|
+
),
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
/** A file that is not JSON at all, refused before anything leaves the browser. */
|
|
156
|
+
export const ChooseFileNotJson: Story = {
|
|
157
|
+
render: () => (
|
|
158
|
+
<StepChooseFile
|
|
159
|
+
file={{ name: "vakantiefotos.zip", size: 4_194_304 }}
|
|
160
|
+
failure={{
|
|
161
|
+
title: "That file is not JSON",
|
|
162
|
+
explanation:
|
|
163
|
+
"vakantiefotos.zip could not be read as JSON at all, so there is nothing in it to import.",
|
|
164
|
+
fix: "Pick the .json file written by Settings › Advanced, or by remit config save.",
|
|
165
|
+
raw: "Unexpected token 'P', \"PK...\" is not valid JSON",
|
|
166
|
+
}}
|
|
167
|
+
/>
|
|
168
|
+
),
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* The dry run: every section with a per-entry verdict, and a stated reason
|
|
173
|
+
* under everything that will not land.
|
|
174
|
+
*/
|
|
175
|
+
export const DryRunReport: Story = {
|
|
176
|
+
render: () => (
|
|
177
|
+
<StepDryRunReport
|
|
178
|
+
sections={dryRunSections}
|
|
179
|
+
fileName={CONFIG_FILE_NAME}
|
|
180
|
+
warnings={dryRunReport.warnings.map((warning) => warning.message)}
|
|
181
|
+
/>
|
|
182
|
+
),
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
/** A fresh instance — the same file with nothing to skip or reject. */
|
|
186
|
+
export const DryRunReportCleanInstance: Story = {
|
|
187
|
+
render: () => (
|
|
188
|
+
<StepDryRunReport
|
|
189
|
+
sections={groupReportSections(cleanRunReport.items)}
|
|
190
|
+
fileName={CONFIG_FILE_NAME}
|
|
191
|
+
/>
|
|
192
|
+
),
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
/** The apply is in flight; the control says so rather than going quiet. */
|
|
196
|
+
export const DryRunReportApplying: Story = {
|
|
197
|
+
render: () => (
|
|
198
|
+
<StepDryRunReport
|
|
199
|
+
sections={dryRunSections}
|
|
200
|
+
fileName={CONFIG_FILE_NAME}
|
|
201
|
+
applying
|
|
202
|
+
/>
|
|
203
|
+
),
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* A file from a newer Reader carrying a section this one does not know. It gets
|
|
208
|
+
* its own heading rather than being filed under a section we recognise, because
|
|
209
|
+
* telling someone their settings landed somewhere they did not is the failure
|
|
210
|
+
* the whole format exists to prevent.
|
|
211
|
+
*/
|
|
212
|
+
export const DryRunReportUnknownSection: Story = {
|
|
213
|
+
render: () => (
|
|
214
|
+
<StepDryRunReport
|
|
215
|
+
sections={groupReportSections([
|
|
216
|
+
...dryRunReport.items,
|
|
217
|
+
{
|
|
218
|
+
section: "messageDecisions",
|
|
219
|
+
key: "3.412 message labels",
|
|
220
|
+
verdict: "created",
|
|
221
|
+
},
|
|
222
|
+
])}
|
|
223
|
+
fileName={CONFIG_FILE_NAME}
|
|
224
|
+
/>
|
|
225
|
+
),
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
/** Dry run at phone width — the verdict badges wrap rather than clip. */
|
|
229
|
+
export const DryRunReportPhone: Story = {
|
|
230
|
+
globals: { viewport: { value: "mobile" } },
|
|
231
|
+
render: () => (
|
|
232
|
+
<StepDryRunReport sections={dryRunSections} fileName={CONFIG_FILE_NAME} />
|
|
233
|
+
),
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
const HELD =
|
|
237
|
+
"2 accounts, 14 rules and 31 flagged senders are set up here already.";
|
|
238
|
+
|
|
239
|
+
/** 409: the instance is already configured, and neither choice is preselected. */
|
|
240
|
+
export const AlreadyConfigured: Story = {
|
|
241
|
+
render: () => <StepInstanceAlreadyConfigured held={HELD} />,
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
/** Abort chosen: nothing is written. */
|
|
245
|
+
export const AlreadyConfiguredAbort: Story = {
|
|
246
|
+
render: () => <StepInstanceAlreadyConfigured held={HELD} choice="abort" />,
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
/** Merge chosen: adds and updates only, existing accounts keep their servers. */
|
|
250
|
+
export const AlreadyConfiguredMerge: Story = {
|
|
251
|
+
render: () => <StepInstanceAlreadyConfigured held={HELD} choice="merge" />,
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
/** Two of three accounts cannot sync until someone signs in. */
|
|
255
|
+
export const CredentialsOverview: Story = {
|
|
256
|
+
render: () => <StepCredentialsOverview accounts={importedAccounts} />,
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
/** Straight after the import: no account has credentials yet. */
|
|
260
|
+
export const CredentialsOverviewAllPending: Story = {
|
|
261
|
+
render: () => <StepCredentialsOverview accounts={accountsAllPending} />,
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
/** A password was entered and the server refused it — the row says why. */
|
|
265
|
+
export const CredentialsOverviewSignInFailed: Story = {
|
|
266
|
+
render: () => <StepCredentialsOverview accounts={accountsOneFailed} />,
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
/** A Microsoft reconnect is in flight; its row is busy, the rest are live. */
|
|
270
|
+
export const CredentialsOverviewRedirecting: Story = {
|
|
271
|
+
render: () => (
|
|
272
|
+
<StepCredentialsOverview
|
|
273
|
+
accounts={importedAccounts}
|
|
274
|
+
busyAccountId="acct-amstelveen"
|
|
275
|
+
/>
|
|
276
|
+
),
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
/** Every account connected — the step is finally satisfiable. */
|
|
280
|
+
export const CredentialsOverviewComplete: Story = {
|
|
281
|
+
render: () => <StepCredentialsOverview accounts={accountsAllConnected} />,
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
/** The same account rows as they read in Settings, outside the wizard. */
|
|
285
|
+
export const AccountsNeedingCredentialsInSettings: Story = {
|
|
286
|
+
render: () => (
|
|
287
|
+
<div className="min-h-dvh bg-canvas p-6 font-sans">
|
|
288
|
+
<div className="max-w-2xl">
|
|
289
|
+
<AccountCredentialsList accounts={accountsOneFailed} />
|
|
290
|
+
</div>
|
|
291
|
+
</div>
|
|
292
|
+
),
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
const passwordStepProps = {
|
|
296
|
+
steps: IMPORT_STEPS,
|
|
297
|
+
activeStep: 2,
|
|
298
|
+
title: "Sign in to post@vanhenten-advies.nl",
|
|
299
|
+
subtitle: "Account 2 of 3 · mail.antagonist.nl:993",
|
|
300
|
+
email: "post@vanhenten-advies.nl",
|
|
301
|
+
username: "post@vanhenten-advies.nl",
|
|
302
|
+
password: "",
|
|
303
|
+
continueLabel: "Test and continue",
|
|
304
|
+
notice: (
|
|
305
|
+
<div className="flex items-center gap-2 text-xs text-fg-muted">
|
|
306
|
+
<Server className="size-3.5" />
|
|
307
|
+
Server settings came from the config file — edit them in Settings if they
|
|
308
|
+
are wrong.
|
|
309
|
+
</div>
|
|
310
|
+
),
|
|
311
|
+
onContinue: () => undefined,
|
|
312
|
+
onBack: () => undefined,
|
|
313
|
+
onChange: () => undefined,
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
/** Password entry for one imported IMAP account. */
|
|
317
|
+
export const AccountPassword: Story = {
|
|
318
|
+
render: () => <CredentialsStep {...passwordStepProps} />,
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
/** Retry after a refused password: the server's own words, then the fix. */
|
|
322
|
+
export const AccountPasswordRejected: Story = {
|
|
323
|
+
render: () => (
|
|
324
|
+
<CredentialsStep
|
|
325
|
+
{...passwordStepProps}
|
|
326
|
+
error="535 5.7.8 Authentication credentials invalid. Antagonist wants the mailbox password, not your control-panel login."
|
|
327
|
+
/>
|
|
328
|
+
),
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
/** The Microsoft branch: no token travels in a config file. */
|
|
332
|
+
export const AccountReconnectMicrosoft: Story = {
|
|
333
|
+
render: () => (
|
|
334
|
+
<StepAccountReconnectMicrosoft
|
|
335
|
+
account={importedAccounts[2]}
|
|
336
|
+
position={3}
|
|
337
|
+
total={3}
|
|
338
|
+
/>
|
|
339
|
+
),
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
/** Microsoft sign-in stopped at the tenant's admin-consent screen. */
|
|
343
|
+
export const AccountReconnectMicrosoftBlocked: Story = {
|
|
344
|
+
render: () => (
|
|
345
|
+
<StepAccountReconnectMicrosoft
|
|
346
|
+
account={importedAccounts[2]}
|
|
347
|
+
position={3}
|
|
348
|
+
total={3}
|
|
349
|
+
error="Microsoft stopped the sign-in: your organisation requires an administrator to approve Reader. Ask IT to approve it, then reconnect."
|
|
350
|
+
/>
|
|
351
|
+
),
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
/** Imported settings that name folders discovery has not produced yet. */
|
|
355
|
+
export const PendingFolders: Story = {
|
|
356
|
+
render: () => <StepPendingFolders folders={pendingFolders(dryRunReport)} />,
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
/** Nothing left waiting: every folder the file named already resolved. */
|
|
360
|
+
export const PendingFoldersNone: Story = {
|
|
361
|
+
render: () => <StepPendingFolders folders={[]} />,
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
/** Not a Reader config file at all. */
|
|
365
|
+
export const RejectedWrongFileKind: Story = {
|
|
366
|
+
render: () => <StepFileRejected failure={rejectionFor("wrongKind")} />,
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
/** Written by a newer Reader — update before importing. */
|
|
370
|
+
export const RejectedNewerSchema: Story = {
|
|
371
|
+
render: () => <StepFileRejected failure={rejectionFor("newerSchema")} />,
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
/** Keys this version does not know: refused rather than silently ignored. */
|
|
375
|
+
export const RejectedUnknownKey: Story = {
|
|
376
|
+
render: () => <StepFileRejected failure={rejectionFor("unknownKey")} />,
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
/** The file carries a password: rejected outright, never quietly stripped. */
|
|
380
|
+
export const RejectedCredentialField: Story = {
|
|
381
|
+
render: () => <StepFileRejected failure={rejectionFor("credentialField")} />,
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* The same stop, with a server that did not say where. Nothing here may read
|
|
386
|
+
* "landed": the reader has to go and look, and the screen says so.
|
|
387
|
+
*/
|
|
388
|
+
export const PartiallyLandedImportUnnamed: Story = {
|
|
389
|
+
render: () => {
|
|
390
|
+
const report = {
|
|
391
|
+
...partialImportReport,
|
|
392
|
+
errors: [
|
|
393
|
+
{ code: "import_write_failed", message: "the store refused the write" },
|
|
394
|
+
],
|
|
395
|
+
};
|
|
396
|
+
return (
|
|
397
|
+
<StepPartialImport
|
|
398
|
+
results={sectionResults(report)}
|
|
399
|
+
message={writeFailure(report)?.message ?? ""}
|
|
400
|
+
raw="import_write_failed: the store refused the write"
|
|
401
|
+
/>
|
|
402
|
+
);
|
|
403
|
+
},
|
|
404
|
+
};
|
|
405
|
+
|
|
406
|
+
/** An import that stopped half-way, saying exactly what landed. */
|
|
407
|
+
export const PartiallyLandedImport: Story = {
|
|
408
|
+
render: () => (
|
|
409
|
+
<StepPartialImport
|
|
410
|
+
results={sectionResults(partialImportReport)}
|
|
411
|
+
message={writeFailure(partialImportReport)?.message ?? ""}
|
|
412
|
+
raw={`import_write_failed: ${writeFailure(partialImportReport)?.message ?? ""}`}
|
|
413
|
+
/>
|
|
414
|
+
),
|
|
415
|
+
};
|
|
416
|
+
|
|
417
|
+
/** Settings → Advanced: the export half of the pair. */
|
|
418
|
+
export const ExportCard: Story = {
|
|
419
|
+
render: () => (
|
|
420
|
+
<div className="min-h-dvh bg-canvas p-6 font-sans">
|
|
421
|
+
<ConfigExportCard fileName="reader-config.2026-08-28.json" />
|
|
422
|
+
</div>
|
|
423
|
+
),
|
|
424
|
+
};
|
|
425
|
+
|
|
426
|
+
/** The export is being read; the button says so rather than going quiet. */
|
|
427
|
+
export const ExportCardDownloading: Story = {
|
|
428
|
+
render: () => (
|
|
429
|
+
<div className="min-h-dvh bg-canvas p-6 font-sans">
|
|
430
|
+
<ConfigExportCard
|
|
431
|
+
fileName="reader-config.2026-08-28.json"
|
|
432
|
+
state="downloading"
|
|
433
|
+
/>
|
|
434
|
+
</div>
|
|
435
|
+
),
|
|
436
|
+
};
|
|
437
|
+
|
|
438
|
+
/** The export failed — a dead download button would be the worse outcome. */
|
|
439
|
+
export const ExportCardFailed: Story = {
|
|
440
|
+
render: () => (
|
|
441
|
+
<div className="min-h-dvh bg-canvas p-6 font-sans">
|
|
442
|
+
<ConfigExportCard
|
|
443
|
+
fileName="reader-config.2026-08-28.json"
|
|
444
|
+
state="failed"
|
|
445
|
+
error="500 Internal Server Error — GET /config/export: sender store unreadable"
|
|
446
|
+
/>
|
|
447
|
+
</div>
|
|
448
|
+
),
|
|
449
|
+
};
|