File size: 12,966 Bytes
bf8519f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
// ---------------------------------------------------------------------------
// shell/ShareDialog.tsx β€” WAVE 20 items 18 / 23 / 26 (R10, contract C-SHARE):
// ONE dialog that shares a view, a folder or a database, and edits who already
// has it.
//
// ONE dialog for three kinds is the ruling, not a convenience: R10 says folders
// and databases share "with the SAME two-role vocabulary views use". Three
// dialogs would drift into three vocabularies within a wave.
//
// It renders over the ALREADY-LOADED state and decides nothing itself β€” every
// rule (what a junk entry means, who may administer, what the PUT carries) is in
// `shareModel.ts`, where the access gate can run it.
// ---------------------------------------------------------------------------

import { useCallback, useEffect, useState } from "react";
import { API_V1, CREDENTIALS } from "../apiContract";
import {
  SHARE_ROLES,
  addablePeople,
  parseShare,
  sharePutBody,
  shareSummary,
  withEntry,
  withoutEntry,
} from "./shareModel";
import type { ShareEntry, ShareKind, ShareRole, ShareState } from "./shareModel";

const KIND_WORD: Record<ShareKind, string> = {
  view: "view",
  folder: "folder",
  database: "database",
};

/** What each role MEANS on each kind, in the reader's own terms. A role picker
 *  whose options are two nouns makes the reader guess; these are the sentences
 *  the view rail already uses ("Anyone who can see this table can change it"),
 *  extended to the two new kinds rather than re-invented for them. */
const ROLE_BLURB: Record<ShareKind, Record<ShareRole, string>> = {
  view: {
    view: "Can open this view. Cannot rename, refilter or delete it.",
    edit: "Can change this view's filters, sorts and columns.",
  },
  folder: {
    view: "Can open the folder and the views inside it.",
    edit: "Can rename the folder and move views in and out of it.",
  },
  database: {
    view: "Can open this database and read its records.",
    edit: "Can add, edit and delete its records.",
  },
};

export default function ShareDialog({
  kind,
  id,
  label,
  me,
  onClose,
  onToast,
}: {
  kind: ShareKind;
  id: string;
  label: string;
  /** The signed-in account's username, so the reader recognises themselves in the
   *  list. `people` is deliberately the OTHER accounts (it is the add-picker's
   *  source), so without this the owner row prints a raw login where every other
   *  row prints a name. */
  me: string;
  onClose: () => void;
  onToast: (message: string) => void;
}) {
  const [state, setState] = useState<ShareState | null>(null);
  const [entries, setEntries] = useState<ShareEntry[]>([]);
  const [error, setError] = useState("");
  const [busy, setBusy] = useState(false);
  const [pick, setPick] = useState("");
  const [pickRole, setPickRole] = useState<ShareRole>("view");

  const path = `${API_V1}/share/${encodeURIComponent(kind)}/${encodeURIComponent(id)}`;

  useEffect(() => {
    let dead = false;
    setError("");
    void (async () => {
      try {
        const res = await fetch(path, { credentials: CREDENTIALS });
        if (!res.ok) {
          // 4xx text is policy the reader needs; a 5xx's internals are not theirs.
          if (!dead) setError(res.status >= 500
            ? "Something went wrong on our side. Try again in a moment."
            : `The server answered ${res.status}.`);
          return;
        }
        const body = (await res.json().catch(() => null)) as unknown;
        if (dead) return;
        const parsed = parseShare(body);
        setState(parsed);
        setEntries(parsed.entries);
      } catch {
        if (!dead) setError("Cannot reach the server.");
      }
    })();
    return () => {
      dead = true;
    };
  }, [path]);

  // β›” ESCAPE CLOSES A MODAL, or its scrim becomes a trap β€” the wave-18 lesson this
  // shell already carries at its other two dialogs.
  useEffect(() => {
    const onKey = (e: KeyboardEvent) => {
      if (e.key === "Escape" && !busy) onClose();
    };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [busy, onClose]);

  const save = useCallback(
    async (next: ShareEntry[]) => {
      setBusy(true);
      setError("");
      try {
        const res = await fetch(path, {
          method: "PUT",
          credentials: CREDENTIALS,
          headers: { "Content-Type": "application/json" },
          // The WHOLE list, every time: the PUT replaces, so a body assembled from
          // a delta would revoke everyone it failed to mention.
          body: JSON.stringify(sharePutBody(next)),
        });
        if (!res.ok) {
          const body = (await res.json().catch(() => null)) as
            | { error?: { message?: string } }
            | null;
          setError(
            res.status >= 500
              ? "Something went wrong on our side. Try again in a moment."
              : body?.error?.message || `The server answered ${res.status}.`
          );
          return false;
        }
        const body = (await res.json().catch(() => null)) as unknown;
        // Re-read the SERVER's copy rather than trusting the draft: `_clean_entries`
        // drops what it will not store, and an editor that kept showing a row the
        // store rejected would be the "shared, silently inert" failure this feature
        // exists to avoid.
        // ⚠ Only `.entries` is consumed here β€” the PUT answers with the stored record
        // (`{owner, entries}`) and says nothing about this session's standing, so the
        // literal below feeds the parser's required field and is never read back. The
        // editor's `mayAdminister` stays the one the GET established; a save cannot
        // promote anybody, and this line must never be the reason it looks like it can.
        const saved = parseShare({ ...(body as object), mayAdminister: true });
        setEntries(saved.entries);
        return true;
      } catch {
        setError("Cannot reach the server.");
        return false;
      } finally {
        setBusy(false);
      }
    },
    [path]
  );

  const mayAdminister = !!state?.mayAdminister;
  const options = state ? addablePeople(state.people, entries) : [];
  const nameOf = (user: string) =>
    user === "*"
      ? "Everyone"
      : user && user === me.trim().toLowerCase()
        ? "You"
        : state?.people.find((p) => p.user === user)?.name ?? user;

  return (
    <div className="shell-newdb-scrim" onClick={() => (busy ? null : onClose())}>
      <div
        className="shell-newdb shell-share"
        role="dialog"
        aria-label={`Share ${label}`}
        onClick={(e) => e.stopPropagation()}
      >
        <h2>Share {KIND_WORD[kind]}</h2>
        <p className="shell-newdb-sub">
          <strong>{label}</strong> β€” who can reach it, and what they can do with it. Sharing
          never widens past this workspace: everyone here can already open the surface it
          lives on.
        </p>

        {!state && !error ? (
          <div className="shell-share-wait">
            <span className="lp-spin" role="status" aria-label="Loading" />
          </div>
        ) : null}

        {state ? (
          <>
            {/* THE MANAGE-ACCESS EDITOR (item 23): the list first, because the
                question people open this for is "who has this already" β€” with the
                one-line answer above it, since the fact that changes everything
                ("Everyone can edit") is the one a list of rows buries. */}
            <p className="shell-share-summary">{shareSummary(entries)}</p>
            <div className="shell-share-list">
              {state.owner ? (
                <div className="shell-share-row is-owner">
                  <span className="shell-share-who">{nameOf(state.owner)}</span>
                  <span className="shell-share-role">Owner</span>
                </div>
              ) : null}
              {entries.length === 0 ? (
                <div className="shell-share-empty">
                  Not shared with anyone yet.
                </div>
              ) : null}
              {entries.map((e) => (
                <div className="shell-share-row" key={e.user}>
                  <span className="shell-share-who">{nameOf(e.user)}</span>
                  <select
                    className="shell-share-select"
                    value={e.role}
                    disabled={!mayAdminister || busy}
                    aria-label={`Role for ${nameOf(e.user)}`}
                    onChange={(ev) => {
                      const next = withEntry(entries, e.user, ev.target.value as ShareRole);
                      setEntries(next);
                      void save(next);
                    }}
                  >
                    {SHARE_ROLES.map((r) => (
                      <option key={r} value={r}>
                        {r === "edit" ? "Can edit" : "Can view"}
                      </option>
                    ))}
                  </select>
                  <button
                    type="button"
                    className="shell-share-revoke"
                    disabled={!mayAdminister || busy}
                    onClick={() => {
                      const next = withoutEntry(entries, e.user);
                      setEntries(next);
                      void save(next).then((ok) => {
                        if (ok) onToast(`${nameOf(e.user)} no longer has this ${KIND_WORD[kind]}.`);
                      });
                    }}
                  >
                    Remove
                  </button>
                </div>
              ))}
            </div>

            {mayAdminister ? (
              <div className="shell-share-add">
                <select
                  className="shell-share-select"
                  value={pick}
                  disabled={busy}
                  aria-label="Who to share with"
                  onChange={(e) => setPick(e.target.value)}
                >
                  {/* ⚠ An explicit placeholder OPTION, not a blank first row: a
                      <select> whose value matches nothing renders its first option
                      while holding "", so the box would read as a chosen person and
                      the button beside it would grant somebody nobody picked
                      ([[cg-condition-builder-items]]). */}
                  <option value="">Choose a person…</option>
                  <option value="*">Everyone in this workspace</option>
                  {options.map((p) => (
                    <option key={p.user} value={p.user}>
                      {p.name}
                    </option>
                  ))}
                </select>
                <select
                  className="shell-share-select"
                  value={pickRole}
                  disabled={busy}
                  aria-label="Role for the person being added"
                  onChange={(e) => setPickRole(e.target.value as ShareRole)}
                >
                  {SHARE_ROLES.map((r) => (
                    <option key={r} value={r}>
                      {r === "edit" ? "Can edit" : "Can view"}
                    </option>
                  ))}
                </select>
                <button
                  type="button"
                  className="login-submit shell-share-grant"
                  disabled={busy || !pick}
                  onClick={() => {
                    const next = withEntry(entries, pick, pickRole);
                    setEntries(next);
                    void save(next).then((ok) => {
                      if (ok) {
                        onToast(`${nameOf(pick)} can now ${pickRole} this ${KIND_WORD[kind]}.`);
                        setPick("");
                      }
                    });
                  }}
                >
                  Share
                </button>
                <p className="shell-share-blurb">{ROLE_BLURB[kind][pickRole]}</p>
              </div>
            ) : (
              // β›” NOT A HIDDEN EDITOR β€” a stated refusal. A collaborator who can
              // change this object's CONTENT still cannot change who else reaches
              // it (the server's rule; this is the courtesy half). Saying why beats
              // greying three controls and letting the reader guess.
              <p className="shell-share-note">
                Only the owner of this {KIND_WORD[kind]} β€” or an administrator β€” can change who
                it is shared with. You can still use it as your role allows.
              </p>
            )}
          </>
        ) : null}

        {error ? <p className="shell-newdb-err">{error}</p> : null}
        <div className="shell-newdb-actions">
          <button type="button" className="login-submit" disabled={busy} onClick={onClose}>
            Done
          </button>
        </div>
      </div>
    </div>
  );
}