Path: blob/master/src/packages/frontend/account/settings/account-settings.tsx
6023 views
/*1* This file is part of CoCalc: Copyright © 2020 Sagemath, Inc.2* License: MS-RSL – see LICENSE.md for details3*/45import { Alert as AntdAlert, Space } from "antd";6import { List, Map } from "immutable";7import { join } from "path";8import { FormattedMessage, useIntl } from "react-intl";910import {11Alert,12Button,13ButtonToolbar,14Checkbox,15Col,16Panel,17Row,18Well,19} from "@cocalc/frontend/antd-bootstrap";20import {21React,22Rendered,23TypedMap,24redux,25useState,26} from "@cocalc/frontend/app-framework";27import {28A,29ErrorDisplay,30Gap,31Icon,32TimeAgo,33} from "@cocalc/frontend/components";34import { SiteName, TermsOfService } from "@cocalc/frontend/customize";35import { appBasePath } from "@cocalc/frontend/customize/app-base-path";36import { labels } from "@cocalc/frontend/i18n";37import { CancelText } from "@cocalc/frontend/i18n/components";38import { open_new_tab } from "@cocalc/frontend/misc/open-browser-tab";39import {40PassportStrategyIcon,41strategy2display,42} from "@cocalc/frontend/passports";43import { log } from "@cocalc/frontend/user-tracking";44import { webapp_client } from "@cocalc/frontend/webapp-client";45import { checkRequiredSSO } from "@cocalc/util/auth-check-required-sso";46import { keys, startswith } from "@cocalc/util/misc";47import { COLORS } from "@cocalc/util/theme";48import { PassportStrategyFrontend } from "@cocalc/util/types/passport-types";49import { AccountState } from "../types";50import { DeleteAccount } from "../delete-account";51import { ACCOUNT_PROFILE_ICON_NAME } from "../account-preferences-profile";52import { SignOut } from "../sign-out";53import { set_account_table, ugly_error } from "../util";54import { EmailAddressSetting } from "./email-address-setting";55import { EmailVerification } from "./email-verification";56import { PasswordSetting } from "./password-setting";57import { TextSetting } from "./text-setting";5859type ImmutablePassportStrategy = TypedMap<PassportStrategyFrontend>;6061interface Props {62account_id?: string;63first_name?: string;64last_name?: string;65name?: string;66unlisted?: boolean;67email_address?: string;68email_address_verified?: Map<string, any>;69passports?: Map<string, any>;70sign_out_error?: string;71delete_account_error?: string;72other_settings?: AccountState["other_settings"];73is_anonymous?: boolean;74email_enabled?: boolean;75verify_emails?: boolean;76created?: Date;77strategies?: List<ImmutablePassportStrategy>;78}7980export function AccountSettings(props: Readonly<Props>) {81const intl = useIntl();8283const [add_strategy_link, set_add_strategy_link] = useState<84string | undefined85>(undefined);86const [remove_strategy_button, set_remove_strategy_button] = useState<87string | undefined88>(undefined);89const [terms_checkbox, set_terms_checkbox] = useState<boolean>(false);90const [show_delete_confirmation, set_show_delete_confirmation] =91useState<boolean>(false);92const [username, set_username] = useState<boolean>(false);9394const actions = () => redux.getActions("account");9596function handle_change(evt, field) {97actions().setState({ [field]: evt.target.value });98}99100function save_change(evt, field: string): void {101const { value } = evt.target;102set_account_table({ [field]: value });103}104105// Check if SSO restrictions apply to this account106function getSSORestrictions(): {107disableEmail: boolean;108disableName: boolean;109ssoStrategyId?: string;110ssoStrategyName?: string;111} {112if (113!props.email_address ||114!props.strategies ||115props.strategies.size === 0116) {117return { disableEmail: false, disableName: false };118}119120// Convert Immutable.List to plain array for checkRequiredSSO121const strategies = props.strategies122.map((s) => {123const exclusiveDomainsRaw = s.get("exclusive_domains");124const exclusiveDomains = List.isList(exclusiveDomainsRaw)125? exclusiveDomainsRaw.toArray()126: (exclusiveDomainsRaw ?? []);127// Map frontend field names to the format expected by checkRequiredSSO128return {129name: s.get("name"),130display: s.get("display") ?? s.get("name"),131backgroundColor: "",132public: s.get("public") ?? true,133exclusiveDomains,134doNotHide: s.get("do_not_hide") ?? false,135updateOnLogin: s.get("update_on_login") ?? false,136icon: s.get("icon"),137};138})139.toArray();140141const matchedStrategy = checkRequiredSSO({142email: props.email_address,143strategies,144});145146if (!matchedStrategy) {147return { disableEmail: false, disableName: false };148}149150// Email is always disabled for exclusive domains151// Name is disabled only if updateOnLogin is true152return {153disableEmail: true,154disableName: matchedStrategy.updateOnLogin ?? false,155ssoStrategyId: matchedStrategy.name,156ssoStrategyName: matchedStrategy.display,157};158}159160function get_strategy(name: string): ImmutablePassportStrategy | undefined {161if (props.strategies == null) return undefined;162return props.strategies.find((val) => val.get("name") == name);163}164165function render_add_strategy_link(): Rendered {166if (!add_strategy_link) {167return;168}169const strategy_name = add_strategy_link;170const strategy = get_strategy(strategy_name);171if (strategy == null) return;172const strategy_js = strategy.toJS();173const name = strategy2display(strategy_js);174const href = join(appBasePath, "auth", add_strategy_link);175return (176<Well>177<h4>178<PassportStrategyIcon strategy={strategy_js} /> {name}179</h4>180Link to your {name} account, so you can use {name} to login to your{" "}181<SiteName /> account.182<br /> <br />183<ButtonToolbar style={{ textAlign: "center" }}>184<Button185href={href}186target="_blank"187onClick={() => {188set_add_strategy_link(undefined);189if (props.is_anonymous) {190log("add_passport", {191passport: name,192source: "anonymous_account",193});194}195}}196>197<Icon name="external-link" /> Link My {name} Account198</Button>199<Button onClick={() => set_add_strategy_link(undefined)}>200<CancelText />201</Button>202</ButtonToolbar>203</Well>204);205}206207async function remove_strategy_click(): Promise<void> {208const strategy = remove_strategy_button;209set_remove_strategy_button(undefined);210set_add_strategy_link(undefined);211if (strategy == null) return;212const obj = props.passports?.toJS() ?? {};213let id: string | undefined = undefined;214for (const k in obj) {215if (startswith(k, strategy)) {216id = k.split("-")[1];217break;218}219}220if (!id) {221return;222}223try {224await webapp_client.account_client.unlink_passport(strategy, id);225// console.log("ret:", x);226} catch (err) {227ugly_error(err);228}229}230231function render_remove_strategy_button(): Rendered {232if (!remove_strategy_button) {233return;234}235const strategy_name = remove_strategy_button;236const strategy = get_strategy(strategy_name);237if (strategy == null) return;238const strategy_js = strategy.toJS();239const name = strategy2display(strategy_js);240if ((props.passports?.size ?? 0) <= 1 && !props.email_address) {241return (242<Well>243You must set an email address above or add another login method before244you can disable login to your <SiteName /> account using your {name}{" "}245account. Otherwise you would completely lose access to your account!246</Well>247);248// TODO: flesh out the case where the UI prevents a user from unlinking an exclusive sso strategy249// Right now, the backend blocks250} else if (false) {251return (252<Well>You are not allowed to remove the passport strategy {name}.</Well>253);254} else {255return (256<Well>257<h4>258<PassportStrategyIcon strategy={strategy_js} /> {name}259</h4>260Your <SiteName /> account is linked to your {name} account, so you can261login using it.262<br /> <br />263If you unlink your {name} account, you will no longer be able to use264this account to log into <SiteName />.265<br /> <br />266<ButtonToolbar style={{ textAlign: "center" }}>267<Button bsStyle="danger" onClick={remove_strategy_click}>268<Icon name="unlink" /> Unlink my {name} account269</Button>270<Button onClick={() => set_remove_strategy_button(undefined)}>271<CancelText />272</Button>273</ButtonToolbar>274</Well>275);276}277}278279function render_strategy(280strategy: ImmutablePassportStrategy,281account_passports: string[],282blockedStrategyName?: string,283): Rendered {284if (strategy.get("name") !== "email") {285const strategyName = strategy.get("name");286const is_configured = account_passports.includes(strategyName);287const is_blocked = is_configured && blockedStrategyName === strategyName;288const strategy_js = strategy.toJS();289const disabled = (props.is_anonymous && !terms_checkbox) || is_blocked;290const btn = (291<Button292disabled={disabled}293onClick={() => {294if (disabled) {295return;296}297if (is_configured) {298set_remove_strategy_button(strategy.get("name"));299set_add_strategy_link(undefined);300} else {301set_add_strategy_link(strategy.get("name"));302set_remove_strategy_button(undefined);303}304}}305key={strategy.get("name")}306bsStyle={is_configured ? "info" : undefined}307>308<PassportStrategyIcon strategy={strategy_js} small={true} />{" "}309{strategy2display(strategy_js)}310</Button>311);312return btn;313}314}315316function render_sign_out_error(): Rendered {317if (!props.sign_out_error) {318return;319}320return (321<ErrorDisplay322style={{ margin: "5px 0" }}323error={props.sign_out_error}324onClose={() => actions().setState({ sign_out_error: "" })}325/>326);327}328329function render_sign_out_buttons(): Rendered {330return (331<Row332style={{333marginTop: "15px",334borderTop: "1px solid #ccc",335paddingTop: "15px",336}}337>338<Col xs={12}>339<div className="pull-right">340<SignOut everywhere={false} highlight={true} />341{!props.is_anonymous ? <Gap /> : undefined}342{!props.is_anonymous ? <SignOut everywhere={true} /> : undefined}343</div>344</Col>345</Row>346);347}348349function get_account_passport_names(): string[] {350return keys(props.passports?.toJS() ?? {}).map((x) =>351x.slice(0, x.indexOf("-")),352);353}354355function render_linked_external_accounts(): Rendered {356if (props.strategies == null || props.strategies.size <= 1) {357// not configured by server358return;359}360const account_passports: string[] = get_account_passport_names();361const ssoRestrictions = getSSORestrictions();362const blockedStrategyName = ssoRestrictions.disableEmail363? ssoRestrictions.ssoStrategyId364: undefined;365366const linked: List<ImmutablePassportStrategy> = props.strategies.filter(367(strategy) => {368const name = strategy?.get("name");369return name !== "email" && account_passports.includes(name);370},371);372if (linked.size === 0) return;373374const btns = linked375.map((strategy) =>376render_strategy(strategy, account_passports, blockedStrategyName),377)378.toArray();379return (380<div>381<hr key="hr0" />382<h5 style={{ color: COLORS.GRAY_M }}>383{intl.formatMessage({384id: "account.settings.sso.account_is_linked",385defaultMessage: blockedStrategyName386? "Your account is controlled by"387: "Your account is linked with (click to unlink)",388})}389</h5>390<ButtonToolbar style={{ marginBottom: "10px", display: "flex" }}>391{btns}392</ButtonToolbar>393{render_remove_strategy_button()}394</div>395);396}397398function render_available_to_link(): Rendered {399if (props.strategies == null || props.strategies.size <= 1) {400// not configured by server yet, or nothing but email401return;402}403const account_passports: string[] = get_account_passport_names();404405let any_hidden = false;406const not_linked: List<ImmutablePassportStrategy> = props.strategies.filter(407(strategy) => {408const name = strategy.get("name");409// skip the email strategy, we don't use it410if (name === "email") return false;411// filter those which are already linked412if (account_passports.includes(name)) return false;413// do not show the non-public ones, unless they shouldn't be hidden414if (415!strategy.get("public", true) &&416!strategy.get("do_not_hide", false)417) {418any_hidden = true;419return false;420}421return true;422},423);424if (any_hidden === false && not_linked.size === 0) return;425426const heading = intl.formatMessage(427{428id: "account.settings.sso.link_your_account",429defaultMessage: `{is_anonymous, select,430true {Sign up using your account at}431other {Click to link your account}}`,432},433{ is_anonymous: props.is_anonymous },434);435436const btns = not_linked437.map((strategy) => render_strategy(strategy, account_passports))438.toArray();439440// add an extra button to link to the non public ones, which aren't shown441if (any_hidden) {442btns.push(443<Button444key="sso"445onClick={() => open_new_tab(join(appBasePath, "sso"))}446bsStyle="info"447>448Other SSO449</Button>,450);451}452return (453<div>454<hr key="hr0" />455<h5 style={{ color: COLORS.GRAY_M }}>{heading}</h5>456<Space size={[10, 10]} wrap style={{ marginBottom: "10px" }}>457{btns}458</Space>459{render_add_strategy_link()}460</div>461);462}463464function render_sso_restriction_notice(): Rendered {465if (props.is_anonymous) {466return; // Don't show SSO notice for anonymous users467}468const ssoRestrictions = getSSORestrictions();469if (!ssoRestrictions.disableEmail && !ssoRestrictions.disableName) {470return; // No restrictions471}472473const restrictedFields: string[] = [];474if (ssoRestrictions.disableEmail) {475restrictedFields.push("email address");476}477if (ssoRestrictions.disableName) {478restrictedFields.push("first and last name");479}480481return (482<AntdAlert483type="info"484showIcon485style={{ marginTop: "10px", marginBottom: "15px" }}486message={487<FormattedMessage488id="account.settings.sso.restriction_notice"489defaultMessage={`Your account is managed by {strategyName} SSO. Your {fields} {isAre} automatically updated when you sign in and cannot be changed here.`}490values={{491strategyName: <strong>{ssoRestrictions.ssoStrategyName}</strong>,492fields: restrictedFields.join(" and "),493isAre: restrictedFields.length > 1 ? "are" : "is",494}}495/>496}497/>498);499}500501function render_anonymous_warning(): Rendered {502if (!props.is_anonymous) {503return;504}505// makes no sense to delete an account that is anonymous; it'll506// get automatically deleted.507return (508<div>509<Alert bsStyle="warning" style={{ marginTop: "10px" }}>510<h4>Sign up</h4>511Signing up is free, avoids losing access to your work, you get added512to projects you were invited to, and you unlock{" "}513<A href="https://doc.cocalc.com/">many additional features</A>!514<br />515<br />516<h4>Sign in</h4>517If you already have a <SiteName /> account, <SignOut sign_in={true} />518. Note that you will lose any work you've done anonymously here.519</Alert>520<hr />521</div>522);523}524525function render_delete_account(): Rendered {526if (props.is_anonymous) {527return;528}529return (530<Row>531<Col xs={12}>532<DeleteAccount533style={{ marginTop: "1ex" }}534initial_click={() => set_show_delete_confirmation(true)}535confirm_click={() => actions().delete_account()}536cancel_click={() => set_show_delete_confirmation(false)}537user_name={(props.first_name + " " + props.last_name).trim()}538show_confirmation={show_delete_confirmation}539/>540</Col>541</Row>542);543}544545function render_password(): Rendered {546if (!props.email_address) {547// makes no sense to change password if don't have an email address548return;549}550return <PasswordSetting />;551}552553function render_terms_of_service(): Rendered {554if (!props.is_anonymous) {555return;556}557const style: React.CSSProperties = { padding: "10px 20px" };558if (terms_checkbox) {559style.border = "2px solid #ccc";560} else {561style.border = "2px solid red";562}563return (564<div style={style}>565<Checkbox566checked={terms_checkbox}567onChange={(e) => set_terms_checkbox(e.target.checked)}568>569<TermsOfService style={{ display: "inline" }} />570</Checkbox>571</div>572);573}574575function render_header(): Rendered {576if (props.is_anonymous) {577return (578<b>579Thank you for using <SiteName />!580</b>581);582} else {583return (584<>585<Icon name={ACCOUNT_PROFILE_ICON_NAME} />{" "}586{intl.formatMessage(labels.account)}587</>588);589}590}591592function render_created(): Rendered {593if (props.is_anonymous || !props.created) {594return;595}596return (597<Row style={{ marginBottom: "15px" }}>598<Col md={4}>599<FormattedMessage600id="account.settings.created.label"601defaultMessage={"Created"}602/>603</Col>604<Col md={8}>605<TimeAgo date={props.created} />606</Col>607</Row>608);609}610611function render_name(): Rendered {612const ssoRestrictions = getSSORestrictions();613const disableName =614(props.is_anonymous && !terms_checkbox) || ssoRestrictions.disableName;615const tooltip = ssoRestrictions.disableName616? `Your account is managed by ${ssoRestrictions.ssoStrategyName} SSO. Your name is automatically updated when you sign in and cannot be changed here.`617: undefined;618619return (620<>621<TextSetting622label={intl.formatMessage(labels.account_first_name)}623value={props.first_name}624onChange={(e) => handle_change(e, "first_name")}625onBlur={(e) => save_change(e, "first_name")}626onPressEnter={(e) => save_change(e, "first_name")}627maxLength={254}628disabled={disableName}629title={tooltip}630/>631<TextSetting632label={intl.formatMessage(labels.account_last_name)}633value={props.last_name}634onChange={(e) => handle_change(e, "last_name")}635onBlur={(e) => save_change(e, "last_name")}636onPressEnter={(e) => save_change(e, "last_name")}637maxLength={254}638disabled={disableName}639title={tooltip}640/>641<TextSetting642label={intl.formatMessage({643id: "account.settings.username.label",644defaultMessage: "Username (optional)",645})}646value={props.name}647onChange={(e) => {648const name = e.target.value?.trim();649actions().setState({ name });650}}651onBlur={(e) => {652set_username(false);653const name = e.target.value?.trim();654if (name) {655set_account_table({ name });656}657}}658onFocus={() => {659set_username(true);660}}661onPressEnter={(e) => {662const name = e.target.value?.trim();663if (name) {664set_account_table({ name });665}666}}667maxLength={39}668disabled={props.is_anonymous && !terms_checkbox}669/>670{username && (671<AntdAlert672showIcon673style={{ margin: "15px 0" }}674message={675<FormattedMessage676id="account.settings.username.info"677defaultMessage={`Setting a username provides optional nicer URL's for shared678public documents. Your username can be between 1 and 39 characters,679contain upper and lower case letters, numbers, and dashes.680{br}681WARNING: If you change your username, existing links using the previous username682will no longer work (automatic redirects are not implemented), so change with caution.`}683values={{ br: <br /> }}684/>685}686type="info"687/>688)}689</>690);691}692693function render_email_address(): Rendered {694if (!props.account_id) {695return; // makes no sense to change email if there is no account696}697const ssoRestrictions = getSSORestrictions();698const disableEmail =699(props.is_anonymous && !terms_checkbox) || ssoRestrictions.disableEmail;700701return (702<EmailAddressSetting703email_address={props.email_address}704is_anonymous={props.is_anonymous}705disabled={disableEmail}706verify_emails={props.verify_emails}707/>708);709}710711function render_unlisted(): Rendered {712if (!props.account_id) {713return; // makes no sense to change unlisted status if there is no account714}715return (716<Checkbox717checked={props.unlisted}718onChange={(e) =>719actions().set_account_table({ unlisted: e.target.checked })720}721>722<FormattedMessage723id="account.settings.unlisted.label"724defaultMessage={725"Unlisted: you can only be found by an exact email address match"726}727/>728</Checkbox>729);730}731732function render_email_verification(): Rendered {733if (props.email_enabled && props.verify_emails && !props.is_anonymous) {734return (735<EmailVerification736email_address={props.email_address}737email_address_verified={props.email_address_verified}738/>739);740}741}742743return (744<Panel header={render_header()}>745{render_anonymous_warning()}746{render_sso_restriction_notice()}747{render_terms_of_service()}748{render_name()}749{render_email_address()}750{render_unlisted()}751<div style={{ marginBottom: "15px" }}></div>752{render_email_verification()}753{render_password()}754{render_created()}755{render_delete_account()}756{render_linked_external_accounts()}757{render_available_to_link()}758{render_sign_out_buttons()}759{render_sign_out_error()}760</Panel>761);762}763764765