@startsimpli/integrations 0.2.22 → 0.2.23

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": "@startsimpli/integrations",
3
- "version": "0.2.22",
3
+ "version": "0.2.23",
4
4
  "description": "Provider-agnostic types, API clients, hooks, and UI primitives for email + calendar integrations across StartSimpli apps.",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -997,3 +997,113 @@ describe('ConnectionsPage — foundry mode is untouched by personal mode', () =>
997
997
  expect(within(section).getByRole('button', { name: /^Save$/i })).toBeInTheDocument();
998
998
  });
999
999
  });
1000
+
1001
+ // --------------------------------------------------------------------------
1002
+ // the members' roster: a verb only where it still does something
1003
+ // (bd startsim-768w.22.25.19)
1004
+ // --------------------------------------------------------------------------
1005
+
1006
+ /**
1007
+ * One row per status, because the whole defect was a button that never read
1008
+ * the status. `disabled` is the row AFTER a disconnect — key deleted, tokens
1009
+ * revoked, fetches stopped. `error` and `needs_reauth` still hold a stored
1010
+ * credential and a live poll leg, and they are the rows an admin offboarding
1011
+ * somebody most needs to cut, so the suppression has to be narrow enough to
1012
+ * leave them alone.
1013
+ */
1014
+ const MEMBER_ROWS = [
1015
+ {
1016
+ id: 'm-live', owner: { id: 'u1', email: 'ana@acme.test', name: 'Ana' },
1017
+ connector_key: 'n8n', connector_name: 'n8n', name: 'Ana n8n',
1018
+ account_label: 'ana@acme.test', status: 'connected', health: 'ok',
1019
+ },
1020
+ {
1021
+ id: 'm-broken', owner: { id: 'u2', email: 'bo@acme.test', name: 'Bo' },
1022
+ connector_key: 'granola', connector_name: 'Granola', name: 'Bo Granola',
1023
+ account_label: 'bo@acme.test', status: 'error', health: 'failing',
1024
+ check_error: 'Vendor said 401',
1025
+ },
1026
+ {
1027
+ id: 'm-stale', owner: { id: 'u3', email: 'cy@acme.test', name: 'Cy' },
1028
+ connector_key: 'granola', connector_name: 'Granola', name: 'Cy Granola',
1029
+ account_label: 'cy@acme.test', status: 'needs_reauth', health: 'expired',
1030
+ },
1031
+ {
1032
+ id: 'm-gone', owner: { id: 'u4', email: 'di@acme.test', name: 'Di' },
1033
+ connector_key: 'n8n', connector_name: 'n8n', name: 'Di n8n',
1034
+ account_label: 'di@acme.test', status: 'disabled', health: 'disconnected',
1035
+ },
1036
+ ];
1037
+
1038
+ function rosterApi(overrides: Partial<ConnectionsApi> = {}): ConnectionsApi {
1039
+ return stubApi({
1040
+ memberConnections: vi.fn().mockResolvedValue({
1041
+ results: MEMBER_ROWS,
1042
+ permissions: { view: true, manage: true, issue_tokens: false },
1043
+ }),
1044
+ disconnectMemberConnection: vi.fn().mockResolvedValue(undefined),
1045
+ ...overrides,
1046
+ } as Partial<ConnectionsApi>);
1047
+ }
1048
+
1049
+ /** The card element for one member row, by the id the roster stamps on it. */
1050
+ async function memberCard(id: string): Promise<HTMLElement> {
1051
+ return waitFor(() => {
1052
+ const el = document.querySelector<HTMLElement>(`div[data-member-connection="${id}"]`);
1053
+ if (!el) throw new Error(`no member row rendered for ${id}`);
1054
+ return el;
1055
+ });
1056
+ }
1057
+
1058
+ describe('MemberConnectionRoster — Disconnect is not offered twice', () => {
1059
+ it('does NOT offer Disconnect on a row that is already disconnected', async () => {
1060
+ render(<ConnectionsPage api={rosterApi()} foundrySlug="acme" />);
1061
+ const gone = await memberCard('m-gone');
1062
+ expect(within(gone).queryByRole('button', { name: /Disconnect/i })).toBeNull();
1063
+ });
1064
+
1065
+ it('says the account is gone and that only the member can bring it back', async () => {
1066
+ // The row STAYS. Who used to hold a pipe into this foundry is the answer an
1067
+ // offboarding review is looking for; hiding it would lose that.
1068
+ render(<ConnectionsPage api={rosterApi()} foundrySlug="acme" />);
1069
+ const gone = await memberCard('m-gone');
1070
+ const note = gone.querySelector('[data-member-connection-disconnected]');
1071
+ expect(note).not.toBeNull();
1072
+ expect(note!.textContent).toMatch(/Disconnected\./);
1073
+ expect(note!.textContent).toMatch(/di@acme\.test has to connect with n8n again/);
1074
+ });
1075
+
1076
+ it('never claims a deleted key "cannot be undone"', async () => {
1077
+ // The exact copy the bead objects to, over a key that is already gone.
1078
+ render(<ConnectionsPage api={rosterApi()} foundrySlug="acme" />);
1079
+ const gone = await memberCard('m-gone');
1080
+ expect(within(gone).queryByText(/cannot be undone/i)).toBeNull();
1081
+ expect(within(gone).queryByText(/stored key is deleted and their/i)).toBeNull();
1082
+ });
1083
+
1084
+ it('KEEPS Disconnect on a failing row, whose key is still stored', async () => {
1085
+ render(<ConnectionsPage api={rosterApi()} foundrySlug="acme" />);
1086
+ const broken = await memberCard('m-broken');
1087
+ expect(within(broken).getByRole('button', { name: /^Disconnect$/i }))
1088
+ .toBeInTheDocument();
1089
+ });
1090
+
1091
+ it('KEEPS Disconnect on a needs_reauth row, whose poll leg is still live', async () => {
1092
+ render(<ConnectionsPage api={rosterApi()} foundrySlug="acme" />);
1093
+ const stale = await memberCard('m-stale');
1094
+ expect(within(stale).getByRole('button', { name: /^Disconnect$/i }))
1095
+ .toBeInTheDocument();
1096
+ });
1097
+
1098
+ it('still cuts a connected row, naming the person before it does', async () => {
1099
+ const api = rosterApi();
1100
+ render(<ConnectionsPage api={api} foundrySlug="acme" />);
1101
+ const live = await memberCard('m-live');
1102
+ await userEvent.click(within(live).getByRole('button', { name: /^Disconnect$/i }));
1103
+ const prompt = within(live).getByText(/cannot be undone/i);
1104
+ expect(prompt.textContent).toContain('ana@acme.test');
1105
+ await userEvent.click(within(live).getByRole('button', { name: /Yes, disconnect/i }));
1106
+ await waitFor(() =>
1107
+ expect(api.disconnectMemberConnection).toHaveBeenCalledWith('acme', 'm-live'));
1108
+ });
1109
+ });
@@ -108,6 +108,19 @@ function MemberConnectionCard({
108
108
  const [error, setError] = React.useState('');
109
109
  const who = row.owner?.email || row.owner?.name || 'Unknown member';
110
110
 
111
+ /**
112
+ * ALREADY CUT (bd startsim-768w.22.25.19). `disabled` is the status a row
113
+ * carries AFTER a disconnect: the key is deleted, the tokens are revoked and
114
+ * the scheduled fetches are stopped. There is nothing left for this verb to
115
+ * do, so offering it — and then confirming with "this cannot be undone" over
116
+ * a key that is already gone — describes an action the server would refuse.
117
+ *
118
+ * ONLY `disabled`. `error` and `needs_reauth` still hold a stored credential
119
+ * and a live poll leg; they are exactly the rows an admin offboarding someone
120
+ * most needs to cut, so the test for this asserts they keep the button.
121
+ */
122
+ const alreadyDisconnected = row.status === 'disabled';
123
+
111
124
  async function cut() {
112
125
  if (!disconnect) return;
113
126
  setBusy(true);
@@ -143,8 +156,18 @@ function MemberConnectionCard({
143
156
  anything the member submitted. An admin deciding whether to cut a
144
157
  pipe is entitled to know it is already broken. */}
145
158
  {row.check_error ? <p className={cn.error}>{row.check_error}</p> : null}
159
+ {/* THE ROW STAYS. Who USED to have a pipe into this foundry is the
160
+ answer an offboarding review is looking for, so a disconnected
161
+ account is not hidden — it is labelled, and its verb is gone. */}
162
+ {alreadyDisconnected ? (
163
+ <p className={cn.muted} data-member-connection-disconnected>
164
+ Disconnected. This account’s key was deleted and its scheduled
165
+ fetches stopped. Nothing here can bring it back —{' '}
166
+ {who} has to connect with {row.connector_name} again themselves.
167
+ </p>
168
+ ) : null}
146
169
  </div>
147
- {canManage && disconnect ? (
170
+ {canManage && disconnect && !alreadyDisconnected ? (
148
171
  <button
149
172
  type="button"
150
173
  className={cn.button}
@@ -155,7 +178,11 @@ function MemberConnectionCard({
155
178
  </button>
156
179
  ) : null}
157
180
  </div>
158
- {confirming ? (
181
+ {/* `alreadyDisconnected` again, not because the button can open this today
182
+ but because a reload can flip the row to `disabled` under an open
183
+ prompt — and the one thing this prompt must never do is warn that an
184
+ action "cannot be undone" about a key that is already gone. */}
185
+ {confirming && !alreadyDisconnected ? (
159
186
  <div className="mt-3 space-y-2">
160
187
  {/* NAMES THE PERSON, because the cost of this button falls on them and
161
188
  not on the admin pressing it. A generic "are you sure?" over a list