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/next/components/licenses/managed.tsx
Views: 687
1
/*
2
* This file is part of CoCalc: Copyright © 2022 Sagemath, Inc.
3
* License: MS-RSL – see LICENSE.md for details
4
*/
5
6
import {
7
Alert,
8
Button,
9
Checkbox,
10
Input,
11
Popconfirm,
12
Popover,
13
Table,
14
} from "antd";
15
import { useMemo, useState } from "react";
16
17
import { Icon } from "@cocalc/frontend/components/icon";
18
import Markdown from "@cocalc/frontend/editors/slate/static-markdown";
19
import { cmp, plural, search_match, search_split } from "@cocalc/util/misc";
20
import Avatar from "components/account/avatar";
21
import UserName from "components/account/name";
22
import SelectUsers from "components/account/select-users";
23
import { Paragraph, Title } from "components/misc";
24
import A from "components/misc/A";
25
import Timestamp from "components/misc/timestamp";
26
import Loading from "components/share/loading";
27
import apiPost from "lib/api/post";
28
import useAPI from "lib/hooks/api";
29
import useCustomize from "lib/use-customize";
30
import { EditableDescription, EditableTitle } from "./editable-license";
31
import License, { DateRange, Quota as LicenseQuota } from "./license";
32
33
const renderTimestamp = (epoch) => <Timestamp epoch={epoch} />;
34
35
export const quotaColumn = {
36
title: (
37
<Popover
38
title="Quota"
39
content={
40
<div style={{ maxWidth: "75ex" }}>
41
This is the license quota. If the license is active on a project, its
42
quotas will be set to at least the values listed here.
43
</div>
44
}
45
>
46
Quota{" "}
47
</Popover>
48
),
49
width: "35%",
50
responsive: ["sm"],
51
render: (_, license) => <Quota {...license} />,
52
};
53
54
export function Quota({ quota, state, upgrades }) {
55
return state != null && state != "running" ? (
56
<span></span>
57
) : (
58
<span
59
style={{
60
wordWrap: "break-word",
61
wordBreak: "break-word",
62
}}
63
>
64
{quota && <LicenseQuota quota={quota} />}
65
{/* upgrades is deprecated, but in case we encounter it, do not ignore it */}
66
{upgrades && (
67
<Markdown
68
value={"```js\n" + JSON.stringify(upgrades, undefined, 2) + "\n```"}
69
/>
70
)}
71
</span>
72
);
73
}
74
75
function TitleDescId({ title, description, id, onChange }) {
76
return (
77
<div
78
style={{
79
wordWrap: "break-word",
80
wordBreak: "break-word",
81
color: "#333",
82
}}
83
>
84
<div style={{ fontFamily: "monospace", fontSize: "9pt" }}>
85
<License license_id={id} />
86
</div>
87
<EditableTitle license_id={id} title={title} onChange={onChange} />
88
<EditableDescription
89
license_id={id}
90
description={description}
91
onChange={onChange}
92
/>
93
</div>
94
);
95
}
96
97
function Managers({ managers, id, onChange }) {
98
return (
99
<>
100
<div style={{ maxHeight: "65px", overflowY: "auto" }}>
101
{managers.map((account_id) => (
102
<Avatar
103
style={{ margin: "0 5px 5px 0" }}
104
key={account_id}
105
account_id={account_id}
106
size={24}
107
extra={
108
<RemoveManager
109
license_id={id}
110
managers={managers}
111
account_id={account_id}
112
onChange={onChange}
113
/>
114
}
115
/>
116
))}
117
</div>
118
<AddManagers license_id={id} managers={managers} onChange={onChange} />
119
</>
120
);
121
}
122
123
function RunLimit({ run_limit }) {
124
return <>{run_limit}</>;
125
}
126
127
function LastUsed({ last_used }) {
128
return renderTimestamp(last_used);
129
}
130
131
function Created({ created }) {
132
return renderTimestamp(created);
133
}
134
135
function columns(onChange) {
136
return [
137
{
138
responsive: ["xs"],
139
title: "Managed Licenses",
140
render: (_, license) => (
141
<div>
142
<TitleDescId {...license} onChange={onChange} />
143
<div>
144
<DateRange {...license} />
145
</div>{" "}
146
Run Limit: <RunLimit {...license} />
147
<div>
148
Quota: <Quota {...license} />
149
</div>
150
Last Used: <LastUsed {...license} />
151
<br />
152
Created: <Created {...license} />
153
<div style={{ border: "1px solid lightgrey", padding: "5px 15px" }}>
154
Managers <Managers {...license} onChange={onChange} />
155
</div>
156
</div>
157
),
158
},
159
{
160
responsive: ["sm"],
161
title: (
162
<Popover
163
placement="top"
164
title="Id, Title and Description of the License"
165
content={
166
<div style={{ maxWidth: "75ex" }}>
167
The first line is the id of the license, which anybody can enter
168
in various places to upgrade projects or courses. The title and
169
description of the license help you keep track of what the license
170
is for, and you can edit both fields here as well by clicking on
171
them.
172
</div>
173
}
174
>
175
License
176
</Popover>
177
),
178
key: "title",
179
width: "40%",
180
sorter: { compare: (a, b) => cmp(a.title, b.title) },
181
render: (_, license) => (
182
<div>
183
<TitleDescId {...license} onChange={onChange} />
184
<div>
185
<DateRange {...license} />
186
</div>
187
</div>
188
),
189
},
190
{
191
responsive: ["sm"],
192
width: "15%",
193
title: (
194
<Popover
195
title="Managers"
196
content={
197
<div style={{ maxWidth: "75ex" }}>
198
These are the managers of this license. They can see extra
199
information about the license, the license is included in any
200
dropdown where they can select a license, and they can add or
201
remove other license managers. You are a manager of all licenses
202
listed here.
203
</div>
204
}
205
>
206
Managers
207
</Popover>
208
),
209
key: "managers",
210
render: (_, license) => <Managers {...license} onChange={onChange} />,
211
},
212
{
213
responsive: ["sm"],
214
title: (
215
<Popover
216
placement="top"
217
title="Run Limit"
218
content={
219
<div style={{ maxWidth: "75ex" }}>
220
The maximum number of simultaneous running projects that this
221
license can upgrade. You can apply the license to any number of
222
projects, but it only impacts this many projects at once.
223
</div>
224
}
225
>
226
Run Limit
227
</Popover>
228
),
229
align: "center",
230
render: (_, license) => <RunLimit {...license} />,
231
sorter: { compare: (a, b) => cmp(a.run_limit, b.run_limit) },
232
},
233
quotaColumn,
234
{
235
responsive: ["sm"],
236
title: (
237
<Popover
238
placement="top"
239
title="When License was Last Used"
240
content={
241
<div style={{ maxWidth: "75ex" }}>
242
This is when this license was last used to upgrade a project when
243
the project was starting. It's the point in time when the project
244
started.
245
</div>
246
}
247
>
248
Last Used{" "}
249
</Popover>
250
),
251
render: (_, license) => <LastUsed {...license} />,
252
sorter: { compare: (a, b) => cmp(a.last_used, b.last_used) },
253
},
254
{
255
responsive: ["sm"],
256
title: (
257
<Popover
258
placement="top"
259
title="When License was Created"
260
content={
261
<div style={{ maxWidth: "75ex" }}>
262
This is when the license was created.
263
</div>
264
}
265
>
266
Created{" "}
267
</Popover>
268
),
269
render: (_, license) => <Created {...license} />,
270
sorter: { compare: (a, b) => cmp(a.created, b.created) },
271
},
272
];
273
}
274
275
export default function ManagedLicenses() {
276
let { result, error, call } = useAPI("licenses/get-managed");
277
const [search, setSearch] = useState<string>("");
278
const [showExpired, setShowExpired] = useState<boolean>(false);
279
const numExpired: number = useMemo(() => {
280
if (!result) return 0;
281
let n = 0;
282
const t = Date.now();
283
for (const x of result) {
284
if (x.expires && x.expires <= t) {
285
n += 1;
286
}
287
}
288
return n;
289
}, [result]);
290
291
if (error) {
292
return <Alert type="error" message={error} />;
293
}
294
if (!result) {
295
return <Loading style={{ fontSize: "16pt", margin: "auto" }} />;
296
}
297
298
if (search) {
299
result = doSearch(result, search);
300
}
301
if (!showExpired) {
302
// filter out anything that is expired
303
result = removeExpired(result);
304
}
305
306
function onChange() {
307
call();
308
}
309
310
return (
311
<div style={{ width: "100%", overflowX: "auto", minHeight: "50vh" }}>
312
<Title level={2}>Licenses that you Manage ({result.length})</Title>
313
<Paragraph>
314
These are the licenses that you have purchased or been added to manage.
315
You can add other people as managers of any of these licenses, if they
316
need to be able to use these licenses to upgrade projects. You can also{" "}
317
<A href="/billing/subscriptions">manage your purchased subscriptions</A>{" "}
318
and browse <A href="/billing/receipts">your receipts and invoices</A>.
319
</Paragraph>
320
<Paragraph>
321
You can also{" "}
322
<A href="/settings/licenses" external>
323
edit or cancel for a refund any license that you purchased...
324
</A>
325
</Paragraph>
326
<Paragraph style={{ margin: "15px 0" }}>
327
<Checkbox
328
disabled={numExpired == 0}
329
style={{ float: "right" }}
330
checked={showExpired}
331
onChange={(e) => setShowExpired(e.target.checked)}
332
>
333
Show Expired ({numExpired})
334
</Checkbox>
335
<Input.Search
336
placeholder="Search..."
337
allowClear
338
onChange={(e) => setSearch(e.target.value)}
339
style={{ width: "50ex", maxWidth: "100%" }}
340
/>
341
</Paragraph>
342
<Table
343
columns={columns(onChange) as any}
344
dataSource={result}
345
rowKey={"id"}
346
style={{ marginTop: "15px" }}
347
pagination={{ hideOnSinglePage: true, pageSize: 100 }}
348
/>
349
</div>
350
);
351
}
352
353
function doSearch(data: object[], search: string): object[] {
354
const v = search_split(search.toLowerCase().trim());
355
const w: object[] = [];
356
for (const x of data) {
357
if (x["search"] == null) {
358
x["search"] = `${x["title"] ?? ""} ${x["description"] ?? ""} ${
359
x["id"]
360
} ${x["info"]?.purchased?.subscription}`.toLowerCase();
361
}
362
if (search_match(x["search"], v)) {
363
w.push(x);
364
}
365
}
366
return w;
367
}
368
369
function removeExpired(data: { expires?: number }[]): { expires?: number }[] {
370
const data1: { expires?: number }[] = [];
371
const now = Date.now();
372
for (const x of data) {
373
if (!(x.expires != null && x.expires <= now)) {
374
data1.push(x);
375
}
376
}
377
return data1;
378
}
379
380
interface AddManagersProps {
381
license_id: string;
382
managers: string[];
383
onChange?: () => void;
384
}
385
386
function AddManagers({ license_id, managers, onChange }: AddManagersProps) {
387
const [adding, setAdding] = useState<boolean>(false);
388
const [accountIds, setAccountIds] = useState<string[]>([]);
389
const [error, setError] = useState<string>("");
390
const { account } = useCustomize();
391
return (
392
<div>
393
{adding && (
394
<Button
395
size="small"
396
style={{ float: "right" }}
397
onClick={() => {
398
setAdding(false);
399
setError("");
400
setAccountIds([]);
401
}}
402
>
403
Cancel
404
</Button>
405
)}
406
<Button
407
disabled={adding}
408
style={{ marginTop: "5px" }}
409
size="small"
410
onClick={() => {
411
setAdding(true);
412
setAccountIds([]);
413
setError("");
414
}}
415
>
416
<Icon name="plus-circle" /> Add
417
</Button>
418
{adding && (
419
<div style={{ width: "300px", marginTop: "5px" }}>
420
{error && <Alert type="error" message={error} />}
421
<Button
422
disabled={accountIds.length == 0}
423
onClick={async () => {
424
setError("");
425
const query = {
426
manager_site_licenses: {
427
id: license_id,
428
managers: managers.concat(accountIds),
429
},
430
};
431
try {
432
await apiPost("/user-query", { query });
433
setAdding(false);
434
onChange?.();
435
} catch (err) {
436
setError(err.message);
437
}
438
}}
439
style={{ marginBottom: "5px", width: "100%" }}
440
type="primary"
441
>
442
<Icon name="check" /> Add {accountIds.length}{" "}
443
{plural(accountIds.length, "selected user")}
444
</Button>
445
<SelectUsers
446
autoFocus
447
onChange={setAccountIds}
448
exclude={managers.concat(
449
account?.account_id ? [account.account_id] : [],
450
)}
451
/>
452
</div>
453
)}
454
</div>
455
);
456
}
457
458
interface RemoveManagerProps {
459
license_id: string;
460
account_id: string;
461
managers: string[];
462
onChange?: () => void;
463
}
464
465
function RemoveManager({
466
license_id,
467
managers,
468
account_id,
469
onChange,
470
}: RemoveManagerProps) {
471
const [error, setError] = useState<string>("");
472
const { account } = useCustomize();
473
return (
474
<Popconfirm
475
zIndex={20000 /* compare with user search */}
476
title={
477
<>
478
{account?.account_id == account_id ? (
479
<>
480
Remove <b>yourself</b> as a manager of this license?
481
</>
482
) : (
483
<>
484
Remove manager{" "}
485
<b>
486
<UserName account_id={account_id} />?
487
</b>
488
<br />
489
<UserName account_id={account_id} /> will no longer see this
490
license listed under licenses they manage.
491
</>
492
)}
493
<br /> The license will <i>not</i> be automatically removed from any
494
projects.
495
</>
496
}
497
onConfirm={async () => {
498
setError("");
499
const query = {
500
manager_site_licenses: {
501
id: license_id,
502
managers: managers.filter((x) => x != account_id),
503
},
504
};
505
try {
506
await apiPost("/user-query", { query });
507
onChange?.();
508
} catch (err) {
509
setError(err.message);
510
}
511
}}
512
okText={"Remove"}
513
cancelText={"Cancel"}
514
>
515
<div>
516
<a>Remove as Manager...</a>
517
{error && (
518
<Alert
519
type="error"
520
message={"Error Removing Manager"}
521
description={error}
522
/>
523
)}
524
</div>
525
</Popconfirm>
526
);
527
}
528
529