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