hyperspace-desktop-client-w.../src/pages/Welcome.tsx

839 lines
31 KiB
TypeScript
Raw Normal View History

import React, { Component } from "react";
import {
withStyles,
Paper,
Typography,
Button,
TextField,
Fade,
Link,
CircularProgress,
Tooltip,
Dialog,
DialogTitle,
DialogActions,
DialogContent,
List,
ListItem,
ListItemText,
ListItemAvatar,
ListItemSecondaryAction,
IconButton
} from "@material-ui/core";
import { styles } from "./WelcomePage.styles";
import Mastodon from "megalodon";
import { SaveClientSession } from "../types/SessionData";
import {
createHyperspaceApp,
getRedirectAddress,
inDisallowedDomains,
instancesBearerKey
} from "../utilities/login";
import { parseUrl } from "query-string";
import { getConfig } from "../utilities/settings";
import { isDarwinApp } from "../utilities/desktop";
import axios from "axios";
import { withSnackbar, withSnackbarProps } from "notistack";
import { Config } from "../types/Config";
import {
addAccountToRegistry,
getAccountRegistry,
loginWithAccount,
removeAccountFromRegistry
} from "../utilities/accounts";
import { Account, MultiAccount } from "../types/Account";
import AccountCircleIcon from "@material-ui/icons/AccountCircle";
import CloseIcon from "@material-ui/icons/Close";
2019-04-23 19:06:31 +02:00
interface IWelcomeProps extends withSnackbarProps {
classes: any;
}
2019-04-07 23:25:39 +02:00
interface IWelcomeState {
logoUrl?: string;
backgroundUrl?: string;
brandName?: string;
registerBase?: string;
federates?: boolean;
proceedToGetCode: boolean;
2019-04-07 23:25:39 +02:00
user: string;
userInputError: boolean;
userInputErrorMessage: string;
2019-04-07 23:25:39 +02:00
clientId?: string;
clientSecret?: string;
authUrl?: string;
foundSavedLogin: boolean;
authorizing: boolean;
license?: string;
repo?: string;
defaultRedirectAddress: string;
2019-04-23 01:05:30 +02:00
openAuthDialog: boolean;
authCode: string;
emergencyMode: boolean;
version: string;
willAddAccount: boolean;
2019-04-07 23:25:39 +02:00
}
2019-04-23 19:06:31 +02:00
class WelcomePage extends Component<IWelcomeProps, IWelcomeState> {
2019-04-07 23:25:39 +02:00
client: any;
constructor(props: any) {
super(props);
this.state = {
proceedToGetCode: false,
2019-04-07 23:25:39 +02:00
user: "",
userInputError: false,
foundSavedLogin: false,
authorizing: false,
userInputErrorMessage: "",
defaultRedirectAddress: "",
2019-04-23 01:05:30 +02:00
openAuthDialog: false,
authCode: "",
emergencyMode: false,
version: "",
willAddAccount: false
};
2019-04-07 23:25:39 +02:00
getConfig()
.then((result: any) => {
if (result !== undefined) {
let config: Config = result;
if (result.location === "dynamic") {
console.warn(
"Redirect URI is set to dynamic, which may affect how sign-in works for some users. Careful!"
);
}
if (
inDisallowedDomains(result.registration.defaultInstance)
) {
console.warn(
`The default instance field in config.json contains an unsupported domain (${result.registration.defaultInstance}), so it's been reset to mastodon.social.`
);
result.registration.defaultInstance = "mastodon.social";
}
2019-05-11 18:59:36 +02:00
this.setState({
logoUrl: config.branding
? result.branding.logo
: "logo.png",
backgroundUrl: config.branding
? result.branding.background
: "background.png",
brandName: config.branding
? result.branding.name
: "Hyperspace",
registerBase: config.registration
? result.registration.defaultInstance
: "",
2019-05-11 18:59:36 +02:00
federates: config.federation.universalLogin,
license: config.license.url,
repo: config.repository,
defaultRedirectAddress:
config.location != "dynamic"
? config.location
: `https://${window.location.host}`,
2019-05-11 18:59:36 +02:00
version: config.version
});
}
})
.catch(() => {
console.error(
"config.json is missing. If you want to customize Hyperspace, please include config.json"
);
});
2019-04-07 23:25:39 +02:00
}
2019-05-13 17:22:35 +02:00
componentDidMount() {
if (localStorage.getItem("login")) {
this.getSavedSession();
this.setState({
foundSavedLogin: true
});
2019-05-13 17:22:35 +02:00
this.checkForToken();
}
}
2019-04-23 19:06:31 +02:00
updateUserInfo(user: string) {
this.setState({ user });
}
updateAuthCode(code: string) {
this.setState({ authCode: code });
}
toggleAuthDialog() {
this.setState({ openAuthDialog: !this.state.openAuthDialog });
}
readyForAuth() {
if (localStorage.getItem("baseurl")) {
2019-04-23 19:06:31 +02:00
return true;
} else {
return false;
}
}
clear() {
localStorage.removeItem("access_token");
localStorage.removeItem("baseurl");
}
2019-04-23 19:06:31 +02:00
getSavedSession() {
let loginData = localStorage.getItem("login");
if (loginData) {
let session: SaveClientSession = JSON.parse(loginData);
this.setState({
clientId: session.clientId,
clientSecret: session.clientSecret,
authUrl: session.authUrl,
emergencyMode: session.emergency
});
2019-04-07 23:25:39 +02:00
}
}
2019-04-23 01:05:30 +02:00
startEmergencyLogin() {
if (!this.state.emergencyMode) {
this.createEmergencyLogin();
}
2019-04-23 01:05:30 +02:00
this.toggleAuthDialog();
}
2019-04-23 19:06:31 +02:00
startRegistration() {
if (this.state.registerBase) {
return "https://" + this.state.registerBase + "/auth/sign_up";
} else {
return "https://joinmastodon.org/#getting-started";
}
}
2019-05-13 17:22:35 +02:00
watchUsernameField(event: any) {
if (event.keyCode === 13) this.startLogin();
2019-05-13 17:22:35 +02:00
}
watchAuthField(event: any) {
if (event.keyCode === 13) this.authorizeEmergencyLogin();
2019-05-13 17:22:35 +02:00
}
2019-04-07 23:25:39 +02:00
getLoginUser(user: string) {
if (user.includes("@")) {
if (this.state.federates) {
let newUser = user;
this.setState({ user: newUser });
return "https://" + newUser.split("@")[1];
} else {
let newUser = `${user}@${
this.state.registerBase
? this.state.registerBase
: "mastodon.social"
}`;
this.setState({ user: newUser });
return (
"https://" +
(this.state.registerBase
? this.state.registerBase
: "mastodon.social")
);
}
2019-04-07 23:25:39 +02:00
} else {
let newUser = `${user}@${
this.state.registerBase
? this.state.registerBase
: "mastodon.social"
}`;
2019-04-07 23:25:39 +02:00
this.setState({ user: newUser });
return (
"https://" +
(this.state.registerBase
? this.state.registerBase
: "mastodon.social")
);
2019-04-07 23:25:39 +02:00
}
}
startLogin() {
let error = this.checkForErrors();
if (!error) {
const scopes = "read write follow";
2019-04-07 23:25:39 +02:00
const baseurl = this.getLoginUser(this.state.user);
localStorage.setItem("baseurl", baseurl);
createHyperspaceApp(
this.state.brandName ? this.state.brandName : "Hyperspace",
scopes,
baseurl,
getRedirectAddress(this.state.defaultRedirectAddress)
).then((resp: any) => {
2019-04-07 23:25:39 +02:00
let saveSessionForCrashing: SaveClientSession = {
clientId: resp.clientId,
clientSecret: resp.clientSecret,
2019-04-23 01:05:30 +02:00
authUrl: resp.url,
emergency: false
};
localStorage.setItem(
"login",
JSON.stringify(saveSessionForCrashing)
);
2019-04-07 23:25:39 +02:00
this.setState({
clientId: resp.clientId,
clientSecret: resp.clientSecret,
authUrl: resp.url,
proceedToGetCode: true
});
});
2019-04-07 23:25:39 +02:00
} else {
}
}
2019-04-23 01:05:30 +02:00
createEmergencyLogin() {
console.log("Creating an emergency login...");
2019-04-23 01:05:30 +02:00
const scopes = "read write follow";
const baseurl =
localStorage.getItem("baseurl") ||
this.getLoginUser(this.state.user);
Mastodon.registerApp(
this.state.brandName ? this.state.brandName : "Hyperspace",
{
scopes: scopes
},
baseurl
).then((appData: any) => {
2019-04-23 01:05:30 +02:00
let saveSessionForCrashing: SaveClientSession = {
2019-04-23 22:32:57 +02:00
clientId: appData.clientId,
clientSecret: appData.clientSecret,
authUrl: appData.url,
2019-04-23 01:05:30 +02:00
emergency: true
};
localStorage.setItem(
"login",
JSON.stringify(saveSessionForCrashing)
);
2019-04-23 01:05:30 +02:00
this.setState({
2019-04-23 22:32:57 +02:00
clientId: appData.clientId,
clientSecret: appData.clientSecret,
authUrl: appData.url
2019-04-23 01:05:30 +02:00
});
2019-04-23 22:32:57 +02:00
});
2019-04-23 01:05:30 +02:00
}
authorizeEmergencyLogin() {
let redirAddress =
this.state.defaultRedirectAddress === "desktop"
? "hyperspace://hyperspace/app"
: this.state.defaultRedirectAddress;
window.location.href = `${redirAddress}/?code=${this.state.authCode}#/`;
2019-04-23 01:05:30 +02:00
}
2019-04-07 23:25:39 +02:00
resumeLogin() {
let loginData = localStorage.getItem("login");
if (loginData) {
let session: SaveClientSession = JSON.parse(loginData);
this.setState({
clientId: session.clientId,
clientSecret: session.clientSecret,
authUrl: session.authUrl,
2019-04-23 01:05:30 +02:00
emergencyMode: session.emergency,
proceedToGetCode: true
});
2019-04-07 23:25:39 +02:00
}
}
checkForErrors(): boolean {
let userInputError = false;
let userInputErrorMessage = "";
if (this.state.user === "") {
userInputError = true;
userInputErrorMessage = "Username cannot be blank.";
this.setState({ userInputError, userInputErrorMessage });
return true;
} else {
if (this.state.user.includes("@")) {
if (this.state.federates && this.state.federates === true) {
let baseUrl = this.state.user.split("@")[1];
if (inDisallowedDomains(baseUrl)) {
this.setState({
userInputError: true,
userInputErrorMessage: `Signing in with an account from ${baseUrl} isn't supported.`
});
return true;
} else {
axios
.get(
"https://instances.social/api/1.0/instances/show?name=" +
baseUrl,
{
headers: {
Authorization: `Bearer ${instancesBearerKey}`
}
}
)
.catch((err: Error) => {
let userInputError = true;
let userInputErrorMessage =
"Instance name is invalid.";
this.setState({
userInputError,
userInputErrorMessage
});
return true;
});
}
} else if (
this.state.user.includes(
this.state.registerBase
? this.state.registerBase
: "mastodon.social"
)
) {
this.setState({ userInputError, userInputErrorMessage });
return false;
} else {
userInputError = true;
userInputErrorMessage =
"You cannot sign in with this username.";
this.setState({ userInputError, userInputErrorMessage });
return true;
}
} else {
this.setState({ userInputError, userInputErrorMessage });
return false;
}
this.setState({ userInputError, userInputErrorMessage });
return false;
}
2019-04-07 23:25:39 +02:00
}
2019-04-23 19:06:31 +02:00
checkForToken() {
let location = window.location.href;
if (location.includes("?code=")) {
let code = parseUrl(location).query.code as string;
this.setState({ authorizing: true });
2019-04-23 19:06:31 +02:00
let loginData = localStorage.getItem("login");
if (loginData) {
let clientLoginSession: SaveClientSession = JSON.parse(
loginData
);
2019-04-23 19:06:31 +02:00
Mastodon.fetchAccessToken(
clientLoginSession.clientId,
clientLoginSession.clientSecret,
code,
localStorage.getItem("baseurl") as string,
this.state.emergencyMode
? undefined
: clientLoginSession.authUrl.includes(
"urn%3Aietf%3Awg%3Aoauth%3A2.0%3Aoob"
)
? undefined
: window.location.protocol === "hyperspace:"
? "hyperspace://hyperspace/app/"
: `https://${window.location.host}`
)
.then((tokenData: any) => {
localStorage.setItem(
"access_token",
tokenData.access_token
);
window.location.href =
window.location.protocol === "hyperspace:"
? "hyperspace://hyperspace/app/"
: `https://${window.location.host}/#/`;
})
.catch((err: Error) => {
this.props.enqueueSnackbar(
`Couldn't authorize ${
this.state.brandName
? this.state.brandName
: "Hyperspace"
}: ${err.name}`,
{ variant: "error" }
);
console.error(err.message);
});
2019-04-23 19:06:31 +02:00
}
2019-04-07 23:25:39 +02:00
}
}
titlebar() {
const { classes } = this.props;
if (isDarwinApp()) {
return (
<div className={classes.titleBarRoot}>
<Typography className={classes.titleBarText}>
{this.state.brandName
? this.state.brandName
: "Hyperspace"}
</Typography>
</div>
);
}
}
showMultiAccount() {
const { classes } = this.props;
return (
<div>
<Typography variant="h5">Select an account</Typography>
<Typography>from the list below or add a new one</Typography>
<List>
{getAccountRegistry().map(
(account: MultiAccount, index: number) => (
<ListItem
onClick={() => {
loginWithAccount(account);
window.location.href =
window.location.protocol ===
"hyperspace:"
? "hyperspace://hyperspace/app/"
: `https://${window.location.host}/#/`;
}}
button={true}
>
<ListItemAvatar>
<AccountCircleIcon color="action" />
</ListItemAvatar>
<ListItemText
primary={`@${account.username}`}
secondary={account.host}
/>
<ListItemSecondaryAction>
<IconButton
onClick={(e: any) => {
e.preventDefault();
removeAccountFromRegistry(index);
window.location.reload();
}}
>
<CloseIcon />
</IconButton>
</ListItemSecondaryAction>
</ListItem>
)
)}
</List>
<div className={classes.middlePadding} />
<Button
onClick={() => {
this.setState({ willAddAccount: true });
this.clear();
}}
color={"primary"}
variant={"contained"}
>
Add Account
</Button>
</div>
);
}
2019-04-07 23:25:39 +02:00
showLanding() {
const { classes } = this.props;
return (
<div>
<Typography variant="h5">Sign in</Typography>
<Typography>with your Mastodon account</Typography>
<div className={classes.middlePadding} />
<TextField
variant="outlined"
label="Username"
fullWidth
placeholder="example@mastodon.example"
onChange={event => this.updateUserInfo(event.target.value)}
onKeyDown={event => this.watchUsernameField(event)}
error={this.state.userInputError}
onBlur={() => this.checkForErrors()}
/>
{this.state.userInputError ? (
<Typography color="error">
{this.state.userInputErrorMessage}
</Typography>
) : null}
<br />
{this.state.registerBase && this.state.federates ? (
<Typography variant="caption">
Not from{" "}
<b>
{this.state.registerBase
? this.state.registerBase
: "noinstance"}
</b>
? Sign in with your{" "}
<Link
href="https://docs.joinmastodon.org/usage/decentralization/#addressing-people"
target="_blank"
rel="noopener noreferrer"
color="secondary"
>
full username
</Link>
.
</Typography>
) : null}
<br />
{this.state.foundSavedLogin ? (
<Typography>
Signing in from a previous session?{" "}
<Link
className={classes.welcomeLink}
onClick={() => this.resumeLogin()}
>
Continue login
</Link>
.
</Typography>
) : null}
<div className={classes.middlePadding} />
<div style={{ display: "flex" }}>
<Tooltip title="Create account on site">
<Button
href={this.startRegistration()}
target="_blank"
rel="noreferrer"
>
Create account
</Button>
</Tooltip>
<div className={classes.flexGrow} />
<Tooltip title="Continue sign-in">
<Button
color="primary"
variant="contained"
onClick={() => this.startLogin()}
>
Next
</Button>
</Tooltip>
2019-04-07 23:25:39 +02:00
</div>
</div>
2019-04-07 23:25:39 +02:00
);
}
showLoginAuth() {
const { classes } = this.props;
return (
<div>
<Typography variant="h5">
Howdy,{" "}
{this.state.user ? this.state.user.split("@")[0] : "user"}
</Typography>
<Typography>
To continue, finish signing in on your instance's website
and authorize{" "}
{this.state.brandName ? this.state.brandName : "Hyperspace"}
.
</Typography>
<div className={classes.middlePadding} />
<div style={{ display: "flex" }}>
<div className={classes.flexGrow} />
<Button
color="primary"
variant="contained"
size="large"
href={this.state.authUrl ? this.state.authUrl : ""}
>
Authorize
</Button>
<div className={classes.flexGrow} />
2019-04-07 23:25:39 +02:00
</div>
<div className={classes.middlePadding} />
<Typography>
Having trouble signing in?{" "}
<Link
onClick={() => this.startEmergencyLogin()}
className={classes.welcomeLink}
>
Sign in with a code.
</Link>
</Typography>
</div>
2019-04-07 23:25:39 +02:00
);
}
2019-04-23 01:05:30 +02:00
showAuthDialog() {
const { classes } = this.props;
2019-04-23 01:05:30 +02:00
return (
<Dialog
open={this.state.openAuthDialog}
disableBackdropClick
disableEscapeKeyDown
maxWidth="sm"
fullWidth={true}
>
<DialogTitle>Authorize with a code</DialogTitle>
2019-04-23 01:05:30 +02:00
<DialogContent>
<Typography paragraph>
If you're having trouble authorizing Hyperspace, you can
manually request for an authorization code. Click
'Request Code' and then paste the code in the
authorization code box to continue.
2019-04-23 01:05:30 +02:00
</Typography>
<Button
color="primary"
variant="contained"
href={this.state.authUrl ? this.state.authUrl : ""}
2019-04-23 01:05:30 +02:00
target="_blank"
rel="noopener noreferrer"
>
Request Code
</Button>
<br />
<br />
2019-04-23 01:05:30 +02:00
<TextField
variant="outlined"
label="Authorization code"
fullWidth
onChange={event =>
this.updateAuthCode(event.target.value)
}
onKeyDown={event => this.watchAuthField(event)}
/>
2019-04-23 01:05:30 +02:00
</DialogContent>
<DialogActions>
<Button onClick={() => this.toggleAuthDialog()}>
Cancel
</Button>
<Button
color="secondary"
onClick={() => this.authorizeEmergencyLogin()}
>
Authorize
</Button>
2019-04-23 01:05:30 +02:00
</DialogActions>
</Dialog>
);
}
showAuthorizationLoader() {
2019-04-07 23:25:39 +02:00
const { classes } = this.props;
return (
<div>
<Typography variant="h5">Authorizing</Typography>
<Typography>
Please wait while Hyperspace authorizes with Mastodon. This
shouldn't take long...
</Typography>
<div className={classes.middlePadding} />
<div style={{ display: "flex" }}>
<div className={classes.flexGrow} />
<CircularProgress />
<div className={classes.flexGrow} />
2019-04-07 23:25:39 +02:00
</div>
<div className={classes.middlePadding} />
</div>
2019-04-07 23:25:39 +02:00
);
}
render() {
const { classes } = this.props;
return (
<div>
{this.titlebar()}
<div
className={classes.root}
style={{
backgroundImage: `url(${
this.state !== null
? this.state.backgroundUrl
: "background.png"
})`
}}
>
<Paper className={classes.paper}>
<img
className={classes.logo}
alt={
this.state ? this.state.brandName : "Hyperspace"
}
src={this.state ? this.state.logoUrl : "logo.png"}
/>
<br />
<Fade in={true}>
{this.state.authorizing
? this.showAuthorizationLoader()
: this.state.proceedToGetCode
? this.showLoginAuth()
: getAccountRegistry().length > 0 &&
!this.state.willAddAccount
? this.showMultiAccount()
: this.showLanding()}
</Fade>
<br />
<Typography variant="caption">
&copy; {new Date().getFullYear()}{" "}
{this.state.brandName &&
this.state.brandName !== "Hyperspace"
? `${this.state.brandName} developers and the `
: ""}{" "}
<Link
className={classes.welcomeLink}
href="https://hyperspace.marquiskurt.net"
target="_blank"
rel="noreferrer"
>
Hyperspace
</Link>{" "}
developers. All rights reserved.
</Typography>
<Typography variant="caption">
{this.state.repo ? (
<span>
<Link
className={classes.welcomeLink}
href={
this.state.repo
? this.state.repo
: "https://github.com/hyperspacedev"
}
target="_blank"
rel="noreferrer"
>
Source code
</Link>{" "}
|{" "}
</span>
) : null}
<Link
className={classes.welcomeLink}
href={
this.state.license
? this.state.license
: "https://www.apache.org/licenses/LICENSE-2.0"
}
target="_blank"
rel="noreferrer"
>
License
</Link>{" "}
|
<Link
className={classes.welcomeLink}
href="https://github.com/hyperspacedev/hyperspace/issues/new"
target="_blank"
rel="noreferrer"
>
File an Issue
</Link>
</Typography>
<Typography variant="caption" color="textSecondary">
{this.state.brandName
? this.state.brandName
: "Hypersapce"}{" "}
v.
{this.state.version}{" "}
{this.state.brandName &&
this.state.brandName !== "Hyperspace"
? "(Hyperspace-like)"
: null}
</Typography>
</Paper>
{this.showAuthDialog()}
</div>
</div>
2019-04-07 23:25:39 +02:00
);
}
}
export default withStyles(styles)(withSnackbar(WelcomePage));