Path: blob/master/src/packages/util/db-schema/accounts.ts
5837 views
/*1* This file is part of CoCalc: Copyright © 2020-2026 Sagemath, Inc.2* License: MS-RSL – see LICENSE.md for details3*/45import { has } from "lodash";67import { NOTES } from "./crm";8import { SCHEMA as schema } from "./index";9import { checkAccountName } from "./name-rules";10import { Table } from "./types";1112import {13DEFAULT_FONT_SIZE,14DEFAULT_NEW_FILENAMES,15NEW_FILENAMES,16OTHER_SETTINGS_USER_DEFINED_LLM,17} from "./defaults";1819import { ssoDispayedName } from "@cocalc/util/auth";20import { checkRequiredSSO } from "@cocalc/util/auth-check-required-sso";21import { ssoNormalizeExclusiveDomains } from "@cocalc/util/sso-normalize-domains";22import { isUserDefinedModelType } from "./llm-utils";2324import { DEFAULT_LOCALE } from "@cocalc/util/consts/locale";25import { Strategy } from "@cocalc/util/types/sso";2627async function getEmailAddressForAccountId(28db,29account_id: string,30): Promise<string | undefined> {31const { rows } = await db.async_query({32query: "SELECT email_address FROM accounts",33where: { "account_id = $::UUID": account_id },34});35if (rows.length === 0) {36return undefined;37}38return rows[0].email_address ?? undefined;39}4041async function getStrategiesSSO(db): Promise<Strategy[]> {42const { rows } = await db.async_query({43query: `44SELECT strategy,45COALESCE(info -> 'icon', conf -> 'icon') as icon,46COALESCE(info -> 'display', conf -> 'display') as display,47COALESCE(info -> 'public', conf -> 'public') as public,48COALESCE(info -> 'exclusive_domains', conf -> 'exclusive_domains') as exclusive_domains,49COALESCE(info -> 'do_not_hide', 'false'::JSONB) as do_not_hide,50COALESCE(info -> 'update_on_login', 'false'::JSONB) as update_on_login51FROM passport_settings52WHERE strategy != 'site_conf'53AND COALESCE(info ->> 'disabled', conf ->> 'disabled', 'false') != 'true'54`,55});5657return rows.map((row) => {58ssoNormalizeExclusiveDomains(row);59return {60name: row.strategy,61display: ssoDispayedName({62display: row.display,63name: row.strategy,64}),65icon: row.icon ?? undefined,66backgroundColor: "",67public: row.public ?? true,68exclusiveDomains: row.exclusive_domains ?? [],69doNotHide: row.do_not_hide ?? false,70updateOnLogin: row.update_on_login ?? false,71};72});73}7475export const USER_SEARCH_LIMIT = 250;76export const ADMIN_SEARCH_LIMIT = 2500;7778export const USE_BALANCE_TOWARD_SUBSCRIPTIONS =79"use_balance_toward_subscriptions";80export const USE_BALANCE_TOWARD_SUBSCRIPTIONS_DEFAULT = true;8182// AutoBalance: Every parameter is in dollars.83export interface AutoBalance {84// deposit money when the balance goes below this85trigger: number;86// amount to automatically add87amount: number;88// max amount of money to add per day89max_day: number;90// max amount of money to add per week91max_week: number;92// max amount of money to add per month93max_month: number;94// period -- which of max_day, max_week, or max_month to actually enforce.95// we always enforce **exactly one of them**.96period: "day" | "week" | "month";97// switch to disable/enable this.98enabled: boolean;99// if credit was not added, last reason why (at most 1024 characters)100reason?: string;101// ms since epoch of last attempt102time?: number;103// how much has been added at the moment when we last updated.104status?: { day: number; week: number; month: number };105}106107// each of the parameters above must be a number in the108// given interval below.109// All fields should always be explicitly specified.110export const AUTOBALANCE_RANGES = {111trigger: [5, 250],112amount: [10, 250],113max_day: [5, 1000],114max_week: [5, 5000],115max_month: [5, 10000],116} as const;117118export const AUTOBALANCE_DEFAULTS: AutoBalance = {119trigger: 10,120amount: 20,121max_day: 200,122max_week: 1000,123max_month: 2500,124period: "week",125enabled: true,126} as const;127128export const DARK_MODE_DEFAULTS = {129brightness: 100,130contrast: 90,131sepia: 0,132} as const;133134// throw error if not valid135export function ensureAutoBalanceValid(obj) {136if (obj == null) {137return;138}139if (typeof obj != "object") {140throw Error("must be an object");141}142for (const key in AUTOBALANCE_RANGES) {143if (obj[key] == null) {144throw Error(`${key} must be specified`);145}146}147for (const key in obj) {148if (key == "period") {149if (!["day", "week", "month"].includes(obj[key])) {150throw Error(`${key} must be 'day', 'week' or 'month'`);151}152continue;153}154if (key == "enabled") {155if (typeof obj[key] != "boolean") {156throw Error(`${key} must be boolean`);157}158continue;159}160if (key == "reason") {161if (typeof obj[key] != "string") {162throw Error(`${key} must be a string`);163}164if (obj[key].length > 1024) {165throw Error(`${key} must be at most 1024 characters`);166}167continue;168}169if (key == "time") {170if (typeof obj[key] != "number") {171throw Error(`${key} must be a number`);172}173continue;174}175if (key == "status") {176if (typeof obj[key] != "object") {177throw Error(`${key} must be an object`);178}179continue;180}181const range = AUTOBALANCE_RANGES[key];182if (range == null) {183throw Error(`invalid key '${key}'`);184}185const value = obj[key];186if (typeof value != "number") {187throw Error("every value must be a number");188}189if (value < range[0]) {190throw Error(`${key} must be at least ${range[0]}`);191}192if (value > range[1]) {193throw Error(`${key} must be at most ${range[1]}`);194}195}196}197198// throw error if not valid; also clamps max_tokens to safe range199export function ensureUserDefinedLLMValid(configs: any): void {200if (!Array.isArray(configs)) {201throw new Error("user_defined_llm must be an array");202}203204const maxLengths = {205display: 128,206model: 256,207endpoint: 256,208apiKey: 1024,209icon: 1024,210};211212for (const llm of configs) {213// Validate required fields214if (typeof llm.id !== "number") {215throw new Error("user_defined_llm: id must be a number");216}217if (!isUserDefinedModelType(llm.service)) {218throw new Error(219`user_defined_llm: service must be one of the supported services, got '${llm.service}'`,220);221}222if (typeof llm.model !== "string" || !llm.model) {223throw new Error("user_defined_llm: model is required");224}225if (typeof llm.display !== "string" || !llm.display) {226throw new Error("user_defined_llm: display is required");227}228if (typeof llm.endpoint !== "string") {229throw new Error("user_defined_llm: endpoint must be a string");230}231if (typeof llm.apiKey !== "string") {232throw new Error("user_defined_llm: apiKey must be a string");233}234if (llm.display.length > maxLengths.display) {235throw new Error(236`user_defined_llm: display must be at most ${maxLengths.display} characters`,237);238}239if (llm.model.length > maxLengths.model) {240throw new Error(241`user_defined_llm: model must be at most ${maxLengths.model} characters`,242);243}244if (llm.endpoint.length > maxLengths.endpoint) {245throw new Error(246`user_defined_llm: endpoint must be at most ${maxLengths.endpoint} characters`,247);248}249if (llm.apiKey.length > maxLengths.apiKey) {250throw new Error(251`user_defined_llm: apiKey must be at most ${maxLengths.apiKey} characters`,252);253}254// apiKey is required for most services, but optional for ollama and custom_openai255const requiresApiKey =256llm.service !== "ollama" && llm.service !== "custom_openai";257if (requiresApiKey && !llm.apiKey) {258throw new Error("user_defined_llm: apiKey is required for this service");259}260261// Validate and clamp max_tokens262if (llm.max_tokens != null) {263if (typeof llm.max_tokens !== "number") {264throw new Error("user_defined_llm: max_tokens must be a number");265}266if (!Number.isInteger(llm.max_tokens)) {267throw new Error("user_defined_llm: max_tokens must be an integer");268}269// Clamp to safe range270if (llm.max_tokens < 1000) {271llm.max_tokens = 1000;272}273if (llm.max_tokens > 2000000) {274llm.max_tokens = 2000000;275}276}277278// Validate optional icon279if (llm.icon != null) {280if (typeof llm.icon !== "string") {281throw new Error("user_defined_llm: icon must be a string");282}283if (llm.icon.length > maxLengths.icon) {284throw new Error(285`user_defined_llm: icon must be at most ${maxLengths.icon} characters`,286);287}288}289}290}291292Table({293name: "accounts",294fields: {295account_id: {296type: "uuid",297desc: "The uuid that determines the user account",298render: { type: "account" },299title: "Account",300},301created: {302type: "timestamp",303desc: "When the account was created.",304},305ephemeral: {306type: "number",307desc: "If set, number of milliseconds this account is allowed to exist after creation.",308},309created_by: {310type: "string",311pg_type: "inet",312desc: "IP address that created the account.",313},314creation_actions_done: {315type: "boolean",316desc: "Set to true after all creation actions (e.g., add to projects) associated to this account are succesfully completed.",317},318password_hash: {319type: "string",320pg_type: "VARCHAR(173)",321desc: "Hash of the password. This is 1000 iterations of sha512 with salt of length 32.",322},323deleted: {324type: "boolean",325desc: "True if the account has been deleted.",326},327name: {328type: "string",329pg_type: "VARCHAR(39)",330desc: "The username of this user. This is optional but globally unique across all accoutns *and* organizations. It can be between 1 and 39 characters from a-z A-Z 0-9 - and must not start with a dash.",331},332org: {333type: "string",334prg_type: "VARCHAR(39)",335desc: "If this account is associated to an organization, then this is the *name* of the organization. An account may be associated with at most one organization.",336},337email_address: {338type: "string",339pg_type: "VARCHAR(254)", // see http://stackoverflow.com/questions/386294/what-is-the-maximum-length-of-a-valid-email-address340desc: "The email address of the user. This is optional, since users may instead be associated to passport logins.",341unique: true,342render: { type: "email_address" },343}, // only one record in database can have this email address (if given)344email_address_before_delete: {345type: "string",346desc: "The email address of the user before they deleted their account.",347},348email_address_verified: {349type: "map",350desc: 'Verified email addresses as { "[email protected]" : <timestamp>, ... }',351},352email_address_challenge: {353type: "map",354desc: 'Contains random token for verification of an address: {"email": "...", "token": <random>, "time" : <timestamp for timeout>}',355},356email_address_problem: {357type: "map",358desc: 'Describes a problem with a given email address. example: { "[email protected]" : { "type": "bounce", "time": "2018-...", "mesg": "554 5.7.1 <....>: Recipient address rejected: Access denied, user does not exist", "status": <status code>}}',359},360passports: {361type: "map",362desc: 'Map from string ("[strategy]-[id]") derived from passport name and id to the corresponding profile',363},364editor_settings: {365type: "map",366desc: "Description of configuration settings for the editor. See the user_query get defaults.",367},368other_settings: {369type: "map",370desc: "Miscellaneous overall configuration settings for CoCalc, e.g., confirm close on exit?",371},372first_name: {373type: "string",374pg_type: "VARCHAR(254)", // some limit (actually around 3000) is required for indexing375desc: "The first name of this user.",376render: { type: "text", maxLength: 254, editable: true },377},378last_name: {379type: "string",380pg_type: "VARCHAR(254)",381desc: "The last name of this user.",382render: { type: "text", maxLength: 254, editable: true },383},384banned: {385type: "boolean",386desc: "Whether or not this user is banned.",387render: {388type: "boolean",389editable: true,390},391},392terminal: {393type: "map",394desc: "Settings for the terminal, e.g., font_size, etc. (see get query)",395},396autosave: {397type: "integer",398desc: "File autosave interval in seconds",399},400evaluate_key: {401type: "string",402desc: "Key used to evaluate code in Sage worksheet.",403},404font_size: {405type: "integer",406desc: "Default font-size for the editor, jupyter, etc. (px)",407},408last_active: {409type: "timestamp",410desc: "When this user was last active.",411},412stripe_customer_id: {413type: "string",414desc: "The id of this customer in the stripe billing system.",415},416stripe_customer: {417type: "map",418desc: "Information about customer from the point of view of stripe (exactly what is returned by stripe.customers.retrieve) ALMOST DEPRECATED -- THIS IS ONLY USED FOR OLD LEGACY UPGRADES.",419},420coupon_history: {421type: "map",422desc: "Information about which coupons the customer has used and the number of times",423},424profile: {425type: "map",426desc: "Information related to displaying an avatar for this user's location and presence in a document or chatroom.",427},428customize: {429type: "map",430desc: "Admin-managed configuration overrides for this account.",431},432groups: {433type: "array",434pg_type: "TEXT[]",435desc: "Array of groups that this user belongs to; usually empty. The only group right now is 'admin', which grants admin rights.",436},437ssh_keys: {438type: "map",439desc: "Map from ssh key fingerprints to ssh key objects.",440},441api_key: {442type: "string",443desc: "Optional API key that grants full API access to anything this account can access. Key is of the form 'sk_9QabcrqJFy7JIhvAGih5c6Nb', where the random part is 24 characters (base 62).",444unique: true,445},446sign_up_usage_intent: {447type: "string",448desc: "What user intended to use CoCalc for at sign up",449render: { type: "text" },450},451lti_id: {452type: "array",453pg_type: "TEXT[]",454desc: "LTI ISS and user ID",455},456lti_data: {457type: "map",458desc: "extra information related to LTI",459},460unlisted: {461type: "boolean",462desc: "If true then exclude user for full name searches (but not exact email address searches).",463render: {464type: "boolean",465editable: true,466},467},468tags: {469type: "array",470pg_type: "TEXT[]",471desc: "Tags expressing what this user is most interested in doing.",472render: { type: "string-tags", editable: true },473},474tours: {475type: "array",476pg_type: "TEXT[]",477desc: "Tours that user has seen, so once they are here they are hidden from the UI. The special tour 'all' means to disable all tour buttons.",478render: { type: "string-tags" },479},480notes: NOTES,481salesloft_id: {482type: "integer",483desc: "The id of corresponding person in salesloft, if they exist there.",484render: {485type: "number",486integer: true,487editable: true,488min: 1,489},490},491purchase_closing_day: {492type: "integer",493desc: "Day of the month when pay-as-you-go purchases are cutoff and charged for this user. It happens at midnight UTC on this day. This should be an integer between 1 and 28.",494render: {495type: "number",496editable: false, // Do NOT change this without going through the reset-closing-date api call...497min: 1,498max: 28,499},500},501min_balance: {502type: "number",503pg_type: "REAL",504desc: "The minimum allowed balance for this user. This is a quota we impose for safety, not something they set. Admins may change this in response to a support request. For most users this is not set at all hence 0, but for some special enterprise-style customers to whom we extend 'credit', it will be set.",505render: {506title: "Minimum Allowed Balance (USD)",507type: "number",508integer: false,509editable: true,510max: 0,511},512},513balance: {514type: "number",515pg_type: "REAL",516desc: "Last computed balance for this user. NOT a source of truth. Meant to ensure all frontend clients show the same thing. Probably also useful for db queries and maybe analytics.",517render: {518title: "Account Balance (USD)",519type: "number",520integer: false,521editable: false,522},523},524balance_alert: {525type: "boolean",526desc: "If true, the UI will very strongly encourage user to open their balance modal.",527render: {528type: "boolean",529editable: true,530},531},532auto_balance: {533type: "map",534desc: "Determines protocol for automatically adding money to account. This is relevant for pay as you go users. The interface AutoBalance describes the parameters. The user can in theory set this to anything, but ]",535},536stripe_checkout_session: {537type: "map",538desc: "Part of the current open stripe checkout session object, namely {id:?, url:?}, but none of the other info. When user is going to add credit to their account, we create a stripe checkout session and store it here until they complete checking out. This makes it possible to guide them back to the checkout session, in case anything goes wrong, and also avoids confusion with potentially multiple checkout sessions at once.",539},540stripe_usage_subscription: {541type: "string",542pg_type: "varchar(256)",543desc: "Id of this user's stripe metered usage subscription, if they have one.",544},545email_daily_statements: {546type: "boolean",547desc: "If true, try to send daily statements to user showing all of their purchases. If false or not set, then do not. NOTE: we always try to email monthly statements to users.",548render: {549type: "boolean",550editable: true,551},552},553owner_id: {554type: "uuid",555desc: "If one user (owner_id) creates an account for another user via the API, then this records who created the account. They may have special privileges at some point.",556render: { type: "account" },557title: "Owner",558},559unread_message_count: {560type: "integer",561desc: "Number of unread messages in the messages table for this user. This gets updated whenever the messages table for this user gets changed, making it easier to have UI etc when there are unread messages.",562render: {563type: "number",564editable: false,565min: 0,566},567},568last_message_summary: {569type: "timestamp",570desc: "The last time the system sent an email to this user with a summary about new messages (see messages.ts).",571},572},573rules: {574desc: "All user accounts.",575primary_key: "account_id",576// db_standby: "unsafe",577pg_indexes: [578"(lower(first_name) text_pattern_ops)",579"(lower(last_name) text_pattern_ops)",580"created_by",581"created",582"last_active DESC NULLS LAST",583"lti_id",584"unlisted",585"((passports IS NOT NULL))",586"((ssh_keys IS NOT NULL))", // used by ssh-gateway to speed up getting all users587],588crm_indexes: [589"(lower(first_name) text_pattern_ops)",590"(lower(last_name) text_pattern_ops)",591"(lower(email_address) text_pattern_ops)",592"created",593"last_active DESC NULLS LAST",594],595pg_unique_indexes: [596"api_key", // we use the map api_key --> account_id, so it better be unique597"LOWER(name)", // ensure user-assigned name is case sensitive globally unique598], // note that we actually require uniqueness across accounts and organizations599// and this index is just a step in that direction; full uniquness must be600// checked as an extra step.601user_query: {602get: {603throttle_changes: 500,604pg_where: [{ "account_id = $::UUID": "account_id" }],605fields: {606// Exactly what from the below is sync'd by default with the frontend app client is explicitly607// listed in frontend/account/table.ts608account_id: null,609email_address: null,610org: null,611lti_id: null,612stripe_checkout_session: null,613email_address_verified: null,614email_address_problem: null,615editor_settings: {616/* NOTE: there is a editor_settings.jupyter = { kernel...} that isn't documented here. */617strip_trailing_whitespace: false,618show_trailing_whitespace: false,619line_wrapping: true,620line_numbers: true,621jupyter_line_numbers: false,622smart_indent: true,623electric_chars: true,624match_brackets: true,625auto_close_brackets: true,626code_folding: true,627match_xml_tags: true,628auto_close_xml_tags: true,629auto_close_latex: true,630spaces_instead_of_tabs: true,631multiple_cursors: true,632track_revisions: true,633extra_button_bar: true,634build_on_save: true,635first_line_number: 1,636indent_unit: 4,637tab_size: 4,638bindings: "standard",639theme: "default",640undo_depth: 300,641jupyter_classic: false,642jupyter_window: false,643disable_jupyter_windowing: true,644show_exec_warning: true,645physical_keyboard: "default",646keyboard_variant: "",647ask_jupyter_kernel: true,648show_my_other_cursors: false,649disable_jupyter_virtualization: true,650},651other_settings: {652katex: true,653confirm_close: false,654mask_files: false,655dim_file_extensions: false,656page_size: 500,657standby_timeout_m: 15,658default_file_sort: "name",659[NEW_FILENAMES]: DEFAULT_NEW_FILENAMES,660show_global_info2: null,661first_steps: true,662newsletter: false,663time_ago_absolute: false,664// if true, do not show warning when using non-member projects665no_free_warnings: false,666allow_mentions: true,667dark_mode: false,668dark_mode_brightness: DARK_MODE_DEFAULTS.brightness,669dark_mode_contrast: DARK_MODE_DEFAULTS.contrast,670dark_mode_sepia: DARK_MODE_DEFAULTS.sepia,671news_read_until: 0,672hide_project_popovers: false,673hide_file_popovers: false,674hide_button_tooltips: false,675[OTHER_SETTINGS_USER_DEFINED_LLM]: "[]",676i18n: DEFAULT_LOCALE,677no_email_new_messages: false,678[USE_BALANCE_TOWARD_SUBSCRIPTIONS]:679USE_BALANCE_TOWARD_SUBSCRIPTIONS_DEFAULT,680hide_navbar_balance: false,681},682name: null,683first_name: "",684last_name: "",685terminal: {686font_size: DEFAULT_FONT_SIZE,687color_scheme: "default",688font: "monospace",689},690autosave: 45,691evaluate_key: "Shift-Enter",692font_size: DEFAULT_FONT_SIZE,693passports: {},694groups: [],695last_active: null,696stripe_customer: null,697coupon_history: null,698profile: {699image: undefined,700color: "rgb(170,170,170)",701},702customize: null,703ssh_keys: {},704created: null,705ephemeral: null,706unlisted: false,707tags: null,708tours: null,709min_balance: null,710balance: null,711balance_alert: null,712auto_balance: null,713purchase_closing_day: null,714stripe_usage_subscription: null,715email_daily_statements: null,716unread_message_count: null,717},718},719set: {720fields: {721account_id: "account_id",722name: true,723editor_settings: true,724other_settings: true,725first_name: true,726last_name: true,727terminal: true,728autosave: true,729evaluate_key: true,730font_size: true,731profile: true,732ssh_keys: true,733sign_up_usage_intent: true,734unlisted: true,735tags: true,736tours: true,737email_daily_statements: true,738// obviously min_balance can't be set!739auto_balance: true,740},741async check_hook(db, obj, account_id, _project_id, cb) {742// db is of type PostgreSQL defined in @cocalc/database/postgres/types743if (obj["name"] != null) {744// NOTE: there is no way to unset/remove a username after one is set...745try {746checkAccountName(obj["name"]);747} catch (err) {748cb(err.toString());749return;750}751const id = await db.nameToAccountOrOrganization(obj["name"]);752if (id != null && id != account_id) {753cb(754`name "${obj["name"]}" is already taken by another organization or account`,755);756return;757}758}759760// Hook to truncate some text fields to at most 254 characters, to avoid761// further trouble down the line.762for (const field of ["first_name", "last_name", "email_address"]) {763if (obj[field] != null) {764obj[field] = obj[field].slice(0, 254);765if (field != "email_address" && !obj[field]) {766// name fields can't be empty767cb(`${field} must be nonempty`);768return;769}770}771}772773// Make sure auto_balance is valid.774if (obj["auto_balance"] != null) {775try {776ensureAutoBalanceValid(obj["auto_balance"]);777} catch (err) {778cb(`${err}`);779return;780}781}782783// if account is exclusively controlled by SSO, you're maybe prohibited from changing account details784const current_email_address = await getEmailAddressForAccountId(785db,786account_id,787);788//console.log({ current_email_address });789if (typeof current_email_address === "string") {790const strategies: Strategy[] = await getStrategiesSSO(db);791const strategy = checkRequiredSSO({792strategies,793email: current_email_address,794});795// we got a required exclusive SSO for the given account_id796if (strategy != null) {797// if user tries to change email_address (including null/undefined)798if (has(obj, "email_address")) {799cb(`You are not allowed to change your email address.`);800return;801}802// ... or tries to change first or last name, but strategy has update_on_login set803if (804strategy.updateOnLogin &&805(has(obj, "first_name") || has(obj, "last_name"))806) {807cb(808`You are not allowed to change your first or last name. You have to change it at your single-sign-on provider: ${strategy.display}.`,809);810return;811}812}813}814815// Validate user-defined LLM configs816if (817obj["other_settings"]?.[OTHER_SETTINGS_USER_DEFINED_LLM] != null818) {819try {820const configs = JSON.parse(821obj["other_settings"][OTHER_SETTINGS_USER_DEFINED_LLM],822);823ensureUserDefinedLLMValid(configs);824// Save back the validated/clamped configs (mutates obj)825obj["other_settings"][OTHER_SETTINGS_USER_DEFINED_LLM] =826JSON.stringify(configs);827} catch (err) {828cb(`Invalid user_defined_llm configuration: ${err}`);829return;830}831}832833cb();834},835},836},837},838});839840export const EDITOR_BINDINGS = {841standard: "Standard",842sublime: "Sublime",843vim: "Vim",844emacs: "Emacs",845};846847export const EDITOR_COLOR_SCHEMES: { [name: string]: string } = {848default: "Default",849"3024-day": "3024 day",850"3024-night": "3024 night",851abcdef: "abcdef",852abbott: "Abbott",853"ayu-dark": "Ayu dark",854"ayu-mirage": "Ayu mirage",855//'ambiance-mobile' : 'Ambiance mobile' # doesn't highlight python, confusing856ambiance: "Ambiance",857"base16-dark": "Base 16 dark",858"base16-light": "Base 16 light",859bespin: "Bespin",860blackboard: "Blackboard",861"cocalc-dark": "CoCalc Dark",862"cocalc-light": "CoCalc Light",863cobalt: "Cobalt",864colorforth: "Colorforth",865darcula: "Darcula",866dracula: "Dracula",867"duotone-dark": "Duotone Dark",868"duotone-light": "Duotone Light",869eclipse: "Eclipse",870elegant: "Elegant",871"erlang-dark": "Erlang dark",872"gruvbox-dark": "Gruvbox-Dark",873hopscotch: "Hopscotch",874icecoder: "Icecoder",875idea: "Idea", // this messes with the global hinter CSS!876isotope: "Isotope",877juejin: "Juejin",878"lesser-dark": "Lesser dark",879liquibyte: "Liquibyte",880lucario: "Lucario",881material: "Material",882"material-darker": "Material darker",883"material-ocean": "Material ocean",884"material-palenight": "Material palenight",885mbo: "mbo",886"mdn-like": "MDN like",887midnight: "Midnight",888monokai: "Monokai",889neat: "Neat",890neo: "Neo",891night: "Night",892"oceanic-next": "Oceanic next",893"panda-syntax": "Panda syntax",894"paraiso-dark": "Paraiso dark",895"paraiso-light": "Paraiso light",896"pastel-on-dark": "Pastel on dark",897railscasts: "Railscasts",898rubyblue: "Rubyblue",899seti: "Seti",900shadowfox: "Shadowfox",901"solarized dark": "Solarized dark",902"solarized light": "Solarized light",903ssms: "ssms",904"the-matrix": "The Matrix",905"tomorrow-night-bright": "Tomorrow Night - Bright",906"tomorrow-night-eighties": "Tomorrow Night - Eighties",907ttcn: "ttcn",908twilight: "Twilight",909"vibrant-ink": "Vibrant ink",910"xq-dark": "Xq dark",911"xq-light": "Xq light",912yeti: "Yeti",913yonce: "Yonce",914zenburn: "Zenburn",915};916917Table({918name: "crm_accounts",919rules: {920virtual: "accounts",921primary_key: "account_id",922user_query: {923get: {924pg_where: [],925admin: true, // only admins can do get queries on this table926fields: {927...schema.accounts.user_query?.get?.fields,928banned: null,929groups: null,930notes: null,931salesloft_id: null,932sign_up_usage_intent: null,933owner_id: null,934deleted: null,935},936},937set: {938admin: true, // only admins can do get queries on this table939fields: {940account_id: true,941name: true,942first_name: true,943last_name: true,944autosave: true,945font_size: true,946banned: true,947unlisted: true,948notes: true,949tags: true,950salesloft_id: true,951purchase_closing_day: true,952min_balance: true, // admins can set this953},954},955},956},957fields: schema.accounts.fields,958});959960Table({961name: "crm_agents",962rules: {963virtual: "accounts",964primary_key: "account_id",965user_query: {966get: {967// There where condition restricts to only admin accounts for now.968// TODO: Later this will change to 'crm'=any(groups) or something like that.969pg_where: ["'admin'=any(groups)"],970admin: true, // only admins can do get queries on this table971fields: schema.accounts.user_query?.get?.fields ?? {},972},973},974},975fields: schema.accounts.fields,976});977978interface Tag {979label: string;980tag: string;981language?: string; // language of jupyter kernel982icon?: any; // I'm not going to import the IconName type from @cocalc/frontend983welcome?: string; // a simple "welcome" of this type984jupyterExtra?: string;985torun?: string; // how to run this in a terminal (e.g., for a .py file).986color?: string;987description?: string;988}989990// They were used up until 2024-01-05991export const TAGS_FEATURES: Tag[] = [992{ label: "Jupyter", tag: "ipynb", color: "magenta" },993{994label: "Python",995tag: "py",996language: "python",997welcome: 'print("Welcome to CoCalc from Python!")',998torun: "# Click Terminal, then type 'python3 welcome.py'",999color: "red",1000},1001{1002label: "AI / GPUs",1003tag: "gpu",1004color: "volcano",1005icon: "gpu",1006},1007{1008label: "R Stats",1009tag: "R",1010language: "r",1011welcome: 'print("Welcome to CoCalc from R!")',1012torun: "# Click Terminal, then type 'Rscript welcome.R'",1013color: "orange",1014},1015{1016label: "SageMath",1017tag: "sage",1018language: "sagemath",1019welcome: "print('Welcome to CoCalc from Sage!', factor(2024))",1020torun: "# Click Terminal, then type 'sage welcome.sage'",1021color: "gold",1022},1023{1024label: "Octave",1025icon: "octave",1026tag: "m",1027language: "octave",1028welcome: `disp("Welcome to CoCalc from Octave!")`,1029torun: "% Click Terminal, then type 'octave --no-window-system welcome.m'",1030color: "geekblue",1031},1032{1033label: "Linux",1034icon: "linux",1035tag: "term",1036language: "bash",1037welcome: "echo 'Welcome to CoCalc from Linux/BASH!'",1038color: "green",1039},1040{1041label: "LaTeX",1042tag: "tex",1043welcome: `\\documentclass{article}1044\\title{Welcome to CoCalc from \\LaTeX{}!}1045\\begin{document}1046\\maketitle1047\\end{document}`,1048color: "cyan",1049},1050{1051label: "C/C++",1052tag: "c",1053language: "C++17",1054icon: "cube",1055welcome: `1056#include <stdio.h>1057int main() {1058printf("Welcome to CoCalc from C!\\n");1059return 0;1060}`,1061jupyterExtra: "\nmain();\n",1062torun: "/* Click Terminal, then type 'gcc welcome.c && ./a.out' */",1063color: "blue",1064},1065{1066label: "Julia",1067language: "julia",1068icon: "julia",1069tag: "jl",1070welcome: 'println("Welcome to CoCalc from Julia!")',1071torun: "# Click Terminal, then type 'julia welcome.jl' */",1072color: "geekblue",1073},1074{1075label: "Markdown",1076tag: "md",1077welcome:1078"# Welcome to CoCalc from Markdown!\n\nYou can directly edit the rendered markdown -- try it!\n\nAnd run code:\n\n```py\n2+3\n```\n",1079color: "purple",1080},1081// {1082// label: "Whiteboard",1083// tag: "board",1084// welcome: `{"data":{"color":"#252937"},"h":96,"id":"1244fb1f","page":"b7cda7e9","str":"# Welcome to CoCalc from a Whiteboard!\\n\\n","type":"text","w":779,"x":-305,"y":-291,"z":1}1085// {"data":{"pos":0},"id":"b7cda7e9","type":"page","z":0}`,1086// },1087{ label: "Teaching", tag: "course", color: "green" },1088];10891090export const TAG_TO_FEATURE: { [key: string]: Readonly<Tag> } = {};1091for (const t of TAGS_FEATURES) {1092TAG_TO_FEATURE[t.tag] = t;1093}10941095const professional = "professional";10961097// Tags specific to user roles or if they want to be contacted1098export const TAGS_USERS: Readonly<Tag[]> = [1099{1100label: "Personal",1101tag: "personal",1102icon: "user",1103description: "You are interesting in using CoCalc for personal use.",1104},1105{1106label: "Professional",1107tag: professional,1108icon: "coffee",1109description: "You are using CoCalc as an employee or freelancer.",1110},1111{1112label: "Instructor",1113tag: "instructor",1114icon: "graduation-cap",1115description: "You are teaching a course.",1116},1117{1118label: "Student",1119tag: "student",1120icon: "smile",1121description: "You are a student in a course.",1122},1123] as const;11241125export const TAGS = TAGS_USERS;11261127export const TAGS_MAP: { [key: string]: Readonly<Tag> } = {};1128for (const x of TAGS) {1129TAGS_MAP[x.tag] = x;1130}11311132export const CONTACT_TAG = "contact";1133export const CONTACT_THESE_TAGS = [professional];11341135export interface UserSearchResult {1136account_id: string;1137first_name?: string;1138last_name?: string;1139name?: string; // "vanity" username1140last_active?: number; // ms since epoch -- when account was last active1141created?: number; // ms since epoch -- when account created1142banned?: boolean; // true if this user has been banned (only set for admin searches, obviously)1143email_address_verified?: boolean; // true if their email has been verified (a sign they are more trustworthy).1144// For security reasons, the email_address *only* occurs in search queries that1145// are by email_address (or for admins); we must not reveal email addresses1146// of users queried by substring searches, obviously.1147email_address?: string;1148}11491150export const ACCOUNT_ID_COOKIE_NAME = "account_id";115111521153