CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutSign UpSign In
sagemathinc

Real-time collaboration for Jupyter Notebooks, Linux Terminals, LaTeX, VS Code, R IDE, and more,
all in one place.

GitHub Repository: sagemathinc/cocalc
Path: blob/master/src/packages/util/db-schema/accounts.ts
Views: 687
1
/*
2
* This file is part of CoCalc: Copyright © 2020 Sagemath, Inc.
3
* License: MS-RSL – see LICENSE.md for details
4
*/
5
6
import { NOTES } from "./crm";
7
import { SCHEMA as schema } from "./index";
8
import { checkAccountName } from "./name-rules";
9
import { Table } from "./types";
10
11
import {
12
DEFAULT_FONT_SIZE,
13
DEFAULT_NEW_FILENAMES,
14
NEW_FILENAMES,
15
OTHER_SETTINGS_USERDEFINED_LLM,
16
} from "./defaults";
17
18
import { DEFAULT_LOCALE } from "@cocalc/util/consts/locale";
19
20
Table({
21
name: "accounts",
22
fields: {
23
account_id: {
24
type: "uuid",
25
desc: "The uuid that determines the user account",
26
render: { type: "account" },
27
title: "Account",
28
},
29
created: {
30
type: "timestamp",
31
desc: "When the account was created.",
32
},
33
created_by: {
34
type: "string",
35
pg_type: "inet",
36
desc: "IP address that created the account.",
37
},
38
creation_actions_done: {
39
type: "boolean",
40
desc: "Set to true after all creation actions (e.g., add to projects) associated to this account are succesfully completed.",
41
},
42
password_hash: {
43
type: "string",
44
pg_type: "VARCHAR(173)",
45
desc: "Hash of the password. This is 1000 iterations of sha512 with salt of length 32.",
46
},
47
deleted: {
48
type: "boolean",
49
desc: "True if the account has been deleted.",
50
},
51
name: {
52
type: "string",
53
pg_type: "VARCHAR(39)",
54
desc: "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.",
55
},
56
email_address: {
57
type: "string",
58
pg_type: "VARCHAR(254)", // see http://stackoverflow.com/questions/386294/what-is-the-maximum-length-of-a-valid-email-address
59
desc: "The email address of the user. This is optional, since users may instead be associated to passport logins.",
60
unique: true,
61
render: { type: "email_address" },
62
}, // only one record in database can have this email address (if given)
63
email_address_before_delete: {
64
type: "string",
65
desc: "The email address of the user before they deleted their account.",
66
},
67
email_address_verified: {
68
type: "map",
69
desc: 'Verified email addresses as { "[email protected]" : <timestamp>, ... }',
70
},
71
email_address_challenge: {
72
type: "map",
73
desc: 'Contains random token for verification of an address: {"email": "...", "token": <random>, "time" : <timestamp for timeout>}',
74
},
75
email_address_problem: {
76
type: "map",
77
desc: '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>}}',
78
},
79
passports: {
80
type: "map",
81
desc: 'Map from string ("[strategy]-[id]") derived from passport name and id to the corresponding profile',
82
},
83
editor_settings: {
84
type: "map",
85
desc: "Description of configuration settings for the editor. See the user_query get defaults.",
86
},
87
other_settings: {
88
type: "map",
89
desc: "Miscellaneous overall configuration settings for CoCalc, e.g., confirm close on exit?",
90
},
91
first_name: {
92
type: "string",
93
pg_type: "VARCHAR(254)", // some limit (actually around 3000) is required for indexing
94
desc: "The first name of this user.",
95
render: { type: "text", maxLength: 254, editable: true },
96
},
97
last_name: {
98
type: "string",
99
pg_type: "VARCHAR(254)",
100
desc: "The last name of this user.",
101
render: { type: "text", maxLength: 254, editable: true },
102
},
103
banned: {
104
type: "boolean",
105
desc: "Whether or not this user is banned.",
106
render: {
107
type: "boolean",
108
editable: true,
109
},
110
},
111
terminal: {
112
type: "map",
113
desc: "Settings for the terminal, e.g., font_size, etc. (see get query)",
114
},
115
autosave: {
116
type: "integer",
117
desc: "File autosave interval in seconds",
118
},
119
evaluate_key: {
120
type: "string",
121
desc: "Key used to evaluate code in Sage worksheet.",
122
},
123
font_size: {
124
type: "integer",
125
desc: "Default font-size for the editor, jupyter, etc. (px)",
126
},
127
last_active: {
128
type: "timestamp",
129
desc: "When this user was last active.",
130
},
131
stripe_customer_id: {
132
type: "string",
133
desc: "The id of this customer in the stripe billing system.",
134
},
135
stripe_customer: {
136
type: "map",
137
desc: "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.",
138
},
139
coupon_history: {
140
type: "map",
141
desc: "Information about which coupons the customer has used and the number of times",
142
},
143
profile: {
144
type: "map",
145
desc: "Information related to displaying an avatar for this user's location and presence in a document or chatroom.",
146
},
147
groups: {
148
type: "array",
149
pg_type: "TEXT[]",
150
desc: "Array of groups that this user belongs to; usually empty. The only group right now is 'admin', which grants admin rights.",
151
},
152
ssh_keys: {
153
type: "map",
154
desc: "Map from ssh key fingerprints to ssh key objects.",
155
},
156
api_key: {
157
type: "string",
158
desc: "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).",
159
unique: true,
160
},
161
sign_up_usage_intent: {
162
type: "string",
163
desc: "What user intended to use CoCalc for at sign up",
164
render: { type: "text" },
165
},
166
lti_id: {
167
type: "array",
168
pg_type: "TEXT[]",
169
desc: "LTI ISS and user ID",
170
},
171
lti_data: {
172
type: "map",
173
desc: "extra information related to LTI",
174
},
175
unlisted: {
176
type: "boolean",
177
desc: "If true then exclude user for full name searches (but not exact email address searches).",
178
render: {
179
type: "boolean",
180
editable: true,
181
},
182
},
183
tags: {
184
type: "array",
185
pg_type: "TEXT[]",
186
desc: "Tags expressing what this user is most interested in doing.",
187
render: { type: "string-tags", editable: true },
188
},
189
tours: {
190
type: "array",
191
pg_type: "TEXT[]",
192
desc: "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.",
193
render: { type: "string-tags" },
194
},
195
notes: NOTES,
196
salesloft_id: {
197
type: "integer",
198
desc: "The id of corresponding person in salesloft, if they exist there.",
199
render: {
200
type: "number",
201
integer: true,
202
editable: true,
203
min: 1,
204
},
205
},
206
purchase_closing_day: {
207
type: "integer",
208
desc: "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.",
209
render: {
210
type: "number",
211
editable: false, // Do NOT change this without going through the reset-closing-date api call...
212
min: 1,
213
max: 28,
214
},
215
},
216
min_balance: {
217
type: "number",
218
pg_type: "REAL",
219
desc: "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.",
220
render: {
221
title: "Minimum Allowed Balance (USD)",
222
type: "number",
223
integer: false,
224
editable: true,
225
max: 0,
226
},
227
},
228
stripe_checkout_session: {
229
type: "map",
230
desc: "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.",
231
},
232
stripe_usage_subscription: {
233
type: "string",
234
pg_type: "varchar(256)",
235
desc: "Id of this user's stripe metered usage subscription, if they have one.",
236
},
237
email_daily_statements: {
238
type: "boolean",
239
desc: "If true (or not set), try to email daily statements to user showing all of their purchases. NOTE: we always try to email monthly statements to users.",
240
render: {
241
type: "boolean",
242
editable: true,
243
},
244
},
245
owner_id: {
246
type: "uuid",
247
desc: "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.",
248
render: { type: "account" },
249
title: "Owner",
250
},
251
},
252
rules: {
253
desc: "All user accounts.",
254
primary_key: "account_id",
255
// db_standby: "unsafe",
256
pg_indexes: [
257
"(lower(first_name) text_pattern_ops)",
258
"(lower(last_name) text_pattern_ops)",
259
"created_by",
260
"created",
261
"last_active DESC NULLS LAST",
262
"lti_id",
263
"unlisted",
264
"((passports IS NOT NULL))",
265
"((ssh_keys IS NOT NULL))", // used by ssh-gateway to speed up getting all users
266
],
267
crm_indexes: [
268
"(lower(first_name) text_pattern_ops)",
269
"(lower(last_name) text_pattern_ops)",
270
"(lower(email_address) text_pattern_ops)",
271
"created",
272
"last_active DESC NULLS LAST",
273
],
274
pg_unique_indexes: [
275
"api_key", // we use the map api_key --> account_id, so it better be unique
276
"LOWER(name)", // ensure user-assigned name is case sensitive globally unique
277
], // note that we actually require uniqueness across accounts and organizations
278
// and this index is just a step in that direction; full uniquness must be
279
// checked as an extra step.
280
user_query: {
281
get: {
282
throttle_changes: 500,
283
pg_where: [{ "account_id = $::UUID": "account_id" }],
284
fields: {
285
// Exactly what from the below is sync'd by default with the frontend app client is explicitly
286
// listed in frontend/account/table.ts
287
account_id: null,
288
email_address: null,
289
lti_id: null,
290
stripe_checkout_session: null,
291
email_address_verified: null,
292
email_address_problem: null,
293
editor_settings: {
294
/* NOTE: there is a editor_settings.jupyter = { kernel...} that isn't documented here. */
295
strip_trailing_whitespace: false,
296
show_trailing_whitespace: false,
297
line_wrapping: true,
298
line_numbers: true,
299
jupyter_line_numbers: false,
300
smart_indent: true,
301
electric_chars: true,
302
match_brackets: true,
303
auto_close_brackets: true,
304
code_folding: true,
305
match_xml_tags: true,
306
auto_close_xml_tags: true,
307
auto_close_latex: true,
308
spaces_instead_of_tabs: true,
309
multiple_cursors: true,
310
track_revisions: true,
311
extra_button_bar: true,
312
build_on_save: true,
313
first_line_number: 1,
314
indent_unit: 4,
315
tab_size: 4,
316
bindings: "standard",
317
theme: "default",
318
undo_depth: 300,
319
jupyter_classic: false,
320
jupyter_window: false,
321
disable_jupyter_windowing: false,
322
show_exec_warning: true,
323
physical_keyboard: "default",
324
keyboard_variant: "",
325
ask_jupyter_kernel: true,
326
show_my_other_cursors: false,
327
disable_jupyter_virtualization: false,
328
},
329
other_settings: {
330
katex: true,
331
confirm_close: false,
332
mask_files: true,
333
page_size: 500,
334
standby_timeout_m: 5,
335
default_file_sort: "name",
336
[NEW_FILENAMES]: DEFAULT_NEW_FILENAMES,
337
show_global_info2: null,
338
first_steps: true,
339
newsletter: false,
340
time_ago_absolute: false,
341
// if true, do not show warning when using non-member projects
342
no_free_warnings: false,
343
allow_mentions: true,
344
dark_mode: false,
345
dark_mode_brightness: 100,
346
dark_mode_contrast: 90,
347
dark_mode_sepia: 0,
348
dark_mode_grayscale: 0,
349
news_read_until: 0,
350
hide_project_popovers: false,
351
hide_file_popovers: false,
352
hide_button_tooltips: false,
353
[OTHER_SETTINGS_USERDEFINED_LLM]: "[]",
354
i18n: DEFAULT_LOCALE,
355
},
356
name: null,
357
first_name: "",
358
last_name: "",
359
terminal: {
360
font_size: DEFAULT_FONT_SIZE,
361
color_scheme: "default",
362
font: "monospace",
363
},
364
autosave: 45,
365
evaluate_key: "Shift-Enter",
366
font_size: DEFAULT_FONT_SIZE,
367
passports: {},
368
groups: [],
369
last_active: null,
370
stripe_customer: null,
371
coupon_history: null,
372
profile: {
373
image: undefined,
374
color: "rgb(170,170,170)",
375
},
376
ssh_keys: {},
377
created: null,
378
unlisted: false,
379
tags: null,
380
tours: null,
381
min_balance: null,
382
purchase_closing_day: null,
383
stripe_usage_subscription: null,
384
email_daily_statements: null,
385
},
386
},
387
set: {
388
fields: {
389
account_id: "account_id",
390
name: true,
391
editor_settings: true,
392
other_settings: true,
393
first_name: true,
394
last_name: true,
395
terminal: true,
396
autosave: true,
397
evaluate_key: true,
398
font_size: true,
399
profile: true,
400
ssh_keys: true,
401
sign_up_usage_intent: true,
402
unlisted: true,
403
tags: true,
404
tours: true,
405
email_daily_statements: true,
406
// obviously min_balance can't be set!
407
},
408
async check_hook(db, obj, account_id, _project_id, cb) {
409
if (obj["name"] != null) {
410
// NOTE: there is no way to unset/remove a username after one is set...
411
try {
412
checkAccountName(obj["name"]);
413
} catch (err) {
414
cb(err.toString());
415
return;
416
}
417
const id = await db.nameToAccountOrOrganization(obj["name"]);
418
if (id != null && id != account_id) {
419
cb(
420
`name "${obj["name"]}" is already taken by another organization or account`,
421
);
422
return;
423
}
424
}
425
// Hook to truncate some text fields to at most 254 characters, to avoid
426
// further trouble down the line.
427
for (const field of ["first_name", "last_name", "email_address"]) {
428
if (obj[field] != null) {
429
obj[field] = obj[field].slice(0, 254);
430
if (field != "email_address" && !obj[field]) {
431
// name fields can't be empty
432
cb(`${field} must be nonempty`);
433
return;
434
}
435
}
436
}
437
cb();
438
},
439
},
440
},
441
},
442
});
443
444
export const EDITOR_BINDINGS = {
445
standard: "Standard",
446
sublime: "Sublime",
447
vim: "Vim",
448
emacs: "Emacs",
449
};
450
451
export const EDITOR_COLOR_SCHEMES: { [name: string]: string } = {
452
default: "Default",
453
"3024-day": "3024 day",
454
"3024-night": "3024 night",
455
abcdef: "abcdef",
456
//'ambiance-mobile' : 'Ambiance mobile' # doesn't highlight python, confusing
457
ambiance: "Ambiance",
458
"base16-dark": "Base 16 dark",
459
"base16-light": "Base 16 light",
460
bespin: "Bespin",
461
blackboard: "Blackboard",
462
cobalt: "Cobalt",
463
colorforth: "Colorforth",
464
darcula: "Darcula",
465
dracula: "Dracula",
466
"duotone-dark": "Duotone Dark",
467
"duotone-light": "Duotone Light",
468
eclipse: "Eclipse",
469
elegant: "Elegant",
470
"erlang-dark": "Erlang dark",
471
"gruvbox-dark": "Gruvbox-Dark",
472
hopscotch: "Hopscotch",
473
icecoder: "Icecoder",
474
idea: "Idea", // this messes with the global hinter CSS!
475
isotope: "Isotope",
476
"lesser-dark": "Lesser dark",
477
liquibyte: "Liquibyte",
478
lucario: "Lucario",
479
material: "Material",
480
mbo: "mbo",
481
"mdn-like": "MDN like",
482
midnight: "Midnight",
483
monokai: "Monokai",
484
neat: "Neat",
485
neo: "Neo",
486
night: "Night",
487
"oceanic-next": "Oceanic next",
488
"panda-syntax": "Panda syntax",
489
"paraiso-dark": "Paraiso dark",
490
"paraiso-light": "Paraiso light",
491
"pastel-on-dark": "Pastel on dark",
492
railscasts: "Railscasts",
493
rubyblue: "Rubyblue",
494
seti: "Seti",
495
shadowfox: "Shadowfox",
496
"solarized dark": "Solarized dark",
497
"solarized light": "Solarized light",
498
ssms: "ssms",
499
"the-matrix": "The Matrix",
500
"tomorrow-night-bright": "Tomorrow Night - Bright",
501
"tomorrow-night-eighties": "Tomorrow Night - Eighties",
502
ttcn: "ttcn",
503
twilight: "Twilight",
504
"vibrant-ink": "Vibrant ink",
505
"xq-dark": "Xq dark",
506
"xq-light": "Xq light",
507
yeti: "Yeti",
508
zenburn: "Zenburn",
509
};
510
511
Table({
512
name: "crm_accounts",
513
rules: {
514
virtual: "accounts",
515
primary_key: "account_id",
516
user_query: {
517
get: {
518
pg_where: [],
519
admin: true, // only admins can do get queries on this table
520
fields: {
521
...schema.accounts.user_query?.get?.fields,
522
banned: null,
523
groups: null,
524
notes: null,
525
salesloft_id: null,
526
sign_up_usage_intent: null,
527
owner_id: null,
528
},
529
},
530
set: {
531
admin: true, // only admins can do get queries on this table
532
fields: {
533
account_id: true,
534
name: true,
535
first_name: true,
536
last_name: true,
537
autosave: true,
538
font_size: true,
539
banned: true,
540
unlisted: true,
541
notes: true,
542
salesloft_id: true,
543
purchase_closing_day: true,
544
min_balance: true, // admins can set this
545
},
546
},
547
},
548
},
549
fields: schema.accounts.fields,
550
});
551
552
Table({
553
name: "crm_agents",
554
rules: {
555
virtual: "accounts",
556
primary_key: "account_id",
557
user_query: {
558
get: {
559
// There where condition restricts to only admin accounts for now.
560
// TODO: Later this will change to 'crm'=any(groups) or something like that.
561
pg_where: ["'admin'=any(groups)"],
562
admin: true, // only admins can do get queries on this table
563
fields: schema.accounts.user_query?.get?.fields ?? {},
564
},
565
},
566
},
567
fields: schema.accounts.fields,
568
});
569
570
interface Tag {
571
label: string;
572
tag: string;
573
language?: string; // language of jupyter kernel
574
icon?: any; // I'm not going to import the IconName type from @cocalc/frontend
575
welcome?: string; // a simple "welcome" of this type
576
jupyterExtra?: string;
577
torun?: string; // how to run this in a terminal (e.g., for a .py file).
578
color?: string;
579
description?: string;
580
}
581
582
// They were used up until 2024-01-05
583
export const TAGS_FEATURES: Tag[] = [
584
{ label: "Jupyter", tag: "ipynb", color: "magenta" },
585
{
586
label: "Python",
587
tag: "py",
588
language: "python",
589
welcome: 'print("Welcome to CoCalc from Python!")',
590
torun: "# Click Terminal, then type 'python3 welcome.py'",
591
color: "red",
592
},
593
{
594
label: "AI / GPUs",
595
tag: "gpu",
596
color: "volcano",
597
icon: "gpu",
598
},
599
{
600
label: "R Stats",
601
tag: "R",
602
language: "r",
603
welcome: 'print("Welcome to CoCalc from R!")',
604
torun: "# Click Terminal, then type 'Rscript welcome.R'",
605
color: "orange",
606
},
607
{
608
label: "SageMath",
609
tag: "sage",
610
language: "sagemath",
611
welcome: "print('Welcome to CoCalc from Sage!', factor(2024))",
612
torun: "# Click Terminal, then type 'sage welcome.sage'",
613
color: "gold",
614
},
615
{
616
label: "Octave",
617
icon: "octave",
618
tag: "m",
619
language: "octave",
620
welcome: `disp("Welcome to CoCalc from Octave!")`,
621
torun: "% Click Terminal, then type 'octave --no-window-system welcome.m'",
622
color: "geekblue",
623
},
624
{
625
label: "Linux",
626
icon: "linux",
627
tag: "term",
628
language: "bash",
629
welcome: "echo 'Welcome to CoCalc from Linux/BASH!'",
630
color: "green",
631
},
632
{
633
label: "LaTeX",
634
tag: "tex",
635
welcome: `\\documentclass{article}
636
\\title{Welcome to CoCalc from \\LaTeX{}!}
637
\\begin{document}
638
\\maketitle
639
\\end{document}`,
640
color: "cyan",
641
},
642
{
643
label: "C/C++",
644
tag: "c",
645
language: "C++17",
646
icon: "cube",
647
welcome: `
648
#include <stdio.h>
649
int main() {
650
printf("Welcome to CoCalc from C!\\n");
651
return 0;
652
}`,
653
jupyterExtra: "\nmain();\n",
654
torun: "/* Click Terminal, then type 'gcc welcome.c && ./a.out' */",
655
color: "blue",
656
},
657
{
658
label: "Julia",
659
language: "julia",
660
icon: "julia",
661
tag: "jl",
662
welcome: 'println("Welcome to CoCalc from Julia!")',
663
torun: "# Click Terminal, then type 'julia welcome.jl' */",
664
color: "geekblue",
665
},
666
{
667
label: "Markdown",
668
tag: "md",
669
welcome:
670
"# 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",
671
color: "purple",
672
},
673
// {
674
// label: "Whiteboard",
675
// tag: "board",
676
// 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}
677
// {"data":{"pos":0},"id":"b7cda7e9","type":"page","z":0}`,
678
// },
679
{ label: "Teaching", tag: "course", color: "green" },
680
];
681
682
export const TAG_TO_FEATURE: { [key: string]: Readonly<Tag> } = {};
683
for (const t of TAGS_FEATURES) {
684
TAG_TO_FEATURE[t.tag] = t;
685
}
686
687
const professional = "professional";
688
689
// Tags specific to user roles or if they want to be contacted
690
export const TAGS_USERS: Readonly<Tag[]> = [
691
{
692
label: "Personal",
693
tag: "personal",
694
icon: "user",
695
description: "You are interesting in using CoCalc for personal use.",
696
},
697
{
698
label: "Professional",
699
tag: professional,
700
icon: "coffee",
701
description: "You are using CoCalc as an employee or freelancer.",
702
},
703
{
704
label: "Instructor",
705
tag: "instructor",
706
icon: "graduation-cap",
707
description: "You are teaching a course.",
708
},
709
{
710
label: "Student",
711
tag: "student",
712
icon: "smile",
713
description: "You are a student in a course.",
714
},
715
] as const;
716
717
export const TAGS = TAGS_USERS;
718
719
export const TAGS_MAP: { [key: string]: Readonly<Tag> } = {};
720
for (const x of TAGS) {
721
TAGS_MAP[x.tag] = x;
722
}
723
724
export const CONTACT_TAG = "contact";
725
export const CONTACT_THESE_TAGS = [professional];
726
727