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/collaborators/project-invite-tokens.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
Manage tokens that can be used to add new users who
8
know the token to a project.
9
10
TODO:
11
- we don't allow adjusting the usage_limit, so hide that for now.
12
- the default expire time is "2 weeks" and user can't edit that yet, except to set expire to now.
13
14
*/
15
16
// Load the code that checks for the PROJECT_INVITE_QUERY_PARAM
17
// when user gets signed in, and handles it.
18
19
import { Button, Card, DatePicker, Form, Modal, Popconfirm, Table } from "antd";
20
import dayjs from "dayjs";
21
import { join } from "path";
22
23
import { alert_message } from "@cocalc/frontend/alerts";
24
import {
25
React,
26
useIsMountedRef,
27
useState,
28
} from "@cocalc/frontend/app-framework";
29
import {
30
CopyToClipBoard,
31
Gap,
32
Icon,
33
Loading,
34
TimeAgo,
35
} from "@cocalc/frontend/components";
36
import { appBasePath } from "@cocalc/frontend/customize/app-base-path";
37
import { CancelText } from "@cocalc/frontend/i18n/components";
38
import { webapp_client } from "@cocalc/frontend/webapp-client";
39
import { ProjectInviteToken } from "@cocalc/util/db-schema/project-invite-tokens";
40
import { secure_random_token, server_weeks_ago } from "@cocalc/util/misc";
41
import { PROJECT_INVITE_QUERY_PARAM } from "./handle-project-invite";
42
43
const { useForm } = Form;
44
45
const TOKEN_LENGTH = 16;
46
const MAX_TOKENS = 200;
47
const COLUMNS = [
48
{ title: "Invite Link", dataIndex: "token", key: "token", width: 300 },
49
{ title: "Created", dataIndex: "created", key: "created", width: 150 },
50
{ title: "Expires", dataIndex: "expires", key: "expires", width: 150 },
51
{ title: "Redemption Count", dataIndex: "counter", key: "counter" },
52
/* { title: "Limit", dataIndex: "usage_limit", key: "usage_limit" },*/
53
];
54
55
interface Props {
56
project_id: string;
57
}
58
59
export const ProjectInviteTokens: React.FC<Props> = React.memo(
60
({ project_id }) => {
61
// blah
62
const [expanded, set_expanded] = useState<boolean>(false);
63
const [tokens, set_tokens] = useState<undefined | ProjectInviteToken[]>(
64
undefined,
65
);
66
const is_mounted_ref = useIsMountedRef();
67
const [fetching, set_fetching] = useState<boolean>(false);
68
const [addModalVisible, setAddModalVisible] = useState<boolean>(false);
69
const [form] = useForm();
70
71
async function fetch_tokens() {
72
try {
73
set_fetching(true);
74
const { query } = await webapp_client.async_query({
75
query: {
76
project_invite_tokens: [
77
{
78
project_id,
79
token: null,
80
created: null,
81
expires: null,
82
usage_limit: null,
83
counter: null,
84
},
85
],
86
},
87
});
88
if (!is_mounted_ref.current) return;
89
set_tokens(query.project_invite_tokens);
90
} catch (err) {
91
alert_message({
92
type: "error",
93
message: `Error getting project invite tokens: ${err}`,
94
});
95
} finally {
96
if (is_mounted_ref.current) {
97
set_fetching(false);
98
}
99
}
100
}
101
102
const heading = (
103
<div>
104
<a
105
onClick={() => {
106
if (!expanded) {
107
fetch_tokens();
108
}
109
set_expanded(!expanded);
110
}}
111
style={{ cursor: "pointer", fontSize: "12pt" }}
112
>
113
{" "}
114
<Icon
115
style={{ width: "20px" }}
116
name={expanded ? "caret-down" : "caret-right"}
117
/>{" "}
118
Invite collaborators by sending them an invite URL...
119
</a>
120
</div>
121
);
122
if (!expanded) {
123
return heading;
124
}
125
126
async function add_token(expires) {
127
if (tokens != null && tokens.length > MAX_TOKENS) {
128
// TODO: just in case of some weird abuse... and until we implement
129
// deletion of tokens. Maybe the backend will just purge
130
// anything that has expired after a while.
131
alert_message({
132
type: "error",
133
message:
134
"You have hit the hard limit on the number of invite tokens for a single project. Please contact support.",
135
});
136
return;
137
}
138
const token = secure_random_token(TOKEN_LENGTH);
139
140
try {
141
await webapp_client.async_query({
142
query: {
143
project_invite_tokens: {
144
token,
145
project_id,
146
created: webapp_client.server_time(),
147
expires: expires,
148
},
149
},
150
});
151
} catch (err) {
152
alert_message({
153
type: "error",
154
message: `Error creating project invite token: ${err}`,
155
});
156
}
157
if (!is_mounted_ref.current) return;
158
fetch_tokens();
159
}
160
161
async function add_token_two_week() {
162
let expires = server_weeks_ago(-2);
163
add_token(expires);
164
}
165
166
function render_create_token() {
167
return (
168
<Popconfirm
169
title={
170
"Create a link that people can use to get added as a collaborator to this project."
171
}
172
onConfirm={add_token_two_week}
173
okText={"Yes, create token"}
174
cancelText={<CancelText />}
175
>
176
<Button disabled={fetching}>
177
<Icon name="plus-circle" />
178
<Gap /> Create two weeks token
179
</Button>
180
</Popconfirm>
181
);
182
}
183
const handleAdd = () => {
184
setAddModalVisible(true);
185
};
186
187
const handleModalOK = () => {
188
// const name = form.getFieldValue("name");
189
const expire = form.getFieldValue("expire");
190
add_token(expire);
191
setAddModalVisible(false);
192
form.resetFields();
193
};
194
195
const handleModalCancel = () => {
196
setAddModalVisible(false);
197
form.resetFields();
198
};
199
200
function render_create_custom_token() {
201
return (
202
<Button onClick={handleAdd}>
203
<Icon name="plus-circle" /> Create custom token
204
</Button>
205
);
206
}
207
208
function render_refresh() {
209
return (
210
<Button onClick={fetch_tokens} disabled={fetching}>
211
<Icon name="refresh" spin={fetching} />
212
<Gap /> Refresh
213
</Button>
214
);
215
}
216
217
async function expire_token(token) {
218
// set token to be expired
219
try {
220
await webapp_client.async_query({
221
query: {
222
project_invite_tokens: {
223
token,
224
project_id,
225
expires: webapp_client.server_time(),
226
},
227
},
228
});
229
} catch (err) {
230
alert_message({
231
type: "error",
232
message: `Error expiring project invite token: ${err}`,
233
});
234
}
235
if (!is_mounted_ref.current) return;
236
fetch_tokens();
237
}
238
239
function render_expire_button(token, expires) {
240
if (expires && expires <= webapp_client.server_time()) {
241
return "(REVOKED)";
242
}
243
return (
244
<Popconfirm
245
title={"Revoke this token?"}
246
description={
247
<div style={{ maxWidth: "400px" }}>
248
This will make it so this token cannot be used anymore. Anybody
249
who has already redeemed the token is not removed from this
250
project.
251
</div>
252
}
253
onConfirm={() => expire_token(token)}
254
okText={"Yes, revoke this token"}
255
cancelText={"Cancel"}
256
>
257
<Button size="small">Revoke...</Button>
258
</Popconfirm>
259
);
260
}
261
262
function render_tokens() {
263
if (tokens == null) return <Loading />;
264
const dataSource: any[] = [];
265
for (const data of tokens) {
266
const { token, counter, usage_limit, created, expires } = data;
267
dataSource.push({
268
key: token,
269
token:
270
expires && expires <= webapp_client.server_time() ? (
271
<span style={{ textDecoration: "line-through" }}>{token}</span>
272
) : (
273
<CopyToClipBoard
274
inputWidth="250px"
275
value={`${document.location.origin}${join(
276
appBasePath,
277
"app",
278
)}?${PROJECT_INVITE_QUERY_PARAM}=${token}`}
279
/>
280
),
281
counter,
282
usage_limit: usage_limit ?? "∞",
283
created: created ? <TimeAgo date={created} /> : undefined,
284
expires: expires ? (
285
<span>
286
<TimeAgo date={expires} /> <Gap />
287
{render_expire_button(token, expires)}
288
</span>
289
) : undefined,
290
data,
291
});
292
}
293
return (
294
<Table
295
dataSource={dataSource}
296
columns={COLUMNS}
297
pagination={{ pageSize: 4 }}
298
scroll={{ y: 240 }}
299
/>
300
);
301
}
302
303
return (
304
<Card style={{ width: "100%", overflowX: "auto" }}>
305
{heading}
306
<br />
307
<br />
308
{render_create_token()}
309
<Gap />
310
{render_create_custom_token()}
311
<Gap />
312
{render_refresh()}
313
<br />
314
<br />
315
{render_tokens()}
316
<br />
317
<br />
318
<Modal
319
open={addModalVisible}
320
title="Create a New Inviting Token"
321
okText="Create token"
322
cancelText={<CancelText />}
323
onCancel={handleModalCancel}
324
onOk={handleModalOK}
325
>
326
<Form form={form} layout="vertical">
327
<Form.Item
328
name="expire"
329
label="Expire"
330
rules={[
331
{
332
required: false,
333
message:
334
"Optional date when token will be automatically expired",
335
},
336
]}
337
>
338
<DatePicker
339
changeOnBlur
340
showTime
341
disabledDate={(current) => {
342
// disable all dates before today
343
return current && current < dayjs();
344
}}
345
/>
346
</Form.Item>
347
</Form>
348
</Modal>
349
</Card>
350
);
351
},
352
);
353
354