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

1099 lines
38 KiB
TypeScript

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,
InputAdornment
} 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 {
getAccountRegistry,
loginWithAccount,
removeAccountFromRegistry
} from "../utilities/accounts";
import { MultiAccount } from "../types/Account";
import AccountCircleIcon from "@material-ui/icons/AccountCircle";
import CloseIcon from "@material-ui/icons/Close";
/**
* Basic props for Welcome page
*/
interface IWelcomeProps extends withSnackbarProps {
classes: any;
}
/**
* Basic state for welcome page
*/
interface IWelcomeState {
/**
* The custom-defined URL to the logo to display
*/
logoUrl?: string;
/**
* The custom-defined URL to the background image to display
*/
backgroundUrl?: string;
/**
* The custom-defined brand name of this app
*/
brandName?: string;
/**
* The custom-defined server address to register to
*/
registerBase?: string;
/**
* Whether this version of Hyperspace has federation
*/
federates?: boolean;
/**
* Whether Hyperspace is ready to get the auth code
*/
proceedToGetCode: boolean;
/**
* The currently "logged-in" user after the first step
*/
user: string;
/**
* Whether the user's input errors
*/
userInputError: boolean;
/**
* The user input error message, if any
*/
userInputErrorMessage: string;
/**
* The app's client ID, if registered
*/
clientId?: string;
/**
* The app's client secret, if registered
*/
clientSecret?: string;
/**
* The authorization URL provided by Mastodon from the
* client ID and secret
*/
authUrl?: string;
/**
* Whether a previous login attempt is present
*/
foundSavedLogin: boolean;
/**
* Whether Hyperspace is in the process of authorizing
*/
authorizing: boolean;
/**
* The custom-defined license for the Hyperspace source code
*/
license?: string;
/**
* The custom-defined URL to the source code of Hyperspace
*/
repo?: string;
/**
* The default address to redirect to. Used in login inits and
* when the authorization code completes.
*/
defaultRedirectAddress: string;
/**
* Whether the redirect address is set to 'dynamic'.
*/
redirectAddressIsDynamic: boolean;
/**
* Whether the authorization dialog for the emergency login is
* open.
*/
openAuthDialog: boolean;
/**
* The authorization code to fetch an access token with
*/
authCode: string;
/**
* Whether the Emergency Mode has been initiated
*/
emergencyMode: boolean;
/**
* The current app version
*/
version: string;
/**
* Whether we are in the process of adding a new account or not
*/
willAddAccount: boolean;
}
/**
* The base class for the Welcome page.
*
* The Welcome page is responsible for handling the registration,
* login, and authorization of accounts into the Hyperspace app.
*/
class WelcomePage extends Component<IWelcomeProps, IWelcomeState> {
/**
* The associated Mastodon client to handle logins/authorizations
* with
*/
client: any;
/**
* Construct the state and other components of the Welcome page
* @param props The properties passed onto the page
*/
constructor(props: any) {
super(props);
// Set up our state
this.state = {
proceedToGetCode: false,
user: "",
userInputError: false,
foundSavedLogin: false,
authorizing: false,
userInputErrorMessage: "",
defaultRedirectAddress: "",
redirectAddressIsDynamic: false,
openAuthDialog: false,
authCode: "",
emergencyMode: false,
version: "",
willAddAccount: false
};
// Read the configuration data and update the state
getConfig()
.then((result: any) => {
if (result !== undefined) {
let config: Config = result;
// Warn if the location is dynamic (unexpected behavior)
if (config.location === "dynamic") {
console.warn(
"Redirect URI is set to dynamic, which may affect how sign-in works for some users. Careful!"
);
}
// Reset to mastodon.online if the location is a disallowed
// domain.
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.online.`
);
result.registration.defaultInstance = "mastodon.online";
}
// Update the state as per the configuration
this.setState({
logoUrl: config.branding?.logo ?? "logo.png",
backgroundUrl:
config.branding?.background ?? "background.png",
brandName: config.branding?.name ?? "Hyperspace",
registerBase:
result.registration?.defaultInstance ?? "",
federates: config.federation.universalLogin,
license: config.license.url,
repo: config.repository,
defaultRedirectAddress:
config.location !== "dynamic"
? config.location
: `https://${window.location.host}`,
redirectAddressIsDynamic: config.location === "dynamic",
version: config.version
});
}
})
// Print an error if the config wasn't found.
.catch(() => {
console.error(
"config.json is missing. If you want to customize Hyperspace, please include config.json"
);
});
}
/**
* Look for any existing logins and tokens before presenting
* the login page
*/
componentDidMount() {
if (localStorage.getItem("login")) {
this.getSavedSession();
this.setState({
foundSavedLogin: true
});
this.checkForToken();
}
}
/**
* Update the user field in the state
* @param user The string to update the state to
*/
updateUserInfo(user: string) {
this.checkForErrors(user);
this.setState({ user });
}
/**
* Update the auth code in the state
* @param code The authorization code to update the state to
*/
updateAuthCode(code: string) {
this.setState({ authCode: code });
}
/**
* Toggle the visibility of the authorization dialog
*/
toggleAuthDialog() {
this.setState({ openAuthDialog: !this.state.openAuthDialog });
}
/**
* Determine whether the app is ready to open the authorization
* process.
*/
readyForAuth() {
return localStorage.getItem("baseurl") !== null;
}
/**
* Clear the current access token and base URL
*/
clear() {
localStorage.removeItem("access_token");
localStorage.removeItem("baseurl");
}
/**
* Get the current saved session from the previous login
* attempt and update the state
*/
getSavedSession() {
if (localStorage.getItem("login") === null) {
return;
}
let loginData = localStorage.getItem("login") as string;
let session: SaveClientSession = JSON.parse(loginData);
this.setState({
clientId: session.clientId,
clientSecret: session.clientSecret,
authUrl: session.authUrl,
emergencyMode: session.emergency
});
}
/**
* Start the emergency login mode.
*/
startEmergencyLogin() {
if (!this.state.emergencyMode) {
this.createEmergencyLogin();
}
this.toggleAuthDialog();
}
/**
* Start the registration process.
* @returns A URL pointing to the signup page of the base as defined
* in the config's `registerBase` field
*/
startRegistration() {
return this.state.registerBase
? "https://" + this.state.registerBase + "/auth/sign_up"
: "https://joinmastodon.org/#getting-started";
}
/**
* Watch the keyboard and start the login procedure if the user
* presses the ENTER/RETURN key
* @param event The keyboard event
*/
watchUsernameField(event: any) {
if (event.keyCode === 13) this.startLogin();
}
/**
* Watch the keyboard and start the emergency login auth procedure
* if the user presses the ENTER/RETURN key
* @param event The keyboard event
*/
watchAuthField(event: any) {
if (event.keyCode === 13) this.authorizeEmergencyLogin();
}
/**
* Get the "logged-in" user by reading the username string
* from the first field on the login page.
* @param user The user string to parse
* @returns The base URL of the user
*/
getLoginUser(user: string) {
// Did the user include "@"? They probably are not from the
// server defined in config
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 ??
"mastodon.online"}`;
this.setState({ user: newUser });
return (
"https://" + (this.state.registerBase ?? "mastodon.online")
);
}
}
// Otherwise, treat them as if they're from the server
else {
let newUser = `${user}@${this.state.registerBase ??
"mastodon.online"}`;
this.setState({ user: newUser });
return "https://" + (this.state.registerBase ?? "mastodon.online");
}
}
/**
* Check the user string for any errors and then create a client with an
* ID and secret to start the authorization process.
* @param bypassChecks Whether to bypass the checks in place.
*/
startLogin(bypassChecks: boolean = false) {
// Check if we have errored
let error = this.checkForErrors(this.state.user, bypassChecks);
// If we didn't, create the Hyperspace app to register onto that Mastodon
// server.
if (!error) {
// Define the app's scopes and base URL
const scopes = "read write follow";
const baseurl = this.getLoginUser(this.state.user);
localStorage.setItem("baseurl", baseurl);
// Create the Hyperspace app
createHyperspaceApp(
this.state.brandName ?? "Hyperspace",
scopes,
baseurl,
getRedirectAddress(this.state.defaultRedirectAddress)
)
// If we succeeded, create a login attempt for later reference
.then((resp: any) => {
let saveSessionForCrashing: SaveClientSession = {
clientId: resp.clientId,
clientSecret: resp.clientSecret,
authUrl: resp.url,
emergency: false
};
localStorage.setItem(
"login",
JSON.stringify(saveSessionForCrashing)
);
// Finally, update the state
this.setState({
clientId: resp.clientId,
clientSecret: resp.clientSecret,
authUrl: resp.url,
proceedToGetCode: true
});
})
.catch((err: Error) => {
this.props.enqueueSnackbar(
`Failed to register app at ${baseurl.replace(
"https://",
""
)}`
);
console.error(err);
});
}
}
/**
* Create an emergency mode login. This is usually initiated when the
* "click-to-authorize" method fails and the user needs to copy and paste
* an authorization code manually.
*/
createEmergencyLogin() {
console.log("Creating an emergency login...");
// Set up the scopes and base URL
const scopes = "read write follow";
const baseurl =
localStorage.getItem("baseurl") ||
this.getLoginUser(this.state.user);
// Register the Mastodon app with the Mastodon server
Mastodon.registerApp(
this.state.brandName ?? "Hyperspace",
{
scopes: scopes
},
baseurl
)
// If we succeed, create a login attempt for later reference
.then((appData: any) => {
let saveSessionForCrashing: SaveClientSession = {
clientId: appData.clientId,
clientSecret: appData.clientSecret,
authUrl: appData.url,
emergency: true
};
localStorage.setItem(
"login",
JSON.stringify(saveSessionForCrashing)
);
// Finally, update the state
this.setState({
clientId: appData.clientId,
clientSecret: appData.clientSecret,
authUrl: appData.url
});
});
}
/**
* Open the URL to redirect to an authorization sequence from an emergency
* login.
*
* Since Hyperspace reads the auth code from the URL, we need to redirect to
* a URL with the code inside to trigger an auth
*/
authorizeEmergencyLogin() {
let redirAddress =
this.state.defaultRedirectAddress === "desktop"
? "hyperspace://hyperspace/app/"
: this.state.defaultRedirectAddress;
window.location.href = `${redirAddress}/?code=${this.state.authCode}#/`;
}
/**
* Restore a login attempt from a session
*/
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,
emergencyMode: session.emergency,
proceedToGetCode: true
});
}
}
/**
* Check the user input string for any possible errors
* @param username The username to read and check for errors
* @param bypassesInstanceNameCheck Whether to bypass the instance name validation process. Defaults to false.
* @return Whether an error has occured in the validation.
*/
checkForErrors(
username: string,
bypassesInstanceNameCheck: boolean = false
): boolean {
let userInputError = false;
let userInputErrorMessage = "";
// Is the user string blank?
if (username === "") {
userInputError = true;
userInputErrorMessage = "Username cannot be blank.";
this.setState({ userInputError, userInputErrorMessage });
return true;
} else {
if (username.includes("@")) {
if (this.state.federates && this.state.federates === true) {
let baseUrl = username.split("@")[1];
// Is the user's domain in the disallowed list?
if (inDisallowedDomains(baseUrl)) {
this.setState({
userInputError: true,
userInputErrorMessage: `Signing in with an account from ${baseUrl} isn't supported.`
});
return true;
} else {
if (bypassesInstanceNameCheck) {
return false;
}
// Are we unable to ping the server?
axios
.get(
"https://instances.social/api/1.0/instances/show?name=" +
baseUrl,
{
headers: {
Authorization: `Bearer ${instancesBearerKey}`
}
}
)
.catch((err: Error) => {
let userInputError = true;
let userInputErrorMessage =
"We couldn't recognize this instance.";
this.setState({
userInputError,
userInputErrorMessage
});
return true;
});
}
} else if (
username.includes(
this.state.registerBase ?? "mastodon.online"
)
) {
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;
}
}
/**
* Read the URL and determine whether or not there's an auth code
* in the URL. If there is, try to authorize and get the access
* token for storage.
*/
checkForToken() {
let location = window.location.href;
// Is there an auth code?
if (location.includes("?code=")) {
let code = parseUrl(location).query.code as string;
this.setState({ authorizing: true });
let loginData = localStorage.getItem("login");
// If there's login data, try to fetch an access token
if (loginData) {
let clientLoginSession: SaveClientSession = JSON.parse(
loginData
);
getConfig().then((resp: any) => {
if (resp === undefined) {
return;
}
let conf: Config = resp;
let redirectUrl: string | undefined =
this.state.emergencyMode ||
clientLoginSession.authUrl.includes(
"urn%3Aietf%3Awg%3Aoauth%3A2.0%3Aoob"
)
? undefined
: getRedirectAddress(conf.location);
Mastodon.fetchAccessToken(
clientLoginSession.clientId,
clientLoginSession.clientSecret,
code,
localStorage.getItem("baseurl") as string,
redirectUrl
)
.then((tokenData: any) => {
localStorage.setItem(
"access_token",
tokenData.access_token
);
window.location.href =
window.location.protocol === "hyperspace:"
? "hyperspace://hyperspace/app/"
: this.state.defaultRedirectAddress;
})
.catch((err: Error) => {
this.props.enqueueSnackbar(
`Couldn't authorize ${this.state.brandName ??
"Hyperspace"}: ${err.name}`,
{ variant: "error" }
);
console.error(err.message);
});
});
}
}
}
/**
* Redirect to the app's main view after a login.
*/
redirectToApp() {
window.location.href =
window.location.protocol === "hyperspace:"
? "hyperspace://hyperspace/app/"
: this.state.redirectAddressIsDynamic
? `https://${window.location.host}/#/`
: this.state.defaultRedirectAddress + "/#/";
}
/**
* Render the title bar for macOS
*/
titlebar() {
const { classes } = this.props;
if (isDarwinApp()) {
return (
<div className={classes.titleBarRoot}>
<Typography className={classes.titleBarText}>
{this.state.brandName ?? "Hyperspace"}
</Typography>
</div>
);
}
}
/**
* Show the multi-user account panel
*/
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);
this.redirectToApp();
}}
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>
);
}
/**
* Show the main landing panel
*/
showLanding() {
const { classes } = this.props;
return (
<div>
<Typography variant="h5">Sign in</Typography>
<Typography>with your fediverse 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}
InputProps={{
startAdornment: (
<InputAdornment position="start">@</InputAdornment>
)
}}
/>
{this.state.userInputError ? (
<Typography color="error">
{this.state.userInputErrorMessage}
{this.state.userInputErrorMessage ===
"We couldn't recognize this instance." ? (
<span>
<br />
<Link
// className={classes.welcomeLink}
onClick={() => this.startLogin(true)}
>
Try anyway
</Link>
</span>
) : null}
</Typography>
) : null}
<br />
{this.state.registerBase && this.state.federates ? (
<Typography variant="caption">
Not from{" "}
<b>{this.state.registerBase ?? "noinstance"}</b>? Sign
in with your{" "}
<Link
href="https://docs.joinmastodon.org/user/signup/#address"
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>
</div>
</div>
);
}
/**
* Show the login auth panel
*/
showLoginAuth() {
const { classes } = this.props;
return (
<div>
<Typography variant="h5">
Howdy, {this.state.user?.split("@")[0] ?? "user"}
</Typography>
<Typography>
To continue, finish signing in on your instance's website
and authorize {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 ?? ""}
>
Authorize
</Button>
<div className={classes.flexGrow} />
</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>
);
}
/**
* Show the emergency login panel
*/
showAuthDialog() {
return (
<Dialog
open={this.state.openAuthDialog}
disableBackdropClick
disableEscapeKeyDown
maxWidth="sm"
fullWidth={true}
>
<DialogTitle>Authorize with a code</DialogTitle>
<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.
</Typography>
<Button
color="primary"
variant="contained"
href={this.state.authUrl ?? ""}
target="_blank"
rel="noopener noreferrer"
>
Request Code
</Button>
<br />
<br />
<TextField
variant="outlined"
label="Authorization code"
fullWidth
onChange={event =>
this.updateAuthCode(event.target.value)
}
onKeyDown={event => this.watchAuthField(event)}
/>
</DialogContent>
<DialogActions>
<Button onClick={() => this.toggleAuthDialog()}>
Cancel
</Button>
<Button
color="secondary"
onClick={() => this.authorizeEmergencyLogin()}
>
Authorize
</Button>
</DialogActions>
</Dialog>
);
}
/**
* Show the authorizing panel
*/
showAuthorizationLoader() {
const { classes } = this.props;
return (
<div>
<Typography variant="h5">Authorizing</Typography>
<Typography>
Please wait while Hyperspace authorizes with your instance.
This shouldn't take long...
</Typography>
<div className={classes.middlePadding} />
<div style={{ display: "flex" }}>
<div className={classes.flexGrow} />
<CircularProgress />
<div className={classes.flexGrow} />
</div>
<div className={classes.middlePadding} />
</div>
);
}
/**
* Render the page
*/
render() {
const { classes } = this.props;
return (
<div>
{this.titlebar()}
<div
className={classes.root}
style={{
backgroundImage: `url(${this.state.backgroundUrl ??
"background.png"})`
}}
>
<Paper className={classes.paper}>
<img
className={classes.logo}
alt={this.state.brandName ?? "Hyperspace"}
src={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 ??
"https://github.com/hyperspacedev"
}
target="_blank"
rel="noreferrer"
>
Source code
</Link>{" "}
|{" "}
</span>
) : null}
<Link
className={classes.welcomeLink}
href={
this.state.license ??
"https://thufie.lain.haus/NPL.html"
}
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 ?? "Hypersapce"} v.
{this.state.version}{" "}
{this.state.brandName &&
this.state.brandName !== "Hyperspace"
? "(Hyperspace-like)"
: null}
</Typography>
</Paper>
{this.showAuthDialog()}
</div>
</div>
);
}
}
export default withStyles(styles)(withSnackbar(WelcomePage));