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