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