Skip to content

Commit d9f3029

Browse files
committed
Migrate the existing login form to use React
1 parent 0ab3768 commit d9f3029

File tree

15 files changed

+322
-72
lines changed

15 files changed

+322
-72
lines changed

package.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
"@hot-loader/react-dom": "^16.8.6",
55
"axios": "^0.18.0",
66
"brace": "^0.11.1",
7+
"classnames": "^2.2.6",
78
"date-fns": "^1.29.0",
89
"feather-icons": "^4.10.0",
910
"jquery": "^3.3.1",
@@ -12,6 +13,7 @@
1213
"react-dom": "^16.8.6",
1314
"react-hot-loader": "^4.9.0",
1415
"react-router-dom": "^5.0.1",
16+
"react-transition-group": "^4.1.0",
1517
"redux": "^4.0.1",
1618
"socket.io-client": "^2.2.0",
1719
"ws-wrapper": "^2.0.0",
@@ -22,10 +24,13 @@
2224
"@babel/plugin-syntax-dynamic-import": "^7.2.0",
2325
"@babel/preset-env": "^7.3.1",
2426
"@babel/preset-react": "^7.0.0",
27+
"@types/classnames": "^2.2.8",
2528
"@types/feather-icons": "^4.7.0",
2629
"@types/lodash": "^4.14.119",
2730
"@types/react": "^16.8.19",
2831
"@types/react-dom": "^16.8.4",
32+
"@types/react-router-dom": "^4.3.3",
33+
"@types/react-transition-group": "^2.9.2",
2934
"@types/webpack-env": "^1.13.6",
3035
"@typescript-eslint/eslint-plugin": "^1.10.1",
3136
"@typescript-eslint/parser": "^1.10.1",
Lines changed: 11 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -1,68 +1,19 @@
1-
.animate {
2-
&.fadein {
3-
animation: fadein 500ms;
4-
}
5-
}
6-
7-
.animated-fade-in {
8-
animation: fadein 500ms;
1+
/*! purgecss start ignore */
2+
.fade-enter {
3+
@apply .opacity-0;
94
}
105

116
.fade-enter-active {
12-
animation: fadein 500ms;
13-
}
14-
15-
.fade-leave-active {
16-
animation: fadein 500ms reverse;
17-
}
18-
19-
@keyframes fadein {
20-
from { opacity: 0; }
21-
to { opacity: 1; }
22-
}
23-
24-
@keyframes onlineblink {
25-
0% {
26-
@apply .bg-green-500;
27-
}
28-
100% {
29-
@apply .bg-green-600;
30-
}
31-
}
32-
33-
@keyframes offlineblink {
34-
0% {
35-
@apply .bg-red-500;
36-
}
37-
100% {
38-
@apply .bg-red-600;
39-
}
40-
}
41-
42-
/*
43-
* transition="modal"
44-
*/
45-
.modal-enter, .modal-leave-active {
46-
opacity: 0;
47-
}
48-
49-
.modal-enter .modal-container,
50-
.modal-leave-active .modal-container {
51-
animation: opacity 250ms linear;
52-
}
53-
54-
/**
55-
* name="slide-fade" mode="out-in"
56-
*/
57-
.slide-fade-enter-active {
58-
transition: all 250ms ease;
7+
@apply .opacity-100;
8+
transition: opacity 150ms;
599
}
6010

61-
.slide-fade-leave-active {
62-
transition: all 250ms cubic-bezier(1.0, 0.5, 0.8, 1.0);
11+
.fade-exit {
12+
@apply .opacity-100;
6313
}
6414

65-
.slide-fade-enter, .slide-fade-leave-to {
66-
transform: translateX(10px);
67-
opacity: 0;
15+
.fade-exit-active {
16+
@apply .opacity-0;
17+
transition: opacity 150ms;
6818
}
19+
/*! purgecss end ignore */
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import http from '@/api/http';
2+
3+
interface LoginResponse {
4+
complete: boolean;
5+
intended?: string;
6+
token?: string;
7+
}
8+
9+
export default (user: string, password: string): Promise<LoginResponse> => {
10+
return new Promise((resolve, reject) => {
11+
http.post('/auth/login', { user, password })
12+
.then(response => {
13+
if (!(response.data instanceof Object)) {
14+
return reject(new Error('An error occurred while processing the login request.'));
15+
}
16+
17+
return resolve({
18+
complete: response.data.complete,
19+
intended: response.data.intended || undefined,
20+
token: response.data.token || undefined,
21+
});
22+
})
23+
.catch(reject);
24+
});
25+
};

resources/scripts/api/http.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import axios, { AxiosInstance } from 'axios';
2+
3+
// This token is set in the bootstrap.js file at the beginning of the request
4+
// and is carried through from there.
5+
// const token: string = '';
6+
7+
const http: AxiosInstance = axios.create({
8+
headers: {
9+
'X-Requested-With': 'XMLHttpRequest',
10+
'Accept': 'application/json',
11+
'Content-Type': 'application/json',
12+
},
13+
});
14+
15+
// If we have a phpdebugbar instance registered at this point in time go
16+
// ahead and route the response data through to it so things show up.
17+
// @ts-ignore
18+
if (typeof window.phpdebugbar !== 'undefined') {
19+
http.interceptors.response.use(response => {
20+
// @ts-ignore
21+
window.phpdebugbar.ajaxHandler.handle(response.request);
22+
23+
return response;
24+
});
25+
}
26+
27+
export default http;
28+
29+
/**
30+
* Converts an error into a human readable response. Mostly just a generic helper to
31+
* make sure we display the message from the server back to the user if we can.
32+
*/
33+
export function httpErrorToHuman (error: any): string {
34+
if (error.response && error.response.data) {
35+
const { data } = error.response;
36+
if (data.errors && data.errors[0] && data.errors[0].detail) {
37+
return data.errors[0].detail;
38+
}
39+
}
40+
41+
return error.message;
42+
}

resources/scripts/components/App.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,17 @@
11
import * as React from 'react';
22
import { hot } from 'react-hot-loader/root';
3+
import { BrowserRouter as Router, Route } from 'react-router-dom';
4+
import AuthenticationRouter from '@/routers/AuthenticationRouter';
35

46
class App extends React.PureComponent {
57
render () {
68
return (
7-
<h1>Hello</h1>
9+
<Router>
10+
<div>
11+
<Route exact path="/"/>
12+
<Route path="/auth" component={AuthenticationRouter}/>
13+
</div>
14+
</Router>
815
);
916
}
1017
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import * as React from 'react';
2+
3+
interface Props {
4+
title?: string;
5+
message: string;
6+
type?: 'success' | 'info' | 'warning' | 'error';
7+
}
8+
9+
export default ({ title, message, type }: Props) => (
10+
<div className={`lg:inline-flex alert ${type}`} role={'alert'}>
11+
{title && <span className={'title'}>{title}</span>}
12+
<span className={'message'}>{message}</span>
13+
</div>
14+
);
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import * as React from 'react';
2+
import OpenInputField from '@/components/forms/OpenInputField';
3+
import { Link } from 'react-router-dom';
4+
import login from '@/api/auth/login';
5+
import { httpErrorToHuman } from '@/api/http';
6+
import MessageBox from '@/components/MessageBox';
7+
8+
type State = Readonly<{
9+
errorMessage?: string;
10+
isLoading: boolean;
11+
username?: string;
12+
password?: string;
13+
}>;
14+
15+
export default class LoginContainer extends React.PureComponent<{}, State> {
16+
username = React.createRef<HTMLInputElement>();
17+
18+
state: State = {
19+
isLoading: false,
20+
};
21+
22+
submit = (e: React.FormEvent<HTMLFormElement>) => {
23+
e.preventDefault();
24+
25+
const { username, password } = this.state;
26+
27+
this.setState({ isLoading: true }, () => {
28+
login(username!, password!)
29+
.then(response => {
30+
31+
})
32+
.catch(error => this.setState({
33+
isLoading: false,
34+
errorMessage: httpErrorToHuman(error),
35+
}, () => console.error(error)));
36+
});
37+
};
38+
39+
canSubmit () {
40+
if (!this.state.username || !this.state.password) {
41+
return false;
42+
}
43+
44+
return this.state.username.length > 0 && this.state.password.length > 0;
45+
}
46+
47+
// @ts-ignore
48+
handleFieldUpdate = (e: React.ChangeEvent<HTMLInputElement>) => this.setState({
49+
[e.target.id]: e.target.value,
50+
});
51+
52+
render () {
53+
return (
54+
<React.Fragment>
55+
{this.state.errorMessage &&
56+
<div className={'mb-4'}>
57+
<MessageBox
58+
type={'error'}
59+
title={'Error'}
60+
message={this.state.errorMessage}
61+
/>
62+
</div>
63+
}
64+
<form className={'login-box'} onSubmit={this.submit}>
65+
<div className={'-mx-3'}>
66+
<OpenInputField
67+
autoFocus={true}
68+
label={'Username or Email'}
69+
type={'text'}
70+
required={true}
71+
id={'username'}
72+
onChange={this.handleFieldUpdate}
73+
disabled={this.state.isLoading}
74+
/>
75+
</div>
76+
<div className={'-mx-3 mt-6'}>
77+
<OpenInputField
78+
label={'Password'}
79+
type={'password'}
80+
required={true}
81+
id={'password'}
82+
onChange={this.handleFieldUpdate}
83+
disabled={this.state.isLoading}
84+
/>
85+
</div>
86+
<div className={'mt-6'}>
87+
<button
88+
type={'submit'}
89+
className={'btn btn-primary btn-jumbo'}
90+
disabled={this.state.isLoading || !this.canSubmit()}
91+
>
92+
{this.state.isLoading ?
93+
<span className={'spinner white'}>&nbsp;</span>
94+
:
95+
'Login'
96+
}
97+
</button>
98+
</div>
99+
<div className={'mt-6 text-center'}>
100+
<Link
101+
to={'/auth/forgot-password'}
102+
className={'text-xs text-neutral-500 tracking-wide no-underline uppercase hover:text-neutral-600'}
103+
>
104+
Forgot password?
105+
</Link>
106+
</div>
107+
</form>
108+
</React.Fragment>
109+
);
110+
}
111+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import * as React from 'react';
2+
import classNames from 'classnames';
3+
4+
type Props = React.InputHTMLAttributes<HTMLInputElement> & {
5+
label: string;
6+
};
7+
8+
export default ({ className, onChange, label, ...props }: Props) => {
9+
const [ value, setValue ] = React.useState('');
10+
11+
const classes = classNames('input open-label', {
12+
'has-content': value && value.length > 0,
13+
});
14+
15+
return (
16+
<div className={'input-open'}>
17+
<input
18+
className={classes}
19+
onChange={e => {
20+
setValue(e.target.value);
21+
if (onChange) {
22+
onChange(e);
23+
}
24+
}}
25+
{...props}
26+
/>
27+
<label htmlFor={props.id}>{label}</label>
28+
</div>
29+
);
30+
};
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import * as React from 'react';
2+
import { BrowserRouter, Route, Switch } from 'react-router-dom';
3+
import LoginContainer from '@/components/auth/LoginContainer';
4+
import { CSSTransition, TransitionGroup } from 'react-transition-group';
5+
6+
export default class AuthenticationRouter extends React.PureComponent {
7+
render () {
8+
return (
9+
<BrowserRouter basename={'/auth'}>
10+
<Route
11+
render={({ location }) => (
12+
<TransitionGroup>
13+
<CSSTransition key={location.key} timeout={150} classNames={'fade'}>
14+
<Switch location={location}>
15+
<Route path={'/login'} component={LoginContainer}/>
16+
<Route path={'/forgot-password'}/>
17+
<Route path={'/checkpoint'}/>
18+
</Switch>
19+
</CSSTransition>
20+
</TransitionGroup>
21+
)}
22+
/>
23+
</BrowserRouter>
24+
);
25+
}
26+
}

resources/themes/pterodactyl/templates/auth/core.blade.php

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,7 @@
44

55
@section('container')
66
<div class="w-full max-w-xs sm:max-w-sm m-auto mt-8">
7-
<div class="text-center hidden sm:block">
8-
<img src="/assets/img/pterodactyl-flat.svg" class="max-w-xxs">
9-
</div>
10-
<router-view></router-view>
7+
<div id="app"></div>
118
<p class="text-center text-neutral-500 text-xs">
129
{!! trans('strings.copyright', ['year' => date('Y')]) !!}
1310
</p>

0 commit comments

Comments
 (0)