CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutSign UpSign In
sagemathinc

Real-time collaboration for Jupyter Notebooks, Linux Terminals, LaTeX, VS Code, R IDE, and more,
all in one place.

GitHub Repository: sagemathinc/cocalc
Path: blob/master/src/packages/frontend/admin/site-settings/index.tsx
Views: 687
1
/*
2
* This file is part of CoCalc: Copyright © 2020 Sagemath, Inc.
3
* License: MS-RSL – see LICENSE.md for details
4
*/
5
6
import {
7
Alert,
8
Tag as AntdTag,
9
Button,
10
Col,
11
Input,
12
InputRef,
13
Modal,
14
Row,
15
} from "antd";
16
import { delay } from "awaiting";
17
import { isEqual } from "lodash";
18
import { useEffect, useMemo, useRef, useState } from "react";
19
import { alert_message } from "@cocalc/frontend/alerts";
20
import { Well } from "@cocalc/frontend/antd-bootstrap";
21
import { redux } from "@cocalc/frontend/app-framework";
22
import useCounter from "@cocalc/frontend/app-framework/counter-hook";
23
import { Gap, Icon, Loading, Paragraph } from "@cocalc/frontend/components";
24
import { query } from "@cocalc/frontend/frame-editors/generic/client";
25
import { TAGS, Tag } from "@cocalc/util/db-schema/site-defaults";
26
import { EXTRAS } from "@cocalc/util/db-schema/site-settings-extras";
27
import { deep_copy, keys, unreachable } from "@cocalc/util/misc";
28
import { site_settings_conf } from "@cocalc/util/schema";
29
import { RenderRow } from "./render-row";
30
import { Data, IsReadonly, State } from "./types";
31
import {
32
toCustomOpenAIModel,
33
toOllamaModel,
34
} from "@cocalc/util/db-schema/llm-utils";
35
36
const { CheckableTag } = AntdTag;
37
38
export default function SiteSettings({ close }) {
39
const { inc: change } = useCounter();
40
const testEmailRef = useRef<InputRef>(null);
41
const [disableTests, setDisableTests] = useState<boolean>(false);
42
const [state, setState] = useState<State>("load");
43
const [error, setError] = useState<string>("");
44
const [data, setData] = useState<Data | null>(null);
45
const [filterStr, setFilterStr] = useState<string>("");
46
const [filterTag, setFilterTag] = useState<Tag | null>(null);
47
const editedRef = useRef<Data | null>(null);
48
const savedRef = useRef<Data | null>(null);
49
const [isReadonly, setIsReadonly] = useState<IsReadonly | null>(null);
50
const update = () => {
51
setData(deep_copy(editedRef.current));
52
};
53
54
useEffect(() => {
55
load();
56
}, []);
57
58
async function load(): Promise<void> {
59
setState("load");
60
let result: any;
61
try {
62
result = await query({
63
query: {
64
site_settings: [{ name: null, value: null, readonly: null }],
65
},
66
});
67
} catch (err) {
68
setState("error");
69
setError(`${err} – query error, please try again…`);
70
return;
71
}
72
const data: { [name: string]: string } = {};
73
const isReadonly: IsReadonly = {};
74
for (const x of result.query.site_settings) {
75
data[x.name] = x.value;
76
isReadonly[x.name] = !!x.readonly;
77
}
78
setState("edit");
79
setData(data);
80
setIsReadonly(isReadonly);
81
editedRef.current = deep_copy(data);
82
savedRef.current = deep_copy(data);
83
setDisableTests(false);
84
}
85
86
// returns true if the given settings key is a header
87
function isHeader(name: string): boolean {
88
return (
89
EXTRAS[name]?.type == "header" ||
90
site_settings_conf[name]?.type == "header"
91
);
92
}
93
94
function isModified(name: string) {
95
if (data == null || editedRef.current == null || savedRef.current == null)
96
return false;
97
98
const edited = editedRef.current[name];
99
const saved = savedRef.current[name];
100
return !isEqual(edited, saved);
101
}
102
103
function getModifiedSettings() {
104
if (data == null || editedRef.current == null || savedRef.current == null)
105
return [];
106
107
const ret: { name: string; value: string }[] = [];
108
for (const name in editedRef.current) {
109
const value = editedRef.current[name];
110
if (isHeader[name]) continue;
111
if (isModified(name)) {
112
ret.push({ name, value });
113
}
114
}
115
ret.sort((a, b) => a.name.localeCompare(b.name));
116
return ret;
117
}
118
119
async function store(): Promise<void> {
120
if (data == null || editedRef.current == null || savedRef.current == null)
121
return;
122
for (const { name, value } of getModifiedSettings()) {
123
try {
124
await query({
125
query: {
126
site_settings: { name, value },
127
},
128
});
129
savedRef.current[name] = value;
130
} catch (err) {
131
setState("error");
132
setError(err);
133
return;
134
}
135
}
136
// success save of everything, so clear error message
137
setError("");
138
}
139
140
async function saveAll(): Promise<void> {
141
// list the names of changed settings
142
const content = (
143
<Paragraph>
144
<ul>
145
{getModifiedSettings().map(({ name, value }) => {
146
const label =
147
(site_settings_conf[name] ?? EXTRAS[name]).name ?? name;
148
return (
149
<li key={name}>
150
<b>{label}</b>: <code>{value}</code>
151
</li>
152
);
153
})}
154
</ul>
155
</Paragraph>
156
);
157
158
setState("save");
159
160
Modal.confirm({
161
title: "Confirm changing the following settings?",
162
icon: <Icon name="warning" />,
163
width: 700,
164
content,
165
onOk() {
166
return new Promise<void>(async (done, error) => {
167
try {
168
await store();
169
setState("edit");
170
await load();
171
done();
172
} catch (err) {
173
error(err);
174
}
175
});
176
},
177
onCancel() {
178
close();
179
},
180
});
181
}
182
183
// this is the small grene button, there is no confirmation
184
async function saveSingleSetting(name: string): Promise<void> {
185
if (data == null || editedRef.current == null || savedRef.current == null)
186
return;
187
const value = editedRef.current[name];
188
setState("save");
189
try {
190
await query({
191
query: {
192
site_settings: { name, value },
193
},
194
});
195
savedRef.current[name] = value;
196
setState("edit");
197
} catch (err) {
198
setState("error");
199
setError(err);
200
return;
201
}
202
}
203
204
function SaveButton() {
205
if (data == null || savedRef.current == null) return null;
206
let disabled: boolean = true;
207
for (const name in { ...savedRef.current, ...data }) {
208
const value = savedRef.current[name];
209
if (!isEqual(value, data[name])) {
210
disabled = false;
211
break;
212
}
213
}
214
215
return (
216
<Button type="primary" disabled={disabled} onClick={saveAll}>
217
{state == "save" ? <Loading text="Saving" /> : "Save All"}
218
</Button>
219
);
220
}
221
222
function CancelButton() {
223
return <Button onClick={close}>Cancel</Button>;
224
}
225
226
function onChangeEntry(name: string, val: string) {
227
if (editedRef.current == null) return;
228
editedRef.current[name] = val;
229
change();
230
update();
231
}
232
233
function onJsonEntryChange(name: string, new_val?: string) {
234
if (editedRef.current == null) return;
235
try {
236
if (new_val == null) return;
237
JSON.parse(new_val); // does it throw?
238
editedRef.current[name] = new_val;
239
} catch (err) {
240
// TODO: obviously this should be visible to the user! Gees.
241
console.warn(`Error saving json of ${name}`, err.message);
242
}
243
change();
244
update(); // without that, the "green save button" does not show up. this makes it consistent.
245
}
246
247
function Buttons() {
248
return (
249
<div>
250
<CancelButton />
251
<Gap />
252
<SaveButton />
253
</div>
254
);
255
}
256
257
async function sendTestEmail(
258
type: "password_reset" | "invite_email" | "mention" | "verification",
259
): Promise<void> {
260
const email = testEmailRef.current?.input?.value;
261
if (!email) {
262
alert_message({
263
type: "error",
264
message: "NOT sending test email, since email field is empty",
265
});
266
return;
267
}
268
alert_message({
269
type: "info",
270
message: `sending test email "${type}" to ${email}`,
271
});
272
// saving info
273
await store();
274
setDisableTests(true);
275
// wait 3 secs
276
await delay(3000);
277
switch (type) {
278
case "password_reset":
279
redux.getActions("account").forgot_password(email);
280
break;
281
case "invite_email":
282
alert_message({
283
type: "error",
284
message: "Simulated invite emails are not implemented yet",
285
});
286
break;
287
case "mention":
288
alert_message({
289
type: "error",
290
message: "Simulated mention emails are not implemented yet",
291
});
292
break;
293
case "verification":
294
// The code below "looks good" but it doesn't work ???
295
// const users = await user_search({
296
// query: email,
297
// admin: true,
298
// limit: 1
299
// });
300
// if (users.length == 1) {
301
// await webapp_client.account_client.send_verification_email(users[0].account_id);
302
// }
303
break;
304
default:
305
unreachable(type);
306
}
307
setDisableTests(false);
308
}
309
310
function Tests() {
311
return (
312
<div style={{ marginBottom: "1rem" }}>
313
<strong>Tests:</strong>
314
<Gap />
315
Email:
316
<Gap />
317
<Input
318
style={{ width: "auto" }}
319
defaultValue={redux.getStore("account").get("email_address")}
320
ref={testEmailRef}
321
/>
322
<Button
323
style={{ marginLeft: "10px" }}
324
size={"small"}
325
disabled={disableTests}
326
onClick={() => sendTestEmail("password_reset")}
327
>
328
Send Test Forgot Password Email
329
</Button>
330
{
331
// commented out since they aren't implemented
332
// <Button
333
// disabled={disableTests}
334
// size={"small"}
335
// onClick={() => sendTestEmail("verification")}
336
// >
337
// Verify
338
// </Button>
339
}
340
{
341
// <Button
342
// disabled={disableTests}
343
// size={"small"}
344
// onClick={() => sendTestEmail("invite_email")}
345
// >
346
// Invite
347
// </Button>
348
// <Button
349
// disabled={disableTests}
350
// size={"small"}
351
// onClick={() => sendTestEmail("mention")}
352
// >
353
// @mention
354
// </Button>
355
}
356
</div>
357
);
358
}
359
360
function Warning() {
361
return (
362
<div>
363
<Alert
364
type="warning"
365
style={{
366
maxWidth: "800px",
367
margin: "0 auto 20px auto",
368
border: "1px solid lightgrey",
369
}}
370
message={
371
<div>
372
<i>
373
<ul style={{ marginBottom: 0 }}>
374
<li>
375
Most settings will take effect within 1 minute of save;
376
however, some might require restarting the server.
377
</li>
378
<li>
379
If the box containing a setting has a red border, that means
380
the value that you entered is invalid.
381
</li>
382
</ul>
383
</i>
384
</div>
385
}
386
/>
387
</div>
388
);
389
}
390
391
const editRows = useMemo(() => {
392
return (
393
<>
394
{[site_settings_conf, EXTRAS].map((configData) =>
395
keys(configData).map((name) => {
396
const conf = configData[name];
397
398
// This is a weird special case, where the valid value depends on other values
399
if (name === "default_llm") {
400
const c = site_settings_conf.selectable_llms;
401
const llms = c.to_val?.(data?.selectable_llms ?? c.default) ?? [];
402
const o = EXTRAS.ollama_configuration;
403
const oll = Object.keys(
404
o.to_val?.(data?.ollama_configuration) ?? {},
405
).map(toOllamaModel);
406
const a = EXTRAS.ollama_configuration;
407
const oaic = data?.custom_openai_configuration;
408
const oai = (
409
oaic != null ? Object.keys(a.to_val?.(oaic) ?? {}) : []
410
).map(toCustomOpenAIModel);
411
if (Array.isArray(llms)) {
412
conf.valid = [...llms, ...oll, ...oai];
413
}
414
}
415
416
return (
417
<RenderRow
418
filterStr={filterStr}
419
filterTag={filterTag}
420
key={name}
421
name={name}
422
conf={conf}
423
data={data}
424
update={update}
425
isReadonly={isReadonly}
426
onChangeEntry={onChangeEntry}
427
onJsonEntryChange={onJsonEntryChange}
428
isModified={isModified}
429
isHeader={isHeader(name)}
430
saveSingleSetting={saveSingleSetting}
431
/>
432
);
433
}),
434
)}
435
</>
436
);
437
}, [state, data, filterStr, filterTag]);
438
439
const activeFilter = !filterStr.trim() || filterTag;
440
441
return (
442
<div>
443
{state == "save" && (
444
<Loading
445
delay={1000}
446
style={{ float: "right", fontSize: "15pt" }}
447
text="Saving site configuration..."
448
/>
449
)}
450
{state == "load" && (
451
<Loading
452
delay={1000}
453
style={{ float: "right", fontSize: "15pt" }}
454
text="Loading site configuration..."
455
/>
456
)}
457
<Well
458
style={{
459
margin: "auto",
460
maxWidth: "80%",
461
}}
462
>
463
<Warning />
464
{error && (
465
<Alert
466
type="error"
467
showIcon
468
closable
469
description={error}
470
onClose={() => setError("")}
471
style={{ margin: "30px auto", maxWidth: "800px" }}
472
/>
473
)}
474
<Row key="filter">
475
<Col span={12}>
476
<Buttons />
477
</Col>
478
<Col span={12}>
479
<Input.Search
480
style={{ marginBottom: "5px" }}
481
allowClear
482
value={filterStr}
483
placeholder="Filter Site Settings..."
484
onChange={(e) => setFilterStr(e.target.value)}
485
/>
486
{[...TAGS].sort().map((name) => (
487
<CheckableTag
488
key={name}
489
style={{ cursor: "pointer" }}
490
checked={filterTag === name}
491
onChange={(checked) => {
492
if (checked) {
493
setFilterTag(name);
494
} else {
495
setFilterTag(null);
496
}
497
}}
498
>
499
{name}
500
</CheckableTag>
501
))}
502
</Col>
503
</Row>
504
{editRows}
505
<Gap />
506
{!activeFilter && <Tests />}
507
{!activeFilter && <Buttons />}
508
{activeFilter ? (
509
<Alert
510
showIcon
511
type="warning"
512
message={`Some items may be hidden by the search filter or a selected tag.`}
513
/>
514
) : undefined}
515
</Well>
516
</div>
517
);
518
}
519
520