Path: blob/master/src/packages/frontend/admin/registration-token.tsx
5842 views
/*1* This file is part of CoCalc: Copyright © 2020-2025 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,11Descriptions,12Popconfirm,13Progress,14Space,15Switch,16Table,17} from "antd";18import type { DescriptionsProps } from "antd";19import dayjs from "dayjs";20import { List } from "immutable";21import { sortBy } from "lodash";2223import { CopyOutlined, DeleteOutlined, EditOutlined } from "@ant-design/icons";24import { Alert } from "@cocalc/frontend/antd-bootstrap";25import { redux, Rendered, TypedMap } from "@cocalc/frontend/app-framework";26import {27ErrorDisplay,28Icon,29Saving,30TimeAgo,31Tip,32} from "@cocalc/frontend/components";33import Copyable from "@cocalc/frontend/components/copy-to-clipboard";34import { webapp_client } from "@cocalc/frontend/webapp-client";35import {36cmp_dayjs,37round1,38seconds2hms,39trunc,40trunc_middle,41} from "@cocalc/util/misc";42import { COLORS } from "@cocalc/util/theme";43import { PassportStrategyFrontend } from "@cocalc/util/types/passport-types";4445import RegistrationTokenDialog from "./registration-token-dialog";46import {47formatEphemeralHours,48useRegistrationTokens,49} from "./registration-token-hook";50import LicenseSummary from "./registration-token-license-summary";51import { type Token } from "./types";5253export function RegistrationToken() {54// TODO I'm sure this could be done in a smarter way ...55const {56data,57form,58error,59setError,60deleting,61deleteToken,62deleteTokens,63saving,64selRows,65setSelRows,66lastSaved,67newRandomToken,68noOrAllInactive,69save,70load,71loading,72// Modal-related73modalVisible,74editingToken,75modalError,76licenseInputKey,77handleModalOpen,78handleModalCancel,79handleModalReset,80handleModalSave,81} = useRegistrationTokens();8283function render_buttons() {84const any_selected = selRows.length > 0;85return (86<Space.Compact style={{ margin: "10px 0" }}>87<AntdButton88type={!any_selected ? "primary" : "default"}89disabled={any_selected}90onClick={() => handleModalOpen()}91>92<Icon name="plus" />93Add94</AntdButton>9596<AntdButton97type={any_selected ? "primary" : "default"}98onClick={deleteTokens}99disabled={!any_selected}100loading={deleting}101>102<Icon name="trash" />103{any_selected ? `Delete ${selRows.length} token(s)` : "Delete"}104</AntdButton>105106<AntdButton onClick={() => load()}>107<Icon name="refresh" />108Refresh109</AntdButton>110</Space.Compact>111);112}113114function ephemeralSignupUrl(token: Token): string {115if (!token || token.ephemeral == null) return "";116if (typeof window === "undefined") {117return `/ephemeral?token=${token.token}`;118}119const { protocol, host } = window.location;120return `${protocol}//${host}/ephemeral?token=${token.token}`;121}122123function render_expanded_row(token: Token): Rendered {124const uses = token.counter ?? 0;125const limit = token.limit;126const pct =127limit == null128? undefined129: limit === 0130? 100131: round1((100 * uses) / limit);132const usageLabel =133pct == null134? `${uses}/${limit ?? "∞"} (–%)`135: `${uses}/${limit} (${pct}%)`;136const lifetime =137token.ephemeral != null138? seconds2hms(token.ephemeral / 1000, true)139: "No";140const ephemeralLink = ephemeralSignupUrl(token);141142const items: DescriptionsProps["items"] = [143{144key: "descr",145label: "Description",146children: token.descr || "(no description)",147span: 2,148},149{150key: "usage",151label: "Usage",152children: usageLabel,153},154{155key: "ephemeral",156label: "Ephemeral link",157span: 2,158children: ephemeralLink ? (159<Copyable value={ephemeralLink} size={"small"} />160) : (161"Not available"162),163},164{165key: "lifetime",166label: "Lifetime",167children: lifetime,168},169{170key: "disableCollaborators",171label: "Restrict collaborators",172children: token.customize?.disableCollaborators ? "Yes" : "No",173},174{175key: "disableAI",176label: "Disable AI",177children: token.customize?.disableAI ? "Yes" : "No",178},179{180key: "disableInternet",181label: "Disable internet",182children: token.customize?.disableInternet ? "Yes" : "No",183},184{185key: "license",186label: "License",187span: 3,188children: <LicenseSummary licenseId={token.customize?.license} />,189},190];191192return <Descriptions items={items} column={3} size="small" />;193}194195function render_view(): Rendered {196const table_data = sortBy(197Object.values(data).map((v) => ({ ...v, key: v.token })),198"token",199);200const rowSelection = {201selectedRowKeys: selRows,202onChange: setSelRows,203};204return (205<>206{render_buttons()}207208<Table<Token>209size={"small"}210dataSource={table_data}211loading={loading}212rowSelection={rowSelection}213pagination={{214position: ["bottomRight"],215defaultPageSize: 10,216showSizeChanger: true,217}}218rowClassName={(row) =>219row.token === lastSaved?.token ? "cocalc-highlight-saved-token" : ""220}221expandable={{222expandedRowRender: (record) => render_expanded_row(record),223}}224>225<Table.Column<Token>226title="Token"227dataIndex="token"228defaultSortOrder={"ascend"}229sorter={(a, b) => a.token.localeCompare(b.token)}230render={(token: string) => {231return (232<div233style={{234display: "flex",235justifyContent: "space-between",236alignItems: "center",237gap: "8px",238}}239>240<span title={token}>{trunc_middle(token, 7)}</span>241<Tip title={`Click to copy token`}>242<AntdButton243type="text"244size="small"245icon={<CopyOutlined />}246onClick={() => {247navigator.clipboard.writeText(token);248}}249/>250</Tip>251</div>252);253}}254/>255<Table.Column<Token>256title="Description"257dataIndex="descr"258render={(text) =>259text ? <span title={text}>{trunc(text, 30)}</span> : ""260}261sorter={(a, b) => {262const aDescr = a.descr || "";263const bDescr = b.descr || "";264return aDescr.localeCompare(bDescr);265}}266/>267<Table.Column<Token>268title="Ephemeral"269dataIndex="ephemeral"270render={(value, token) => {271if (value == null) return "-";272const url = ephemeralSignupUrl(token);273return (274<div275style={{276display: "flex",277justifyContent: "space-between",278alignItems: "center",279gap: "8px",280}}281>282<span>{formatEphemeralHours(value)}</span>283{url && (284<AntdButton285type="text"286size="small"287icon={<Icon name="link" />}288onClick={() => {289navigator.clipboard.writeText(url);290}}291title={`${url} - Click to copy`}292/>293)}294</div>295);296}}297/>298<Table.Column<Token>299title="% Used"300dataIndex="used"301render={(_text, token) => {302const { limit, counter } = token;303if (limit == null) return "";304305const c = counter ?? 0;306const pct = limit === 0 ? 100 : (100 * c) / limit;307const status =308pct > 90 ? "exception" : pct > 75 ? "normal" : "success";309310const tooltipContent = (311<div>312<div>313<strong>Uses:</strong> {c}314</div>315<div>316<strong>Limit:</strong> {limit}317</div>318<div>319<strong>Percentage:</strong> {round1(pct)}%320</div>321</div>322);323324return (325<Tip title={tooltipContent}>326<Progress327percent={round1(pct)}328size="small"329status={status}330strokeColor={pct > 90 ? COLORS.ANTD_RED : undefined}331/>332</Tip>333);334}}335/>336<Table.Column<Token>337title="Expires"338dataIndex="expires"339sortDirections={["ascend", "descend"]}340render={(v) => {341const now = dayjs(webapp_client.server_time());342const expired = v != null && cmp_dayjs(v, now) < 0;343return {344props: {345style: {346background: expired ? COLORS.ANTD_BG_RED_L : undefined,347padding: "0 4px",348},349},350children: v != null ? <TimeAgo date={v} /> : "never",351};352}}353sorter={(a, b) => cmp_dayjs(a.expires, b.expires, true)}354/>355356<Table.Column<Token>357title="Active"358dataIndex="disabled"359render={(_text, token) => {360const onChange = async (checked: boolean) => {361try {362await save({ ...token, active: checked });363} catch (err) {364// Error already set by save(), just prevent unhandled rejection365}366};367return <Switch checked={token.active} onChange={onChange} />;368}}369sorter={(a, b) => {370const aActive = a.active ? 1 : 0;371const bActive = b.active ? 1 : 0;372return aActive - bActive;373}}374/>375<Table.Column<Token>376title="Edit"377dataIndex="edit"378render={(_text, token) => (379<EditOutlined onClick={() => handleModalOpen(token)} />380)}381/>382<Table.Column<Token>383title="Delete"384dataIndex="delete"385render={(_text, token) => (386<Popconfirm387title="Sure to delete?"388onConfirm={() => deleteToken(token.key, true)}389>390<DeleteOutlined />391</Popconfirm>392)}393/>394</Table>395</>396);397}398399function render_error(): Rendered {400if (error) {401return <ErrorDisplay error={error} onClose={() => setError("")} />;402}403}404405// this tells an admin that users can sign in freely if there are no tokens or no active tokens406function render_no_active_token_warning(): Rendered {407if (noOrAllInactive) {408return (409<Alert bsStyle="warning">410No tokens, or there are no active tokens. This means anybody can use411your server.412<br />413Create at least one active token to prevent just anybody from signing414up for your server!415</Alert>416);417}418}419420function render_unsupported() {421// see https://github.com/sagemathinc/cocalc/issues/333422return (423<div style={{ color: COLORS.GRAY }}>424Not supported! At least one "public" passport strategy is enabled.425</div>426);427}428429function render_info(): Rendered {430return (431<div style={{ color: COLORS.GRAY, fontStyle: "italic" }}>432{saving && (433<>434<Saving />435<br />436</>437)}438Note: You can disable email sign up in Site Settings439</div>440);441}442443// disable token editing if any strategy besides email is public444function not_supported(strategies): boolean {445return strategies446.filterNot((s) => s.get("name") === "email")447.some((s) => s.get("public"));448}449450function render_dialog() {451return (452<RegistrationTokenDialog453open={modalVisible}454isEdit={editingToken != null}455editingToken={editingToken}456onCancel={handleModalCancel}457onSave={handleModalSave}458onReset={handleModalReset}459error={modalError}460form={form}461newRandomToken={newRandomToken}462saving={saving}463licenseInputKey={licenseInputKey}464/>465);466}467468const account_store: any = redux.getStore("account");469if (account_store == null) {470return <div>Account store not defined -- try again...</div>;471}472const strategies: List<TypedMap<PassportStrategyFrontend>> | undefined =473account_store.get("strategies");474if (strategies == null) {475// I hit this in production once and it crashed my browser.476return <div>strategies not loaded -- try again...</div>;477}478if (not_supported(strategies)) {479return render_unsupported();480} else {481return (482<div>483{render_no_active_token_warning()}484{render_error()}485{render_view()}486{render_dialog()}487{render_info()}488</div>489);490}491}492493494