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/registration-token.tsx
Views: 687
/*1* This file is part of CoCalc: Copyright © 2020 Sagemath, Inc.2* License: MS-RSL – see LICENSE.md for details3*/45/*6Input box for setting the account creation token.7*/89import {10Button as AntdButton,11Checkbox,12DatePicker,13Form,14Input,15InputNumber,16Popconfirm,17Switch,18Table,19} from "antd";20import dayjs from "dayjs";21import { List } from "immutable";22import { pick, sortBy } from "lodash";2324import { DeleteOutlined, EditOutlined } from "@ant-design/icons";25import { Alert } from "@cocalc/frontend/antd-bootstrap";26import {27React,28redux,29Rendered,30TypedMap,31} from "@cocalc/frontend/app-framework";32import {33ErrorDisplay,34Icon,35Saving,36TimeAgo,37} from "@cocalc/frontend/components";38import { query } from "@cocalc/frontend/frame-editors/generic/client";39import { CancelText } from "@cocalc/frontend/i18n/components";40import { RegistrationTokenSetFields } from "@cocalc/util/db-schema/types";41import { cmp_dayjs, round1, secure_random_token } from "@cocalc/util/misc";42import { COLORS } from "@cocalc/util/theme";43import { PassportStrategyFrontend } from "@cocalc/util/types/passport-types";4445interface Token {46key?: string; // used in the table, not for the database47token: string;48disabled?: boolean;49active?: boolean; // active is just !disabled50descr?: string;51limit?: number;52counter?: number; // readonly53expires?: dayjs.Dayjs; // DB uses Date objects, watch out!54}5556function use_registration_tokens() {57const [data, set_data] = React.useState<{ [key: string]: Token }>({});58const [no_or_all_inactive, set_no_or_all_inactive] =59React.useState<boolean>(false);60const [editing, set_editing] = React.useState<Token | null>(null);61const [saving, set_saving] = React.useState<boolean>(false);62const [deleting, set_deleting] = React.useState<boolean>(false);63const [loading, set_loading] = React.useState<boolean>(false);64const [last_saved, set_last_saved] = React.useState<Token | null>(null);65const [error, set_error] = React.useState<string>("");66const [sel_rows, set_sel_rows] = React.useState<any>([]);6768// Antd69const [form] = Form.useForm();7071// we load the data in a map, indexed by the token72// dates are converted to dayjs on the fly73async function load() {74let result: any;75set_loading(true);76try {77// TODO query should be limited by disabled != true78result = await query({79query: {80registration_tokens: {81token: "*",82descr: null,83expires: null,84limit: null,85disabled: null,86},87},88});89const data = {};90let warn_signup = true;91for (const x of result.query.registration_tokens) {92if (x.expires) x.expires = dayjs(x.expires);93x.active = !x.disabled;94data[x.token] = x;95// we have at least one active token → no need to warn user96if (x.active) warn_signup = false;97}98set_no_or_all_inactive(warn_signup);99set_error("");100set_data(data);101} catch (err) {102set_error(err.message);103} finally {104set_loading(false);105}106}107108React.useEffect(() => {109// every time we show or hide, clear the selection110set_sel_rows([]);111load();112}, []);113114React.useEffect(() => {115if (editing != null) {116// antd's form want's something called "Store" – which is just this?117form.setFieldsValue(editing as any);118}119if (last_saved != null) {120set_last_saved(null);121}122}, [editing]);123124// saving a specific token value converts dayjs back to pure Date objects125// we also record the last saved token as a template for the next add operation126async function save(val): Promise<void> {127// antd wraps the time in a dayjs object128const val_orig: Token = { ...val };129if (editing != null) set_editing(null);130131// data preparation132if (val.expires != null && dayjs.isDayjs(val.expires)) {133val.expires = dayjs(val.expires).toDate();134}135val.disabled = !val.active;136val = pick(val, [137"token",138"disabled",139"expires",140"limit",141"descr",142] as RegistrationTokenSetFields[]);143// set optional field to undefined (to get rid of it)144["descr", "limit", "expires"].forEach(145(k: RegistrationTokenSetFields) => (val[k] = val[k] ?? undefined),146);147try {148set_saving(true);149await query({150query: {151registration_tokens: val,152},153});154// we save the original one, with dayjs in it!155set_last_saved(val_orig);156set_saving(false);157await load();158} catch (err) {159set_error(err);160set_editing(val_orig);161} finally {162set_saving(false);163}164}165166async function delete_token(167token: string | undefined,168single: boolean = false,169) {170if (token == null) return;171if (single) set_deleting(true);172173try {174await query({175query: {176registration_tokens: { token },177},178options: [{ delete: true }],179});180if (single) load();181} catch (err) {182if (single) {183set_error(err);184} else {185throw err;186}187} finally {188if (single) set_deleting(false);189}190}191192async function delete_tokens(): Promise<void> {193set_deleting(true);194try {195// it's not possible to delete several tokens at once196await sel_rows.map(async (token) => await delete_token(token));197set_sel_rows([]);198load();199} catch (err) {200set_error(err);201} finally {202set_deleting(false);203}204}205206// we generate a random token and make sure it doesn't exist207// TODO also let the user generate one with a validation check208function new_random_token(): string {209return secure_random_token(16);210}211212function edit_new_token(): void {213set_editing({214...last_saved,215...{ token: new_random_token(), active: true },216});217}218219return {220data,221form,222editing,223saving,224deleting,225delete_token,226delete_tokens,227loading,228last_saved,229error,230set_error,231sel_rows,232set_sel_rows,233set_deleting,234set_editing,235new_random_token,236edit_new_token,237save,238load,239no_or_all_inactive,240};241}242243export function RegistrationToken() {244// TODO I'm sure this could be done in a smarter way ...245const {246data,247form,248error,249set_error,250deleting,251delete_token,252delete_tokens,253editing,254set_editing,255saving,256sel_rows,257set_sel_rows,258last_saved,259new_random_token,260no_or_all_inactive,261edit_new_token,262save,263load,264loading,265} = use_registration_tokens();266267function render_edit(): Rendered {268const layout = {269style: { margin: "20px 0" },270labelCol: { span: 2 },271wrapperCol: { span: 8 },272};273274const tailLayout = {275wrapperCol: { offset: 2, span: 8 },276};277278const onFinish = (values) => save(values);279const onRandom = () => form.setFieldsValue({ token: new_random_token() });280const limit_min = editing != null ? editing.counter ?? 0 : 0;281282return (283<Form284{...layout}285size={"middle"}286form={form}287name="add-account-token"288onFinish={onFinish}289>290<Form.Item name="token" label="Token" rules={[{ required: true }]}>291<Input disabled={true} />292</Form.Item>293<Form.Item294name="descr"295label="Description"296rules={[{ required: false }]}297>298<Input />299</Form.Item>300<Form.Item name="expires" label="Expires" rules={[{ required: false }]}>301<DatePicker />302</Form.Item>303<Form.Item name="limit" label="Limit" rules={[{ required: false }]}>304<InputNumber min={limit_min} step={1} />305</Form.Item>306<Form.Item name="active" label="Active" valuePropName="checked">307<Switch />308</Form.Item>309<Form.Item {...tailLayout}>310<AntdButton.Group>311<AntdButton type="primary" htmlType="submit">312Save313</AntdButton>314<AntdButton315htmlType="button"316onClick={() => {317form.resetFields();318edit_new_token();319}}320>321Reset322</AntdButton>323<AntdButton htmlType="button" onClick={() => set_editing(null)}>324<CancelText />325</AntdButton>326<AntdButton onClick={onRandom}>Randomize</AntdButton>327</AntdButton.Group>328</Form.Item>329</Form>330);331}332333function render_buttons() {334const any_selected = sel_rows.length > 0;335return (336<AntdButton.Group style={{ margin: "10px 0" }}>337<AntdButton338type={!any_selected ? "primary" : "default"}339disabled={any_selected}340onClick={() => edit_new_token()}341>342<Icon name="plus" />343Add344</AntdButton>345346<AntdButton347type={any_selected ? "primary" : "default"}348onClick={delete_tokens}349disabled={!any_selected}350loading={deleting}351>352<Icon name="trash" />353{any_selected ? `Delete ${sel_rows.length} token(s)` : "Delete"}354</AntdButton>355356<AntdButton onClick={() => load()}>357<Icon name="refresh" />358Refresh359</AntdButton>360</AntdButton.Group>361);362}363364function render_view(): Rendered {365const table_data = sortBy(366Object.values(data).map((v) => {367v.key = v.token;368return v;369}),370"token",371);372const rowSelection = {373selectedRowKeys: sel_rows,374onChange: set_sel_rows,375};376return (377<>378{render_buttons()}379380<Table<Token>381size={"small"}382dataSource={table_data}383loading={loading}384rowSelection={rowSelection}385pagination={{386position: ["bottomRight"],387defaultPageSize: 10,388showSizeChanger: true,389}}390rowClassName={(row) =>391row.token === last_saved?.token392? "cocalc-highlight-saved-token"393: ""394}395>396<Table.Column<Token>397title="Token"398dataIndex="token"399defaultSortOrder={"ascend"}400sorter={(a, b) => a.token.localeCompare(b.token)}401/>402<Table.Column<Token> title="Description" dataIndex="descr" />403<Table.Column<Token>404title="Uses"405dataIndex="counter"406render={(text) => text ?? 0}407/>408<Table.Column<Token>409title="Limit"410dataIndex="limit"411render={(text) => (text != null ? text : "∞")}412/>413<Table.Column<Token>414title="% Used"415dataIndex="used"416render={(_text, token) => {417const { limit, counter } = token;418if (limit != null) {419if (limit == 0) {420return "100%";421} else {422// codemirror -_-423const c = counter ?? 0;424const pct = (100 * c) / limit;425return {426props: {427style: {428backgroundColor:429pct > 90 ? COLORS.ATND_BG_RED_L : undefined,430},431},432children: `${round1(pct)}%`,433};434}435} else {436return "";437}438}}439/>440<Table.Column<Token>441title="Expires"442dataIndex="expires"443sortDirections={["ascend", "descend"]}444render={(v) => (v != null ? <TimeAgo date={v} /> : "never")}445sorter={(a, b) => cmp_dayjs(a.expires, b.expires, true)}446/>447448<Table.Column<Token>449title="Active"450dataIndex="disabled"451render={(_text, token) => {452const click = () => save({ ...token, active: !token.active });453return (454<Checkbox checked={token.active} onChange={click}></Checkbox>455);456}}457/>458<Table.Column<Token>459title="Edit"460dataIndex="edit"461render={(_text, token) => (462<EditOutlined onClick={() => set_editing(token)} />463)}464/>465<Table.Column<Token>466title="Delete"467dataIndex="delete"468render={(_text, token) => (469<Popconfirm470title="Sure to delete?"471onConfirm={() => delete_token(token.key, true)}472>473<DeleteOutlined />474</Popconfirm>475)}476/>477</Table>478</>479);480}481482function render_control(): Rendered {483if (editing != null) {484return render_edit();485} else {486return render_view();487}488}489490function render_error(): Rendered {491if (error) {492return <ErrorDisplay error={error} onClose={() => set_error("")} />;493}494}495496// this tells an admin that users can sign in freely if there are no tokens or no active tokens497function render_no_active_token_warning(): Rendered {498if (no_or_all_inactive) {499return (500<Alert bsStyle="warning">501No tokens, or there are no active tokens. This means anybody can use502your server.503<br />504Create at least one active token to prevent just anybody from signing505up for your server!506</Alert>507);508}509}510511function render_unsupported() {512// see https://github.com/sagemathinc/cocalc/issues/333513return (514<div style={{ color: COLORS.GRAY }}>515Not supported! At least one "public" passport strategy is enabled.516</div>517);518}519520function render_info(): Rendered {521return (522<div style={{ color: COLORS.GRAY, fontStyle: "italic" }}>523{saving && (524<>525<Saving />526<br />527</>528)}529Note: You can disable email sign up in Site Settings530</div>531);532}533534// disable token editing if any strategy besides email is public535function not_supported(strategies): boolean {536return strategies537.filterNot((s) => s.get("name") === "email")538.some((s) => s.get("public"));539}540541const account_store: any = redux.getStore("account");542if (account_store == null) {543return <div>Account store not defined -- try again...</div>;544}545const strategies: List<TypedMap<PassportStrategyFrontend>> | undefined =546account_store.get("strategies");547if (strategies == null) {548// I hit this in production once and it crashed my browser.549return <div>strategies not loaded -- try again...</div>;550}551if (not_supported(strategies)) {552return render_unsupported();553} else {554return (555<div>556{render_no_active_token_warning()}557{render_error()}558{render_control()}559{render_info()}560</div>561);562}563}564565566