@remit/web-client 0.0.70 → 0.0.71

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/web-client",
3
- "version": "0.0.70",
3
+ "version": "0.0.71",
4
4
  "type": "module",
5
5
  "description": "Remit web client, published as composable primitives — the app shell, auth shells, and runtime config. A distributor imports what it composes and bundles it.",
6
6
  "exports": {
@@ -14,6 +14,7 @@ import { useTranslation } from "react-i18next";
14
14
  import { Drawer } from "vaul";
15
15
  import { ErrorState } from "@/components/ui/ErrorState";
16
16
  import { useFolderAppointments } from "@/hooks/useArchiveMailbox";
17
+ import { useCreateMailbox } from "@/hooks/useCreateMailbox";
17
18
  import { useIsDesktop } from "@/hooks/useMediaQuery";
18
19
  import { buildMailboxRoleMap, labelForMailbox } from "@/lib/folder-roles";
19
20
  import { buildMoveTargets } from "@/lib/move-targets";
@@ -95,6 +96,7 @@ export const MoveToTrigger = ({
95
96
  enabled: isOpen,
96
97
  });
97
98
  const folderAppointments = useFolderAppointments(accountId);
99
+ const { createFolder } = useCreateMailbox(accountId);
98
100
 
99
101
  const options = useMemo<MoveMailboxOption[]>(() => {
100
102
  const targets = buildMoveTargets(
@@ -127,6 +129,14 @@ export const MoveToTrigger = ({
127
129
  [onMove],
128
130
  );
129
131
 
132
+ const handleCreateFolder = useCallback(
133
+ async (name: string): Promise<MoveMailboxOption> => {
134
+ const folder = await createFolder(name);
135
+ return { id: folder.id, label: folder.label };
136
+ },
137
+ [createFolder],
138
+ );
139
+
130
140
  // Desktop popover: dismiss on outside click + Escape.
131
141
  useEffect(() => {
132
142
  if (!isOpen || !isDesktop) return;
@@ -190,9 +200,11 @@ export const MoveToTrigger = ({
190
200
  <MoveMailboxPicker
191
201
  mailboxes={options}
192
202
  onSelect={handleSelect}
203
+ onCreateFolder={handleCreateFolder}
193
204
  onCancel={() => setIsOpen(false)}
194
205
  autoFocus={!isDesktop}
195
206
  labels={{
207
+ createLabel: (folderName) => `Create "${folderName}"`,
196
208
  searchPlaceholder: t("move_picker_placeholder", {
197
209
  defaultValue: "Move to…",
198
210
  }),
@@ -7,6 +7,7 @@ import {
7
7
  } from "@remit/ui";
8
8
  import { useQuery } from "@tanstack/react-query";
9
9
  import { useMemo, useState } from "react";
10
+ import { useCreateMailbox } from "@/hooks/useCreateMailbox";
10
11
  import { useCreateFilter } from "@/hooks/useFilters";
11
12
  import { useOrganizeJob } from "@/hooks/useOrganizeJob";
12
13
  import { useRuleEditorState } from "@/hooks/useRuleEditorState";
@@ -104,6 +105,7 @@ export function OrganizeRuleEditor({
104
105
 
105
106
  const organizeJob = useOrganizeJob(accountId);
106
107
  const createFilter = useCreateFilter(accountId);
108
+ const { createFolder } = useCreateMailbox(accountId);
107
109
 
108
110
  const commit = () => {
109
111
  const draft = ruleToDraft(rule, anchorMessageId);
@@ -153,6 +155,7 @@ export function OrganizeRuleEditor({
153
155
  semanticAvailable={!semanticUnavailable}
154
156
  clauseFields={SUPPORTED_CLAUSE_FIELDS}
155
157
  {...handlers}
158
+ onCreateFolder={createFolder}
156
159
  onCommit={commit}
157
160
  onCancel={onClose}
158
161
  />
@@ -11,6 +11,7 @@ import {
11
11
  } from "@remit/ui";
12
12
  import { CheckCircle2, Loader2 } from "lucide-react";
13
13
  import { useMemo, useRef, useState } from "react";
14
+ import { useCreateMailbox } from "@/hooks/useCreateMailbox";
14
15
  import { useOrganizeJob } from "@/hooks/useOrganizeJob";
15
16
  import { useRulePreview } from "@/hooks/useRulePreview";
16
17
  import { useUpdateFilter } from "@/hooks/useUpdateFilter";
@@ -67,6 +68,7 @@ export function FilterEditor({
67
68
  const preview = useRulePreview(accountId, rulePredicate(rule));
68
69
  const update = useUpdateFilter(accountId, filter.filterId);
69
70
  const organizeJob = useOrganizeJob(accountId);
71
+ const { createFolder } = useCreateMailbox(accountId);
70
72
 
71
73
  const startAddClause = () =>
72
74
  setClauseEdit({
@@ -203,6 +205,7 @@ export function FilterEditor({
203
205
  onCancelClause={() => setClauseEdit(undefined)}
204
206
  onChangeMatchOperator={changeMatchOperator}
205
207
  onChangeMove={changeMove}
208
+ onCreateFolder={createFolder}
206
209
  onChangeName={changeName}
207
210
  onCommit={commit}
208
211
  onCancel={onClose}
@@ -0,0 +1,131 @@
1
+ /**
2
+ * useCreateMailbox.createFolder — the shared create seam the kit surfaces call.
3
+ * It validates the typed name against the account's current folders with the
4
+ * same IMAP-aware rules the settings form uses, and rejects with the
5
+ * human-readable reason before any request. The mailbox list is seeded into the
6
+ * query cache the hook reads, so validation runs against real paths.
7
+ */
8
+
9
+ import assert from "node:assert/strict";
10
+ import { afterEach, describe, it } from "node:test";
11
+ import { mailboxOperationsListMailboxesQueryKey } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
12
+ import type {
13
+ MailboxOperationsListMailboxesResponse,
14
+ RemitImapMailboxResponse,
15
+ } from "@remit/api-http-client/types.gen.ts";
16
+ import type { FolderOption } from "@remit/ui";
17
+ import { act, createElement } from "react";
18
+ import { createDomHarness, type DomHarness } from "../test-support/dom";
19
+ import { type HttpMock, mockFetch } from "../test-support/http";
20
+ import { useCreateMailbox } from "./useCreateMailbox";
21
+
22
+ const ACCOUNT = "acc-1";
23
+
24
+ let harness: DomHarness | undefined;
25
+ let http: HttpMock | undefined;
26
+ let createFolder: ((name: string) => Promise<FolderOption>) | undefined;
27
+
28
+ afterEach(() => {
29
+ harness?.close();
30
+ harness = undefined;
31
+ http?.restore();
32
+ http = undefined;
33
+ createFolder = undefined;
34
+ });
35
+
36
+ const mailbox = (
37
+ fullPath: string,
38
+ delimiter: string,
39
+ ): RemitImapMailboxResponse =>
40
+ ({
41
+ mailboxId: `mbx-${fullPath}`,
42
+ accountId: ACCOUNT,
43
+ fullPath,
44
+ hierarchyDelimiter: delimiter,
45
+ }) as RemitImapMailboxResponse;
46
+
47
+ function Probe() {
48
+ createFolder = useCreateMailbox(ACCOUNT).createFolder;
49
+ return null;
50
+ }
51
+
52
+ const mount = (items: RemitImapMailboxResponse[]) => {
53
+ http = mockFetch((call) => {
54
+ if (call.method === "POST") {
55
+ const body = call.body as { fullPath: string };
56
+ return { mailboxId: `mbx-${body.fullPath}`, fullPath: body.fullPath };
57
+ }
58
+ return { items };
59
+ });
60
+ harness = createDomHarness();
61
+ harness.queryClient.setQueryData<MailboxOperationsListMailboxesResponse>(
62
+ mailboxOperationsListMailboxesQueryKey({ path: { accountId: ACCOUNT } }),
63
+ { items },
64
+ );
65
+ harness.renderApp(createElement(Probe));
66
+ };
67
+
68
+ const reject = async (name: string): Promise<unknown> => {
69
+ const run = createFolder;
70
+ if (!run) throw new Error("createFolder is not mounted");
71
+ let caught: unknown;
72
+ await act(async () => {
73
+ caught = await run(name).then(
74
+ () => undefined,
75
+ (error: unknown) => error,
76
+ );
77
+ });
78
+ return caught;
79
+ };
80
+
81
+ const postCount = (): number =>
82
+ (http?.calls ?? []).filter((call) => call.method === "POST").length;
83
+
84
+ describe("useCreateMailbox.createFolder validation", () => {
85
+ it("rejects a name containing the account delimiter before any request", async () => {
86
+ mount([mailbox("INBOX", "/")]);
87
+ const error = await reject("Work/Receipts");
88
+ assert.ok(error instanceof Error);
89
+ assert.match(error.message, /can't contain/);
90
+ assert.equal(postCount(), 0);
91
+ });
92
+
93
+ it("honours a non-slash account delimiter", async () => {
94
+ mount([mailbox("INBOX", ".")]);
95
+ const error = await reject("Work.Receipts");
96
+ assert.ok(error instanceof Error);
97
+ assert.match(error.message, /can't contain/);
98
+ assert.equal(postCount(), 0);
99
+ });
100
+
101
+ it("rejects a collision with an existing top-level fullPath before any request", async () => {
102
+ mount([mailbox("INBOX", "/"), mailbox("Work", "/")]);
103
+ const error = await reject("Work");
104
+ assert.ok(error instanceof Error);
105
+ assert.match(error.message, /already exists/);
106
+ assert.equal(postCount(), 0);
107
+ });
108
+
109
+ it("treats INBOX case-insensitively for collisions", async () => {
110
+ mount([mailbox("INBOX", "/")]);
111
+ const error = await reject("inbox");
112
+ assert.ok(error instanceof Error);
113
+ assert.match(error.message, /already exists/);
114
+ assert.equal(postCount(), 0);
115
+ });
116
+
117
+ it("passes a valid name through to the create request and maps the result", async () => {
118
+ mount([mailbox("INBOX", "/")]);
119
+ let result: FolderOption | undefined;
120
+ await act(async () => {
121
+ result = await createFolder?.("Taxes");
122
+ });
123
+ const posts = (http?.calls ?? []).filter((call) => call.method === "POST");
124
+ assert.equal(posts.length, 1);
125
+ assert.deepEqual(posts[0].body, {
126
+ fullPath: "Taxes",
127
+ namespaceType: "personal",
128
+ });
129
+ assert.equal(result?.label, "Taxes");
130
+ });
131
+ });
@@ -0,0 +1,65 @@
1
+ import {
2
+ mailboxOperationsCreateMailboxMutation,
3
+ mailboxOperationsListMailboxesOptions,
4
+ mailboxOperationsListMailboxesQueryKey,
5
+ } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
6
+ import type { FolderOption } from "@remit/ui";
7
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
8
+ import { useCallback } from "react";
9
+ import { getMailboxDisplayName } from "@/lib/folder-roles";
10
+ import { composeFolderPath, validateNewFolderName } from "@/lib/new-folder";
11
+
12
+ /**
13
+ * Creates a mailbox for an account and refreshes the folder list on success.
14
+ * The backend creates the row with a pending sync status and queues the IMAP
15
+ * create, so the folder is usable as a move destination immediately.
16
+ *
17
+ * `createFolder` takes a leaf name, validates it against the account's current
18
+ * folders with the same IMAP-aware rules the settings form uses (non-empty, no
19
+ * hierarchy delimiter, no collision — INBOX case-insensitive), and rejects with
20
+ * the human-readable reason before any request. The kit surfaces that pick the
21
+ * result render that rejection inline. `mutation` is exposed for callers that
22
+ * drive their own form state and error surface.
23
+ */
24
+ export function useCreateMailbox(accountId: string) {
25
+ const queryClient = useQueryClient();
26
+
27
+ const { data } = useQuery(
28
+ mailboxOperationsListMailboxesOptions({ path: { accountId } }),
29
+ );
30
+
31
+ const mutation = useMutation({
32
+ ...mailboxOperationsCreateMailboxMutation(),
33
+ onSuccess: () => {
34
+ queryClient.invalidateQueries({
35
+ queryKey: mailboxOperationsListMailboxesQueryKey({
36
+ path: { accountId },
37
+ }),
38
+ });
39
+ },
40
+ });
41
+
42
+ const createFolder = useCallback(
43
+ async (name: string): Promise<FolderOption> => {
44
+ const items = data?.items ?? [];
45
+ const delimiter = items[0]?.hierarchyDelimiter ?? "/";
46
+ const problem = validateNewFolderName({
47
+ name,
48
+ delimiter,
49
+ existingPaths: items.map((item) => item.fullPath),
50
+ });
51
+ if (problem) throw new Error(problem);
52
+ const mailbox = await mutation.mutateAsync({
53
+ path: { accountId },
54
+ body: { fullPath: composeFolderPath(name), namespaceType: "personal" },
55
+ });
56
+ return {
57
+ id: mailbox.mailboxId,
58
+ label: getMailboxDisplayName(mailbox.fullPath),
59
+ };
60
+ },
61
+ [mutation, accountId, data],
62
+ );
63
+
64
+ return { createFolder, mutation };
65
+ }
@@ -0,0 +1,109 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import {
4
+ composeFolderPath,
5
+ type FolderTarget,
6
+ validateNewFolderName,
7
+ } from "./new-folder.js";
8
+
9
+ const parent: FolderTarget = { fullPath: "INBOX", hierarchyDelimiter: "/" };
10
+
11
+ describe("composeFolderPath", () => {
12
+ it("returns the trimmed name for a root folder", () => {
13
+ assert.equal(composeFolderPath(" Receipts "), "Receipts");
14
+ });
15
+
16
+ it("joins the parent path by the parent's delimiter", () => {
17
+ assert.equal(composeFolderPath("Receipts", parent), "INBOX/Receipts");
18
+ });
19
+
20
+ it("honours a non-slash delimiter", () => {
21
+ assert.equal(
22
+ composeFolderPath("Receipts", {
23
+ fullPath: "INBOX",
24
+ hierarchyDelimiter: ".",
25
+ }),
26
+ "INBOX.Receipts",
27
+ );
28
+ });
29
+ });
30
+
31
+ describe("validateNewFolderName", () => {
32
+ const base = { delimiter: "/", existingPaths: ["INBOX", "INBOX/Archive"] };
33
+
34
+ it("rejects an empty or whitespace name", () => {
35
+ assert.match(
36
+ validateNewFolderName({ ...base, name: " " }) ?? "",
37
+ /Enter a folder name/,
38
+ );
39
+ });
40
+
41
+ it("rejects a name containing the hierarchy delimiter", () => {
42
+ assert.match(
43
+ validateNewFolderName({ ...base, name: "a/b" }) ?? "",
44
+ /can't contain/,
45
+ );
46
+ });
47
+
48
+ it("rejects a name that would contain a non-slash delimiter", () => {
49
+ assert.match(
50
+ validateNewFolderName({
51
+ delimiter: ".",
52
+ existingPaths: [],
53
+ name: "a.b",
54
+ }) ?? "",
55
+ /can't contain/,
56
+ );
57
+ });
58
+
59
+ it("rejects an exact collision with an existing path", () => {
60
+ assert.match(
61
+ validateNewFolderName({
62
+ ...base,
63
+ name: "Archive",
64
+ parent,
65
+ }) ?? "",
66
+ /already exists/,
67
+ );
68
+ });
69
+
70
+ it("rejects a root name that collides with an existing top-level fullPath", () => {
71
+ assert.match(
72
+ validateNewFolderName({
73
+ delimiter: "/",
74
+ existingPaths: ["Work"],
75
+ name: "Work",
76
+ }) ?? "",
77
+ /already exists/,
78
+ );
79
+ });
80
+
81
+ it("treats INBOX case-insensitively for collisions", () => {
82
+ assert.match(
83
+ validateNewFolderName({
84
+ delimiter: "/",
85
+ existingPaths: ["INBOX"],
86
+ name: "inbox",
87
+ }) ?? "",
88
+ /already exists/,
89
+ );
90
+ });
91
+
92
+ it("compares non-INBOX segments case-sensitively", () => {
93
+ assert.equal(
94
+ validateNewFolderName({
95
+ delimiter: "/",
96
+ existingPaths: ["Work"],
97
+ name: "work",
98
+ }),
99
+ undefined,
100
+ );
101
+ });
102
+
103
+ it("accepts a valid new nested folder", () => {
104
+ assert.equal(
105
+ validateNewFolderName({ ...base, name: "Receipts", parent }),
106
+ undefined,
107
+ );
108
+ });
109
+ });
@@ -0,0 +1,58 @@
1
+ export interface FolderTarget {
2
+ fullPath: string;
3
+ hierarchyDelimiter: string;
4
+ }
5
+
6
+ /**
7
+ * The full path of a new folder: the leaf name on its own for a root folder, or
8
+ * the parent's path joined to the name by the parent's hierarchy delimiter.
9
+ */
10
+ export function composeFolderPath(name: string, parent?: FolderTarget): string {
11
+ const trimmed = name.trim();
12
+ if (!parent) return trimmed;
13
+ return `${parent.fullPath}${parent.hierarchyDelimiter}${trimmed}`;
14
+ }
15
+
16
+ /**
17
+ * IMAP treats the INBOX root case-insensitively; every other path segment is
18
+ * exact. Canonicalizing only a leading `inbox` segment lets the collision check
19
+ * match `inbox/x` against `INBOX/x` without folding case anywhere else.
20
+ */
21
+ const canonicalizePath = (path: string, delimiter: string): string => {
22
+ const parts = path.split(delimiter);
23
+ if (parts[0]?.toLowerCase() === "inbox") parts[0] = "INBOX";
24
+ return parts.join(delimiter);
25
+ };
26
+
27
+ /**
28
+ * Why the folder can't be created, or `undefined` when the name is valid. A
29
+ * name must be non-empty, must not contain the hierarchy delimiter (nesting is
30
+ * expressed by the parent select, not by typing a path), and must not collide
31
+ * with an existing folder — INBOX compared case-insensitively, everything else
32
+ * exactly.
33
+ */
34
+ export function validateNewFolderName({
35
+ name,
36
+ delimiter,
37
+ parent,
38
+ existingPaths,
39
+ }: {
40
+ name: string;
41
+ delimiter: string;
42
+ parent?: FolderTarget;
43
+ existingPaths: readonly string[];
44
+ }): string | undefined {
45
+ const trimmed = name.trim();
46
+ if (trimmed === "") return "Enter a folder name.";
47
+ if (trimmed.includes(delimiter))
48
+ return `A folder name can't contain "${delimiter}".`;
49
+ const candidate = canonicalizePath(
50
+ composeFolderPath(trimmed, parent),
51
+ delimiter,
52
+ );
53
+ const collides = existingPaths.some(
54
+ (path) => canonicalizePath(path, delimiter) === candidate,
55
+ );
56
+ if (collides) return "A folder with that name already exists.";
57
+ return undefined;
58
+ }
@@ -6,22 +6,35 @@ import {
6
6
  mailboxOperationsListMailboxesOptions,
7
7
  mailboxOperationsListMailboxesQueryKey,
8
8
  } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
9
- import type { RemitImapAccountResponse } from "@remit/api-http-client/types.gen.ts";
9
+ import type {
10
+ RemitImapAccountResponse,
11
+ RemitImapMailboxResponse,
12
+ } from "@remit/api-http-client/types.gen.ts";
10
13
  import {
11
14
  Banner,
15
+ Button,
12
16
  type CandidateFolder,
13
17
  type FolderRole,
18
+ Input,
14
19
  RoleAppointmentList,
20
+ Select,
15
21
  SettingsShell,
16
22
  } from "@remit/ui";
17
23
  import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
18
24
  import { createFileRoute, useNavigate } from "@tanstack/react-router";
19
25
  import { useState } from "react";
20
26
  import { ErrorState } from "@/components/ui/ErrorState";
27
+ import { useCreateMailbox } from "@/hooks/useCreateMailbox";
21
28
  import {
22
29
  CANONICAL_TO_NAV_ROLE,
30
+ getMailboxDisplayName,
23
31
  NAV_ROLE_TO_CANONICAL,
24
32
  } from "@/lib/folder-roles";
33
+ import {
34
+ composeFolderPath,
35
+ type FolderTarget,
36
+ validateNewFolderName,
37
+ } from "@/lib/new-folder";
25
38
  import { SETTINGS_ID_TO_PATH, SETTINGS_NAV_ITEMS } from "@/routes/settings";
26
39
 
27
40
  export const Route = createFileRoute("/settings/folders")({
@@ -48,6 +61,121 @@ const foldersHelp = (
48
61
  </div>
49
62
  );
50
63
 
64
+ /**
65
+ * Create a folder for one account. A name, an optional parent to nest under,
66
+ * and a create button. The new folder is queued on the server with a pending
67
+ * sync and appears in the list below once it refetches.
68
+ */
69
+ function NewFolder({
70
+ accountId,
71
+ mailboxes,
72
+ }: {
73
+ accountId: string;
74
+ mailboxes: RemitImapMailboxResponse[];
75
+ }) {
76
+ const { mutation } = useCreateMailbox(accountId);
77
+ const [name, setName] = useState("");
78
+ const [parentId, setParentId] = useState("");
79
+ const [validationError, setValidationError] = useState<string>();
80
+
81
+ const accountDelimiter = mailboxes[0]?.hierarchyDelimiter ?? "/";
82
+ const parentMailbox = mailboxes.find((box) => box.mailboxId === parentId);
83
+ const parent: FolderTarget | undefined = parentMailbox
84
+ ? {
85
+ fullPath: parentMailbox.fullPath,
86
+ hierarchyDelimiter: parentMailbox.hierarchyDelimiter,
87
+ }
88
+ : undefined;
89
+ const delimiter = parent?.hierarchyDelimiter ?? accountDelimiter;
90
+
91
+ const handleCreate = () => {
92
+ const problem = validateNewFolderName({
93
+ name,
94
+ delimiter,
95
+ parent,
96
+ existingPaths: mailboxes.map((box) => box.fullPath),
97
+ });
98
+ if (problem) {
99
+ setValidationError(problem);
100
+ return;
101
+ }
102
+ setValidationError(undefined);
103
+ mutation.mutate(
104
+ {
105
+ path: { accountId },
106
+ body: {
107
+ fullPath: composeFolderPath(name, parent),
108
+ namespaceType: "personal",
109
+ },
110
+ },
111
+ {
112
+ onSuccess: () => {
113
+ setName("");
114
+ setParentId("");
115
+ },
116
+ },
117
+ );
118
+ };
119
+
120
+ return (
121
+ <div className="space-y-2 rounded-sm border border-line bg-surface p-3">
122
+ <p className="text-sm font-medium text-fg">New folder</p>
123
+ <div className="flex flex-col gap-2 sm:flex-row sm:items-end">
124
+ <div className="flex-1 space-y-1">
125
+ <span className="text-xs text-fg-muted">Name</span>
126
+ <Input
127
+ value={name}
128
+ onChange={(event) => {
129
+ setName(event.target.value);
130
+ if (validationError) setValidationError(undefined);
131
+ }}
132
+ placeholder="e.g. Receipts"
133
+ aria-label="Folder name"
134
+ onKeyDown={(event) => {
135
+ if (event.key === "Enter") {
136
+ event.preventDefault();
137
+ handleCreate();
138
+ }
139
+ }}
140
+ />
141
+ </div>
142
+ <div className="flex-1 space-y-1">
143
+ <span className="text-xs text-fg-muted">Inside (optional)</span>
144
+ <Select
145
+ value={parentId}
146
+ onChange={(event) => setParentId(event.target.value)}
147
+ aria-label="Parent folder"
148
+ >
149
+ <option value="">No parent — top level</option>
150
+ {mailboxes.map((box) => (
151
+ <option key={box.mailboxId} value={box.mailboxId}>
152
+ {box.fullPath}
153
+ </option>
154
+ ))}
155
+ </Select>
156
+ </div>
157
+ <Button
158
+ variant="primary"
159
+ onClick={handleCreate}
160
+ disabled={mutation.isPending || name.trim() === ""}
161
+ >
162
+ {mutation.isPending ? "Creating…" : "Create folder"}
163
+ </Button>
164
+ </div>
165
+ {validationError && (
166
+ <p className="text-xs text-danger" role="alert">
167
+ {validationError}
168
+ </p>
169
+ )}
170
+ {mutation.isError && (
171
+ <Banner tone="danger" variant="soft">
172
+ Couldn't create that folder. Please try again.
173
+ </Banner>
174
+ )}
175
+ </div>
176
+ );
177
+ }
178
+
51
179
  /** One account's folder roles, fed to the kit list. Owns its own queries + mutations. */
52
180
  function AccountFolderRoles({
53
181
  account,
@@ -154,6 +282,22 @@ function AccountFolderRoles({
154
282
  onAppoint={handleAppoint}
155
283
  onRename={handleRename}
156
284
  />
285
+ <NewFolder accountId={accountId} mailboxes={data.items} />
286
+ <ul className="space-y-1" aria-label={`All folders for ${account.email}`}>
287
+ {data.items.map((mailbox) => (
288
+ <li
289
+ key={mailbox.mailboxId}
290
+ className="flex items-center justify-between rounded-sm px-2 py-1 text-sm text-fg"
291
+ >
292
+ <span className="truncate">
293
+ {getMailboxDisplayName(mailbox.fullPath)}
294
+ </span>
295
+ <span className="shrink-0 text-xs text-fg-muted">
296
+ {mailbox.fullPath}
297
+ </span>
298
+ </li>
299
+ ))}
300
+ </ul>
157
301
  </div>
158
302
  );
159
303
  }