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