@wral/studio.mods.auth 1.0.1 → 2.0.1

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.
@@ -1,19 +1,9 @@
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
1
  import { LitElement, html, css } from 'lit';
11
- import login from '../login.mjs';
12
- import { ForgotPasswordForm } from './forgot-password-form.mjs';
13
2
  import { createClient } from '@wral/sdk-auth';
3
+ import './forgot-password-form.mjs';
14
4
 
15
5
 
16
- class LoginForm extends LitElement {
6
+ class AuthLoginForm extends LitElement {
17
7
 
18
8
  static get properties() {
19
9
  return {
@@ -25,10 +15,9 @@ class LoginForm extends LitElement {
25
15
  return css`
26
16
  :host {
27
17
  display: block;
28
- font-family: Arial, sans-serif;
29
18
  }
30
19
  .login-form {
31
- background-color: var(--color-gray-0, #fff);
20
+ background-color: var(--color-gray-50);
32
21
  padding: var(--spacing-xl, 3rem);
33
22
  border-radius: var(--radius-md, 5px);
34
23
  box-shadow: var(--drop-shadow-md, none);
@@ -39,14 +28,14 @@ class LoginForm extends LitElement {
39
28
  margin: 5px 0 15px 0;
40
29
  padding: var(--spacing-sm, 0.5rem) var(--spacing-md, 1rem);
41
30
  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);
31
+ border: 1px solid var(--color-gray-300, #CCC);
32
+ border-radius: var(--radius-sm, 3px);
33
+ color: var(--color-gray-700, #333);
34
+ background-color: var(--color-gray-100, #FFF);
46
35
  }
47
36
  form input[type="submit"] {
48
37
  background-color: var(--color-primary, inherit);
49
- color: var(--color-gray-0, #fff);
38
+ color: var(--color-gray-50, #fff);
50
39
  border: none;
51
40
  padding: var(--spacing-sm, 0.5rem) var(--spacing-md, 1rem);
52
41
  border-radius: var(--radius-dynamic, 5px);
@@ -57,15 +46,15 @@ class LoginForm extends LitElement {
57
46
  }
58
47
  form label {
59
48
  font-size: 14px;
60
- color: var(--color-gray-9, #333);
49
+ color: var(--color-gray-900, #333);
61
50
  font-weight: bold;
62
51
  margin-bottom: var(--spacing-sm, 0.5rem);
63
52
  }
64
53
  a.forgot {
65
54
  color: var(--color-primary, inherit);
66
55
  text-decoration: none;
67
- font-size: 12px;
68
56
  font-weight: bold;
57
+ font-size: 12px;
69
58
  }
70
59
  .error {
71
60
  color: var(--color-error, red);
@@ -80,7 +69,7 @@ class LoginForm extends LitElement {
80
69
  .header .intro {
81
70
  width: 200px;
82
71
  margin-bottom: 0;
83
- color: var(--color-gray-3);
72
+ color: var(--color-gray-300, #333);
84
73
  }
85
74
  .header .brand {
86
75
  font-size: 32px;
@@ -105,42 +94,55 @@ class LoginForm extends LitElement {
105
94
 
106
95
  constructor() {
107
96
  super();
108
- this.formState = LoginForm.states.DEFAULT;
109
- this.username = '';
110
- this.password = '';
111
- this.token = undefined;
97
+ this.formState = this.constructor.states.DEFAULT;
112
98
  this.errors = [];
99
+ this.isBusy = false; // indicates some operation is in progress
113
100
  }
114
101
 
115
- onSubmit(e) {
116
- e.preventDefault();
117
- const formElem = e.explicitOriginalTarget.parentElement;
118
- console.log(formElem);
102
+ login({ username, password }) {
103
+ const authClient = createClient({
104
+ baseUrl: this.api,
105
+ });
106
+ return authClient.mintToken({ username, password });
107
+ }
108
+
109
+ emitLoginSuccess(token) {
110
+ this.dispatchEvent(new CustomEvent('login-success', {
111
+ detail: { token },
112
+ bubbles: true,
113
+ composed: true,
114
+ cancelable: true,
115
+ }));
116
+ }
117
+
118
+ handleSubmitLogin(event) {
119
+ event.preventDefault();
120
+ const formElem = event.currentTarget;
119
121
  const formData = new FormData(formElem).entries().reduce((acc, [key, value]) => {
120
122
  acc[key] = value;
121
123
  return acc;
122
124
  }, {});
123
125
 
124
- login(this, formData).then(({token, challenge}) => {
125
- if (challenge && challenge.name === 'NEW_PASSWORD_REQUIRED') {
126
-
126
+ this.isBusy = true;
127
+ this.errors = [];
128
+ this.requestUpdate();
129
+ this.login(formData).then(({ token, challenge }) => {
130
+ if (challenge?.name === 'NEW_PASSWORD_REQUIRED') {
127
131
  // store these to use in later forms
128
132
  this.token = token;
129
133
  this.username = formData['username'];
130
134
  this.password = formData['password'];
131
135
 
132
- this.setState(LoginForm.states.CHALLENGE_CHANGE_PW);
133
- // don't dispatch a success event with the token
136
+ this.setState(this.constructor.states.CHALLENGE_CHANGE_PW);
137
+ // don't dispatch a success event yet
134
138
  // The user should change their password first
135
139
  return;
136
140
  }
137
-
138
- // emit a success event
139
- this.dispatchEvent(new CustomEvent('login-success', {
140
- detail: { token },
141
- }));
141
+
142
+ // Emit a success event
143
+ this.emitLoginSuccess(token);
142
144
  }).catch((error) => {
143
- console.log(error);
145
+ console.error(error);
144
146
  this.errors = [error.message];
145
147
  this.requestUpdate();
146
148
  });
@@ -148,17 +150,17 @@ class LoginForm extends LitElement {
148
150
 
149
151
  onSubmitChangePasswordChallenge(e) {
150
152
  e.preventDefault();
151
- const formElem = e.explicitOriginalTarget;
153
+ const formElem = e.currentTarget;
152
154
  const formData = new FormData(formElem).entries().reduce((acc, [key, value]) => {
153
155
  acc[key] = value;
154
156
  return acc;
155
157
  }, {});
156
-
157
- // validate password
158
+
159
+ // validate password, client-side
158
160
  const password = formData['password'];
159
161
  const confirmPassword = formData['confirm-password'];
160
162
  if (password !== confirmPassword) {
161
- this.errors = ['Passwords do not match.'];
163
+ this.errors = ['Passwords do not match'];
162
164
  this.requestUpdate();
163
165
  return;
164
166
  }
@@ -171,81 +173,72 @@ class LoginForm extends LitElement {
171
173
  currentPassword: this.password,
172
174
  newPassword: password,
173
175
  }).then(() => {
174
- // emit a success event
175
- this.dispatchEvent(new CustomEvent('login-success', {
176
- detail: { token: this.token },
177
- }));
176
+ this.emitLoginSuccess(this.token);
178
177
  }).catch((error) => {
179
- console.log(error);
178
+ console.error(error);
180
179
  this.errors = [error.message];
181
180
  this.requestUpdate();
182
181
  });
183
182
  }
184
183
 
185
- setState(newState) {
186
- if (newState !== this.formState) {
187
- this.formState = newState;
188
- this.errors = [];
189
- this.requestUpdate();
190
- }
184
+ setState(state) {
185
+ this.formState = state;
186
+ this.isBusy = false;
187
+ this.errors = [];
188
+ this.requestUpdate();
191
189
  }
192
190
 
193
191
  render() {
194
192
  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() : ''}
193
+ ${ this.renderHeader() }
194
+ ${ this.formState === this.constructor.states.DEFAULT ?
195
+ this.renderLoginForm() : '' }
196
+ ${ this.formState === this.constructor.states.CHALLENGE_CHANGE_PW ?
197
+ this.renderChangePasswordForm() : '' }
198
+ ${ this.formState === this.constructor.states.FORGOT ?
199
+ this.renderForgotPasswordForm() : '' }
202
200
  </div>`;
203
201
  }
204
202
 
205
- /**
206
- * TODO: accept the header as a slot
207
- */
208
203
  renderHeader() {
209
204
  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>
205
+ <div class="intro">Welcome to</div>
206
+ <div class="brand"><em>WRAL</em>.studio</div>
216
207
  </div>`;
217
208
  }
218
209
 
219
210
  renderLoginForm() {
220
211
  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>
212
+ <form @submit=${this.handleSubmitLogin}>
213
+ <label for="username">User</label>
214
+ <input
215
+ type="text"
216
+ name="username"
217
+ placeholder="Email or Username"
218
+ required
219
+ />
242
220
 
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} />
221
+ <label for="password">Password</label>
222
+ <input
223
+ type="password"
224
+ name="password"
225
+ placeholder="Password"
226
+ required
227
+ />
228
+ <div>
229
+ <a href="#" class="forgot"
230
+ @click=${() => {
231
+ this.setState(this.constructor.states.FORGOT);
232
+ }}>Forgot password</a>
233
+ </div>
234
+ ${this.errors
235
+ ? this.errors.map(error => html`<p class="error">${error}</p>`) : ''}
247
236
 
248
- </form>`;
237
+ <input type="submit" value="Log in"
238
+ ?disabled=${this.isBusy}
239
+ />
240
+ </form>
241
+ `;
249
242
  }
250
243
 
251
244
  renderForgotPasswordForm() {
@@ -258,31 +251,41 @@ class LoginForm extends LitElement {
258
251
 
259
252
  renderChangePasswordForm() {
260
253
  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>
254
+ <p>You have logged in successfully, but you must change your password.</p>
255
+ <form @submit="${this.onSubmitChangePasswordChallenge}">
256
+ <input type="hidden" name="username" value="${this.username}" />
257
+ <label for="password">New Password</label>
258
+ <input
259
+ type="password"
260
+ name="password"
261
+ placeholder="Password"
262
+ required
263
+ />
264
+ <label for="confirm-password">Confirm Password</label>
265
+ <input
266
+ type="password"
267
+ name="confirm-password"
268
+ placeholder="Confirm Password"
269
+ required
270
+ />
271
+ ${this.errors
272
+ ? this.errors.map(error => html`<p class="error">${error}</p>`) : ''}
273
+ <input type="submit" value="Change Password" />
274
+ </form>
283
275
  `;
284
276
  }
277
+
278
+ }
279
+
280
+ if (!customElements.get('auth-login-form')) {
281
+ customElements.define('auth-login-form', AuthLoginForm);
282
+ }
283
+
284
+ export function render(state, onLoginSuccess) {
285
+ return html`<auth-login-form slot="main"
286
+ api=${state?.apiBaseUrl}
287
+ @login-success=${onLoginSuccess}>
288
+ </auth-login-form>`;
285
289
  }
286
290
 
287
- customElements.define('auth-login-form', LoginForm);
288
- customElements.define('auth-forgot-password-form', ForgotPasswordForm);
291
+ export default render;
package/src/token.mjs CHANGED
@@ -1,40 +1,44 @@
1
+ import { createClient } from '@wral/sdk-auth';
1
2
 
2
- /**
3
- * Decodes a JWT (JSON Web Token) to extract its payload as an object.
4
- *
5
- * @param {string} token - The JWT to decode.
6
- * @returns {Object} The decoded payload of the token.
7
- *
8
- * @example
9
- * // Decode a sample JWT
10
- * const token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9'
11
- * + '.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2M'
12
- * + 'jM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c';
13
- * const payload = decodeToken(token);
14
- * console.log('Decoded Payload:', payload);
15
- */
16
- export function decodeToken(token) {
17
- return JSON.parse(window.atob(token.split('.')[1]
18
- .replace(/-/g, '+').replace(/_/g, '/')));
3
+ export function getToken({ tokenKey }) {
4
+ return window.localStorage.getItem(tokenKey);
5
+ }
6
+
7
+ export function setToken({ tokenKey }, token) {
8
+ window.localStorage.setItem(tokenKey, token);
19
9
  }
20
10
 
11
+ export function destroyToken({ tokenKey }) {
12
+ window.localStorage.removeItem(tokenKey);
13
+ }
14
+
15
+ export async function getFreshToken(state) {
16
+ const token = getToken(state);
17
+ if (!token || token.length < 1) {
18
+ throw new Error('No token found');
19
+ }
20
+ if (shouldRefreshToken(token)) {
21
+ const authClient = createClient({
22
+ baseUrl: state.apiBaseUrl,
23
+ });
24
+ return await authClient.refreshToken({ token }).then(({ token }) => token);
25
+ }
26
+ return token;
27
+ }
28
+
21
29
  /**
22
- * Determines if the token needs to be refreshed based on its expiry time.
23
- *
24
- * @param {string} token - The authentication token to check.
25
- * @param {number} [offset=300] - The offset in milliseconds to consider
26
- * for refreshing before actual expiry.
27
- * @param {number} [now=Date.now()] - The current timestamp, primarily used
28
- * for testing.
29
- * @returns {boolean} True if the token needs to be refreshed, false otherwise.
30
- *
31
- * @example
32
- * // Check if token needs to be refreshed
33
- * const tokenNeedsRefresh = shouldRefreshToken('someToken', 300);
34
- * console.log('Token needs refresh:', tokenNeedsRefresh);
30
+ * Determine if a token needs to be refreshed, based on its expiration date
31
+ * @param {string} token
32
+ * @param {number} offset - milliseconds early to consider refreshing
33
+ * @param {number} now - current time
34
+ * @returns {boolean}
35
35
  */
36
36
  export function shouldRefreshToken(token, offset = 300, now = Date.now()) {
37
37
  const decoded = decodeToken(token);
38
38
  return decoded.exp * 1000 - offset < now;
39
39
  }
40
40
 
41
+ export function decodeToken(token) {
42
+ return JSON.parse(window.atob(token.split('.')[1]
43
+ .replace(/-/g, '+').replace(/_/g, '/')));
44
+ }
@@ -0,0 +1 @@
1
+ This is an example Vellum app that consumes this auth mod
@@ -0,0 +1,126 @@
1
+ import { html } from 'lit';
2
+ /**
3
+ * The App Manager has the responsibility of handling requests related to Apps
4
+ * It handles app registration (adding an app to the main application),
5
+ * and activation/deactivation of apps.
6
+ * @param toolkit
7
+ * @return void - Bootstraps app management within the toolkit as a side-effect
8
+ */
9
+ export function init(toolkit) {
10
+
11
+ const modName = 'mod-app-manager';
12
+ const debug = true; // TODO: use data-debug attribute
13
+
14
+ const state = {
15
+ apps: new Map(), // { id: { id, name, content, fragment } }
16
+ activeAppId: null,
17
+ toolkit,
18
+ debug: (...args) => debug && console.log('[app-manager]',...args),
19
+ };
20
+
21
+ Object.entries({
22
+ 'app:register': registerApp,
23
+ 'app:activate': activateApp,
24
+ 'app:close': closeApp,
25
+ }).forEach(([actionType, fn]) => toolkit.dispatchAction({
26
+ type: 'action:register',
27
+ detail: {
28
+ actionType,
29
+ modName,
30
+ handler: (...args) => fn(state, ...args),
31
+ },
32
+ }));
33
+
34
+ }
35
+
36
+ function registerApp(state, detail) {
37
+ state.debug('registerApp', detail);
38
+ const { id, name, content } = detail;
39
+ if (!id || !name || !content) {
40
+ console.error('Invalid app registration', detail);
41
+ return; // TODO: consider throwing
42
+ }
43
+ state.apps.set(id, { id, name, content, fragment: null });
44
+
45
+ state.toolkit.dispatchAction({
46
+ type: 'layout:content:append',
47
+ detail: {
48
+ content: appToolbarButton(state, detail),
49
+ id: `app-btn-${id}`, // must match id in appToolbarButton
50
+ },
51
+ });
52
+
53
+ }
54
+
55
+ function activateApp(state, detail) {
56
+ const { id } = detail;
57
+ const { apps, activeAppId, toolkit } = state;
58
+
59
+ // no-op if already active
60
+ if (activeAppId === id) {
61
+ state.debug('activateApp - already active', id);
62
+ return;
63
+ }
64
+
65
+ if (!apps.has(id)) {
66
+ state.debug('activateApp - app not found', id);
67
+ console.error('App not found: ', id);
68
+ return; // TODO: consider throwing
69
+ }
70
+ const app = apps.get(id);
71
+
72
+ // remove existing app, save fragment for later
73
+ if (activeAppId) {
74
+ state.debug('activateApp - closing', activeAppId);
75
+ toolkit.dispatchAction({
76
+ type: 'layout:content:remove',
77
+ detail: {
78
+ id: `app-content-${activeAppId}`,
79
+ callback: (fragment) => {
80
+ const currentApp = apps.get(activeAppId);
81
+ state.debug('activateApp - closing callback', { activeAppId, fragment });
82
+ if (currentApp) currentApp.fragment = fragment;
83
+ },
84
+ },
85
+ });
86
+ }
87
+
88
+ // present the app, using prior fragment if available
89
+ const content = app.fragment || app.content();
90
+ state.debug('activateApp - presenting', { id, content });
91
+ toolkit.dispatchAction({
92
+ type: 'layout:content:append',
93
+ detail: { content, id: `app-content-${id}` },
94
+ });
95
+ state.activeAppId = id;
96
+
97
+ }
98
+
99
+ function closeApp(state) {
100
+ const { activeAppId, apps, toolkit } = state;
101
+
102
+ // no-op if no active app
103
+ if (!activeAppId) return;
104
+ toolkit.dispatchAction({
105
+ type: 'layout:content:remove',
106
+ detail: { id: `app-content-${activeAppId}` },
107
+ });
108
+ const app = apps.get(activeAppId);
109
+ if (app) app.fragment = null;
110
+ state.activeAppId = null;
111
+ }
112
+
113
+ function appToolbarButton(state, detail) {
114
+ console.log('apToolbarButton', { state, detail });
115
+ return html`
116
+ <button slot="menu"
117
+ id="app-btn-${detail.id}"
118
+ @click=${() => state.toolkit.dispatchAction({
119
+ type: 'app:activate',
120
+ detail,
121
+ })}
122
+ >
123
+ ${detail.name}
124
+ </button>
125
+ `;
126
+ }
@@ -0,0 +1,34 @@
1
+ import { html } from 'lit';
2
+
3
+ export async function init(toolkit, mod) {
4
+
5
+ let token = null;
6
+
7
+ const content = html`
8
+ <div slot="main" id="example-app">
9
+ <h1>Example</h1>
10
+ <p>This is an example app.</p>
11
+ <pre><code>${JSON.stringify(mod, null, 2)}</code></pre>
12
+ </div>
13
+ `;
14
+
15
+ toolkit.dispatchAction({
16
+ type: 'app:register',
17
+ detail: {
18
+ id: 'example',
19
+ name: 'Example App',
20
+ content: () => content,
21
+ },
22
+ });
23
+
24
+ toolkit.dispatchAction({
25
+ type: 'auth:requestToken',
26
+ detail: {
27
+ callback: (t) => {
28
+ token = t;
29
+ console.log("got a token!", token);
30
+ },
31
+ }
32
+ });
33
+
34
+ }
@@ -0,0 +1,26 @@
1
+ import
2
+ VellumHarness
3
+ from '@thefarce/vellum';
4
+ import { render } from './render.mjs';
5
+ import { init as _init } from './mod-setup.mjs';
6
+
7
+ /*
8
+ * Define the web-components that will make up the Studio App
9
+ */
10
+ if (!customElements.get('wral-studio')) {
11
+ customElements.define('wral-studio', VellumHarness);
12
+ }
13
+
14
+ /*
15
+ * Add our custom renderer to the wral-studio App element
16
+ */
17
+ document.addEventListener('DOMContentLoaded', () => {
18
+ const app = document.querySelector('wral-studio');
19
+ if (app) {
20
+ app.setRenderer(render);
21
+ } else {
22
+ console.error('Missing \'wral-studio\' element');
23
+ }
24
+ });
25
+
26
+ export const init = _init;