Real-time collaboration for Jupyter Notebooks, Linux Terminals, LaTeX, VS Code, R IDE, and more,
all in one place.
Real-time collaboration for Jupyter Notebooks, Linux Terminals, LaTeX, VS Code, R IDE, and more,
all in one place.
Path: blob/master/src/packages/frontend/account/settings/account-settings.tsx
Views: 687
/*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 { keys, startswith } from "@cocalc/util/misc";46import { COLORS } from "@cocalc/util/theme";47import { PassportStrategyFrontend } from "@cocalc/util/types/passport-types";48import { DeleteAccount } from "../delete-account";49import { SignOut } from "../sign-out";50import { set_account_table, ugly_error } from "../util";51import { EmailAddressSetting } from "./email-address-setting";52import { EmailVerification } from "./email-verification";53import { PasswordSetting } from "./password-setting";54import { TextSetting } from "./text-setting";5556type ImmutablePassportStrategy = TypedMap<PassportStrategyFrontend>;5758interface Props {59account_id?: string;60first_name?: string;61last_name?: string;62name?: string;63unlisted?: boolean;64email_address?: string;65email_address_verified?: Map<string, any>;66passports?: Map<string, any>;67sign_out_error?: string;68delete_account_error?: string;69other_settings?: Map<string, any>;70is_anonymous?: boolean;71email_enabled?: boolean;72verify_emails?: boolean;73created?: Date;74strategies?: List<ImmutablePassportStrategy>;75}7677export function AccountSettings(props: Readonly<Props>) {78const intl = useIntl();7980const [add_strategy_link, set_add_strategy_link] = useState<81string | undefined82>(undefined);83const [remove_strategy_button, set_remove_strategy_button] = useState<84string | undefined85>(undefined);86const [terms_checkbox, set_terms_checkbox] = useState<boolean>(false);87const [show_delete_confirmation, set_show_delete_confirmation] =88useState<boolean>(false);89const [username, set_username] = useState<boolean>(false);9091const actions = () => redux.getActions("account");9293function handle_change(evt, field) {94actions().setState({ [field]: evt.target.value });95}9697function save_change(evt, field: string): void {98const { value } = evt.target;99set_account_table({ [field]: value });100}101102function get_strategy(name: string): ImmutablePassportStrategy | undefined {103if (props.strategies == null) return undefined;104return props.strategies.find((val) => val.get("name") == name);105}106107function render_add_strategy_link(): Rendered {108if (!add_strategy_link) {109return;110}111const strategy_name = add_strategy_link;112const strategy = get_strategy(strategy_name);113if (strategy == null) return;114const strategy_js = strategy.toJS();115const name = strategy2display(strategy_js);116const href = join(appBasePath, "auth", add_strategy_link);117return (118<Well>119<h4>120<PassportStrategyIcon strategy={strategy_js} /> {name}121</h4>122Link to your {name} account, so you can use {name} to login to your{" "}123<SiteName /> account.124<br /> <br />125<ButtonToolbar style={{ textAlign: "center" }}>126<Button127href={href}128target="_blank"129onClick={() => {130set_add_strategy_link(undefined);131if (props.is_anonymous) {132log("add_passport", {133passport: name,134source: "anonymous_account",135});136}137}}138>139<Icon name="external-link" /> Link My {name} Account140</Button>141<Button onClick={() => set_add_strategy_link(undefined)}>142<CancelText />143</Button>144</ButtonToolbar>145</Well>146);147}148149async function remove_strategy_click(): Promise<void> {150const strategy = remove_strategy_button;151set_remove_strategy_button(undefined);152set_add_strategy_link(undefined);153if (strategy == null) return;154const obj = props.passports?.toJS() ?? {};155let id: string | undefined = undefined;156for (const k in obj) {157if (startswith(k, strategy)) {158id = k.split("-")[1];159break;160}161}162if (!id) {163return;164}165try {166await webapp_client.account_client.unlink_passport(strategy, id);167// console.log("ret:", x);168} catch (err) {169ugly_error(err);170}171}172173function render_remove_strategy_button(): Rendered {174if (!remove_strategy_button) {175return;176}177const strategy_name = remove_strategy_button;178const strategy = get_strategy(strategy_name);179if (strategy == null) return;180const strategy_js = strategy.toJS();181const name = strategy2display(strategy_js);182if ((props.passports?.size ?? 0) <= 1 && !props.email_address) {183return (184<Well>185You must set an email address above or add another login method before186you can disable login to your <SiteName /> account using your {name}{" "}187account. Otherwise you would completely lose access to your account!188</Well>189);190// TODO: flesh out the case where the UI prevents a user from unlinking an exclusive sso strategy191// Right now, the backend blocks192} else if (false) {193return (194<Well>You are not allowed to remove the passport strategy {name}.</Well>195);196} else {197return (198<Well>199<h4>200<PassportStrategyIcon strategy={strategy_js} /> {name}201</h4>202Your <SiteName /> account is linked to your {name} account, so you can203login using it.204<br /> <br />205If you unlink your {name} account, you will no longer be able to use206this account to log into <SiteName />.207<br /> <br />208<ButtonToolbar style={{ textAlign: "center" }}>209<Button bsStyle="danger" onClick={remove_strategy_click}>210<Icon name="unlink" /> Unlink my {name} account211</Button>212<Button onClick={() => set_remove_strategy_button(undefined)}>213<CancelText />214</Button>215</ButtonToolbar>216</Well>217);218}219}220221function render_strategy(222strategy: ImmutablePassportStrategy,223account_passports: string[],224): Rendered {225if (strategy.get("name") !== "email") {226const is_configured = account_passports.includes(strategy.get("name"));227const strategy_js = strategy.toJS();228const btn = (229<Button230disabled={props.is_anonymous && !terms_checkbox}231onClick={() => {232if (is_configured) {233set_remove_strategy_button(strategy.get("name"));234set_add_strategy_link(undefined);235} else {236set_add_strategy_link(strategy.get("name"));237set_remove_strategy_button(undefined);238}239}}240key={strategy.get("name")}241bsStyle={is_configured ? "info" : undefined}242>243<PassportStrategyIcon strategy={strategy_js} small={true} />{" "}244{strategy2display(strategy_js)}245</Button>246);247return btn;248}249}250251function render_sign_out_error(): Rendered {252if (!props.sign_out_error) {253return;254}255return (256<ErrorDisplay257style={{ margin: "5px 0" }}258error={props.sign_out_error}259onClose={() => actions().setState({ sign_out_error: "" })}260/>261);262}263264function render_sign_out_buttons(): Rendered {265return (266<Row267style={{268marginTop: "15px",269borderTop: "1px solid #ccc",270paddingTop: "15px",271}}272>273<Col xs={12}>274<div className="pull-right">275<SignOut everywhere={false} highlight={true} />276{!props.is_anonymous ? <Gap /> : undefined}277{!props.is_anonymous ? <SignOut everywhere={true} /> : undefined}278</div>279</Col>280</Row>281);282}283284function get_account_passport_names(): string[] {285return keys(props.passports?.toJS() ?? {}).map((x) =>286x.slice(0, x.indexOf("-")),287);288}289290function render_linked_external_accounts(): Rendered {291if (props.strategies == null || props.strategies.size <= 1) {292// not configured by server293return;294}295const account_passports: string[] = get_account_passport_names();296297const linked: List<ImmutablePassportStrategy> = props.strategies.filter(298(strategy) => {299const name = strategy?.get("name");300return name !== "email" && account_passports.includes(name);301},302);303if (linked.size === 0) return;304305const btns = linked306.map((strategy) => render_strategy(strategy, account_passports))307.toArray();308return (309<div>310<hr key="hr0" />311<h5 style={{ color: COLORS.GRAY_M }}>312{intl.formatMessage({313id: "account.settings.sso.account_is_linked",314defaultMessage: "Your account is linked with (click to unlink)",315})}316</h5>317<ButtonToolbar style={{ marginBottom: "10px", display: "flex" }}>318{btns}319</ButtonToolbar>320{render_remove_strategy_button()}321</div>322);323}324325function render_available_to_link(): Rendered {326if (props.strategies == null || props.strategies.size <= 1) {327// not configured by server yet, or nothing but email328return;329}330const account_passports: string[] = get_account_passport_names();331332let any_hidden = false;333const not_linked: List<ImmutablePassportStrategy> = props.strategies.filter(334(strategy) => {335const name = strategy.get("name");336// skip the email strategy, we don't use it337if (name === "email") return false;338// filter those which are already linked339if (account_passports.includes(name)) return false;340// do not show the non-public ones, unless they shouldn't be hidden341if (342!strategy.get("public", true) &&343!strategy.get("do_not_hide", false)344) {345any_hidden = true;346return false;347}348return true;349},350);351if (any_hidden === false && not_linked.size === 0) return;352353const heading = intl.formatMessage(354{355id: "account.settings.sso.link_your_account",356defaultMessage: `{is_anonymous, select,357true {Sign up using your account at}358other {Click to link your account}}`,359},360{ is_anonymous: props.is_anonymous },361);362363const btns = not_linked364.map((strategy) => render_strategy(strategy, account_passports))365.toArray();366367// add an extra button to link to the non public ones, which aren't shown368if (any_hidden) {369btns.push(370<Button371key="sso"372onClick={() => open_new_tab(join(appBasePath, "sso"))}373bsStyle="info"374>375Other SSO376</Button>,377);378}379return (380<div>381<hr key="hr0" />382<h5 style={{ color: COLORS.GRAY_M }}>{heading}</h5>383<Space size={[10, 10]} wrap style={{ marginBottom: "10px" }}>384{btns}385</Space>386{render_add_strategy_link()}387</div>388);389}390391function render_anonymous_warning(): Rendered {392if (!props.is_anonymous) {393return;394}395// makes no sense to delete an account that is anonymous; it'll396// get automatically deleted.397return (398<div>399<Alert bsStyle="warning" style={{ marginTop: "10px" }}>400<h4>Sign up</h4>401Signing up is free, avoids losing access to your work, you get added402to projects you were invited to, and you unlock{" "}403<A href="https://doc.cocalc.com/">many additional features</A>!404<br />405<br />406<h4>Sign in</h4>407If you already have a <SiteName /> account, <SignOut sign_in={true} />408. Note that you will lose any work you've done anonymously here.409</Alert>410<hr />411</div>412);413}414415function render_delete_account(): Rendered {416if (props.is_anonymous) {417return;418}419return (420<Row>421<Col xs={12}>422<DeleteAccount423style={{ marginTop: "1ex" }}424initial_click={() => set_show_delete_confirmation(true)}425confirm_click={() => actions().delete_account()}426cancel_click={() => set_show_delete_confirmation(false)}427user_name={(props.first_name + " " + props.last_name).trim()}428show_confirmation={show_delete_confirmation}429/>430</Col>431</Row>432);433}434435function render_password(): Rendered {436if (!props.email_address) {437// makes no sense to change password if don't have an email address438return;439}440return <PasswordSetting />;441}442443function render_terms_of_service(): Rendered {444if (!props.is_anonymous) {445return;446}447const style: React.CSSProperties = { padding: "10px 20px" };448if (terms_checkbox) {449style.border = "2px solid #ccc";450} else {451style.border = "2px solid red";452}453return (454<div style={style}>455<Checkbox456checked={terms_checkbox}457onChange={(e) => set_terms_checkbox(e.target.checked)}458>459<TermsOfService style={{ display: "inline" }} />460</Checkbox>461</div>462);463}464465function render_header(): Rendered {466if (props.is_anonymous) {467return (468<b>469Thank you for using <SiteName />!470</b>471);472} else {473return (474<>475<Icon name="user" /> {intl.formatMessage(labels.account)}476</>477);478}479}480481function render_created(): Rendered {482if (props.is_anonymous || !props.created) {483return;484}485return (486<Row style={{ marginBottom: "15px" }}>487<Col md={4}>488<FormattedMessage489id="account.settings.created.label"490defaultMessage={"Created"}491/>492</Col>493<Col md={8}>494<TimeAgo date={props.created} />495</Col>496</Row>497);498}499500function render_name(): Rendered {501return (502<>503<TextSetting504label={intl.formatMessage(labels.account_first_name)}505value={props.first_name}506onChange={(e) => handle_change(e, "first_name")}507onBlur={(e) => save_change(e, "first_name")}508onPressEnter={(e) => save_change(e, "first_name")}509maxLength={254}510disabled={props.is_anonymous && !terms_checkbox}511/>512<TextSetting513label={intl.formatMessage(labels.account_last_name)}514value={props.last_name}515onChange={(e) => handle_change(e, "last_name")}516onBlur={(e) => save_change(e, "last_name")}517onPressEnter={(e) => save_change(e, "last_name")}518maxLength={254}519disabled={props.is_anonymous && !terms_checkbox}520/>521<TextSetting522label={intl.formatMessage({523id: "account.settings.username.label",524defaultMessage: "Username (optional)",525})}526value={props.name}527onChange={(e) => {528const name = e.target.value?.trim();529actions().setState({ name });530}}531onBlur={(e) => {532set_username(false);533const name = e.target.value?.trim();534if (name) {535set_account_table({ name });536}537}}538onFocus={() => {539set_username(true);540}}541onPressEnter={(e) => {542const name = e.target.value?.trim();543if (name) {544set_account_table({ name });545}546}}547maxLength={39}548disabled={props.is_anonymous && !terms_checkbox}549/>550{username && (551<AntdAlert552showIcon553style={{ margin: "15px 0" }}554message={555<FormattedMessage556id="account.settings.username.info"557defaultMessage={`Setting a username provides optional nicer URL's for shared558public documents. Your username can be between 1 and 39 characters,559contain upper and lower case letters, numbers, and dashes.560{br}561WARNING: If you change your username, existing links using the previous username562will no longer work (automatic redirects are not implemented), so change with caution.`}563values={{ br: <br /> }}564/>565}566type="info"567/>568)}569</>570);571}572573function render_email_address(): Rendered {574if (!props.account_id) {575return; // makes no sense to change email if there is no account576}577return (578<EmailAddressSetting579email_address={props.email_address}580is_anonymous={props.is_anonymous}581disabled={props.is_anonymous && !terms_checkbox}582verify_emails={props.verify_emails}583/>584);585}586587function render_unlisted(): Rendered {588if (!props.account_id) {589return; // makes no sense to change unlisted status if there is no account590}591return (592<Checkbox593checked={props.unlisted}594onChange={(e) =>595actions().set_account_table({ unlisted: e.target.checked })596}597>598<FormattedMessage599id="account.settings.unlisted.label"600defaultMessage={601"Unlisted: you can only be found by an exact email address match"602}603/>604</Checkbox>605);606}607608function render_email_verification(): Rendered {609if (props.email_enabled && props.verify_emails && !props.is_anonymous) {610return (611<EmailVerification612email_address={props.email_address}613email_address_verified={props.email_address_verified}614/>615);616}617}618619return (620<Panel header={render_header()}>621{render_anonymous_warning()}622{render_terms_of_service()}623{render_name()}624{render_email_address()}625{render_unlisted()}626<div style={{ marginBottom: "15px" }}></div>627{render_email_verification()}628{render_password()}629{render_created()}630{render_delete_account()}631{render_linked_external_accounts()}632{render_available_to_link()}633{render_sign_out_buttons()}634{render_sign_out_error()}635</Panel>636);637}638639640