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/account/settings/account-settings.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
import { Alert as AntdAlert, Space } from "antd";
7
import { List, Map } from "immutable";
8
import { join } from "path";
9
import { FormattedMessage, useIntl } from "react-intl";
10
11
import {
12
Alert,
13
Button,
14
ButtonToolbar,
15
Checkbox,
16
Col,
17
Panel,
18
Row,
19
Well,
20
} from "@cocalc/frontend/antd-bootstrap";
21
import {
22
React,
23
Rendered,
24
TypedMap,
25
redux,
26
useState,
27
} from "@cocalc/frontend/app-framework";
28
import {
29
A,
30
ErrorDisplay,
31
Gap,
32
Icon,
33
TimeAgo,
34
} from "@cocalc/frontend/components";
35
import { SiteName, TermsOfService } from "@cocalc/frontend/customize";
36
import { appBasePath } from "@cocalc/frontend/customize/app-base-path";
37
import { labels } from "@cocalc/frontend/i18n";
38
import { CancelText } from "@cocalc/frontend/i18n/components";
39
import { open_new_tab } from "@cocalc/frontend/misc/open-browser-tab";
40
import {
41
PassportStrategyIcon,
42
strategy2display,
43
} from "@cocalc/frontend/passports";
44
import { log } from "@cocalc/frontend/user-tracking";
45
import { webapp_client } from "@cocalc/frontend/webapp-client";
46
import { keys, startswith } from "@cocalc/util/misc";
47
import { COLORS } from "@cocalc/util/theme";
48
import { PassportStrategyFrontend } from "@cocalc/util/types/passport-types";
49
import { DeleteAccount } from "../delete-account";
50
import { SignOut } from "../sign-out";
51
import { set_account_table, ugly_error } from "../util";
52
import { EmailAddressSetting } from "./email-address-setting";
53
import { EmailVerification } from "./email-verification";
54
import { PasswordSetting } from "./password-setting";
55
import { TextSetting } from "./text-setting";
56
57
type ImmutablePassportStrategy = TypedMap<PassportStrategyFrontend>;
58
59
interface Props {
60
account_id?: string;
61
first_name?: string;
62
last_name?: string;
63
name?: string;
64
unlisted?: boolean;
65
email_address?: string;
66
email_address_verified?: Map<string, any>;
67
passports?: Map<string, any>;
68
sign_out_error?: string;
69
delete_account_error?: string;
70
other_settings?: Map<string, any>;
71
is_anonymous?: boolean;
72
email_enabled?: boolean;
73
verify_emails?: boolean;
74
created?: Date;
75
strategies?: List<ImmutablePassportStrategy>;
76
}
77
78
export function AccountSettings(props: Readonly<Props>) {
79
const intl = useIntl();
80
81
const [add_strategy_link, set_add_strategy_link] = useState<
82
string | undefined
83
>(undefined);
84
const [remove_strategy_button, set_remove_strategy_button] = useState<
85
string | undefined
86
>(undefined);
87
const [terms_checkbox, set_terms_checkbox] = useState<boolean>(false);
88
const [show_delete_confirmation, set_show_delete_confirmation] =
89
useState<boolean>(false);
90
const [username, set_username] = useState<boolean>(false);
91
92
const actions = () => redux.getActions("account");
93
94
function handle_change(evt, field) {
95
actions().setState({ [field]: evt.target.value });
96
}
97
98
function save_change(evt, field: string): void {
99
const { value } = evt.target;
100
set_account_table({ [field]: value });
101
}
102
103
function get_strategy(name: string): ImmutablePassportStrategy | undefined {
104
if (props.strategies == null) return undefined;
105
return props.strategies.find((val) => val.get("name") == name);
106
}
107
108
function render_add_strategy_link(): Rendered {
109
if (!add_strategy_link) {
110
return;
111
}
112
const strategy_name = add_strategy_link;
113
const strategy = get_strategy(strategy_name);
114
if (strategy == null) return;
115
const strategy_js = strategy.toJS();
116
const name = strategy2display(strategy_js);
117
const href = join(appBasePath, "auth", add_strategy_link);
118
return (
119
<Well>
120
<h4>
121
<PassportStrategyIcon strategy={strategy_js} /> {name}
122
</h4>
123
Link to your {name} account, so you can use {name} to login to your{" "}
124
<SiteName /> account.
125
<br /> <br />
126
<ButtonToolbar style={{ textAlign: "center" }}>
127
<Button
128
href={href}
129
target="_blank"
130
onClick={() => {
131
set_add_strategy_link(undefined);
132
if (props.is_anonymous) {
133
log("add_passport", {
134
passport: name,
135
source: "anonymous_account",
136
});
137
}
138
}}
139
>
140
<Icon name="external-link" /> Link My {name} Account
141
</Button>
142
<Button onClick={() => set_add_strategy_link(undefined)}>
143
<CancelText />
144
</Button>
145
</ButtonToolbar>
146
</Well>
147
);
148
}
149
150
async function remove_strategy_click(): Promise<void> {
151
const strategy = remove_strategy_button;
152
set_remove_strategy_button(undefined);
153
set_add_strategy_link(undefined);
154
if (strategy == null) return;
155
const obj = props.passports?.toJS() ?? {};
156
let id: string | undefined = undefined;
157
for (const k in obj) {
158
if (startswith(k, strategy)) {
159
id = k.split("-")[1];
160
break;
161
}
162
}
163
if (!id) {
164
return;
165
}
166
try {
167
await webapp_client.account_client.unlink_passport(strategy, id);
168
// console.log("ret:", x);
169
} catch (err) {
170
ugly_error(err);
171
}
172
}
173
174
function render_remove_strategy_button(): Rendered {
175
if (!remove_strategy_button) {
176
return;
177
}
178
const strategy_name = remove_strategy_button;
179
const strategy = get_strategy(strategy_name);
180
if (strategy == null) return;
181
const strategy_js = strategy.toJS();
182
const name = strategy2display(strategy_js);
183
if ((props.passports?.size ?? 0) <= 1 && !props.email_address) {
184
return (
185
<Well>
186
You must set an email address above or add another login method before
187
you can disable login to your <SiteName /> account using your {name}{" "}
188
account. Otherwise you would completely lose access to your account!
189
</Well>
190
);
191
// TODO: flesh out the case where the UI prevents a user from unlinking an exclusive sso strategy
192
// Right now, the backend blocks
193
} else if (false) {
194
return (
195
<Well>You are not allowed to remove the passport strategy {name}.</Well>
196
);
197
} else {
198
return (
199
<Well>
200
<h4>
201
<PassportStrategyIcon strategy={strategy_js} /> {name}
202
</h4>
203
Your <SiteName /> account is linked to your {name} account, so you can
204
login using it.
205
<br /> <br />
206
If you unlink your {name} account, you will no longer be able to use
207
this account to log into <SiteName />.
208
<br /> <br />
209
<ButtonToolbar style={{ textAlign: "center" }}>
210
<Button bsStyle="danger" onClick={remove_strategy_click}>
211
<Icon name="unlink" /> Unlink my {name} account
212
</Button>
213
<Button onClick={() => set_remove_strategy_button(undefined)}>
214
<CancelText />
215
</Button>
216
</ButtonToolbar>
217
</Well>
218
);
219
}
220
}
221
222
function render_strategy(
223
strategy: ImmutablePassportStrategy,
224
account_passports: string[],
225
): Rendered {
226
if (strategy.get("name") !== "email") {
227
const is_configured = account_passports.includes(strategy.get("name"));
228
const strategy_js = strategy.toJS();
229
const btn = (
230
<Button
231
disabled={props.is_anonymous && !terms_checkbox}
232
onClick={() => {
233
if (is_configured) {
234
set_remove_strategy_button(strategy.get("name"));
235
set_add_strategy_link(undefined);
236
} else {
237
set_add_strategy_link(strategy.get("name"));
238
set_remove_strategy_button(undefined);
239
}
240
}}
241
key={strategy.get("name")}
242
bsStyle={is_configured ? "info" : undefined}
243
>
244
<PassportStrategyIcon strategy={strategy_js} small={true} />{" "}
245
{strategy2display(strategy_js)}
246
</Button>
247
);
248
return btn;
249
}
250
}
251
252
function render_sign_out_error(): Rendered {
253
if (!props.sign_out_error) {
254
return;
255
}
256
return (
257
<ErrorDisplay
258
style={{ margin: "5px 0" }}
259
error={props.sign_out_error}
260
onClose={() => actions().setState({ sign_out_error: "" })}
261
/>
262
);
263
}
264
265
function render_sign_out_buttons(): Rendered {
266
return (
267
<Row
268
style={{
269
marginTop: "15px",
270
borderTop: "1px solid #ccc",
271
paddingTop: "15px",
272
}}
273
>
274
<Col xs={12}>
275
<div className="pull-right">
276
<SignOut everywhere={false} highlight={true} />
277
{!props.is_anonymous ? <Gap /> : undefined}
278
{!props.is_anonymous ? <SignOut everywhere={true} /> : undefined}
279
</div>
280
</Col>
281
</Row>
282
);
283
}
284
285
function get_account_passport_names(): string[] {
286
return keys(props.passports?.toJS() ?? {}).map((x) =>
287
x.slice(0, x.indexOf("-")),
288
);
289
}
290
291
function render_linked_external_accounts(): Rendered {
292
if (props.strategies == null || props.strategies.size <= 1) {
293
// not configured by server
294
return;
295
}
296
const account_passports: string[] = get_account_passport_names();
297
298
const linked: List<ImmutablePassportStrategy> = props.strategies.filter(
299
(strategy) => {
300
const name = strategy?.get("name");
301
return name !== "email" && account_passports.includes(name);
302
},
303
);
304
if (linked.size === 0) return;
305
306
const btns = linked
307
.map((strategy) => render_strategy(strategy, account_passports))
308
.toArray();
309
return (
310
<div>
311
<hr key="hr0" />
312
<h5 style={{ color: COLORS.GRAY_M }}>
313
{intl.formatMessage({
314
id: "account.settings.sso.account_is_linked",
315
defaultMessage: "Your account is linked with (click to unlink)",
316
})}
317
</h5>
318
<ButtonToolbar style={{ marginBottom: "10px", display: "flex" }}>
319
{btns}
320
</ButtonToolbar>
321
{render_remove_strategy_button()}
322
</div>
323
);
324
}
325
326
function render_available_to_link(): Rendered {
327
if (props.strategies == null || props.strategies.size <= 1) {
328
// not configured by server yet, or nothing but email
329
return;
330
}
331
const account_passports: string[] = get_account_passport_names();
332
333
let any_hidden = false;
334
const not_linked: List<ImmutablePassportStrategy> = props.strategies.filter(
335
(strategy) => {
336
const name = strategy.get("name");
337
// skip the email strategy, we don't use it
338
if (name === "email") return false;
339
// filter those which are already linked
340
if (account_passports.includes(name)) return false;
341
// do not show the non-public ones, unless they shouldn't be hidden
342
if (
343
!strategy.get("public", true) &&
344
!strategy.get("do_not_hide", false)
345
) {
346
any_hidden = true;
347
return false;
348
}
349
return true;
350
},
351
);
352
if (any_hidden === false && not_linked.size === 0) return;
353
354
const heading = intl.formatMessage(
355
{
356
id: "account.settings.sso.link_your_account",
357
defaultMessage: `{is_anonymous, select,
358
true {Sign up using your account at}
359
other {Click to link your account}}`,
360
},
361
{ is_anonymous: props.is_anonymous },
362
);
363
364
const btns = not_linked
365
.map((strategy) => render_strategy(strategy, account_passports))
366
.toArray();
367
368
// add an extra button to link to the non public ones, which aren't shown
369
if (any_hidden) {
370
btns.push(
371
<Button
372
key="sso"
373
onClick={() => open_new_tab(join(appBasePath, "sso"))}
374
bsStyle="info"
375
>
376
Other SSO
377
</Button>,
378
);
379
}
380
return (
381
<div>
382
<hr key="hr0" />
383
<h5 style={{ color: COLORS.GRAY_M }}>{heading}</h5>
384
<Space size={[10, 10]} wrap style={{ marginBottom: "10px" }}>
385
{btns}
386
</Space>
387
{render_add_strategy_link()}
388
</div>
389
);
390
}
391
392
function render_anonymous_warning(): Rendered {
393
if (!props.is_anonymous) {
394
return;
395
}
396
// makes no sense to delete an account that is anonymous; it'll
397
// get automatically deleted.
398
return (
399
<div>
400
<Alert bsStyle="warning" style={{ marginTop: "10px" }}>
401
<h4>Sign up</h4>
402
Signing up is free, avoids losing access to your work, you get added
403
to projects you were invited to, and you unlock{" "}
404
<A href="https://doc.cocalc.com/">many additional features</A>!
405
<br />
406
<br />
407
<h4>Sign in</h4>
408
If you already have a <SiteName /> account, <SignOut sign_in={true} />
409
. Note that you will lose any work you've done anonymously here.
410
</Alert>
411
<hr />
412
</div>
413
);
414
}
415
416
function render_delete_account(): Rendered {
417
if (props.is_anonymous) {
418
return;
419
}
420
return (
421
<Row>
422
<Col xs={12}>
423
<DeleteAccount
424
style={{ marginTop: "1ex" }}
425
initial_click={() => set_show_delete_confirmation(true)}
426
confirm_click={() => actions().delete_account()}
427
cancel_click={() => set_show_delete_confirmation(false)}
428
user_name={(props.first_name + " " + props.last_name).trim()}
429
show_confirmation={show_delete_confirmation}
430
/>
431
</Col>
432
</Row>
433
);
434
}
435
436
function render_password(): Rendered {
437
if (!props.email_address) {
438
// makes no sense to change password if don't have an email address
439
return;
440
}
441
return <PasswordSetting />;
442
}
443
444
function render_terms_of_service(): Rendered {
445
if (!props.is_anonymous) {
446
return;
447
}
448
const style: React.CSSProperties = { padding: "10px 20px" };
449
if (terms_checkbox) {
450
style.border = "2px solid #ccc";
451
} else {
452
style.border = "2px solid red";
453
}
454
return (
455
<div style={style}>
456
<Checkbox
457
checked={terms_checkbox}
458
onChange={(e) => set_terms_checkbox(e.target.checked)}
459
>
460
<TermsOfService style={{ display: "inline" }} />
461
</Checkbox>
462
</div>
463
);
464
}
465
466
function render_header(): Rendered {
467
if (props.is_anonymous) {
468
return (
469
<b>
470
Thank you for using <SiteName />!
471
</b>
472
);
473
} else {
474
return (
475
<>
476
<Icon name="user" /> {intl.formatMessage(labels.account)}
477
</>
478
);
479
}
480
}
481
482
function render_created(): Rendered {
483
if (props.is_anonymous || !props.created) {
484
return;
485
}
486
return (
487
<Row style={{ marginBottom: "15px" }}>
488
<Col md={4}>
489
<FormattedMessage
490
id="account.settings.created.label"
491
defaultMessage={"Created"}
492
/>
493
</Col>
494
<Col md={8}>
495
<TimeAgo date={props.created} />
496
</Col>
497
</Row>
498
);
499
}
500
501
function render_name(): Rendered {
502
return (
503
<>
504
<TextSetting
505
label={intl.formatMessage(labels.account_first_name)}
506
value={props.first_name}
507
onChange={(e) => handle_change(e, "first_name")}
508
onBlur={(e) => save_change(e, "first_name")}
509
onPressEnter={(e) => save_change(e, "first_name")}
510
maxLength={254}
511
disabled={props.is_anonymous && !terms_checkbox}
512
/>
513
<TextSetting
514
label={intl.formatMessage(labels.account_last_name)}
515
value={props.last_name}
516
onChange={(e) => handle_change(e, "last_name")}
517
onBlur={(e) => save_change(e, "last_name")}
518
onPressEnter={(e) => save_change(e, "last_name")}
519
maxLength={254}
520
disabled={props.is_anonymous && !terms_checkbox}
521
/>
522
<TextSetting
523
label={intl.formatMessage({
524
id: "account.settings.username.label",
525
defaultMessage: "Username (optional)",
526
})}
527
value={props.name}
528
onChange={(e) => {
529
const name = e.target.value?.trim();
530
actions().setState({ name });
531
}}
532
onBlur={(e) => {
533
set_username(false);
534
const name = e.target.value?.trim();
535
if (name) {
536
set_account_table({ name });
537
}
538
}}
539
onFocus={() => {
540
set_username(true);
541
}}
542
onPressEnter={(e) => {
543
const name = e.target.value?.trim();
544
if (name) {
545
set_account_table({ name });
546
}
547
}}
548
maxLength={39}
549
disabled={props.is_anonymous && !terms_checkbox}
550
/>
551
{username && (
552
<AntdAlert
553
showIcon
554
style={{ margin: "15px 0" }}
555
message={
556
<FormattedMessage
557
id="account.settings.username.info"
558
defaultMessage={`Setting a username provides optional nicer URL's for shared
559
public documents. Your username can be between 1 and 39 characters,
560
contain upper and lower case letters, numbers, and dashes.
561
{br}
562
WARNING: If you change your username, existing links using the previous username
563
will no longer work (automatic redirects are not implemented), so change with caution.`}
564
values={{ br: <br /> }}
565
/>
566
}
567
type="info"
568
/>
569
)}
570
</>
571
);
572
}
573
574
function render_email_address(): Rendered {
575
if (!props.account_id) {
576
return; // makes no sense to change email if there is no account
577
}
578
return (
579
<EmailAddressSetting
580
email_address={props.email_address}
581
is_anonymous={props.is_anonymous}
582
disabled={props.is_anonymous && !terms_checkbox}
583
verify_emails={props.verify_emails}
584
/>
585
);
586
}
587
588
function render_unlisted(): Rendered {
589
if (!props.account_id) {
590
return; // makes no sense to change unlisted status if there is no account
591
}
592
return (
593
<Checkbox
594
checked={props.unlisted}
595
onChange={(e) =>
596
actions().set_account_table({ unlisted: e.target.checked })
597
}
598
>
599
<FormattedMessage
600
id="account.settings.unlisted.label"
601
defaultMessage={
602
"Unlisted: you can only be found by an exact email address match"
603
}
604
/>
605
</Checkbox>
606
);
607
}
608
609
function render_email_verification(): Rendered {
610
if (props.email_enabled && props.verify_emails && !props.is_anonymous) {
611
return (
612
<EmailVerification
613
email_address={props.email_address}
614
email_address_verified={props.email_address_verified}
615
/>
616
);
617
}
618
}
619
620
return (
621
<Panel header={render_header()}>
622
{render_anonymous_warning()}
623
{render_terms_of_service()}
624
{render_name()}
625
{render_email_address()}
626
{render_unlisted()}
627
<div style={{ marginBottom: "15px" }}></div>
628
{render_email_verification()}
629
{render_password()}
630
{render_created()}
631
{render_delete_account()}
632
{render_linked_external_accounts()}
633
{render_available_to_link()}
634
{render_sign_out_buttons()}
635
{render_sign_out_error()}
636
</Panel>
637
);
638
}
639
640