Path: blob/master/src/packages/frontend/admin/registration-token-hook.tsx
5805 views
/*1* This file is part of CoCalc: Copyright © 2020-2025 Sagemath, Inc.2* License: MS-RSL – see LICENSE.md for details3*/45/*6Custom hook for managing registration tokens.7*/89import { Form } from "antd";10import dayjs from "dayjs";11import { pick } from "lodash";12import { useEffect, useState } from "react";1314import { query } from "@cocalc/frontend/frame-editors/generic/client";15import { RegistrationTokenSetFields } from "@cocalc/util/db-schema/types";16import { seconds2hms, secure_random_token } from "@cocalc/util/misc";1718import {19CUSTOM_PRESET_KEY,20EPHEMERAL_OFF_KEY,21findPresetKey,22type Token,23} from "./types";2425export function formatEphemeralHours(value?: number): string {26if (value == null) return "";27const seconds = value / 1000;28return seconds2hms(seconds, false, false, false);29}3031export function getEphemeralMode(ephemeral?: number): string | undefined {32const presetKey = findPresetKey(ephemeral);33if (presetKey) return presetKey;34if (ephemeral == null) return EPHEMERAL_OFF_KEY;35return CUSTOM_PRESET_KEY;36}3738export function useRegistrationTokens() {39const [data, setData] = useState<{ [key: string]: Token }>({});40const [noOrAllInactive, setNoOrAllInactive] = useState<boolean>(false);41const [modalVisible, setModalVisible] = useState<boolean>(false);42const [editingToken, setEditingToken] = useState<Token | null>(null);43const [saving, setSaving] = useState<boolean>(false);44const [deleting, setDeleting] = useState<boolean>(false);45const [loading, setLoading] = useState<boolean>(false);46const [lastSaved, setLastSaved] = useState<Token | null>(null);47const [error, setError] = useState<string>("");48const [selRows, setSelRows] = useState<any>([]);49const [modalError, setModalError] = useState<string>("");50const [licenseInputKey, setLicenseInputKey] = useState<number>(0);5152// Antd53const [form] = Form.useForm();5455// we load the data in a map, indexed by the token56// dates are converted to dayjs on the fly57async function load() {58let result: any;59setLoading(true);60try {61// TODO query should be limited by disabled != true62result = await query({63query: {64registration_tokens: {65token: "*",66descr: null,67expires: null,68limit: null,69disabled: null,70ephemeral: null,71customize: null,72},73},74});75const data = {};76let warn_signup = true;77for (const x of result.query.registration_tokens) {78if (x.expires) x.expires = dayjs(x.expires);79x.active = !x.disabled;80data[x.token] = x;81// we have at least one active token → no need to warn user82if (x.active) warn_signup = false;83}84setNoOrAllInactive(warn_signup);85setError("");86setData(data);87} catch (err) {88setError(err.message);89} finally {90setLoading(false);91}92}9394useEffect(() => {95// every time we show or hide, clear the selection96setSelRows([]);97load();98}, []);99100// saving a specific token value converts dayjs back to pure Date objects101// we also record the last saved token as a template for the next add operation102async function save(val): Promise<void> {103// antd wraps the time in a dayjs object104const val_orig: Token = { ...val };105106// data preparation107if (val.expires != null && dayjs.isDayjs(val.expires)) {108val.expires = dayjs(val.expires).toDate();109}110val.disabled = !val.active;111val = pick(val, [112"token",113"disabled",114"expires",115"limit",116"descr",117"ephemeral",118"customize",119] as RegistrationTokenSetFields[]);120// set optional field to undefined (to get rid of it)121["descr", "limit", "expires", "ephemeral"].forEach(122(k: RegistrationTokenSetFields) => (val[k] = val[k] ?? undefined),123);124if (val.customize != null) {125const { disableCollaborators, disableAI, disableInternet, license } =126val.customize;127if (!disableCollaborators && !disableAI && !disableInternet && !license) {128val.customize = undefined;129}130}131try {132setSaving(true);133await query({134query: {135registration_tokens: val,136},137timeout: 15000,138});139// we save the original one, with dayjs in it!140setLastSaved(val_orig);141setSaving(false);142await load();143} catch (err) {144// Error path - set error (handle non-Error values)145const errorMessage = err?.message ?? String(err);146setError(errorMessage);147throw err; // Re-throw so caller knows it failed148} finally {149setSaving(false);150}151}152153async function deleteToken(154token: string | undefined,155single: boolean = false,156) {157if (token == null) return;158if (single) setDeleting(true);159160try {161await query({162query: {163registration_tokens: { token },164},165options: [{ delete: true }],166});167if (single) load();168} catch (err) {169if (single) {170setError(err);171} else {172throw err;173}174} finally {175if (single) setDeleting(false);176}177}178179async function deleteTokens(): Promise<void> {180setDeleting(true);181try {182// Delete tokens in parallel and wait for all to complete183await Promise.all(selRows.map((token) => deleteToken(token)));184setSelRows([]);185load();186} catch (err) {187setError(err);188} finally {189setDeleting(false);190}191}192193// we generate a random token and make sure it doesn't exist194// TODO also let the user generate one with a validation check195function newRandomToken(): string {196return secure_random_token(16);197}198199// Modal event handlers200function handleModalOpen(token?: Token): void {201setModalError("");202setLicenseInputKey((k) => k + 1); // Force license picker to remount203// IMPORTANT: Reset form first to avoid leaking previous values204form.resetFields();205206if (token) {207// Edit mode208const mode = getEphemeralMode(token.ephemeral);209form.setFieldsValue({ ...token, _ephemeralMode: mode });210setEditingToken(token);211} else {212// Add mode - use lastSaved as template213const newToken = {214...lastSaved,215token: newRandomToken(),216active: true,217};218const mode = getEphemeralMode(newToken.ephemeral);219form.setFieldsValue({ ...newToken, _ephemeralMode: mode });220setEditingToken(null);221}222setModalVisible(true);223setLastSaved(null); // Clear last saved marker (mimics old useEffect)224}225226function handleModalCancel(): void {227setModalVisible(false);228setEditingToken(null);229setModalError("");230form.resetFields();231}232233function handleModalReset(): void {234setModalError("");235setLicenseInputKey((k) => k + 1); // Force license picker to remount236// Mimics old Reset button: regenerate token, keep lastSaved template237form.resetFields(); // Clear first to avoid stale values238const newToken = {239...lastSaved,240token: newRandomToken(),241active: true,242};243const mode = getEphemeralMode(newToken.ephemeral);244form.setFieldsValue({ ...newToken, _ephemeralMode: mode });245setEditingToken(null);246}247248async function handleModalSave(values: Token): Promise<void> {249setModalError("");250const val_orig: Token = { ...values };251252try {253// Call the existing save() function which handles all transformation and persistence254await save(values);255256// Success - close modal257setModalVisible(false);258setEditingToken(null);259} catch (err) {260// Error - keep modal open and preserve user input261// save() already set the error state, we just need to prevent closing262const message = err?.message ?? String(err);263setModalError(message);264form.setFieldsValue(val_orig); // Restore form with user's values265}266}267268return {269data,270form,271saving,272deleting,273deleteToken,274deleteTokens,275loading,276lastSaved,277error,278setError,279selRows,280setSelRows,281setDeleting,282newRandomToken,283save,284load,285noOrAllInactive,286// Modal-related287modalVisible,288editingToken,289modalError,290licenseInputKey,291handleModalOpen,292handleModalCancel,293handleModalReset,294handleModalSave,295};296}297298299