@wral/studio.mods.auth 0.3.7

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.
@@ -0,0 +1 @@
1
+ "use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});function i(t){return new Promise((o,n)=>{t.pub("studio.wral::mod-auth","token-request",{callback:(u,e)=>{e&&n(e),o(u)}})})}exports.getToken=i;
package/dist/lib.es.js ADDED
@@ -0,0 +1,12 @@
1
+ function a(t) {
2
+ return new Promise((n, o) => {
3
+ t.pub("studio.wral::mod-auth", "token-request", {
4
+ callback: (u, e) => {
5
+ e && o(e), n(u);
6
+ }
7
+ });
8
+ });
9
+ }
10
+ export {
11
+ a as getToken
12
+ };
@@ -0,0 +1,39 @@
1
+ export default [
2
+ {
3
+ "ignores": ["coverage", "dist/**", "node_modules"],
4
+ "rules": {
5
+ "no-console": [
6
+ "error",
7
+ ],
8
+ "no-unused-vars": [
9
+ "error",
10
+ ],
11
+ "no-unused-private-class-members": [
12
+ "error",
13
+ ],
14
+ "curly": [
15
+ "warn",
16
+ ],
17
+ 'max-len': [
18
+ 'warn',
19
+ {
20
+ code: 90,
21
+ comments: 80,
22
+ ignoreUrls: true,
23
+ ignoreRegExpLiterals: true,
24
+ },
25
+ ],
26
+ 'object-curly-spacing': ['off'],
27
+ 'comma-dangle': [
28
+ 'warn',
29
+ {
30
+ arrays: 'always-multiline',
31
+ objects: 'always-multiline',
32
+ imports: 'always-multiline',
33
+ exports: 'always-multiline',
34
+ functions: 'ignore',
35
+ },
36
+ ],
37
+ },
38
+ },
39
+ ];
package/index.html ADDED
@@ -0,0 +1,20 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <title>My Studio</title>
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
+ <meta name="robots" content="noindex, nofollow">
8
+ <!-- Load studio-app from CDN -->
9
+ <script src="https://cdn.wral.studio/release/v2.2.3/studio-app.js"></script>
10
+ </head>
11
+ <body>
12
+ <studio-app>
13
+ <studio-mod src="http://localhost:5173/src/auth.mjs"
14
+ apiBaseUrl="https://api.wral.com/dev/auth"
15
+ ></studio-mod>
16
+ <studio-mod src="http://localhost:5173/src/tool-dummy.mjs"
17
+ ></studio-mod>
18
+ </studio-app>
19
+ </body>
20
+ </html>
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@wral/studio.mods.auth",
3
+ "version": "0.3.7",
4
+ "description": "Auth mod for Studio",
5
+ "main": "dist/lib.cjs.js",
6
+ "module": "dist/lib.es.js",
7
+ "scripts": {
8
+ "prepare": "husky",
9
+ "lint": "eslint . --config eslint.config.mjs",
10
+ "update:modules": "npx npm-check-updates --interactive",
11
+ "build": "vite build",
12
+ "dev": "vite",
13
+ "test": "web-test-runner --node-resolve --coverage --polyfills && npm run lint"
14
+ },
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://bitbucket.org/cbcnm/studio.mods.auth.git"
18
+ },
19
+ "author": "Kenneth Barbour <kbarbour@wral.com>",
20
+ "license": "UNLICENSED",
21
+ "bugs": {
22
+ "url": "https://bitbucket.org/cbcnm/studio.mods.auth/issues"
23
+ },
24
+ "homepage": "https://bitbucket.org/cbcnm/studio.mods.auth#readme",
25
+ "dependencies": {
26
+ "@shoelace-style/shoelace": "^2.16.0",
27
+ "@wral/sdk-auth": "^0.2.1",
28
+ "@wral/studio-tools": "^0.9.2",
29
+ "@wral/studio-ui": "^2.4.8",
30
+ "lit": "^3.2.0"
31
+ },
32
+ "devDependencies": {
33
+ "@open-wc/testing": "^4.0.0",
34
+ "@web/test-runner": "^0.19.0",
35
+ "@web/test-runner-coverage-v8": "^0.8.0",
36
+ "@web/test-runner-playwright": "^0.11.0",
37
+ "chai": "^5.1.1",
38
+ "eslint": "^9.10.0",
39
+ "husky": "^9.1.5",
40
+ "playwright": "^1.47.0",
41
+ "sinon": "^18.0.1",
42
+ "vite": "^5.4.4"
43
+ }
44
+ }
package/src/auth.mjs ADDED
@@ -0,0 +1,73 @@
1
+ import { getToken, logout } from './util.mjs';
2
+
3
+ /**
4
+ * Initializes authentication-related subscriptions with the given studio
5
+ * instance and configuration. This function sets up listeners for token
6
+ * requests and logout actions.
7
+ *
8
+ * @module studio-mod_auth
9
+ * @param {Object} studio - The studio instance where the authentication
10
+ * module is registered.
11
+ * @param {Object} config - Configuration object for the initialization
12
+ * (currently not used in the function).
13
+ */
14
+ export function init(studio, config) {
15
+
16
+ /**
17
+ * Event Subscription: 'token-request'
18
+ * Handles requests for authentication tokens.
19
+ *
20
+ * @event studio.wral::mod-auth::token-request
21
+ * @type {object}
22
+ * @property {Object} state - Contains the state information from the
23
+ * event trigger.
24
+ * @property {Function} state.value.callback - Callback function to handle
25
+ * the token response.
26
+ * @param {string|null} state.value.token - Expected to receive parameters
27
+ * `token` (string|null) if successful, or `error` (Error|null) if an
28
+ * error occurs.
29
+ *
30
+ * Usage:
31
+ * studio.pub('studio.wral::mod-auth', 'token-request', {
32
+ * state: {
33
+ * value: {
34
+ * callback: (token, error) => {
35
+ * if (error) {
36
+ * console.error('Error retrieving token:', error);
37
+ * } else {
38
+ * console.log('Received token:', token);
39
+ * }
40
+ * }
41
+ * }
42
+ * }
43
+ * });
44
+ */
45
+ studio.sub('studio.wral::mod-auth', 'token-request', ({state}) => {
46
+ const { callback } = state.value;
47
+ if (callback) {
48
+ getToken({ studio, config })
49
+ .then(token => callback(token))
50
+ .catch(error => callback(null, error));
51
+ } else {
52
+ /** Warn consumers of this mod if a message has been sent without a
53
+ * callback. */
54
+ /* eslint-disable no-console */
55
+ console.warn("No token callback defined");
56
+ }
57
+ });
58
+
59
+ /**
60
+ * Event Subscription: 'logout'
61
+ * Initiates the logout process upon receiving the event.
62
+ *
63
+ * @event studio.wral::mod-auth::logout
64
+ *
65
+ * Usage:
66
+ * studio.pub('studio.wral::mod-auth', 'logout');
67
+ */
68
+ studio.sub('studio.wral::mod-auth', 'logout', () => {
69
+ logout({ studio });
70
+ });
71
+
72
+ // studio.pub('system::workspace', 'present', studioProfileView);
73
+ }
@@ -0,0 +1,133 @@
1
+ import { LitElement, html, css } from 'lit';
2
+ import { createClient } from '@wral/sdk-auth';
3
+ import './studio-login.mjs';
4
+ import './studio-change-password.mjs';
5
+
6
+ /**
7
+ * This is a login form for the auth module. It consumes the generic
8
+ * studio-login component.
9
+ *
10
+ * This component is responsible for sending requests to the auth API
11
+ * to generate a JWT token, and for enforcing a password change flow.
12
+ *
13
+ * @attribute apibaseurl
14
+ *
15
+ * @example
16
+ * <mod-auth-login-form
17
+ * apibaseurl="https://api.wral.com/auth"
18
+ * @login-success=${({ detail }) => console.log(detail.token)}
19
+ * >
20
+ * </mod-auth-login-form>
21
+ *
22
+ * @fires login-success
23
+ */
24
+ class ModAuthLoginForm extends LitElement {
25
+
26
+ constructor() {
27
+ super();
28
+ this.apibaseurl = '';
29
+ this.error = '';
30
+ this.isAwaiting = false;
31
+ this.lastResult = undefined;
32
+ this.forceChangePassword = false;
33
+ this.handleLogin = this.handleLogin.bind(this);
34
+ this.handleLoginResult = this.handleLoginResult.bind(this);
35
+ this.handleLoginError = this.handleLoginError.bind(this);
36
+ this.renderPasswordChangeForm = this.renderPasswordChangeForm.bind(this);
37
+ }
38
+
39
+ static properties = {
40
+ apibaseurl: { type: String },
41
+ error: { type: String },
42
+ };
43
+
44
+ static styles = css`
45
+ :host {
46
+ display: block;
47
+ max-width: 400px;
48
+ margin: 0 auto;
49
+ }
50
+ `;
51
+
52
+ handleLogin(event) {
53
+ this.isAwaiting = true;
54
+ const { username, password } = event.detail;
55
+ const client = createClient({ baseUrl: this.apibaseurl });
56
+ this.requestUpdate();
57
+ client.mintToken({ username, password })
58
+ .then(this.handleLoginResult)
59
+ .catch(this.handleLoginError);
60
+ }
61
+
62
+ handleLoginResult(result) {
63
+ this.error = '';
64
+ this.isAwaiting = false;
65
+ this.lastResult = result;
66
+ if (result.challenge?.name === 'NEW_PASSWORD_REQUIRED') {
67
+ this.forceChangePassword = true;
68
+ this.error = 'You must set a new password to continue.';
69
+ } else {
70
+ this.dispatchEvent(new CustomEvent('login-success', { detail: result }));
71
+ }
72
+ this.requestUpdate();
73
+ }
74
+
75
+ handleLoginError(error) {
76
+ // Debugging login errors is a high priority
77
+ /* eslint-disable no-console */
78
+ console.error("Error logging in:", error);
79
+ this.error = error?.body?.error?.message || 'Invalid username or password';
80
+ this.isAwaiting = false;
81
+ this.requestUpdate();
82
+ }
83
+
84
+ handleChangePassword(event) {
85
+ const { currentPassword, newPassword } = event.detail;
86
+ const { token } = this.lastResult;
87
+ const client = createClient({ baseUrl: this.apibaseurl });
88
+ this.requestUpdate();
89
+ client.updatePassword({ token, currentPassword, newPassword })
90
+ .then(() => {
91
+ this.forceChangePassword = false;
92
+ this.error = 'Login with your new password.';
93
+ this.isAwaiting = false;
94
+ this.requestUpdate();
95
+ })
96
+ .catch((error) => {
97
+ this.error = error?.body?.error?.message || error.message;
98
+ this.isAwaiting = false;
99
+ this.requestUpdate();
100
+ });
101
+ }
102
+
103
+ renderPasswordChangeForm() {
104
+ return html`
105
+ <div>
106
+ ${this.error ? html`<div class="error">${this.error}</div>` : null}
107
+ <studio-change-password @change-password=${this.handleChangePassword}>
108
+ </studio-change-password>
109
+ </div>
110
+ `;
111
+ }
112
+
113
+ renderLoginForm() {
114
+ return html`
115
+ ${this.error ? html`<div class="error">${this.error}</div>` : null}
116
+ <studio-login
117
+ @login-attempt=${this.handleLogin}
118
+ ?disabled=${this.isAwaiting}
119
+ ></studio-login>
120
+ `;
121
+ }
122
+
123
+ render() {
124
+ // TODO: handle resets with a route
125
+ if (this.forceChangePassword) {
126
+ return this.renderPasswordChangeForm();
127
+ } else {
128
+ return this.renderLoginForm();
129
+ }
130
+ }
131
+ }
132
+
133
+ window.customElements.define('mod-auth-login-form', ModAuthLoginForm);
@@ -0,0 +1,84 @@
1
+ import { LitElement, html, css } from 'lit';
2
+
3
+ /**
4
+ * Describes a generic change password form.
5
+ *
6
+ * @attribute currentPassword - The current password
7
+ * @attribute newPassword - The new password
8
+ * @attribute confirmPassword - The new password confirmation
9
+ *
10
+ * @fires change-password - Fired when the user submits the change password form
11
+ */
12
+ class StudioChangePassword extends LitElement {
13
+ static styles = css`
14
+ :host {
15
+ display: block;
16
+ box-sizing: border-box;
17
+ }
18
+ input, button {
19
+ width: 100%;
20
+ margin-bottom: 10px;
21
+ padding: 8px;
22
+ box-sizing: border-box;
23
+ }
24
+ `;
25
+
26
+ static properties = {
27
+ currentPassword: { type: String },
28
+ newPassword: { type: String },
29
+ confirmPassword: { type: String },
30
+ };
31
+
32
+ constructor() {
33
+ super();
34
+ this.currentPassword = '';
35
+ this.newPassword = '';
36
+ this.confirmPassword = '';
37
+ }
38
+
39
+ render() {
40
+ return html`
41
+ <form @submit="${this.handleSubmit}">
42
+ <input type="password" name="currentPassword"
43
+ autocomplete="current-password"
44
+ placeholder="Current Password"
45
+ .value="${this.currentPassword}"
46
+ @input="${this.updateProperty('currentPassword')}"
47
+ >
48
+ <input type="password" name="newPassword"
49
+ placeholder="New Password"
50
+ .value="${this.newPassword}"
51
+ @input="${this.updateProperty('newPassword')}"
52
+ >
53
+ <input type="password" name="confirmPassword"
54
+ placeholder="Confirm New Password"
55
+ .value="${this.confirmPassword}"
56
+ @input="${this.updateProperty('confirmPassword')}"
57
+ >
58
+ <button type="submit">Change Password</button>
59
+ </form>
60
+ `;
61
+ }
62
+
63
+ updateProperty(property) {
64
+ return (e) => {
65
+ this[property] = e.target.value;
66
+ };
67
+ }
68
+
69
+ handleSubmit(e) {
70
+ e.preventDefault();
71
+ if (this.newPassword === this.confirmPassword) {
72
+ this.dispatchEvent(new CustomEvent('change-password', {
73
+ detail: {
74
+ currentPassword: this.currentPassword,
75
+ newPassword: this.newPassword,
76
+ },
77
+ }));
78
+ } else {
79
+ alert('The new passwords do not match.');
80
+ }
81
+ }
82
+ }
83
+
84
+ customElements.define('studio-change-password', StudioChangePassword);
@@ -0,0 +1,94 @@
1
+ import { LitElement, html, css } from 'lit';
2
+ import '@shoelace-style/shoelace/dist/components/card/card.js';
3
+ import '@shoelace-style/shoelace/dist/components/input/input.js';
4
+ import '@shoelace-style/shoelace/dist/components/button/button.js';
5
+
6
+ /**
7
+ * Login Form for the Studio
8
+ * @customElement studio-login
9
+ * @example
10
+ * <studio-login></studio-login>
11
+ *
12
+ * @fires login-attempt - Fired when the user submits the login form
13
+ */
14
+ class StudioLogin extends LitElement {
15
+ static styles = css`
16
+ :host {
17
+ display: flex;
18
+ justify-content: center;
19
+ align-items: center;
20
+ height: 100vh;
21
+ box-sizing: border-box;
22
+ }
23
+ sl-card {
24
+ width: 300px;
25
+ --padding: var(--sl-card-padding);
26
+ --background-color: var(--sl-card-background-color);
27
+ --border-radius: var(--sl-card-border-radius);
28
+ --box-shadow: var(--sl-card-shadow);
29
+ }
30
+ sl-input, sl-button {
31
+ width: 100%;
32
+ margin-bottom: 10px;
33
+ }
34
+ `;
35
+
36
+ static properties = {
37
+ username: { type: String },
38
+ password: { type: String },
39
+ disabled: { type: Boolean, reflect: true },
40
+ };
41
+
42
+ constructor() {
43
+ super();
44
+ this.username = '';
45
+ this.password = '';
46
+ this.disabled = false;
47
+ }
48
+
49
+ handleSubmit(e) {
50
+ e.preventDefault();
51
+ this.dispatchEvent(
52
+ new CustomEvent('login-attempt', {
53
+ detail: {
54
+ username: this.username,
55
+ password: this.password,
56
+ },
57
+ bubbles: true,
58
+ composed: true,
59
+ })
60
+ );
61
+ }
62
+
63
+ handleInputChange(e) {
64
+ const { name, value } = e.target;
65
+ this[name] = value;
66
+ }
67
+
68
+ render() {
69
+ return html`
70
+ <sl-card>
71
+ <form @submit="${this.handleSubmit}">
72
+ <sl-input
73
+ name="username"
74
+ placeholder="Username or Email"
75
+ .value="${this.username}"
76
+ autocomplete="username"
77
+ @sl-change="${this.handleInputChange}">
78
+ </sl-input>
79
+ <sl-input
80
+ type="password"
81
+ name="password"
82
+ placeholder="Password"
83
+ .value="${this.password}"
84
+ autocomplete="current-password"
85
+ @sl-change="${this.handleInputChange}">
86
+ </sl-input>
87
+ <sl-button type="submit" ?disabled="${this.disabled}">Login</sl-button>
88
+ </form>
89
+ </sl-card>
90
+ `;
91
+ }
92
+ }
93
+
94
+ customElements.define('studio-login', StudioLogin);
@@ -0,0 +1,56 @@
1
+ import { LitElement, html } from 'lit';
2
+ import { getToken } from '../lib.mjs';
3
+ import { getStudio } from '@wral/studio-tools';
4
+
5
+ /**
6
+ * Parses the payload of a JWT, without any validation.
7
+ * @param {string} token - The JWT token
8
+ * @returns {object} The parsed token's payload
9
+ */
10
+ function parseToken(token) {
11
+ return JSON.parse(atob(token.split('.')[1]));
12
+ }
13
+
14
+ class StudioProfileView extends LitElement {
15
+
16
+ constructor() {
17
+ super();
18
+ this.token = null;
19
+ this.studio = getStudio();
20
+ }
21
+
22
+ firstUpdated() {
23
+ this.fetchToken().then(() => {
24
+ this.requestUpdate();
25
+ })
26
+ }
27
+
28
+ async fetchToken() {
29
+ return this.token = await getToken(this.studio);
30
+ }
31
+
32
+ render() {
33
+ if (!this.token) {
34
+ return html`<div>You are not logged in</div>`;
35
+ }
36
+ const payload = parseToken(this.token);
37
+ return html`<div>
38
+ <h2>Your Profile</h2>
39
+ <div class="username">${payload.username || payload.sub}</div>
40
+ <div>
41
+ <h3>Permissions</h3>
42
+ <ul>
43
+ ${(payload.scope || '').split(' ')
44
+ .map(permission => html`<li>${permission}</li>`)}
45
+ </ul>
46
+ </div>
47
+ <div>
48
+ <studio-change-password></studio-change-password>
49
+ </div>
50
+ </div>`;
51
+ }
52
+ }
53
+
54
+ customElements.define('studio-profile-view', StudioProfileView);
55
+
56
+ export default html`<studio-profile-view id="profile"></studio-profile-view>`
@@ -0,0 +1,110 @@
1
+ import { LitElement, html, css } from 'lit';
2
+
3
+ /**
4
+ * Describes a reset password form
5
+ *
6
+ * @example
7
+ * <studio-reset-password></studio-reset-password>
8
+ *
9
+ * @attribute hide-confirmation-code - Hides the confirmation code input
10
+ * @attribute triggered - Indicates that the reset password form was triggered
11
+ *
12
+ * @fires request-trigger - Fired when the user submits the login form
13
+ * @fires reset-password - Fired when the user submits the reset password form
14
+ *
15
+ * @TODO: refactor password reset as a form and add browser validation
16
+ */
17
+ class StudioResetPassword extends LitElement {
18
+ static styles = css`
19
+ :host {
20
+ display: block;
21
+ box-sizing: border-box;
22
+ }
23
+ input, button {
24
+ width: 100%;
25
+ margin-bottom: 10px;
26
+ box-sizing: border-box;
27
+ padding: 8px;
28
+ }
29
+ `;
30
+
31
+ static properties = {
32
+ email: { type: String },
33
+ confirmationCode: { type: String },
34
+ hideConfirmationCode: { type: Boolean },
35
+ newPassword: { type: String },
36
+ triggered: { type: Boolean },
37
+ };
38
+
39
+ constructor() {
40
+ super();
41
+ this.email = '';
42
+ this.confirmationCode = '';
43
+ this.newPassword = '';
44
+ this.hideConfirmationCode = false;
45
+ }
46
+
47
+ handleInput(field) {
48
+ return (e) => {
49
+ this[field] = e.target.value;
50
+ };
51
+ }
52
+
53
+ triggerRequest() {
54
+ const event = new CustomEvent('request-trigger', {
55
+ detail: { email: this.email },
56
+ cancelable: true,
57
+ });
58
+ this.dispatchEvent(event);
59
+
60
+ // If the event is not cancelled, set the stage to 2
61
+ if (!event.defaultPrevented) {
62
+ this.triggered = true;
63
+ }
64
+ }
65
+
66
+ handleSubmit(e) {
67
+ e.preventDefault();
68
+ this.dispatchEvent(new CustomEvent('password-reset', {
69
+ detail: {
70
+ email: this.email,
71
+ confirmationCode: this.confirmationCode,
72
+ newPassword: this.newPassword,
73
+ },
74
+ }));
75
+ }
76
+
77
+ render() {
78
+ return html`
79
+ <form @submit="${this.handleSubmit}">
80
+ ${!this.triggered ? html`
81
+ <input type="email"
82
+ name="email"
83
+ placeholder="Email"
84
+ autocomplete="email"
85
+ .value="${this.email}"
86
+ required
87
+ @input="${this.handleInput('email')}">
88
+ <button type="button" @click="${this.triggerRequest}">Request Reset</button>
89
+ ` : html`
90
+ <input name="confirmationCode"
91
+ type=${this.hideConfirmationCode ? 'hidden' : 'text'}
92
+ autocomplete="one-time-code"
93
+ placeholder="Confirmation Code"
94
+ .value="${this.confirmationCode}"
95
+ @input="${this.handleInput('confirmationCode')}">
96
+ <input type="password" name="newPassword"
97
+ autocomplete="new-password"
98
+ placeholder="New Password"
99
+ .value="${this.newPassword}"
100
+ @input="${this.handleInput('newPassword')}"
101
+ required
102
+ >
103
+ <button type="submit">Reset Password</button>
104
+ `}
105
+ </form>
106
+ `;
107
+ }
108
+ }
109
+
110
+ customElements.define('studio-reset-password', StudioResetPassword);
package/src/lib.mjs ADDED
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Get an auth token
3
+ * @param {Studio} studio
4
+ * @return {Promise<string>} bearer token
5
+ */
6
+ export function getToken(studio) {
7
+ return new Promise((resolve, reject) => {
8
+ studio.pub('studio.wral::mod-auth', 'token-request', {
9
+ callback: (token, error) => {
10
+ if (error) {
11
+ reject(error);
12
+ }
13
+ resolve(token);
14
+ }});
15
+ });
16
+ }