@wral/studio.mods.auth 0.3.7 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +39 -47
- package/bitbucket-pipelines.yml +25 -1
- package/dist/auth.cjs.js +326 -1467
- package/dist/auth.es.js +1081 -3093
- package/dist/lib.cjs.js +1 -1
- package/dist/lib.es.js +13 -7
- package/eslint.config.mjs +41 -34
- package/index.html +83 -18
- package/jest.config.mjs +24 -0
- package/jest.setup.mjs +5 -0
- package/package.json +15 -28
- package/src/auth.mjs +204 -69
- package/src/auth.test.mjs +97 -0
- package/src/components/auth-app.mjs +26 -0
- package/src/components/forgot-password-form.mjs +217 -0
- package/src/components/login-form.mjs +288 -0
- package/src/config.mjs +27 -0
- package/src/helper.mjs +31 -0
- package/src/helper.test.mjs +44 -0
- package/src/index.mjs +17 -0
- package/src/login-layout.mjs +32 -0
- package/src/login.mjs +20 -0
- package/src/routes/change-password.mjs +158 -0
- package/src/routes/dashboard.mjs +17 -0
- package/src/routes/index.mjs +15 -0
- package/src/state.mjs +61 -0
- package/src/state.test.mjs +58 -0
- package/src/styles.mjs +9 -0
- package/src/token.mjs +40 -0
- package/src/utils.mjs +3 -0
- package/vellum-fixture.mjs +86 -0
- package/vite.config.mjs +12 -0
- package/components.html +0 -43
- package/development.md +0 -41
- package/src/components/mod-auth-login-form.mjs +0 -133
- package/src/components/studio-change-password.mjs +0 -84
- package/src/components/studio-login.mjs +0 -94
- package/src/components/studio-profile-view.mjs +0 -56
- package/src/components/studio-reset-password.mjs +0 -110
- package/src/lib.mjs +0 -16
- package/src/tool-dummy.mjs +0 -84
- package/src/util.mjs +0 -194
- package/src/util.test.mjs +0 -171
- package/vite.config.js +0 -12
- package/web-test-runner.config.mjs +0 -28
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import {
|
|
2
|
+
makeAuth,
|
|
3
|
+
tokenRequestHandler,
|
|
4
|
+
} from './auth.mjs';
|
|
5
|
+
import { JSDOM } from 'jsdom';
|
|
6
|
+
import { jest } from '@jest/globals';
|
|
7
|
+
|
|
8
|
+
function mockToolkit() {
|
|
9
|
+
const dom = new JSDOM(`<!DOCTYPE html><body><studio-app>
|
|
10
|
+
<studio-mod id="mod-auth"></studio-mod>
|
|
11
|
+
</studio-app></body>`);
|
|
12
|
+
return {
|
|
13
|
+
element: dom.window.document.querySelector('studio-app'),
|
|
14
|
+
mod: {
|
|
15
|
+
element: dom.window.document.querySelector('#mod-auth'),
|
|
16
|
+
},
|
|
17
|
+
dispatchAction: jest.fn(),
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe('auth mod', () => {
|
|
22
|
+
|
|
23
|
+
describe('makeAuth', () => {
|
|
24
|
+
|
|
25
|
+
test('should return an object with method-like properties', () => {
|
|
26
|
+
const mod = makeAuth({
|
|
27
|
+
toolkit: mockToolkit(),
|
|
28
|
+
});
|
|
29
|
+
expect(mod).toHaveProperty('mount');
|
|
30
|
+
expect(mod).toHaveProperty('handleTokenRequest');
|
|
31
|
+
expect(mod).toHaveProperty('handleDestroyAuth');
|
|
32
|
+
expect(mod).toHaveProperty('presentLoginForm');
|
|
33
|
+
expect(mod).toHaveProperty('getToken');
|
|
34
|
+
expect(mod).toHaveProperty('saveToken');
|
|
35
|
+
expect(mod).toHaveProperty('getFreshToken');
|
|
36
|
+
expect(mod).toHaveProperty('requestRender');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe('mount', () => {
|
|
42
|
+
test('registers action handlers and layout', () => {
|
|
43
|
+
const mod = makeAuth({
|
|
44
|
+
toolkit: mockToolkit(),
|
|
45
|
+
});
|
|
46
|
+
mod.mount();
|
|
47
|
+
expect(mod.config.toolkit.dispatchAction).toHaveBeenCalledWith({
|
|
48
|
+
type: 'action:register',
|
|
49
|
+
detail: {
|
|
50
|
+
actionType: 'auth:requestToken',
|
|
51
|
+
handler: expect.any(Function),
|
|
52
|
+
modName: 'auth',
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
expect(mod.config.toolkit.dispatchAction).toHaveBeenCalledWith({
|
|
56
|
+
type: 'action:register',
|
|
57
|
+
detail: {
|
|
58
|
+
actionType: 'auth:destroy',
|
|
59
|
+
handler: expect.any(Function),
|
|
60
|
+
modName: 'auth',
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
expect(mod.config.toolkit.dispatchAction).toHaveBeenCalledWith({
|
|
64
|
+
type: 'layout:register',
|
|
65
|
+
detail: {
|
|
66
|
+
name: 'auth:login',
|
|
67
|
+
slots: ['main'],
|
|
68
|
+
templateFn: expect.any(Function),
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe('tokenRequestHandler', () => {
|
|
75
|
+
test('returns a handler that resolves to a fresh token', async () => {
|
|
76
|
+
const handler = tokenRequestHandler({
|
|
77
|
+
getFreshToken: jest.fn(async () => 'token'),
|
|
78
|
+
});
|
|
79
|
+
const callback = jest.fn();
|
|
80
|
+
await handler({ callback });
|
|
81
|
+
expect(callback).toHaveBeenCalledWith('token');
|
|
82
|
+
});
|
|
83
|
+
test('presents login form if getFreshToken fails', async () => {
|
|
84
|
+
const presentLoginForm = jest.fn();
|
|
85
|
+
const handler = tokenRequestHandler({
|
|
86
|
+
log: jest.fn(),
|
|
87
|
+
getFreshToken: jest.fn().mockRejectedValue(new Error('fail')),
|
|
88
|
+
presentLoginForm,
|
|
89
|
+
});
|
|
90
|
+
const callback = jest.fn();
|
|
91
|
+
await handler({ callback });
|
|
92
|
+
expect(callback).not.toHaveBeenCalled();
|
|
93
|
+
expect(presentLoginForm).toHaveBeenCalled();
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { LitElement } from 'lit';
|
|
2
|
+
import { renderRoute } from '../routes/index.mjs';
|
|
3
|
+
import appStyles from '../styles.mjs';
|
|
4
|
+
|
|
5
|
+
class AuthApp extends LitElement {
|
|
6
|
+
static get styles() {
|
|
7
|
+
return [appStyles];
|
|
8
|
+
}
|
|
9
|
+
static get properties() {
|
|
10
|
+
return {
|
|
11
|
+
state: { type: Object },
|
|
12
|
+
auth: { type: Object },
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
render() {
|
|
17
|
+
const { state, auth } = this;
|
|
18
|
+
return renderRoute(state, auth);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export default AuthApp;
|
|
23
|
+
|
|
24
|
+
if (!customElements.get('auth-app')) {
|
|
25
|
+
customElements.define('auth-app', AuthApp);
|
|
26
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { html, css, LitElement } from 'lit';
|
|
2
|
+
import login from '../login.mjs';
|
|
3
|
+
import { createClient } from '@wral/sdk-auth';
|
|
4
|
+
|
|
5
|
+
const stages = Object.freeze({
|
|
6
|
+
REQUEST: 'request',
|
|
7
|
+
CONFIRM: 'confirm',
|
|
8
|
+
FINISHED: 'finished',
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
export class ForgotPasswordForm extends LitElement {
|
|
12
|
+
|
|
13
|
+
static get properties() {
|
|
14
|
+
return {
|
|
15
|
+
api: { type: String, attribute: 'api' },
|
|
16
|
+
confirmation: { type: 'String', attribute: 'confirmation' },
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
static get styles() {
|
|
21
|
+
return css`
|
|
22
|
+
:host {
|
|
23
|
+
display: block;
|
|
24
|
+
font-family: Arial, sans-serif;
|
|
25
|
+
}
|
|
26
|
+
form input[type="text"],
|
|
27
|
+
form input[type="password"] {
|
|
28
|
+
width: 100%;
|
|
29
|
+
margin: 5px 0 15px 0;
|
|
30
|
+
padding: var(--spacing-sm, 0.5rem) var(--spacing-md, 1rem);
|
|
31
|
+
box-sizing: border-box;
|
|
32
|
+
border: 1px solid var(--color-gray-300, #ccc);
|
|
33
|
+
border-radius: var(--radius-sm, 5px);
|
|
34
|
+
color: var(--color-gray-9, #333);
|
|
35
|
+
background-color: var(--color-gray-0, transparent);
|
|
36
|
+
}
|
|
37
|
+
form input[type="submit"] {
|
|
38
|
+
background-color:var(--color-primary, inherit);
|
|
39
|
+
color: var(--color-gray-0, #fff);
|
|
40
|
+
border: none;
|
|
41
|
+
padding: var(--spacing-sm, 0.5rem) var(--spacing-md, 1rem);
|
|
42
|
+
border-radius: var(--radius-dynamic, 5px);
|
|
43
|
+
width: 200px;
|
|
44
|
+
display: block;
|
|
45
|
+
margin: var(--spacing-md, 1rem) auto;
|
|
46
|
+
cursor: pointer;
|
|
47
|
+
}
|
|
48
|
+
form label {
|
|
49
|
+
font-size: 14px;
|
|
50
|
+
color: var(--color-gray-9, #333);
|
|
51
|
+
font-weight: bold;
|
|
52
|
+
margin-bottom: var(--spacing-sm, 0.5rem);
|
|
53
|
+
}
|
|
54
|
+
.error {
|
|
55
|
+
color: var(--color-danger, red);
|
|
56
|
+
font-size: 12px;
|
|
57
|
+
}
|
|
58
|
+
`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
constructor() {
|
|
62
|
+
super();
|
|
63
|
+
this.errors = {};
|
|
64
|
+
this.stage = stages.REQUEST;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
connectedCallback() {
|
|
68
|
+
console.log('[auth]','[forgot-password]', { confirmation: this.confirmation });
|
|
69
|
+
super.connectedCallback();
|
|
70
|
+
// If there is a confirmation, update stage
|
|
71
|
+
if (this.confirmation && this.stage !== stages.FINISHED) {
|
|
72
|
+
this.stage = stages.CONFIRM;
|
|
73
|
+
}
|
|
74
|
+
this.requestUpdate();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
handleSubmitRequest(e) {
|
|
78
|
+
e.preventDefault();
|
|
79
|
+
// Check for submission errors (none since username is required)
|
|
80
|
+
const username = e.target.username.value;
|
|
81
|
+
this.username = username;
|
|
82
|
+
|
|
83
|
+
// Send API Request
|
|
84
|
+
const authClient = createClient({
|
|
85
|
+
baseUrl: this.api,
|
|
86
|
+
});
|
|
87
|
+
authClient.triggerResetPassword({ username }).then(() => {
|
|
88
|
+
// Update stage
|
|
89
|
+
console.log('[auth]', 'triggered reset password for ', username);
|
|
90
|
+
this.stage = stages.CONFIRM;
|
|
91
|
+
}).catch((error) => {
|
|
92
|
+
console.error('[auth] error triggering reset password', error);
|
|
93
|
+
this.errors['general'] = error.message ||
|
|
94
|
+
'Unable to send password reset request. Please try again later.';
|
|
95
|
+
}).finally(() => {
|
|
96
|
+
this.requestUpdate();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
handleSubmitConfirm(e) {
|
|
102
|
+
e.preventDefault();
|
|
103
|
+
|
|
104
|
+
// Check for submission errors (passwords match)
|
|
105
|
+
const password = e.target.password.value;
|
|
106
|
+
const confirmPassword = e.target['confirm-password'].value;
|
|
107
|
+
if (password !== confirmPassword) {
|
|
108
|
+
this.errors['confirm-password'] = 'Passwords do not match.';
|
|
109
|
+
this.requestUpdate();
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
const confirmationCode = this.confirmation || e.target['confirmation'].value;
|
|
113
|
+
if (!confirmationCode) {
|
|
114
|
+
this.errors['general'] = 'Confirmation code is required.';
|
|
115
|
+
this.requestUpdate();
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Send API request
|
|
120
|
+
const authClient = createClient({
|
|
121
|
+
baseUrl: this.api,
|
|
122
|
+
});
|
|
123
|
+
authClient.confirmResetPassword({
|
|
124
|
+
username: this.username,
|
|
125
|
+
newPassword: password,
|
|
126
|
+
confirmationCode,
|
|
127
|
+
}).then(() => {
|
|
128
|
+
// Login and emit a login event
|
|
129
|
+
// TODO: DRY this login event
|
|
130
|
+
return login(this, { username: this.username, password }).then(({token}) => {
|
|
131
|
+
// emit a success event
|
|
132
|
+
this.dispatchEvent(new CustomEvent('login-success', {
|
|
133
|
+
detail: { token },
|
|
134
|
+
composed: true,
|
|
135
|
+
bubbles: true,
|
|
136
|
+
}));
|
|
137
|
+
});
|
|
138
|
+
}).catch((error) => {
|
|
139
|
+
console.error('[auth][forgot-password-form]',error);
|
|
140
|
+
this.errors['general'] = 'Unable to reset password. Please try again later.';
|
|
141
|
+
}).finally(() => {
|
|
142
|
+
this.requestUpdate();
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
render() {
|
|
147
|
+
switch (this.stage) {
|
|
148
|
+
case stages.CONFIRM:
|
|
149
|
+
return this.renderConfirmForm();
|
|
150
|
+
case stages.FINISHED:
|
|
151
|
+
return html`<p>Your password has been reset.</p>`;
|
|
152
|
+
case stages.REQUEST_SENT:
|
|
153
|
+
return html`<p>A email has been sent with instructions to finish
|
|
154
|
+
resetting your password.</p>`;
|
|
155
|
+
case stages.REQUEST:
|
|
156
|
+
default:
|
|
157
|
+
return this.renderRequestForm();
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
renderRequestForm() {
|
|
162
|
+
return html`<form @submit=${this.handleSubmitRequest}>
|
|
163
|
+
<label for="username">Username/Email</label>
|
|
164
|
+
<input
|
|
165
|
+
type="text"
|
|
166
|
+
name="username"
|
|
167
|
+
placeholder="Email or Username"
|
|
168
|
+
required
|
|
169
|
+
/>
|
|
170
|
+
<p class="error">${this.errors['general']}</p>
|
|
171
|
+
<input type="submit" value="Request password reset" />
|
|
172
|
+
</form>`;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
renderConfirmForm() {
|
|
176
|
+
return html`<form @submit=${this.handleSubmitConfirm}>
|
|
177
|
+
<p>You should have received a confirmation code to reset your password</p>
|
|
178
|
+
|
|
179
|
+
${this.confirmation ? ''
|
|
180
|
+
: html`<label for="confirmation">Confirmation Code</label>
|
|
181
|
+
<input
|
|
182
|
+
type="text"
|
|
183
|
+
name="confirmation"
|
|
184
|
+
placeholder="Confirmation Code"
|
|
185
|
+
required
|
|
186
|
+
/>`}
|
|
187
|
+
|
|
188
|
+
<label for="password">New Password</label>
|
|
189
|
+
<input
|
|
190
|
+
type="password"
|
|
191
|
+
name="password"
|
|
192
|
+
placeholder="Password"
|
|
193
|
+
required
|
|
194
|
+
/>
|
|
195
|
+
${this.errors['password']
|
|
196
|
+
? html`<p class="error">${this.errors['password']}</p>`
|
|
197
|
+
: ''}
|
|
198
|
+
|
|
199
|
+
<label for="confirm-password">Confirm Password</label>
|
|
200
|
+
<input
|
|
201
|
+
type="password"
|
|
202
|
+
name="confirm-password"
|
|
203
|
+
placeholder="Confirm Password"
|
|
204
|
+
required
|
|
205
|
+
/>
|
|
206
|
+
${this.errors['confirm-password']
|
|
207
|
+
? html`<p class="error">${this.errors['confirm-password']}</p>`
|
|
208
|
+
: ''}
|
|
209
|
+
|
|
210
|
+
${this.errors['general']
|
|
211
|
+
? html`<p class="error">${this.errors['general']}</p>`
|
|
212
|
+
: ''}
|
|
213
|
+
<input type="submit" value="Reset Password" />
|
|
214
|
+
</form>`;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
}
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This login form is not part of any application route, because it represents
|
|
3
|
+
* the public facing unauthenticated side of the application.
|
|
4
|
+
*
|
|
5
|
+
* Anything regarding login that is handled by an unauthenticated user should
|
|
6
|
+
* be handled through here.
|
|
7
|
+
*
|
|
8
|
+
* Other routes assume the user is authenticated.
|
|
9
|
+
*/
|
|
10
|
+
import { LitElement, html, css } from 'lit';
|
|
11
|
+
import login from '../login.mjs';
|
|
12
|
+
import { ForgotPasswordForm } from './forgot-password-form.mjs';
|
|
13
|
+
import { createClient } from '@wral/sdk-auth';
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class LoginForm extends LitElement {
|
|
17
|
+
|
|
18
|
+
static get properties() {
|
|
19
|
+
return {
|
|
20
|
+
api: { type: String, attribute: 'api' },
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
static get styles() {
|
|
25
|
+
return css`
|
|
26
|
+
:host {
|
|
27
|
+
display: block;
|
|
28
|
+
font-family: Arial, sans-serif;
|
|
29
|
+
}
|
|
30
|
+
.login-form {
|
|
31
|
+
background-color: var(--color-gray-0, #fff);
|
|
32
|
+
padding: var(--spacing-xl, 3rem);
|
|
33
|
+
border-radius: var(--radius-md, 5px);
|
|
34
|
+
box-shadow: var(--drop-shadow-md, none);
|
|
35
|
+
}
|
|
36
|
+
form input[type="text"],
|
|
37
|
+
form input[type="password"] {
|
|
38
|
+
width: 100%;
|
|
39
|
+
margin: 5px 0 15px 0;
|
|
40
|
+
padding: var(--spacing-sm, 0.5rem) var(--spacing-md, 1rem);
|
|
41
|
+
box-sizing: border-box;
|
|
42
|
+
border: 1px solid var(--color-gray-300, #ccc);
|
|
43
|
+
border-radius: var(--radius-sm, 5px);
|
|
44
|
+
color: var(--color-gray-9, #333);
|
|
45
|
+
background-color: var(--color-gray-0, transparent);
|
|
46
|
+
}
|
|
47
|
+
form input[type="submit"] {
|
|
48
|
+
background-color: var(--color-primary, inherit);
|
|
49
|
+
color: var(--color-gray-0, #fff);
|
|
50
|
+
border: none;
|
|
51
|
+
padding: var(--spacing-sm, 0.5rem) var(--spacing-md, 1rem);
|
|
52
|
+
border-radius: var(--radius-dynamic, 5px);
|
|
53
|
+
width: 200px;
|
|
54
|
+
display: block;
|
|
55
|
+
margin: var(--spacing-md, 1rem) auto;
|
|
56
|
+
cursor: pointer;
|
|
57
|
+
}
|
|
58
|
+
form label {
|
|
59
|
+
font-size: 14px;
|
|
60
|
+
color: var(--color-gray-9, #333);
|
|
61
|
+
font-weight: bold;
|
|
62
|
+
margin-bottom: var(--spacing-sm, 0.5rem);
|
|
63
|
+
}
|
|
64
|
+
a.forgot {
|
|
65
|
+
color: var(--color-primary, inherit);
|
|
66
|
+
text-decoration: none;
|
|
67
|
+
font-size: 12px;
|
|
68
|
+
font-weight: bold;
|
|
69
|
+
}
|
|
70
|
+
.error {
|
|
71
|
+
color: var(--color-error, red);
|
|
72
|
+
font-size: 12px;
|
|
73
|
+
margin-top: var(--spacing-sm, 0.5rem);
|
|
74
|
+
}
|
|
75
|
+
.header {
|
|
76
|
+
display: flex;
|
|
77
|
+
flex-direction: column;
|
|
78
|
+
align-items: center;
|
|
79
|
+
}
|
|
80
|
+
.header .intro {
|
|
81
|
+
width: 200px;
|
|
82
|
+
margin-bottom: 0;
|
|
83
|
+
color: var(--color-gray-3);
|
|
84
|
+
}
|
|
85
|
+
.header .brand {
|
|
86
|
+
font-size: 32px;
|
|
87
|
+
min-width: 200px;
|
|
88
|
+
margin-bottom: var(--spacing-lg, 2rem);
|
|
89
|
+
}
|
|
90
|
+
.header .brand em {
|
|
91
|
+
text-transform: uppercase;
|
|
92
|
+
font-weight: bold;
|
|
93
|
+
font-style: normal;
|
|
94
|
+
}
|
|
95
|
+
`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
static get states() {
|
|
99
|
+
return {
|
|
100
|
+
DEFAULT: 'DEFAULT',
|
|
101
|
+
FORGOT: 'FORGOT',
|
|
102
|
+
CHALLENGE_CHANGE_PW: 'CHALLENGE_CHANGE_PW',
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
constructor() {
|
|
107
|
+
super();
|
|
108
|
+
this.formState = LoginForm.states.DEFAULT;
|
|
109
|
+
this.username = '';
|
|
110
|
+
this.password = '';
|
|
111
|
+
this.token = undefined;
|
|
112
|
+
this.errors = [];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
onSubmit(e) {
|
|
116
|
+
e.preventDefault();
|
|
117
|
+
const formElem = e.explicitOriginalTarget.parentElement;
|
|
118
|
+
console.log(formElem);
|
|
119
|
+
const formData = new FormData(formElem).entries().reduce((acc, [key, value]) => {
|
|
120
|
+
acc[key] = value;
|
|
121
|
+
return acc;
|
|
122
|
+
}, {});
|
|
123
|
+
|
|
124
|
+
login(this, formData).then(({token, challenge}) => {
|
|
125
|
+
if (challenge && challenge.name === 'NEW_PASSWORD_REQUIRED') {
|
|
126
|
+
|
|
127
|
+
// store these to use in later forms
|
|
128
|
+
this.token = token;
|
|
129
|
+
this.username = formData['username'];
|
|
130
|
+
this.password = formData['password'];
|
|
131
|
+
|
|
132
|
+
this.setState(LoginForm.states.CHALLENGE_CHANGE_PW);
|
|
133
|
+
// don't dispatch a success event with the token
|
|
134
|
+
// The user should change their password first
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// emit a success event
|
|
139
|
+
this.dispatchEvent(new CustomEvent('login-success', {
|
|
140
|
+
detail: { token },
|
|
141
|
+
}));
|
|
142
|
+
}).catch((error) => {
|
|
143
|
+
console.log(error);
|
|
144
|
+
this.errors = [error.message];
|
|
145
|
+
this.requestUpdate();
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
onSubmitChangePasswordChallenge(e) {
|
|
150
|
+
e.preventDefault();
|
|
151
|
+
const formElem = e.explicitOriginalTarget;
|
|
152
|
+
const formData = new FormData(formElem).entries().reduce((acc, [key, value]) => {
|
|
153
|
+
acc[key] = value;
|
|
154
|
+
return acc;
|
|
155
|
+
}, {});
|
|
156
|
+
|
|
157
|
+
// validate password
|
|
158
|
+
const password = formData['password'];
|
|
159
|
+
const confirmPassword = formData['confirm-password'];
|
|
160
|
+
if (password !== confirmPassword) {
|
|
161
|
+
this.errors = ['Passwords do not match.'];
|
|
162
|
+
this.requestUpdate();
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const authClient = createClient({
|
|
167
|
+
baseUrl: this.api,
|
|
168
|
+
});
|
|
169
|
+
authClient.updatePassword({
|
|
170
|
+
token: this.token,
|
|
171
|
+
currentPassword: this.password,
|
|
172
|
+
newPassword: password,
|
|
173
|
+
}).then(() => {
|
|
174
|
+
// emit a success event
|
|
175
|
+
this.dispatchEvent(new CustomEvent('login-success', {
|
|
176
|
+
detail: { token: this.token },
|
|
177
|
+
}));
|
|
178
|
+
}).catch((error) => {
|
|
179
|
+
console.log(error);
|
|
180
|
+
this.errors = [error.message];
|
|
181
|
+
this.requestUpdate();
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
setState(newState) {
|
|
186
|
+
if (newState !== this.formState) {
|
|
187
|
+
this.formState = newState;
|
|
188
|
+
this.errors = [];
|
|
189
|
+
this.requestUpdate();
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
render() {
|
|
194
|
+
return html`<div class="login-form">
|
|
195
|
+
${this.renderHeader()}
|
|
196
|
+
${this.formState === LoginForm.states.FORGOT
|
|
197
|
+
? this.renderForgotPasswordForm() : ''}
|
|
198
|
+
${this.formState === LoginForm.states.CHALLENGE_CHANGE_PW
|
|
199
|
+
? this.renderChangePasswordForm() : ''}
|
|
200
|
+
${this.formState === LoginForm.states.DEFAULT
|
|
201
|
+
? this.renderLoginForm() : ''}
|
|
202
|
+
</div>`;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* TODO: accept the header as a slot
|
|
207
|
+
*/
|
|
208
|
+
renderHeader() {
|
|
209
|
+
return html`<div class="header">
|
|
210
|
+
<div class="intro">
|
|
211
|
+
Welcome to
|
|
212
|
+
</div>
|
|
213
|
+
<div class="brand">
|
|
214
|
+
<em>WRAL</em>.studio
|
|
215
|
+
</div>
|
|
216
|
+
</div>`;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
renderLoginForm() {
|
|
220
|
+
return html`
|
|
221
|
+
<form>
|
|
222
|
+
<label for="username">User</label>
|
|
223
|
+
<input
|
|
224
|
+
type="text"
|
|
225
|
+
name="username"
|
|
226
|
+
placeholder="Email or Username"
|
|
227
|
+
required
|
|
228
|
+
/>
|
|
229
|
+
|
|
230
|
+
<label for="password">Password</label>
|
|
231
|
+
<input
|
|
232
|
+
type="password"
|
|
233
|
+
name="password"
|
|
234
|
+
placeholder="Password"
|
|
235
|
+
required
|
|
236
|
+
/>
|
|
237
|
+
<div>
|
|
238
|
+
<a href="#" class="forgot"
|
|
239
|
+
@click=${() => {this.setState(LoginForm.states.FORGOT);}}
|
|
240
|
+
>Forgot password</a>
|
|
241
|
+
</div>
|
|
242
|
+
|
|
243
|
+
${this.errors
|
|
244
|
+
? this.errors.map(error => html`<p class="error">${error}</p>`)
|
|
245
|
+
: ''}
|
|
246
|
+
<input type="submit" value="Log in" @click=${this.onSubmit} />
|
|
247
|
+
|
|
248
|
+
</form>`;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
renderForgotPasswordForm() {
|
|
252
|
+
return html`
|
|
253
|
+
<auth-forgot-password-form
|
|
254
|
+
api=${this.api}>
|
|
255
|
+
</auth-forgot-password-form>
|
|
256
|
+
`;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
renderChangePasswordForm() {
|
|
260
|
+
return html`
|
|
261
|
+
<p>You have logged in successfully, but you must change your password.</p>
|
|
262
|
+
<form @submit=${this.onSubmitChangePasswordChallenge}>
|
|
263
|
+
<input type="hidden" name="username" value="${this.username}" />
|
|
264
|
+
<label for="password">New Password</label>
|
|
265
|
+
<input
|
|
266
|
+
type="password"
|
|
267
|
+
name="password"
|
|
268
|
+
placeholder="Password"
|
|
269
|
+
required
|
|
270
|
+
/>
|
|
271
|
+
<label for="confirm-password">Confirm Password</label>
|
|
272
|
+
<input
|
|
273
|
+
type="password"
|
|
274
|
+
name="confirm-password"
|
|
275
|
+
placeholder="Confirm Password"
|
|
276
|
+
required
|
|
277
|
+
/>
|
|
278
|
+
${this.errors
|
|
279
|
+
? this.errors.map(error => html`<p class="error">${error}</p>`)
|
|
280
|
+
: ''}
|
|
281
|
+
<input type="submit" value="Change Password" />
|
|
282
|
+
</form>
|
|
283
|
+
`;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
customElements.define('auth-login-form', LoginForm);
|
|
288
|
+
customElements.define('auth-forgot-password-form', ForgotPasswordForm);
|
package/src/config.mjs
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gets configuration for the Auth Mod from the attributes
|
|
3
|
+
* of the mod element (in DOM).
|
|
4
|
+
* @param {VellumToolkit} toolkit
|
|
5
|
+
* @returns {Object}
|
|
6
|
+
*/
|
|
7
|
+
export function getConfig(toolkit, mod) {
|
|
8
|
+
const modElement = mod.element;
|
|
9
|
+
const attrs = modElement.attributes;
|
|
10
|
+
console.log(attrs);
|
|
11
|
+
return {
|
|
12
|
+
api: attrs.api.value || requiredConfig('auth:api is required'),
|
|
13
|
+
forceLogin: attrs['force-login'] !== undefined, // TODO: handle false
|
|
14
|
+
storageKey: attrs['storage-key']?.value,
|
|
15
|
+
mount: attrs['path']?.value || '/auth',
|
|
16
|
+
toolkit,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Helper for required config elements
|
|
22
|
+
* This defines what to do when a required config is missing
|
|
23
|
+
* @param {string} message
|
|
24
|
+
*/
|
|
25
|
+
function requiredConfig(message) {
|
|
26
|
+
throw new Error(message);
|
|
27
|
+
}
|
package/src/helper.mjs
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This is a helper for consumers of the Auth mod to get
|
|
3
|
+
* an API token.
|
|
4
|
+
* @example <code>
|
|
5
|
+
* async () => {
|
|
6
|
+
* const myElem = document.querySelector('my-element');
|
|
7
|
+
* const token = await getToken(myElem);
|
|
8
|
+
* someApiCall({ token, ... });
|
|
9
|
+
* }
|
|
10
|
+
* </code>
|
|
11
|
+
* @param {Element} eventTarget - a DOM element that will trigger the event
|
|
12
|
+
* @returns {Promise} - a promise that resolves with the token
|
|
13
|
+
*/
|
|
14
|
+
export function getToken(eventTarget) {
|
|
15
|
+
return new Promise((resolve, reject) => {
|
|
16
|
+
if (!eventTarget || !eventTarget.dispatchEvent) {
|
|
17
|
+
reject(new Error('getToken must be called with a DOM element'));
|
|
18
|
+
}
|
|
19
|
+
eventTarget.dispatchEvent(new CustomEvent('harness:action', {
|
|
20
|
+
detail: {
|
|
21
|
+
type: 'auth:requestToken',
|
|
22
|
+
detail: {
|
|
23
|
+
callback: resolve,
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
bubbles: true,
|
|
27
|
+
composed: true,
|
|
28
|
+
cancelable: true,
|
|
29
|
+
}));
|
|
30
|
+
});
|
|
31
|
+
}
|