Merge pull request #961 from desktop/preferences-sign-in-flow

Preferences sign in flow
This commit is contained in:
William Shepherd 2017-03-03 09:59:46 -06:00 committed by GitHub
commit c84af6df97
34 changed files with 1541 additions and 479 deletions

View file

@ -7,7 +7,7 @@ import { Branch } from '../models/branch'
import { Tip } from '../models/tip'
import { Commit } from '../models/commit'
import { FileChange, WorkingDirectoryStatus, WorkingDirectoryFileChange } from '../models/status'
import { CloningRepository, ICloningRepositoryState, IGitHubUser } from './dispatcher'
import { CloningRepository, ICloningRepositoryState, IGitHubUser, SignInState } from './dispatcher'
import { ICommitMessage } from './dispatcher/git-store'
import { IMenu } from '../models/app-menu'
import { IRemote } from '../models/remote'
@ -31,6 +31,15 @@ export interface IAppState {
readonly selectedState: PossibleSelections | null
/**
* The state of the ongoing (if any) sign in process. See SignInState
* and SignInStore for more details. Null if no current sign in flow
* is active. Sign in flows are initiated through the dispatcher methods
* beginDotComSignIn and beginEnterpriseSign in or via the
* showDotcomSignInDialog and showEnterpriseSignInDialog methods.
*/
readonly signInState: SignInState | null
readonly showWelcomeFlow: boolean
readonly loading: boolean
readonly currentPopup: Popup | null
@ -97,6 +106,7 @@ export enum PopupType {
Preferences,
MergeBranch,
RepositorySettings,
SignIn,
}
export type Popup = { type: PopupType.RenameBranch, repository: Repository, branch: Branch } |
@ -105,7 +115,8 @@ export type Popup = { type: PopupType.RenameBranch, repository: Repository, bran
{ type: PopupType.UpdateAvailable } |
{ type: PopupType.Preferences } |
{ type: PopupType.MergeBranch, repository: Repository } |
{ type: PopupType.RepositorySettings, repository: Repository }
{ type: PopupType.RepositorySettings, repository: Repository } |
{ type: PopupType.SignIn }
export enum FoldoutType {
Repository,

View file

@ -40,6 +40,7 @@ import { getAppMenu } from '../../ui/main-process-proxy'
import { merge } from '../merge'
import { getAppPath } from '../../ui/lib/app-proxy'
import { StatsStore, ILaunchStats } from '../stats'
import { SignInStore } from './sign-in-store'
import {
getGitDir,
@ -110,6 +111,8 @@ export class AppStore {
/** GitStores keyed by their associated Repository ID. */
private readonly gitStores = new Map<number, GitStore>()
private readonly signInStore: SignInStore
/**
* The Application menu as an AppMenu instance or null if
* the main process has not yet provided the renderer with
@ -129,12 +132,13 @@ export class AppStore {
private readonly statsStore: StatsStore
public constructor(gitHubUserStore: GitHubUserStore, cloningRepositoriesStore: CloningRepositoriesStore, emojiStore: EmojiStore, issuesStore: IssuesStore, statsStore: StatsStore) {
public constructor(gitHubUserStore: GitHubUserStore, cloningRepositoriesStore: CloningRepositoriesStore, emojiStore: EmojiStore, issuesStore: IssuesStore, statsStore: StatsStore, signInStore: SignInStore) {
this.gitHubUserStore = gitHubUserStore
this.cloningRepositoriesStore = cloningRepositoriesStore
this.emojiStore = emojiStore
this._issuesStore = issuesStore
this.statsStore = statsStore
this.signInStore = signInStore
const hasShownWelcomeFlow = localStorage.getItem(HasShownWelcomeFlowKey)
this.showWelcomeFlow = !hasShownWelcomeFlow || !parseInt(hasShownWelcomeFlow, 10)
@ -155,10 +159,18 @@ export class AppStore {
this.cloningRepositoriesStore.onDidError(e => this.emitError(e))
this.signInStore.onDidAuthenticate(user => this.emitAuthenticate(user))
this.signInStore.onDidUpdate(() => this.emitUpdate())
this.signInStore.onDidError(error => this.emitError(error))
const rootDir = getAppPath()
this.emojiStore.read(rootDir).then(() => this.emitUpdate())
}
private emitAuthenticate(user: User) {
this.emitter.emit('did-authenticate', user)
}
private emitUpdate() {
if (this.emitQueued) { return }
@ -170,6 +182,14 @@ export class AppStore {
})
}
/**
* Registers an event handler which will be invoked whenever
* a user has successfully completed a sign-in process.
*/
public onDidAuthenticate(fn: (user: User) => void): Disposable {
return this.emitter.on('did-authenticate', fn)
}
public onDidUpdate(fn: (state: IAppState) => void): Disposable {
return this.emitter.on('did-update', fn)
}
@ -292,6 +312,7 @@ export class AppStore {
...this.cloningRepositoriesStore.repositories,
],
selectedState: this.getSelectedState(),
signInState: this.signInStore.getState(),
currentPopup: this.currentPopup,
currentFoldout: this.currentFoldout,
errors: this.errors,
@ -1367,4 +1388,35 @@ export class AppStore {
return this._refreshRepository(repository)
}
public _resetSignInState(): Promise<void> {
this.signInStore.reset()
return Promise.resolve()
}
public _beginDotComSignIn(): Promise<void> {
this.signInStore.beginDotComSignIn()
return Promise.resolve()
}
public _beginEnterpriseSignIn(): Promise<void> {
this.signInStore.beginEnterpriseSignIn()
return Promise.resolve()
}
public _setSignInEndpoint(url: string): Promise<void> {
return this.signInStore.setEndpoint(url)
}
public _setSignInCredentials(username: string, password: string): Promise<void> {
return this.signInStore.authenticateWithBasicAuth(username, password)
}
public _requestBrowserAuthentication(): Promise<void> {
return this.signInStore.authenticateWithBrowser()
}
public _setSignInOTP(otp: string): Promise<void> {
return this.signInStore.setTwoFactorOTP(otp)
}
}

View file

@ -4,7 +4,7 @@ import { User, IUser } from '../../models/user'
import { Repository, IRepository } from '../../models/repository'
import { WorkingDirectoryFileChange, FileChange } from '../../models/status'
import { DiffSelection } from '../../models/diff'
import { RepositorySection, Popup, Foldout, FoldoutType } from '../app-state'
import { RepositorySection, Popup, PopupType, Foldout, FoldoutType } from '../app-state'
import { Action } from './actions'
import { AppStore } from './app-store'
import { CloningRepository } from './cloning-repositories-store'
@ -67,6 +67,10 @@ export class Dispatcher {
public constructor(appStore: AppStore) {
this.appStore = appStore
appStore.onDidAuthenticate((user) => {
this.addUser(user)
})
ipcRenderer.on('shared/did-update', (event, args) => this.onSharedDidUpdate(event, args))
}
@ -550,6 +554,113 @@ export class Dispatcher {
return this.appStore._setStatsOptOut(optOut)
}
/**
* Clear any in-flight sign in state and return to the
* initial (no sign-in) state.
*/
public resetSignInState(): Promise<void> {
return this.appStore._resetSignInState()
}
/**
* Initiate a sign in flow for github.com. This will put the store
* in the Authentication step ready to receive user credentials.
*/
public beginDotComSignIn(): Promise<void> {
return this.appStore._beginDotComSignIn()
}
/**
* Initiate a sign in flow for a GitHub Enterprise instance. This will
* put the store in the EndpointEntry step ready to receive the url
* to the enterprise instance.
*/
public beginEnterpriseSignIn(): Promise<void> {
return this.appStore._beginEnterpriseSignIn()
}
/**
* Attempt to advance from the EndpointEntry step with the given endpoint
* url. This method must only be called when the store is in the authentication
* step or an error will be thrown.
*
* The provided endpoint url will be validated for syntactic correctness as
* well as connectivity before the promise resolves. If the endpoint url is
* invalid or the host can't be reached the promise will be rejected and the
* sign in state updated with an error to be presented to the user.
*
* If validation is successful the store will advance to the authentication
* step.
*/
public setSignInEndpoint(url: string): Promise<void> {
return this.appStore._setSignInEndpoint(url)
}
/**
* Attempt to advance from the authentication step using a username
* and password. This method must only be called when the store is
* in the authentication step or an error will be thrown. If the
* provided credentials are valid the store will either advance to
* the Success step or to the TwoFactorAuthentication step if the
* user has enabled two factor authentication.
*
* If an error occurs during sign in (such as invalid credentials)
* the authentication state will be updated with that error so that
* the responsible component can present it to the user.
*/
public setSignInCredentials(username: string, password: string): Promise<void> {
return this.appStore._setSignInCredentials(username, password)
}
/**
* Initiate an OAuth sign in using the system configured browser.
* This method must only be called when the store is in the authentication
* step or an error will be thrown.
*
* The promise returned will only resolve once the user has successfully
* authenticated. If the user terminates the sign-in process by closing
* their browser before the protocol handler is invoked, by denying the
* protocol handler to execute or by providing the wrong credentials
* this promise will never complete.
*/
public requestBrowserAuthentication(): Promise<void> {
return this.appStore._requestBrowserAuthentication()
}
/**
* Attempt to complete the sign in flow with the given OTP token.\
* This method must only be called when the store is in the
* TwoFactorAuthentication step or an error will be thrown.
*
* If the provided token is valid the store will advance to
* the Success step.
*
* If an error occurs during sign in (such as invalid credentials)
* the authentication state will be updated with that error so that
* the responsible component can present it to the user.
*/
public setSignInOTP(otp: string): Promise<void> {
return this.appStore._setSignInOTP(otp)
}
/**
* Launch a sign in dialog for authenticating a user with
* GitHub.com.
*/
public async showDotComSignInDialog(): Promise<void> {
await this.appStore._beginDotComSignIn()
await this.appStore._showPopup({ type: PopupType.SignIn })
}
/**
* Launch a sign in dialog for authenticating a user with
* a GitHub Enterprise instance.
*/
public async showEnterpriseSignInDialog(): Promise<void> {
await this.appStore._beginEnterpriseSignIn()
await this.appStore._showPopup({ type: PopupType.SignIn })
}
/**
* Register a new error handler.
*

View file

@ -7,4 +7,5 @@ export * from './github-user-database'
export * from './github-user-store'
export * from './issues-database'
export * from './issues-store'
export * from './sign-in-store'
export * from './error-handlers'

View file

@ -0,0 +1,518 @@
import { Emitter, Disposable } from 'event-kit'
import { User } from '../../models/user'
import { assertNever, fatalError } from '../fatal-error'
import { askUserToOAuth } from '../../lib/oauth'
import { validateURL, InvalidURLErrorName, InvalidProtocolErrorName } from '../../ui/lib/enterprise-validate-url'
import {
createAuthorization,
AuthorizationResponse,
fetchUser,
AuthorizationResponseKind,
getHTMLURL,
getDotComAPIEndpoint,
getEnterpriseAPIURL,
fetchMetadata,
} from '../../lib/api'
import { minimumSupportedEnterpriseVersion } from '../../lib/enterprise'
/**
* An enumeration of the possible steps that the sign in
* store can be in save for the unitialized state (null).
*/
export enum SignInStep {
EndpointEntry,
Authentication,
TwoFactorAuthentication,
Success,
}
/**
* The union type of all possible states that the sign in
* store can be in save the unitialized state (null).
*/
export type SignInState =
IEndpointEntryState |
IAuthenticationState |
ITwoFactorAuthenticationState |
ISuccessState
/**
* Base interface for shared properties between states
*/
export interface ISignInState {
/**
* The sign in step represented by this state
*/
readonly kind: SignInStep
/**
* An error which, if present, should be presented to the
* user in close proximity to the actions or input fields
* related to the current step.
*/
readonly error: Error | null
/**
* A value indicating whether or not the sign in store is
* busy processing a request. While this value is true all
* form inputs and actions save for a cancel action should
* be disabled and the user should be made aware that the
* sign in process is ongoing.
*/
readonly loading: boolean
}
/**
* State interface representing the endpoint entry step.
* This is the initial step in the Enterprise sign in flow
* and is not present when signing in to GitHub.com
*/
export interface IEndpointEntryState extends ISignInState {
readonly kind: SignInStep.EndpointEntry
}
/**
* State interface representing the Authentication step where
* the user provides credentials and/or initiates a browser
* OAuth sign in process. This step occurs as the first step
* when signing in to GitHub.com and as the second step when
* signing in to a GitHub Enterprise instance.
*/
export interface IAuthenticationState extends ISignInState {
readonly kind: SignInStep.Authentication
/**
* The URL to the host which we're currently authenticating
* against. This will be either https://api.github.com when
* signing in against GitHub.com or a user-specified
* URL when signing in against a GitHub Enterprise instance.
*/
readonly endpoint: string
/**
* A value indicating whether or not the endpoint supports
* basic authentication (i.e. username and password). All
* GitHub Enterprise instances support OAuth (or web flow
* sign-in).
*/
readonly supportsBasicAuth: boolean
/**
* The endpoint-specific URL for resetting credentials.
*/
readonly forgotPasswordUrl: string
}
/**
* State interface representing the TwoFactorAuthentication
* step where the user provides an OTP token. This step
* occurs after the authentication step both for GitHub.com,
* and GitHub Enterprise when the user has enabled two factor
* authentication on the host.
*/
export interface ITwoFactorAuthenticationState extends ISignInState {
readonly kind: SignInStep.TwoFactorAuthentication
/**
* The URL to the host which we're currently authenticating
* against. This will be either https://api.github.com when
* signing in against GitHub.com or a user-specified
* URL when signing in against a GitHub Enterprise instance.
*/
readonly endpoint: string
/**
* The username specified by the user in the preceeding
* Authentication step
*/
readonly username: string
/**
* The password specified by the user in the preceeding
* Authentication step
*/
readonly password: string
}
/**
* Sentinel step representing a successful sign in process. Sign in
* components may use this as a signal to dismiss the ongoing flow
* or to show a message to the user indicating that they've been
* successfully signed in.
*/
export interface ISuccessState {
readonly kind: SignInStep.Success
}
/**
* A store encapsulating all logic related to signing in a user
* to GitHub.com, or a GitHub Enterprise instance.
*/
export class SignInStore {
private readonly emitter = new Emitter()
private state: SignInState | null = null
private emitUpdate() {
this.emitter.emit('did-update', this.getState())
}
private emitAuthenticate(user: User) {
this.emitter.emit('did-authenticate', user)
}
private emitError(error: Error) {
this.emitter.emit('did-error', error)
}
/** Register a function to be called when the store updates. */
public onDidUpdate(fn: (state: ISignInState) => void): Disposable {
return this.emitter.on('did-update', fn)
}
/**
* Registers an event handler which will be invoked whenever
* a user has successfully completed a sign-in process.
*/
public onDidAuthenticate(fn: (user: User) => void): Disposable {
return this.emitter.on('did-authenticate', fn)
}
/**
* Register an even handler which will be invoked whenever
* an unexpected error occurs during the sign-in process. Note
* that some error are handled in the flow and passed along in
* the sign in state for inline presentation to the user.
*/
public onDidError(fn: (error: Error) => void): Disposable {
return this.emitter.on('did-error', fn)
}
/**
* Returns the current state of the sign in store or null if
* no sign in process is in flight.
*/
public getState(): SignInState | null {
return this.state
}
/**
* Update the internal state of the store and emit an update
* event.
*/
private setState(state: SignInState | null) {
this.state = state
this.emitUpdate()
}
private async endpointSupportsBasicAuth(endpoint: string): Promise<boolean> {
const response = await fetchMetadata(endpoint)
if (response) {
if (response.verifiable_password_authentication === false) {
return false
} else {
return true
}
} else {
throw new Error(`Unable to authenticate with the GitHub Enterprise instance. Verify that the URL is correct and that your GitHub Enterprise instance is running version ${minimumSupportedEnterpriseVersion} or later.`)
}
}
private getForgotPasswordURL(endpoint: string): string {
return `${getHTMLURL(endpoint)}/password_reset`
}
/**
* Clear any in-flight sign in state and return to the
* initial (no sign-in) state.
*/
public reset() {
this.setState(null)
}
/**
* Initiate a sign in flow for github.com. This will put the store
* in the Authentication step ready to receive user credentials.
*/
public beginDotComSignIn() {
const endpoint = getDotComAPIEndpoint()
this.setState({
kind: SignInStep.Authentication,
endpoint,
supportsBasicAuth: true,
error: null,
loading: false,
forgotPasswordUrl: this.getForgotPasswordURL(endpoint),
})
}
/**
* Attempt to advance from the authentication step using a username
* and password. This method must only be called when the store is
* in the authentication step or an error will be thrown. If the
* provided credentials are valid the store will either advance to
* the Success step or to the TwoFactorAuthentication step if the
* user has enabled two factor authentication.
*
* If an error occurs during sign in (such as invalid credentials)
* the authentication state will be updated with that error so that
* the responsible component can present it to the user.
*/
public async authenticateWithBasicAuth(username: string, password: string): Promise<void> {
const currentState = this.state
if (!currentState || currentState.kind !== SignInStep.Authentication) {
const stepText = currentState ? currentState.kind : 'null'
return fatalError(`Sign in step '${stepText}' not compatible with authentication`)
}
const endpoint = currentState.endpoint
this.setState({ ...currentState, loading: true })
let response: AuthorizationResponse
try {
response = await createAuthorization(endpoint, username, password, null)
} catch (e) {
this.emitError(e)
return
}
if (!this.state || this.state.kind !== SignInStep.Authentication) {
// Looks like the sign in flow has been aborted
return
}
if (response.kind === AuthorizationResponseKind.Authorized) {
const token = response.token
const user = await fetchUser(endpoint, token)
if (!this.state || this.state.kind !== SignInStep.Authentication) {
// Looks like the sign in flow has been aborted
return
}
this.emitAuthenticate(user)
this.setState({ kind: SignInStep.Success })
} else if (response.kind === AuthorizationResponseKind.TwoFactorAuthenticationRequired) {
this.setState({
kind: SignInStep.TwoFactorAuthentication,
endpoint,
username,
password,
error: null,
loading: false,
})
} else {
if (response.kind === AuthorizationResponseKind.Error) {
if (response.response.error) {
this.emitError(response.response.error)
} else {
this.emitError(new Error(`The server responded with an error while attempting to authenticate (${response.response.statusCode})\n\n${response.response.body}`))
}
this.setState({ ...currentState, loading: false })
} else if (response.kind === AuthorizationResponseKind.Failed) {
this.setState({
...currentState,
loading: false,
error: new Error('Incorrect username or password.'),
})
} else {
return assertNever(response, `Unsupported response: ${response}`)
}
}
}
/**
* Initiate an OAuth sign in using the system configured browser.
* This method must only be called when the store is in the authentication
* step or an error will be thrown.
*
* The promise returned will only resolve once the user has successfully
* authenticated. If the user terminates the sign-in process by closing
* their browser before the protocol handler is invoked, by denying the
* protocol handler to execute or by providing the wrong credentials
* this promise will never complete.
*/
public async authenticateWithBrowser(): Promise<void> {
const currentState = this.state
if (!currentState || currentState.kind !== SignInStep.Authentication) {
const stepText = currentState ? currentState.kind : 'null'
return fatalError(`Sign in step '${stepText}' not compatible with browser authentication`)
}
this.setState({ ...currentState, loading: true })
let user: User
try {
user = await askUserToOAuth(currentState.endpoint)
} catch (e) {
this.setState({ ...currentState, error: e, loading: false })
return
}
if (!this.state || this.state.kind !== SignInStep.Authentication) {
// Looks like the sign in flow has been aborted
return
}
this.emitAuthenticate(user)
this.setState({ kind: SignInStep.Success })
}
/**
* Initiate a sign in flow for a GitHub Enterprise instance. This will
* put the store in the EndpointEntry step ready to receive the url
* to the enterprise instance.
*/
public beginEnterpriseSignIn() {
this.setState({ kind: SignInStep.EndpointEntry, error: null, loading: false })
}
/**
* Attempt to advance from the EndpointEntry step with the given endpoint
* url. This method must only be called when the store is in the authentication
* step or an error will be thrown.
*
* The provided endpoint url will be validated for syntactic correctness as
* well as connectivity before the promise resolves. If the endpoint url is
* invalid or the host can't be reached the promise will be rejected and the
* sign in state updated with an error to be presented to the user.
*
* If validation is successful the store will advance to the authentication
* step.
*/
public async setEndpoint(url: string): Promise<void> {
const currentState = this.state
if (!currentState || currentState.kind !== SignInStep.EndpointEntry) {
const stepText = currentState ? currentState.kind : 'null'
return fatalError(`Sign in step '${stepText}' not compatible with endpoint entry`)
}
this.setState({ ...currentState, loading: true })
let validUrl: string
try {
validUrl = validateURL(url)
} catch (e) {
let error = e
if (e.name === InvalidURLErrorName) {
error = new Error(`The GitHub Enterprise instance address doesn't appear to be a valid URL. We're expecting something like https://github.example.com.`)
} else if (e.name === InvalidProtocolErrorName) {
error = new Error('Unsupported protocol. Only http or https is supported when authenticating with GitHub Enterprise instances.')
}
this.setState({ ...currentState, loading: false, error })
return
}
const endpoint = getEnterpriseAPIURL(validUrl)
try {
const supportsBasicAuth = await this.endpointSupportsBasicAuth(endpoint)
if (!this.state || this.state.kind !== SignInStep.EndpointEntry) {
// Looks like the sign in flow has been aborted
return
}
this.setState({
kind: SignInStep.Authentication,
endpoint,
supportsBasicAuth,
error: null,
loading: false,
forgotPasswordUrl: this.getForgotPasswordURL(endpoint),
})
} catch (e) {
let error = e
// We'll get an ENOTFOUND if the address couldn't be resolved.
if (e.code === 'ENOTFOUND') {
error = new Error('The server could not be found. Please verify that the URL is correct and that you have a stable internet connection.')
}
this.setState({ ...currentState, loading: false, error })
}
}
/**
* Attempt to complete the sign in flow with the given OTP token.\
* This method must only be called when the store is in the
* TwoFactorAuthentication step or an error will be thrown.
*
* If the provided token is valid the store will advance to
* the Success step.
*
* If an error occurs during sign in (such as invalid credentials)
* the authentication state will be updated with that error so that
* the responsible component can present it to the user.
*/
public async setTwoFactorOTP(otp: string) {
const currentState = this.state
if (!currentState || currentState.kind !== SignInStep.TwoFactorAuthentication) {
const stepText = currentState ? currentState.kind : 'null'
return fatalError(`Sign in step '${stepText}' not compatible with two factor authentication`)
}
this.setState({ ...currentState, loading: true })
let response: AuthorizationResponse
try {
response = await createAuthorization(
currentState.endpoint,
currentState.username,
currentState.password,
otp
)
} catch (e) {
this.emitError(e)
return
}
if (!this.state || this.state.kind !== SignInStep.TwoFactorAuthentication) {
// Looks like the sign in flow has been aborted
return
}
if (response.kind === AuthorizationResponseKind.Authorized) {
const token = response.token
const user = await fetchUser(currentState.endpoint, token)
if (!this.state || this.state.kind !== SignInStep.TwoFactorAuthentication) {
// Looks like the sign in flow has been aborted
return
}
this.emitAuthenticate(user)
this.setState({ kind: SignInStep.Success })
} else {
switch (response.kind) {
case AuthorizationResponseKind.Failed:
case AuthorizationResponseKind.TwoFactorAuthenticationRequired:
this.setState({
...currentState,
loading: false,
error: new Error('Two-factor authentication failed.'),
})
break
case AuthorizationResponseKind.Error:
const error = response.response.error
if (error) {
this.emitError(error)
} else {
this.emitError(new Error(`The server responded with an error (${response.response.statusCode})\n\n${response.response.body}`))
}
break
default:
return assertNever(response, `Unknown response: ${response}`)
}
}
}
}

12
app/src/lib/enterprise.ts Normal file
View file

@ -0,0 +1,12 @@
/**
* The oldest officially supported version of GitHub Enterprise.
* This information is used in user-facing text and shouldn't be
* considered a hard limit, i.e. older versions of GitHub Enterprise
* might (and probably do) work just fine but this should be a fairly
* recent version that we can safely say that we'll work well with.
*
* I picked the current minimum (2.8) because it was the version
* running on our internal GitHub Enterprise instance at the time
* we implemented Enterprise sign in (desktop/desktop#664)
*/
export const minimumSupportedEnterpriseVersion = '2.8.0'

View file

@ -33,6 +33,7 @@ import { shouldRenderApplicationMenu } from './lib/features'
import { Merge } from './merge-branch'
import { RepositorySettings } from './repository-settings'
import { AppError } from './app-error'
import { SignIn } from './sign-in'
/** The interval at which we should check for updates. */
const UpdateCheckInterval = 1000 * 60 * 60 * 4
@ -539,6 +540,11 @@ export class App extends React.Component<IAppProps, IAppState> {
this.props.dispatcher.closePopup()
}
private onSignInDialogDismissed = () => {
this.props.dispatcher.resetSignInState()
this.onPopupDismissed()
}
private currentPopupContent(): JSX.Element | null {
// Hide any dialogs while we're displaying an error
@ -580,8 +586,7 @@ export class App extends React.Component<IAppProps, IAppState> {
branches={state.branchesState.allBranches}
onDismissed={this.onPopupDismissed}
/>
}
else if (popup.type === PopupType.RepositorySettings) {
} else if (popup.type === PopupType.RepositorySettings) {
const repository = popup.repository
const state = this.props.appStore.getRepositoryState(repository)
@ -591,6 +596,14 @@ export class App extends React.Component<IAppProps, IAppState> {
repository={repository}
onDismissed={this.onPopupDismissed}
/>
} else if (popup.type === PopupType.SignIn) {
return (
<SignIn
signInState={this.state.signInState}
dispatcher={this.props.dispatcher}
onDismissed={this.onSignInDialogDismissed}
/>
)
}
return assertNever(popup, `Unknown popup type: ${popup}`)
@ -758,7 +771,8 @@ export class App extends React.Component<IAppProps, IAppState> {
lastFetched={state.lastFetched}
networkActionInProgress={state.pushPullInProgress}
isPublishing={isPublishing}
users={this.state.users}/>
users={this.state.users}
signInState={this.state.signInState}/>
}
private renderBranchFoldout = (): JSX.Element | null => {
@ -894,7 +908,11 @@ export class App extends React.Component<IAppProps, IAppState> {
private renderWelcomeFlow() {
return (
<Welcome dispatcher={this.props.dispatcher} appStore={this.props.appStore}/>
<Welcome
dispatcher={this.props.dispatcher}
appStore={this.props.appStore}
signInState={this.state.signInState}
/>
)
}

View file

@ -2,13 +2,16 @@ import * as React from 'react'
import * as classNames from 'classnames'
interface IDialogContentProps {
/**
* An optional className to be applied to the rendered div element.
*/
readonly className?: string
}
/**
* A container component for content (ie non-header, non-footer) in a Dialog.
* This component should only be used once in any given dialog.
*
*
* If a dialog implements a tabbed interface where each tab is a child component
* the child components _should_ render the DialogContent component themselves
* to avoid excessive nesting and to ensure that styles applying to phrasing

View file

@ -16,7 +16,7 @@ import { sendReady } from './main-process-proxy'
import { reportError } from '../lib/exception-reporting'
import { getVersion } from './lib/app-proxy'
import { StatsDatabase, StatsStore } from '../lib/stats'
import { IssuesDatabase, IssuesStore } from '../lib/dispatcher'
import { IssuesDatabase, IssuesStore, SignInStore } from '../lib/dispatcher'
import { requestAuthenticatedUser, resolveOAuthRequest, rejectOAuthRequest } from '../lib/oauth'
import { defaultErrorHandler } from '../lib/dispatcher'
@ -45,7 +45,17 @@ const cloningRepositoriesStore = new CloningRepositoriesStore()
const emojiStore = new EmojiStore()
const issuesStore = new IssuesStore(new IssuesDatabase('IssuesDatabase'))
const statsStore = new StatsStore(new StatsDatabase('StatsDatabase'))
const appStore = new AppStore(gitHubUserStore, cloningRepositoriesStore, emojiStore, issuesStore, statsStore)
const signInStore = new SignInStore()
const appStore = new AppStore(
gitHubUserStore,
cloningRepositoriesStore,
emojiStore,
issuesStore,
statsStore,
signInStore,
)
const dispatcher = new Dispatcher(appStore)
dispatcher.registerErrorHandler(defaultErrorHandler)

View file

@ -1,16 +1,6 @@
import * as React from 'react'
import { LinkButton } from '../lib/link-button'
import { Octicon, OcticonSymbol } from '../octicons'
import {
createAuthorization,
AuthorizationResponse,
fetchUser,
AuthorizationResponseKind,
getHTMLURL,
} from '../../lib/api'
import { User } from '../../models/user'
import { assertNever } from '../../lib/fatal-error'
import { askUserToOAuth } from '../../lib/oauth'
import { Loading } from './loading'
import { Form } from './form'
import { Button } from './button'
@ -18,28 +8,47 @@ import { TextBox } from './text-box'
import { Errors } from './errors'
interface IAuthenticationFormProps {
/** The endpoint against which the user is authenticating. */
readonly endpoint: string
/** Does the server support basic auth? */
readonly supportsBasicAuth: boolean
/** Called after the user has signed in. */
readonly onDidSignIn: (user: User) => void
/**
* A callback which is invoked once the user has entered a username
* and password and submitted those either by clicking on the submit
* button or by submitting the form through other means (ie hitting Enter).
*/
readonly onSubmit: (username: string, password: string) => void
/** Called when two-factor authentication is required. */
readonly onNeeds2FA: (login: string, password: string) => void
/**
* A callback which is invoked if the user requests OAuth sign in using
* their system configured browser.
*/
readonly onBrowserSignInRequested: () => void
/** An array of additional buttons to render after the "Sign In" button. */
readonly additionalButtons?: ReadonlyArray<JSX.Element>
/**
* An error which, if present, is presented to the
* user in close proximity to the actions or input fields
* related to the current step.
*/
readonly error: Error | null
/**
* A value indicating whether or not the sign in store is
* busy processing a request. While this value is true all
* form inputs and actions save for a cancel action will
* be disabled.
*/
readonly loading: boolean
readonly forgotPasswordUrl: string
}
interface IAuthenticationFormState {
readonly username: string
readonly password: string
readonly loading: boolean
readonly response: AuthorizationResponse | null
}
/** The GitHub authentication component. */
@ -47,7 +56,7 @@ export class AuthenticationForm extends React.Component<IAuthenticationFormProps
public constructor(props: IAuthenticationFormProps) {
super(props)
this.state = { username: '', password: '', loading: false, response: null }
this.state = { username: '', password: '' }
}
public render() {
@ -65,7 +74,7 @@ export class AuthenticationForm extends React.Component<IAuthenticationFormProps
private renderUsernamePassword() {
if (!this.props.supportsBasicAuth) { return null }
const disabled = this.state.loading
const disabled = this.props.loading
return (
<div>
<TextBox
@ -80,7 +89,7 @@ export class AuthenticationForm extends React.Component<IAuthenticationFormProps
disabled={disabled}
onChange={this.onPasswordChange}
labelLinkText='Forgot password?'
labelLinkUri={this.getForgotPasswordURL()}/>
labelLinkUri={this.props.forgotPasswordUrl}/>
{this.renderActions()}
</div>
@ -88,12 +97,12 @@ export class AuthenticationForm extends React.Component<IAuthenticationFormProps
}
private renderActions() {
const signInDisabled = Boolean(!this.state.username.length || !this.state.password.length || this.state.loading)
const signInDisabled = Boolean(!this.state.username.length || !this.state.password.length || this.props.loading)
return (
<div className='actions'>
{this.props.supportsBasicAuth ? <Button type='submit' disabled={signInDisabled}>Sign in</Button> : null}
{this.props.additionalButtons}
{this.state.loading ? <Loading/> : null}
{this.props.loading ? <Loading/> : null}
</div>
)
}
@ -117,78 +126,25 @@ export class AuthenticationForm extends React.Component<IAuthenticationFormProps
}
private renderError() {
const response = this.state.response
if (!response) { return null }
const error = this.props.error
if (!error) { return null }
switch (response.kind) {
case AuthorizationResponseKind.Failed: return <Errors>The username or password are incorrect.</Errors>
case AuthorizationResponseKind.Error: {
const error = response.response.error
if (error) {
return <Errors>An error occurred: {error.message}</Errors>
} else {
return <Errors>An unknown error occurred: {response.response.statusCode}: {response.response.body}</Errors>
}
}
case AuthorizationResponseKind.TwoFactorAuthenticationRequired: return null
case AuthorizationResponseKind.Authorized: return null
default: return assertNever(response, `Unknown response: ${response}`)
}
}
private getForgotPasswordURL(): string {
return `${getHTMLURL(this.props.endpoint)}/password_reset`
return <Errors>{error.message}</Errors>
}
private onUsernameChange = (event: React.FormEvent<HTMLInputElement>) => {
this.setState({
username: event.currentTarget.value,
password: this.state.password,
loading: this.state.loading,
response: null,
})
this.setState({ username: event.currentTarget.value })
}
private onPasswordChange = (event: React.FormEvent<HTMLInputElement>) => {
this.setState({
username: this.state.username,
password: event.currentTarget.value,
loading: this.state.loading,
response: null,
})
this.setState({ password: event.currentTarget.value })
}
private signInWithBrowser = async () => {
const user = await askUserToOAuth(this.props.endpoint)
this.props.onDidSignIn(user)
private signInWithBrowser = () => {
this.props.onBrowserSignInRequested()
}
private signIn = async () => {
const username = this.state.username
const password = this.state.password
this.setState({
username,
password,
loading: true,
response: null,
})
const endpoint = this.props.endpoint
const response = await createAuthorization(endpoint, username, password, null)
if (response.kind === AuthorizationResponseKind.Authorized) {
const token = response.token
const user = await fetchUser(endpoint, token)
this.props.onDidSignIn(user)
} else if (response.kind === AuthorizationResponseKind.TwoFactorAuthenticationRequired) {
this.props.onNeeds2FA(username, password)
} else {
this.setState({
username,
password,
loading: false,
response,
})
}
private signIn = () => {
this.props.onSubmit(this.state.username, this.state.password)
}
}

View file

@ -2,8 +2,13 @@ import * as React from 'react'
import * as classNames from 'classnames'
export interface IButtonProps {
/** A function to call on click. */
readonly onClick?: () => void
/**
* A callback which is invoked when the button is clicked
* using a pointer device or keyboard. The source event is
* passed along and can be used to prevent the default action
* or stop the even from bubbling.
*/
readonly onClick?: (event: React.FormEvent<HTMLButtonElement>) => void
/** The title of the button. */
readonly children?: string
@ -25,6 +30,9 @@ export interface IButtonProps {
* this will be unnecessary.
*/
readonly onButtonRef?: (instance: HTMLButtonElement) => void
/** The tab index of the button element. */
readonly tabIndex?: number
}
/** A button component. */
@ -38,7 +46,8 @@ export class Button extends React.Component<IButtonProps, void> {
disabled={this.props.disabled}
onClick={this.onClick}
type={this.props.type || 'button'}
ref={this.props.onButtonRef}>
ref={this.props.onButtonRef}
tabIndex={this.props.tabIndex}>
{this.props.children}
</button>
)
@ -49,9 +58,8 @@ export class Button extends React.Component<IButtonProps, void> {
event.preventDefault()
}
const onClick = this.props.onClick
if (onClick) {
onClick()
if (this.props.onClick) {
this.props.onClick(event)
}
}
}

View file

@ -1,24 +1,32 @@
import * as React from 'react'
import { getEnterpriseAPIURL, fetchMetadata } from '../../lib/api'
import { Loading } from './loading'
import { validateURL, InvalidURLErrorName, InvalidProtocolErrorName } from './enterprise-validate-url'
import { Form } from './form'
import { TextBox } from './text-box'
import { Button } from './button'
import { Errors } from './errors'
/** The authentication methods server allows. */
export enum AuthenticationMethods {
/** Basic auth in order to create authorization tokens. */
BasicAuth,
/** OAuth web flow. */
OAuth,
}
interface IEnterpriseServerEntryProps {
/** Called after the user has entered their Enterprise server address. */
readonly onContinue: (endpoint: string, authMethods: Set<AuthenticationMethods>) => void
/**
* An error which, if present, is presented to the
* user in close proximity to the actions or input fields
* related to the current step.
*/
readonly error: Error | null
/**
* A value indicating whether or not the sign in store is
* busy processing a request. While this value is true all
* form inputs and actions save for a cancel action will
* be disabled.
*/
readonly loading: boolean
/**
* A callback which is invoked once the user has entered an
* endpoint url and submitted it either by clicking on the submit
* button or by submitting the form through other means (ie hitting Enter).
*/
readonly onSubmit: (url: string) => void
/** An array of additional buttons to render after the "Continue" button. */
readonly additionalButtons?: ReadonlyArray<JSX.Element>
@ -26,23 +34,18 @@ interface IEnterpriseServerEntryProps {
interface IEnterpriseServerEntryState {
readonly serverAddress: string
readonly loading: boolean
readonly error: Error | null
}
/** An entry form for an Enterprise server address. */
export class EnterpriseServerEntry extends React.Component<IEnterpriseServerEntryProps, IEnterpriseServerEntryState> {
public constructor(props: IEnterpriseServerEntryProps) {
super(props)
this.state = { serverAddress: '', loading: false, error: null }
this.state = { serverAddress: '' }
}
public render() {
const disableEntry = this.state.loading
const disableSubmission = !this.state.serverAddress.length || this.state.loading
const disableEntry = this.props.loading
const disableSubmission = !this.state.serverAddress.length || this.props.loading
return (
<Form onSubmit={this.onSubmit}>
<TextBox
@ -55,93 +58,18 @@ export class EnterpriseServerEntry extends React.Component<IEnterpriseServerEntr
{this.props.additionalButtons}
{this.state.loading ? <Loading/> : null}
{this.props.loading ? <Loading/> : null}
{this.state.error ? <Errors>{this.state.error.message}</Errors> : null}
{this.props.error ? <Errors>{this.props.error.message}</Errors> : null}
</Form>
)
}
private onServerAddressChanged = (event: React.FormEvent<HTMLInputElement>) => {
this.setState({
serverAddress: event.currentTarget.value,
loading: false,
error: null,
})
this.setState({ serverAddress: event.currentTarget.value })
}
private async fetchAllowedAuthenticationMethods(endpoint: string): Promise<Set<AuthenticationMethods>> {
const response = await fetchMetadata(endpoint)
if (response) {
const authMethods = new Set([
AuthenticationMethods.BasicAuth,
AuthenticationMethods.OAuth,
])
if (response.verifiable_password_authentication === false) {
authMethods.delete(AuthenticationMethods.BasicAuth)
}
return authMethods
} else {
throw new Error('Unsupported Enterprise server')
}
}
private onSubmit = async () => {
const userEnteredAddress = this.state.serverAddress
let address: string
try {
address = validateURL(this.state.serverAddress)
} catch (e) {
let humanFacingError = e
if (e.name === InvalidURLErrorName) {
humanFacingError = new Error(`The Enterprise server address doesn't appear to be a valid URL. We're expecting something like https://github.example.com.`)
} else if (e.name === InvalidProtocolErrorName) {
humanFacingError = new Error('Unsupported protocol. We can only sign in to GitHub Enterprise instances over http or https.')
}
this.setState({
serverAddress: userEnteredAddress,
loading: false,
error: humanFacingError,
})
return
}
this.setState({
serverAddress: userEnteredAddress,
loading: true,
error: null,
})
const endpoint = getEnterpriseAPIURL(address)
try {
const methods = await this.fetchAllowedAuthenticationMethods(endpoint)
this.setState({
serverAddress: userEnteredAddress,
loading: false,
error: null,
})
this.props.onContinue(endpoint, methods)
} catch (e) {
// We'll get an ENOTFOUND if the address couldn't be resolved.
if (e.code === 'ENOTFOUND') {
this.setState({
serverAddress: userEnteredAddress,
loading: false,
error: new Error('The server could not be found'),
})
} else {
this.setState({
serverAddress: userEnteredAddress,
loading: false,
error: e,
})
}
}
private onSubmit = () => {
this.props.onSubmit(this.state.serverAddress)
}
}

View file

@ -14,6 +14,9 @@ interface ILinkButtonProps {
/** CSS classes attached to the component */
readonly className?: string
/** The tab index of the anchor element. */
readonly tabIndex?: number
}
/** A link component. */
@ -23,7 +26,12 @@ export class LinkButton extends React.Component<ILinkButtonProps, void> {
const className = classNames('link-button-component', this.props.className)
return (
<a className={className} href={href} onClick={this.onClick}>
<a
className={className}
href={href}
onClick={this.onClick}
tabIndex={this.props.tabIndex}
>
{this.props.children}
</a>
)

View file

@ -1,133 +1,92 @@
import * as React from 'react'
import { AuthenticationForm } from './authentication-form'
import { User } from '../../models/user'
import { assertNever, fatalError } from '../../lib/fatal-error'
import { assertNever } from '../../lib/fatal-error'
import { TwoFactorAuthentication } from '../lib/two-factor-authentication'
import { EnterpriseServerEntry, AuthenticationMethods } from '../lib/enterprise-server-entry'
import { EnterpriseServerEntry } from '../lib/enterprise-server-entry'
import {
Dispatcher,
SignInState,
SignInStep,
IEndpointEntryState,
IAuthenticationState,
ITwoFactorAuthenticationState,
} from '../../lib/dispatcher'
interface ISignInProps {
/**
* The endpoint against which the user is authenticating. If omitted, the
* component will prompt for endpoint entry before moving on to the sign in
* flow.
*/
readonly endpoint?: string
/**
* The set of authentication methods supported by the endpoint. This is only
* used if `endpoint` is defined in the props.
*/
readonly authenticationMethods?: Set<AuthenticationMethods>
/** Called after the user has signed in. */
readonly onDidSignIn: (user: User) => void
readonly signInState: SignInState
readonly dispatcher: Dispatcher
/** An array of additional buttons to render after the "Sign In" button. */
readonly children?: ReadonlyArray<JSX.Element>
}
enum SignInStep {
EndpointEntry,
Authentication,
TwoFactorAuthentication,
}
/** The default set of authentication methods. */
export const DefaultAuthMethods = new Set([
AuthenticationMethods.BasicAuth,
AuthenticationMethods.OAuth,
])
type Step = { kind: SignInStep.EndpointEntry } |
{ kind: SignInStep.Authentication, endpoint: string, authMethods: Set<AuthenticationMethods> } |
{ kind: SignInStep.TwoFactorAuthentication, endpoint: string, login: string, password: string }
interface ISignInState {
readonly step: Step
}
/** The sign in flow for GitHub. */
export class SignIn extends React.Component<ISignInProps, ISignInState> {
public constructor(props: ISignInProps) {
super(props)
export class SignIn extends React.Component<ISignInProps, void> {
this.state = { step: this.stepForProps(props) }
private onEndpointEntered = (url: string) => {
this.props.dispatcher.setSignInEndpoint(url)
}
public componentWillReceiveProps(nextProps: ISignInProps) {
if (nextProps.endpoint !== this.props.endpoint) {
this.setState({ step: this.stepForProps(nextProps) })
}
private onCredentialsEntered = (username: string, password: string) => {
this.props.dispatcher.setSignInCredentials(username, password)
}
private stepForProps(props: ISignInProps): Step {
if (props.endpoint) {
return {
kind: SignInStep.Authentication,
endpoint: props.endpoint,
authMethods: props.authenticationMethods || DefaultAuthMethods,
}
} else {
return {
kind: SignInStep.EndpointEntry,
}
}
private onBrowserSignInRequested = () => {
this.props.dispatcher.requestBrowserAuthentication()
}
private onOTPEntered = (otp: string) => {
this.props.dispatcher.setSignInOTP(otp)
}
private renderEndpointEntryStep(state: IEndpointEntryState) {
return <EnterpriseServerEntry
loading={state.loading}
error={state.error}
onSubmit={this.onEndpointEntered}
additionalButtons={this.props.children}
/>
}
private renderAuthenticationStep(state: IAuthenticationState) {
return (
<AuthenticationForm
loading={state.loading}
error={state.error}
supportsBasicAuth={state.supportsBasicAuth}
additionalButtons={this.props.children}
onBrowserSignInRequested={this.onBrowserSignInRequested}
onSubmit={this.onCredentialsEntered}
forgotPasswordUrl={state.forgotPasswordUrl}
/>
)
}
private renderTwoFactorAuthenticationStep(state: ITwoFactorAuthenticationState) {
return (
<TwoFactorAuthentication
loading={state.loading}
error={state.error}
onOTPEntered={this.onOTPEntered}
/>
)
}
public render() {
const step = this.state.step
if (step.kind === SignInStep.EndpointEntry) {
return <EnterpriseServerEntry
onContinue={this.onContinue}
additionalButtons={this.props.children}
/>
} else if (step.kind === SignInStep.Authentication) {
const supportsBasicAuth = step.authMethods.has(AuthenticationMethods.BasicAuth)
return <AuthenticationForm
endpoint={step.endpoint}
supportsBasicAuth={supportsBasicAuth}
additionalButtons={this.props.children}
onDidSignIn={this.onDidSignIn}
onNeeds2FA={this.onNeeds2FA}/>
} else if (step.kind === SignInStep.TwoFactorAuthentication) {
return <TwoFactorAuthentication
endpoint={step.endpoint}
login={step.login}
password={step.password}
onDidSignIn={this.onDidSignIn}/>
} else {
return assertNever(step, `Unknown sign-in step: ${step}`)
const state = this.props.signInState
const stepText = this.props.signInState.kind
switch (state.kind) {
case SignInStep.EndpointEntry:
return this.renderEndpointEntryStep(state)
case SignInStep.Authentication:
return this.renderAuthenticationStep(state)
case SignInStep.TwoFactorAuthentication:
return this.renderTwoFactorAuthenticationStep(state)
case SignInStep.Success:
return null
default:
return assertNever(state, `Unknown sign-in step: ${stepText}`)
}
}
private onContinue = (endpoint: string, authMethods: Set<AuthenticationMethods>) => {
this.setState({
step: {
kind: SignInStep.Authentication,
endpoint,
authMethods,
},
})
}
private onDidSignIn = (user: User) => {
this.props.onDidSignIn(user)
}
private onNeeds2FA = (login: string, password: string) => {
const currentStep = this.state.step
if (currentStep.kind !== SignInStep.Authentication) {
fatalError('You should only enter 2FA after authenticating!')
return
}
this.setState({
step: {
kind: SignInStep.TwoFactorAuthentication,
endpoint: currentStep.endpoint,
login,
password,
},
})
}
}

View file

@ -76,6 +76,9 @@ interface ITextBoxProps {
* event on the LinkButton component for more details.
*/
readonly onLabelLinkClick?: () => void
/** The tab index of the input element. */
readonly tabIndex?: number
}
interface ITextBoxState {
@ -159,7 +162,8 @@ export class TextBox extends React.Component<ITextBoxProps, ITextBoxState> {
value={this.props.value}
onChange={this.onChange}
onKeyDown={this.props.onKeyDown}
ref={this.props.onInputRef}/>
ref={this.props.onInputRef}
tabIndex={this.props.tabIndex}/>
</div>
)
}

View file

@ -1,7 +1,4 @@
import * as React from 'react'
import { createAuthorization, AuthorizationResponse, fetchUser, AuthorizationResponseKind } from '../../lib/api'
import { User } from '../../models/user'
import { assertNever } from '../../lib/fatal-error'
import { Loading } from './loading'
import { Button } from './button'
import { TextBox } from './text-box'
@ -9,23 +6,32 @@ import { Form } from './form'
import { Errors } from './errors'
interface ITwoFactorAuthenticationProps {
/** The endpoint to authenticate against. */
readonly endpoint: string
/** The login to authenticate with. */
readonly login: string
/**
* A callback which is invoked once the user has entered a
* OTP token and submitted it either by clicking on the submit
* button or by submitting the form through other means (ie hitting Enter).
*/
readonly onOTPEntered: (otp: string) => void
/** The password to authenticate with. */
readonly password: string
/**
* An error which, if present, is presented to the
* user in close proximity to the actions or input fields
* related to the current step.
*/
readonly error: Error | null
/** Called after successfully authenticating. */
readonly onDidSignIn: (user: User) => void
/**
* A value indicating whether or not the sign in store is
* busy processing a request. While this value is true all
* form inputs and actions save for a cancel action will
* be disabled.
*/
readonly loading: boolean
}
interface ITwoFactorAuthenticationState {
readonly otp: string
readonly response: AuthorizationResponse | null
readonly loading: boolean
}
/** The two-factor authentication component. */
@ -33,12 +39,16 @@ export class TwoFactorAuthentication extends React.Component<ITwoFactorAuthentic
public constructor(props: ITwoFactorAuthenticationProps) {
super(props)
this.state = { otp: '', response: null, loading: false }
this.state = { otp: '' }
}
public render() {
const textEntryDisabled = this.state.loading
const signInDisabled = !this.state.otp.length || this.state.loading
const textEntryDisabled = this.props.loading
const signInDisabled = !this.state.otp.length || this.props.loading
const errors = this.props.error
? <Errors>{this.props.error.message}</Errors>
: null
return (
<div>
<p className='welcome-text'>
@ -53,62 +63,21 @@ export class TwoFactorAuthentication extends React.Component<ITwoFactorAuthentic
autoFocus={true}
onChange={this.onOTPChange}/>
{this.renderError()}
{errors}
<Button type='submit' disabled={signInDisabled}>Verify</Button>
{this.state.loading ? <Loading/> : null}
{this.props.loading ? <Loading/> : null}
</Form>
</div>
)
}
private renderError() {
const response = this.state.response
if (!response) { return null }
switch (response.kind) {
case AuthorizationResponseKind.Authorized: return null
case AuthorizationResponseKind.Failed: return <Errors>Failed</Errors>
case AuthorizationResponseKind.TwoFactorAuthenticationRequired: return <Errors>2fa</Errors>
case AuthorizationResponseKind.Error: {
const error = response.response.error
if (error) {
return <Errors>An error occurred: {error.message}</Errors>
} else {
return <Errors>An unknown error occurred: {response.response.statusCode}: {response.response.body}</Errors>
}
}
default: return assertNever(response, `Unknown response: ${response}`)
}
}
private onOTPChange = (event: React.FormEvent<HTMLInputElement>) => {
this.setState({
otp: event.currentTarget.value,
response: null,
loading: false,
})
this.setState({ otp: event.currentTarget.value })
}
private signIn = async () => {
this.setState({
otp: this.state.otp,
response: null,
loading: true,
})
const response = await createAuthorization(this.props.endpoint, this.props.login, this.props.password, this.state.otp)
if (response.kind === AuthorizationResponseKind.Authorized) {
const token = response.token
const user = await fetchUser(this.props.endpoint, token)
this.props.onDidSignIn(user)
} else {
this.setState({
otp: this.state.otp,
response,
loading: false,
})
}
private signIn = () => {
this.props.onOTPEntered(this.state.otp)
}
}

View file

@ -1,16 +1,18 @@
import * as React from 'react'
import { User } from '../../models/user'
import { Dispatcher } from '../../lib/dispatcher'
import { Button } from '../lib/button'
import { SignIn } from '../lib/sign-in'
import { Row } from '../lib/row'
import { assertNever } from '../../lib/fatal-error'
import { getDotComAPIEndpoint } from '../../lib/api'
import { DialogContent } from '../dialog'
import { Avatar, IAvatarUser } from '../lib/avatar'
interface IAccountsProps {
readonly dispatcher: Dispatcher
readonly dotComUser: User | null
readonly enterpriseUser: User | null
readonly onDotComSignIn: () => void
readonly onEnterpriseSignIn: () => void
readonly onLogout: (user: User) => void
}
enum SignInType {
@ -21,7 +23,7 @@ enum SignInType {
export class Accounts extends React.Component<IAccountsProps, void> {
public render() {
return (
<DialogContent>
<DialogContent className='accounts-tab'>
<h2>GitHub.com</h2>
{this.props.dotComUser ? this.renderUser(this.props.dotComUser) : this.renderSignIn(SignInType.DotCom)}
@ -32,34 +34,68 @@ export class Accounts extends React.Component<IAccountsProps, void> {
}
private renderUser(user: User) {
const email = user.emails[0] || ''
const avatarUser: IAvatarUser = {
name: user.name,
email: email,
avatarURL: user.avatarURL,
}
return (
<div>
<img className='avatar' src={user.avatarURL}/>
<span>{user.login}</span>
<Row className='account-info'>
<Avatar user={avatarUser} />
<div className='user-info'>
<div className='name'>{user.name}</div>
<div className='login'>@{user.login}</div>
</div>
<Button onClick={this.logout(user)}>Log Out</Button>
</div>
</Row>
)
}
private onDotComSignIn = (event: React.FormEvent<HTMLButtonElement>) => {
event.preventDefault()
this.props.onDotComSignIn()
}
private onEnterpriseSignIn = (event: React.FormEvent<HTMLButtonElement>) => {
event.preventDefault()
this.props.onEnterpriseSignIn()
}
private renderSignIn(type: SignInType) {
switch (type) {
case SignInType.DotCom: {
return <SignIn
endpoint={getDotComAPIEndpoint()}
onDidSignIn={this.onDidSignIn}/>
return (
<Row className='account-sign-in'>
<div>
Sign in to your GitHub.com account to access your
repositories
</div>
<Button type='submit' onClick={this.onDotComSignIn}>Sign in</Button>
</Row>
)
}
case SignInType.Enterprise: return <SignIn onDidSignIn={this.onDidSignIn}/>
default: return assertNever(type, `Unknown sign in type: ${type}`)
case SignInType.Enterprise:
return (
<Row className='account-sign-in'>
<div>
If you have a GitHub Enterprise account at work, sign in to it
to get access to your repositories.
</div>
<Button type='submit' onClick={this.onEnterpriseSignIn}>Sign in</Button>
</Row>
)
default:
return assertNever(type, `Unknown sign in type: ${type}`)
}
}
private logout = (user: User) => {
return () => {
this.props.dispatcher.removeUser(user)
this.props.onLogout(user)
}
}
private onDidSignIn = async (user: User) => {
await this.props.dispatcher.addUser(user)
}
}

View file

@ -84,11 +84,31 @@ export class Preferences extends React.Component<IPreferencesProps, IPreferences
)
}
private onDotComSignIn = () => {
this.props.onDismissed()
this.props.dispatcher.showDotComSignInDialog()
}
private onEnterpriseSignIn = () => {
this.props.onDismissed()
this.props.dispatcher.showEnterpriseSignInDialog()
}
private onLogout = (user: User) => {
this.props.dispatcher.removeUser(user)
}
private renderActiveTab() {
const index = this.state.selectedIndex
switch (index) {
case PreferencesTab.Accounts:
return <Accounts {...this.props}/>
return <Accounts
dotComUser={this.props.dotComUser}
enterpriseUser={this.props.enterpriseUser}
onDotComSignIn={this.onDotComSignIn}
onEnterpriseSignIn={this.onEnterpriseSignIn}
onLogout={this.onLogout}
/>
case PreferencesTab.Git: {
return <Git
name={this.state.committerName}

View file

@ -1,7 +1,7 @@
import * as React from 'react'
import { SignIn } from './sign-in'
import { PublishRepository } from './publish-repository'
import { Dispatcher } from '../../lib/dispatcher'
import { Dispatcher, SignInState } from '../../lib/dispatcher'
import { User } from '../../models/user'
import { Repository } from '../../models/repository'
@ -13,6 +13,8 @@ interface IPublishProps {
/** The signed in users. */
readonly users: ReadonlyArray<User>
readonly signInState: SignInState | null
}
/**
@ -20,6 +22,7 @@ interface IPublishProps {
* in component.
*/
export class Publish extends React.Component<IPublishProps, void> {
public render() {
if (this.props.users.length > 0) {
return <PublishRepository
@ -27,7 +30,12 @@ export class Publish extends React.Component<IPublishProps, void> {
repository={this.props.repository}
users={this.props.users}/>
} else {
return <SignIn dispatcher={this.props.dispatcher}/>
return (
<SignIn
dispatcher={this.props.dispatcher}
signInState={this.props.signInState}
/>
)
}
}
}

View file

@ -1,13 +1,12 @@
import * as React from 'react'
import { TabBar } from '../tab-bar'
import { SignIn as SignInCore } from '../lib/sign-in'
import { assertNever } from '../../lib/fatal-error'
import { Dispatcher } from '../../lib/dispatcher'
import { getDotComAPIEndpoint } from '../../lib/api'
import { User } from '../../models/user'
import { fatalError } from '../../lib/fatal-error'
import { Dispatcher, SignInState } from '../../lib/dispatcher'
interface ISignInProps {
readonly dispatcher: Dispatcher
readonly signInState: SignInState | null
}
enum SignInTab {
@ -30,6 +29,10 @@ export class SignIn extends React.Component<ISignInProps, ISignInState> {
this.state = { selectedIndex: SignInTab.DotCom }
}
public componentWillMount() {
this.props.dispatcher.beginDotComSignIn()
}
public render() {
return (
<div>
@ -46,25 +49,31 @@ export class SignIn extends React.Component<ISignInProps, ISignInState> {
}
private renderActiveTab() {
const index = this.state.selectedIndex
switch (index) {
case SignInTab.DotCom: {
return <SignInCore
endpoint={getDotComAPIEndpoint()}
onDidSignIn={this.onDidSignIn}/>
}
case SignInTab.Enterprise: {
return <SignInCore onDidSignIn={this.onDidSignIn}/>
}
default: return assertNever(index, `Unknown tab index: ${index}`)
const signInState = this.props.signInState
if (!signInState) {
return null
}
return (
<SignInCore
signInState={signInState}
dispatcher={this.props.dispatcher}
/>
)
}
private onTabClicked = (index: number) => {
if (index === SignInTab.DotCom) {
this.props.dispatcher.beginDotComSignIn()
} else if (index === SignInTab.Enterprise) {
this.props.dispatcher.beginEnterpriseSignIn()
} else {
return fatalError(`Unsupported tab index ${index}`)
}
this.setState({ selectedIndex: index })
}
private onDidSignIn = (user: User) => {
this.props.dispatcher.addUser(user)
}
}

View file

@ -0,0 +1 @@
export * from './sign-in'

View file

@ -0,0 +1,270 @@
import * as React from 'react'
import {
Dispatcher,
SignInState,
SignInStep,
IEndpointEntryState,
IAuthenticationState,
ITwoFactorAuthenticationState,
} from '../../lib/dispatcher'
import { assertNever } from '../../lib/fatal-error'
import { Button } from '../lib/button'
import { LinkButton } from '../lib/link-button'
import { Octicon, OcticonSymbol } from '../octicons'
import { Row } from '../lib/row'
import { TextBox } from '../lib/text-box'
import { ButtonGroup } from '../lib/button-group'
import { Dialog, DialogError, DialogContent, DialogFooter } from '../dialog'
interface ISignInProps {
readonly dispatcher: Dispatcher
readonly signInState: SignInState | null
readonly onDismissed: () => void
}
interface ISignInState {
readonly endpoint: string
readonly username: string
readonly password: string
readonly otpToken: string
}
export class SignIn extends React.Component<ISignInProps, ISignInState> {
public constructor(props: ISignInProps) {
super(props)
this.state = {
endpoint: '',
username: '',
password: '',
otpToken: '',
}
}
public componentWillReceiveProps(nextProps: ISignInProps) {
if (nextProps.signInState !== this.props.signInState) {
if (nextProps.signInState && nextProps.signInState.kind === SignInStep.Success) {
this.props.onDismissed()
}
}
}
private onSubmit = () => {
const state = this.props.signInState
if (!state) {
return
}
const stepKind = state.kind
switch (state.kind) {
case SignInStep.EndpointEntry:
this.props.dispatcher.setSignInEndpoint(this.state.endpoint)
break
case SignInStep.Authentication:
if (!state.supportsBasicAuth) {
this.props.dispatcher.requestBrowserAuthentication()
} else {
this.props.dispatcher.setSignInCredentials(this.state.username, this.state.password)
}
break
case SignInStep.TwoFactorAuthentication:
this.props.dispatcher.setSignInOTP(this.state.otpToken)
break
case SignInStep.Success:
this.props.onDismissed()
break
default:
return assertNever(state, `Unknown sign in step ${stepKind}`)
}
}
private onEndpointChanged = (endpoint: string) => {
this.setState({ endpoint })
}
private onUsernameChanged = (username: string) => {
this.setState({ username })
}
private onPasswordChanged = (password: string) => {
this.setState({ password })
}
private onOTPTokenChanged = (otpToken: string) => {
this.setState({ otpToken })
}
private onSignInWithBrowser = () => {
this.props.dispatcher.requestBrowserAuthentication()
}
private renderFooter(): JSX.Element | null {
const state = this.props.signInState
if (!state || state.kind === SignInStep.Success) {
return null
}
let primaryButtonText: string
const stepKind = state.kind
switch (state.kind) {
case SignInStep.EndpointEntry:
primaryButtonText = 'Continue'
break
case SignInStep.TwoFactorAuthentication:
primaryButtonText = 'Sign in'
break
case SignInStep.Authentication:
if (!state.supportsBasicAuth) {
primaryButtonText = 'Continue with browser'
} else {
primaryButtonText = 'Sign in'
}
break
default:
return assertNever(state, `Unknown sign in step ${stepKind}`)
}
return (
<DialogFooter>
<ButtonGroup>
<Button type='submit'>{primaryButtonText}</Button>
<Button onClick={this.props.onDismissed}>Cancel</Button>
</ButtonGroup>
</DialogFooter>
)
}
private renderEndpointEntryStep(state: IEndpointEntryState) {
return (
<DialogContent>
<Row>
<TextBox
label='Enterprise server address'
value={this.state.endpoint}
onValueChanged={this.onEndpointChanged}
placeholder='https://github.example.com'
/>
</Row>
</DialogContent>
)
}
private renderAuthenticationStep(state: IAuthenticationState) {
if (!state.supportsBasicAuth) {
return (
<DialogContent>
<p>
Your GitHub Enterprise instance requires you to sign in with your browser.
</p>
</DialogContent>
)
}
return (
<DialogContent>
<Row>
<TextBox
label='Username or email address'
value={this.state.username}
onValueChanged={this.onUsernameChanged}
/>
</Row>
<Row>
<TextBox
label='Password'
value={this.state.password}
type='password'
onValueChanged={this.onPasswordChanged}
labelLinkText='Forgot password?'
labelLinkUri={state.forgotPasswordUrl}
/>
</Row>
<Row>
<div className='horizontal-rule'><span className='horizontal-rule-content'>or</span></div>
</Row>
<Row className='sign-in-with-browser'>
<LinkButton className='link-with-icon' onClick={this.onSignInWithBrowser}>
Sign in using your browser
<Octicon symbol={OcticonSymbol.linkExternal} />
</LinkButton>
</Row>
</DialogContent>
)
}
private renderTwoFactorAuthenticationStep(state: ITwoFactorAuthenticationState) {
return (
<DialogContent>
<p>
Open the two-factor authentication app on your device to view your
authentication code and verify your identity.
</p>
<Row>
<TextBox
label='Authentication code'
value={this.state.otpToken}
onValueChanged={this.onOTPTokenChanged}
labelLinkText={`What's this?`}
labelLinkUri='https://help.github.com/articles/providing-your-2fa-authentication-code/'
/>
</Row>
</DialogContent>
)
}
private renderStep(): JSX.Element | null {
const state = this.props.signInState
if (!state) {
return null
}
const stepKind = state.kind
switch (state.kind) {
case SignInStep.EndpointEntry: return this.renderEndpointEntryStep(state)
case SignInStep.Authentication: return this.renderAuthenticationStep(state)
case SignInStep.TwoFactorAuthentication: return this.renderTwoFactorAuthenticationStep(state)
case SignInStep.Success: return null
default: return assertNever(state, `Unknown sign in step ${stepKind}`)
}
}
public render() {
const state = this.props.signInState
if (!state || state.kind === SignInStep.Success) {
return null
}
const disabled = state.loading
const errors = state.error
? <DialogError>{state.error.message}</DialogError>
: null
return (
<Dialog
id='sign-in'
title='Sign in'
disabled={disabled}
onDismissed={this.props.onDismissed}
onSubmit={this.onSubmit}
loading={state.loading}
>
{errors}
{this.renderStep()}
{this.renderFooter()}
</Dialog>
)
}
}

View file

@ -2,7 +2,7 @@ import * as React from 'react'
import { ToolbarDropdown } from './dropdown'
import { ToolbarButtonStyle } from './button'
import { IAheadBehind } from '../../lib/app-state'
import { Dispatcher } from '../../lib/dispatcher'
import { Dispatcher, SignInState } from '../../lib/dispatcher'
import { Octicon, OcticonSymbol } from '../octicons'
import { Repository } from '../../models/repository'
import { RelativeTime } from '../relative-time'
@ -33,6 +33,8 @@ interface IPushPullButtonProps {
readonly dispatcher: Dispatcher
readonly repository: Repository
readonly signInState: SignInState | null
}
/**
@ -63,6 +65,7 @@ export class PushPullButton extends React.Component<IPushPullButtonProps, void>
return <Publish
repository={this.props.repository}
dispatcher={this.props.dispatcher}
signInState={this.props.signInState}
users={this.props.users}/>
}

View file

@ -1,26 +1,37 @@
import * as React from 'react'
import { WelcomeStep } from './welcome'
import { SignIn } from '../lib/sign-in'
import { Dispatcher } from '../../lib/dispatcher'
import { Dispatcher, SignInState } from '../../lib/dispatcher'
import { Button } from '../lib/button'
import { User } from '../../models/user'
import { getDotComAPIEndpoint } from '../../lib/api'
interface ISignInDotComProps {
readonly dispatcher: Dispatcher
readonly advance: (step: WelcomeStep) => void
readonly signInState: SignInState | null
}
/** The Welcome flow step to login to GitHub.com. */
export class SignInDotCom extends React.Component<ISignInDotComProps, void> {
public componentWillMount() {
this.props.dispatcher.beginDotComSignIn()
}
public render() {
const state = this.props.signInState
if (!state) {
return null
}
return (
<div id='sign-in-dot-com'>
<h1 className='welcome-title'>Sign in to GitHub.com</h1>
<SignIn
endpoint={getDotComAPIEndpoint()}
onDidSignIn={this.onDidSignIn}>
signInState={state}
dispatcher={this.props.dispatcher}>
<Button onClick={this.cancel}>Cancel</Button>
</SignIn>
</div>
@ -30,10 +41,4 @@ export class SignInDotCom extends React.Component<ISignInDotComProps, void> {
private cancel = () => {
this.props.advance(WelcomeStep.Start)
}
private onDidSignIn = async (user: User) => {
await this.props.dispatcher.addUser(user)
this.props.advance(WelcomeStep.ConfigureGit)
}
}

View file

@ -2,23 +2,34 @@ import * as React from 'react'
import { WelcomeStep } from './welcome'
import { Button } from '../lib/button'
import { SignIn } from '../lib/sign-in'
import { User } from '../../models/user'
import { Dispatcher } from '../../lib/dispatcher'
import { Dispatcher, SignInState } from '../../lib/dispatcher'
interface ISignInEnterpriseProps {
readonly dispatcher: Dispatcher
readonly advance: (step: WelcomeStep) => void
readonly signInState: SignInState | null
}
/** The Welcome flow step to login to an Enterprise instance. */
export class SignInEnterprise extends React.Component<ISignInEnterpriseProps, void> {
public render() {
const state = this.props.signInState
if (!state) {
return null
}
return (
<div id='sign-in-enterprise'>
<h1 className='welcome-title'>Sign in to your GitHub Enterprise server</h1>
<p className='welcome-text'>Get started by signing into GitHub Enterprise</p>
<SignIn onDidSignIn={this.onDidSignIn}>
<SignIn
signInState={state}
dispatcher={this.props.dispatcher}
>
<Button onClick={this.cancel}>Cancel</Button>
</SignIn>
</div>
@ -28,10 +39,4 @@ export class SignInEnterprise extends React.Component<ISignInEnterpriseProps, vo
private cancel = () => {
this.props.advance(WelcomeStep.Start)
}
private onDidSignIn = async (user: User) => {
await this.props.dispatcher.addUser(user)
this.props.advance(WelcomeStep.ConfigureGit)
}
}

View file

@ -1,5 +1,5 @@
import * as React from 'react'
import { Dispatcher, AppStore } from '../../lib/dispatcher'
import { Dispatcher, AppStore, SignInState, SignInStep } from '../../lib/dispatcher'
import { assertNever } from '../../lib/fatal-error'
import { Start } from './start'
import { SignInDotCom } from './sign-in-dot-com'
@ -20,6 +20,7 @@ export enum WelcomeStep {
interface IWelcomeProps {
readonly dispatcher: Dispatcher
readonly appStore: AppStore
readonly signInState: SignInState | null
}
interface IWelcomeState {
@ -34,16 +35,70 @@ export class Welcome extends React.Component<IWelcomeProps, IWelcomeState> {
this.state = { currentStep: WelcomeStep.Start }
}
public componentWillReceiveProps(nextProps: IWelcomeProps) {
this.advanceOnSuccessfulSignIn(nextProps)
}
/**
* Returns a value indicating whether or not the welcome flow is
* currently in one of the sign in steps, i.e. either dotcom sign
* in or enterprise sign in.
*/
private get inSignInStep() {
if (this.state.currentStep === WelcomeStep.SignInToDotCom) {
return true
}
if (this.state.currentStep === WelcomeStep.SignInToEnterprise) {
return true
}
return false
}
/**
* Checks to see whether or not we're currently in a sign in step
* and whether the newly received props signal that the user has
* signed in successfully. If both conditions holds true we move
* the user to the configure git step.
*/
private advanceOnSuccessfulSignIn(nextProps: IWelcomeProps) {
// If we're not currently in a sign in flow we don't care about
// new props
if (!this.inSignInStep) {
return
}
// We need to currently have a sign in state _and_ receive a new
// one in order to be able to make any sort of determination about
// what's going on in the sign in flow.
if (!this.props.signInState || !nextProps.signInState) {
return
}
// Only advance when the state first changes...
if (this.props.signInState.kind !== nextProps.signInState.kind) {
return
}
// ...and changes to success
if (nextProps.signInState.kind === SignInStep.Success) {
this.advanceToStep(WelcomeStep.ConfigureGit)
this.props.dispatcher.resetSignInState()
}
}
private getComponentForCurrentStep() {
const step = this.state.currentStep
const advance = (step: WelcomeStep) => this.advanceToStep(step)
const done = () => this.done()
const signInState = this.props.signInState
const props = { dispatcher: this.props.dispatcher, advance, done }
switch (step) {
case WelcomeStep.Start: return <Start {...props}/>
case WelcomeStep.SignInToDotCom: return <SignInDotCom {...props}/>
case WelcomeStep.SignInToEnterprise: return <SignInEnterprise {...props}/>
case WelcomeStep.SignInToDotCom: return <SignInDotCom {...props} signInState={signInState} />
case WelcomeStep.SignInToEnterprise: return <SignInEnterprise {...props} signInState={signInState} />
case WelcomeStep.ConfigureGit: return <ConfigureGit {...props} users={this.props.appStore.getState().users}/>
case WelcomeStep.UsageOptOut: return <UsageOptOut {...props} optOut={this.props.appStore.getStatsOptOut()}/>
default: return assertNever(step, `Unknown welcome step: ${step}`)
@ -51,6 +106,13 @@ export class Welcome extends React.Component<IWelcomeProps, IWelcomeState> {
}
private advanceToStep(step: WelcomeStep) {
if (step === WelcomeStep.SignInToDotCom) {
this.props.dispatcher.beginDotComSignIn()
} else if (step === WelcomeStep.SignInToEnterprise) {
this.props.dispatcher.beginEnterpriseSignIn()
}
this.setState({ currentStep: step })
}

View file

@ -42,3 +42,4 @@
@import "ui/expand-foldout-button";
@import "ui/discard-changes";
@import "ui/filter-list";
@import "ui/horizontal-rule";

View file

@ -48,7 +48,14 @@
text-decoration: none;
cursor: pointer;
display: inline-flex;
align-items: center;
&:hover {
text-decoration: underline;
}
&.link-with-icon .octicon {
margin-left: var(--spacing-half);
}
}

View file

@ -26,7 +26,7 @@ dialog {
// The modal class here is the transition name for the react css transition
// group which allows us to apply an animation when the popup appears.
&.modal {
&-enter {
opacity: 1;
transform: scale(0.75);
@ -166,13 +166,24 @@ dialog {
margin-bottom: 0;
}
}
h2 {
font-weight: var(--font-weight-light);
font-size: var(--font-size-md);
margin-top: 0;
margin-bottom: var(--spacing-half);
&:last-child {
margin-bottom: 0;
}
}
}
.dialog-footer {
border-top: var(--base-border);
padding: var(--spacing-double);
display: flex;
flex-direction: row;
justify-content: flex-end;
@ -212,6 +223,11 @@ dialog {
padding: var(--spacing);
align-items: center;
// Error messages may contains newlines which separates sections
// of the error of indicate nested errors. We want to preserve these
// while still forcing line breaks if necessary.
white-space: pre-wrap;
background: var(--form-error-background);
border-bottom: 1px solid var(--form-error-border-color);
color: var(--error-color);
@ -238,4 +254,19 @@ dialog {
overflow-y: auto;
}
}
&#preferences { width: 450px; }
&#sign-in {
width: 400px;
.sign-in-with-browser {
justify-content: center;
}
.forgot-password-row, .what-is-this-row {
font-size: var(--font-size-sm);
justify-content: flex-end;
}
}
}

View file

@ -0,0 +1,22 @@
.horizontal-rule {
text-align: center;
position: relative;
width: 100%;
&:after {
border-bottom: var(--base-border);
content: '';
position: absolute;
left: 0;
right: 0;
bottom: 0.6em;
}
.horizontal-rule-content {
display: inline-block;
background: var(--background-color);
padding: 0 0.5em;
position: relative;
z-index: 1;
}
}

View file

@ -1,6 +1,39 @@
#preferences {
.avatar {
width: 32px;
height: 32px;
.accounts-tab {
.account-info, .account-sign-in {
button {
flex-shrink: 0;
flex-grow: 0;
align-self: flex-start;
min-width: 120px;
}
}
.account-info {
.avatar {
// 32px for the image + 2 on each side for the base border.
width: 34px;
height: 34px;
border: var(--base-border);
border-radius: var(--border-radius);
align-self: center;
}
.user-info {
flex-grow: 1;
align-self: flex-start;
.name {
font-weight: var(--font-weight-semibold);
// Tighten it up a little so that the real name and
// username lines up with the avatar.
margin-top: -2px;
margin-bottom: -2px;
}
}
}
}
}

View file

@ -72,28 +72,6 @@
width: 200px;
}
.horizontal-rule {
text-align: center;
position: relative;
&:after {
border-bottom: var(--base-border);
content: '';
position: absolute;
left: 0;
right: 0;
bottom: 0.6em;
}
.horizontal-rule-content {
display: inline-block;
background: var(--background-color);
padding: 0 0.5em;
position: relative;
z-index: 1;
}
}
.sign-in-field {
width: 100%;
}
@ -102,11 +80,6 @@
text-align: center;
}
.link-with-icon svg {
margin-left: 0.25em;
vertical-align: middle;
}
.sign-in-form {
display: flex;
flex-direction: column;

View file

@ -75,4 +75,4 @@
margin-left: var(--spacing-third);
}
}
}
}

View file

@ -13,6 +13,7 @@ import {
CloningRepositoriesStore,
EmojiStore,
IssuesStore,
SignInStore,
} from '../../src/lib/dispatcher'
import { InMemoryDispatcher } from '../in-memory-dispatcher'
import { TestGitHubUserDatabase } from '../test-github-user-database'
@ -36,7 +37,14 @@ describe('App', () => {
await statsDb.reset()
statsStore = new StatsStore(statsDb)
appStore = new AppStore(new GitHubUserStore(db), new CloningRepositoriesStore(), new EmojiStore(), new IssuesStore(issuesDb), statsStore)
appStore = new AppStore(
new GitHubUserStore(db),
new CloningRepositoriesStore(),
new EmojiStore(),
new IssuesStore(issuesDb),
statsStore,
new SignInStore()
)
dispatcher = new InMemoryDispatcher(appStore)
})