Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
sagemathinc
GitHub Repository: sagemathinc/cocalc
Path: blob/master/src/packages/frontend/collaborators/project-invite-tokens.tsx
1496 views
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: Date) {
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
try {
140
await webapp_client.async_query({
141
query: {
142
project_invite_tokens: {
143
token,
144
project_id,
145
created: webapp_client.server_time(),
146
expires,
147
},
148
},
149
});
150
} catch (err) {
151
alert_message({
152
type: "error",
153
message: `Error creating project invite token: ${err}`,
154
});
155
}
156
if (!is_mounted_ref.current) return;
157
fetch_tokens();
158
}
159
160
async function add_token_two_week() {
161
let expires = server_weeks_ago(-2);
162
add_token(expires);
163
}
164
165
function render_create_token() {
166
return (
167
<Popconfirm
168
title={
169
"Create a link that people can use to get added as a collaborator to this project."
170
}
171
onConfirm={add_token_two_week}
172
okText={"Yes, create token"}
173
cancelText={<CancelText />}
174
>
175
<Button disabled={fetching}>
176
<Icon name="plus-circle" />
177
<Gap /> Create two weeks token
178
</Button>
179
</Popconfirm>
180
);
181
}
182
const handleAdd = () => {
183
setAddModalVisible(true);
184
};
185
186
const handleModalOK = () => {
187
// const name = form.getFieldValue("name");
188
const expire = form.getFieldValue("expire");
189
add_token(expire.toDate());
190
setAddModalVisible(false);
191
form.resetFields();
192
};
193
194
const handleModalCancel = () => {
195
setAddModalVisible(false);
196
form.resetFields();
197
};
198
199
function render_create_custom_token() {
200
return (
201
<Button onClick={handleAdd}>
202
<Icon name="plus-circle" /> Create custom token
203
</Button>
204
);
205
}
206
207
function render_refresh() {
208
return (
209
<Button onClick={fetch_tokens} disabled={fetching}>
210
<Icon name="refresh" spin={fetching} />
211
<Gap /> Refresh
212
</Button>
213
);
214
}
215
216
async function expire_token(token) {
217
// set token to be expired
218
try {
219
await webapp_client.async_query({
220
query: {
221
project_invite_tokens: {
222
token,
223
project_id,
224
expires: webapp_client.server_time(),
225
},
226
},
227
});
228
} catch (err) {
229
alert_message({
230
type: "error",
231
message: `Error expiring project invite token: ${err}`,
232
});
233
}
234
if (!is_mounted_ref.current) return;
235
fetch_tokens();
236
}
237
238
function render_expire_button(token, expires) {
239
if (expires && expires <= webapp_client.server_time()) {
240
return "(REVOKED)";
241
}
242
return (
243
<Popconfirm
244
title={"Revoke this token?"}
245
description={
246
<div style={{ maxWidth: "400px" }}>
247
This will make it so this token cannot be used anymore. Anybody
248
who has already redeemed the token is not removed from this
249
project.
250
</div>
251
}
252
onConfirm={() => expire_token(token)}
253
okText={"Yes, revoke this token"}
254
cancelText={"Cancel"}
255
>
256
<Button size="small">Revoke...</Button>
257
</Popconfirm>
258
);
259
}
260
261
function render_tokens() {
262
if (tokens == null) return <Loading />;
263
const dataSource: any[] = [];
264
for (const data of tokens) {
265
const { token, counter, usage_limit, created, expires } = data;
266
dataSource.push({
267
key: token,
268
token:
269
expires && expires <= webapp_client.server_time() ? (
270
<span style={{ textDecoration: "line-through" }}>{token}</span>
271
) : (
272
<CopyToClipBoard
273
inputWidth="250px"
274
value={`${document.location.origin}${join(
275
appBasePath,
276
"app",
277
)}?${PROJECT_INVITE_QUERY_PARAM}=${token}`}
278
/>
279
),
280
counter,
281
usage_limit: usage_limit ?? "∞",
282
created: created ? <TimeAgo date={created} /> : undefined,
283
expires: expires ? (
284
<span>
285
<TimeAgo date={expires} /> <Gap />
286
{render_expire_button(token, expires)}
287
</span>
288
) : undefined,
289
data,
290
});
291
}
292
return (
293
<Table
294
dataSource={dataSource}
295
columns={COLUMNS}
296
pagination={{ pageSize: 4 }}
297
scroll={{ y: 240 }}
298
/>
299
);
300
}
301
302
return (
303
<Card style={{ minWidth: "800px", width: "100%", overflow: "auto" }}>
304
{heading}
305
<br />
306
<br />
307
{render_create_token()}
308
<Gap />
309
{render_create_custom_token()}
310
<Gap />
311
{render_refresh()}
312
<br />
313
<br />
314
{render_tokens()}
315
<br />
316
<br />
317
<Modal
318
open={addModalVisible}
319
title="Create a New Inviting Token"
320
okText="Create token"
321
cancelText={<CancelText />}
322
onCancel={handleModalCancel}
323
onOk={handleModalOK}
324
>
325
<Form form={form} layout="vertical">
326
<Form.Item
327
name="expire"
328
label="Expire"
329
rules={[
330
{
331
required: false,
332
message:
333
"Optional date when token will be automatically expired",
334
},
335
]}
336
>
337
<DatePicker
338
changeOnBlur
339
showTime
340
disabledDate={(current) => {
341
// disable all dates before today
342
return current && current < dayjs();
343
}}
344
/>
345
</Form.Item>
346
</Form>
347
</Modal>
348
</Card>
349
);
350
},
351
);
352
353