@devopsplaybook.io/common-web 0.5.0-beta.12.6909540 → 0.6.0-beta.13.61e7129

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/CHANGELOG.md CHANGED
@@ -5,6 +5,33 @@ All notable changes to this project are documented here.
5
5
  This project follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
6
6
  and [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.6.0] - 2026-10-24
9
+
10
+ ### Added
11
+
12
+ - Propagate auth state changes: `AuthService` emits `AUTH_UPDATED` when a token
13
+ is saved, removed, renewed or expires; the authentication store subscribes and
14
+ refreshes `isAuthenticated`, and cross-tab token changes are synchronised
15
+ through the `storage` event. The additive `refreshAuthentication()` store
16
+ action is available for manual refreshes.
17
+ - Opt-in Axios integration through `registerAxiosAuthInterceptors(instance)`:
18
+ attaches the stored bearer token only when no `Authorization` header is set,
19
+ clears the session on `401` except for the login endpoint (`/users/session`),
20
+ and registers once per Axios instance. No layer plugin is auto-registered, so
21
+ existing applications keep their current behaviour until they opt in.
22
+ - `Config.set()` and `Config.reset()` inject or clear the `SERVER_URL` override
23
+ (`/api` by default).
24
+ - Document the new event, the opt-in Axios helper and the configuration
25
+ injection point, including the HTTPS requirement for non-same-origin servers.
26
+
27
+ ### Changed
28
+
29
+ - `UserService.isInitialized()` resolves `false` on network and HTTP failures
30
+ instead of rejecting.
31
+ - `RefreshIntervalService.get()` validates the stored value and falls back to
32
+ `10000`; `set()` normalises valid values and throws `TypeError` for invalid
33
+ ones. `"0"` remains valid to disable polling.
34
+
8
35
  ## [0.5.0] - 2026-10-24
9
36
 
10
37
  ### Fixed
package/README.md CHANGED
@@ -77,6 +77,65 @@ sessions without requiring users to sign in again.
77
77
  - Renewed tokens are validated (decodable, non-empty, future `exp`, and
78
78
  `exp > iat` when `iat` is present) before they are persisted.
79
79
 
80
+ ## Authentication state and HTTP integration
81
+
82
+ `AuthService.saveToken()` and `removeToken()` emit
83
+ `EventTypes.AUTH_UPDATED` on the `EventBus` when the stored token actually
84
+ changes; renewal and expiry go through those methods. The Pinia store subscribes
85
+ to the event and refreshes `isAuthenticated`, so a renewal, a logout in another
86
+ tab or an expired token no longer leaves the UI stale. Call the additive
87
+ `refreshAuthentication()` action for a manual refresh; `ensureAuthenticated()`
88
+ still loads the initial state. The store also listens for the browser `storage`
89
+ event, which synchronises token changes across tabs.
90
+
91
+ Bearer-token attachment is opt-in: the layer ships only the registration helper
92
+ and does not register a plugin, so existing applications keep their current
93
+ behaviour. Add a client plugin in the consuming application when the default
94
+ Axios instance should authenticate requests and clear the session on `401`:
95
+
96
+ ```ts
97
+ // plugins/auth.client.ts
98
+ import { registerAxiosAuthInterceptors } from "@devopsplaybook.io/common-web/services/AxiosAuthInterceptor";
99
+
100
+ export default defineNuxtPlugin(() => {
101
+ registerAxiosAuthInterceptors();
102
+ });
103
+ ```
104
+
105
+ `registerAxiosAuthInterceptors(instance?)` attaches `Authorization: Bearer
106
+ <token>` only when the request has no explicit `Authorization` header, and is
107
+ idempotent per Axios instance (it returns `false` when already registered). It
108
+ preserves an explicit header without renewing the token, and does not clear the
109
+ session on a failed login to a path ending in `/users/session`; a `401` from
110
+ any other endpoint (including `/users/session/refresh`) removes the stored token
111
+ and emits `AUTH_UPDATED`.
112
+
113
+ ### Server URL configuration
114
+
115
+ `Config.get()` returns `{ SERVER_URL: "/api" }` by default. Inject a different
116
+ base URL, for example from a Nuxt client plugin, and reset it for tests:
117
+
118
+ ```ts
119
+ import Config from "@devopsplaybook.io/common-web/services/Config";
120
+
121
+ Config.set({ SERVER_URL: "https://api.example.com" });
122
+ await Config.get(); // { SERVER_URL: "https://api.example.com" }
123
+ Config.reset(); // back to "/api"
124
+ ```
125
+
126
+ A non-same-origin `SERVER_URL` receives the bearer token when the opt-in Axios
127
+ integration is used, so it must use HTTPS and allow the application origin
128
+ through CORS.
129
+
130
+ ### Service fallbacks
131
+
132
+ `UserService.isInitialized()` resolves `false` on network or HTTP errors
133
+ instead of rejecting, so setup code can gate on it safely.
134
+ `RefreshIntervalService.get()` falls back to `"10000"` when the stored value is
135
+ missing or malformed; `set()` normalises valid non-negative integer strings
136
+ (for example `"05000"` becomes `"5000"`) and throws `TypeError` otherwise.
137
+ `"0"` is valid and keeps polling disabled.
138
+
80
139
  ## Tokens and app-shell styles
81
140
 
82
141
  The layer loads `assets/css/tokens.css` and `assets/css/app-shell.css`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@devopsplaybook.io/common-web",
3
- "version": "0.5.0-beta.12.6909540",
3
+ "version": "0.6.0-beta.13.61e7129",
4
4
  "description": "Shared Nuxt layer for DevOpsPlaybook web applications",
5
5
  "license": "ISC",
6
6
  "engines": {
@@ -49,15 +49,20 @@ export class AuthService {
49
49
  static async saveToken(token: string): Promise<void> {
50
50
  const storage = AuthService.storage();
51
51
  if (!storage) return;
52
+ const previous = storage.getItem(AUTH_TOKEN_KEY);
52
53
  storage.setItem(AUTH_TOKEN_KEY, token);
53
54
  for (const key of LEGACY_TOKEN_KEYS) storage.removeItem(key);
55
+ if (previous !== token) AuthService.emitAuthUpdated();
54
56
  }
55
57
 
56
58
  static async removeToken(_token?: string): Promise<void> {
57
59
  const storage = AuthService.storage();
58
60
  if (!storage) return;
59
- storage.removeItem(AUTH_TOKEN_KEY);
60
- for (const key of LEGACY_TOKEN_KEYS) storage.removeItem(key);
61
+ const keys = [AUTH_TOKEN_KEY, ...LEGACY_TOKEN_KEYS];
62
+ const present = keys.filter((key) => storage.getItem(key) !== null);
63
+ if (present.length === 0) return;
64
+ for (const key of keys) storage.removeItem(key);
65
+ AuthService.emitAuthUpdated();
61
66
  }
62
67
 
63
68
  static async getToken(): Promise<string | null> {
@@ -131,7 +136,10 @@ export class AuthService {
131
136
 
132
137
  private static async clearSession(): Promise<void> {
133
138
  await AuthService.removeToken();
134
- EventBus.emit(EventTypes.AUTH_UPDATED);
139
+ }
140
+
141
+ private static emitAuthUpdated(): void {
142
+ EventBus.emit(EventTypes.AUTH_UPDATED, undefined);
135
143
  }
136
144
 
137
145
  private static async renewToken(token: string): Promise<string | null> {
@@ -0,0 +1,55 @@
1
+ import axios, { AxiosHeaders } from "axios";
2
+ import type { AxiosInstance } from "axios";
3
+ import { AuthService } from "./AuthService";
4
+
5
+ const REGISTRATION_MARKER = Symbol.for("common-web.axios-auth");
6
+
7
+ type MarkedAxiosInstance = AxiosInstance & {
8
+ [key: symbol]: unknown;
9
+ };
10
+
11
+ function isLoginRequest(url: unknown): boolean {
12
+ if (typeof url !== "string" || url.length === 0) return false;
13
+ try {
14
+ const { pathname } = new URL(url, "http://localhost");
15
+ return pathname.replace(/\/+$/, "").endsWith("/users/session");
16
+ } catch {
17
+ return false;
18
+ }
19
+ }
20
+
21
+ export function registerAxiosAuthInterceptors(
22
+ instance: AxiosInstance = axios,
23
+ ): boolean {
24
+ const marked = instance as MarkedAxiosInstance;
25
+ if (marked[REGISTRATION_MARKER]) {
26
+ return false;
27
+ }
28
+ marked[REGISTRATION_MARKER] = true;
29
+
30
+ instance.interceptors.request.use(async (config) => {
31
+ const headers = AxiosHeaders.from(config.headers);
32
+ if (headers.get("Authorization") != null) {
33
+ return config;
34
+ }
35
+
36
+ const token = await AuthService.getToken();
37
+ if (token) {
38
+ headers.set("Authorization", `Bearer ${token}`);
39
+ config.headers = headers;
40
+ }
41
+ return config;
42
+ });
43
+
44
+ instance.interceptors.response.use(
45
+ (response) => response,
46
+ async (error) => {
47
+ if (error?.response?.status === 401 && !isLoginRequest(error?.config?.url)) {
48
+ await AuthService.removeToken();
49
+ }
50
+ return Promise.reject(error);
51
+ },
52
+ );
53
+
54
+ return true;
55
+ }
@@ -2,8 +2,20 @@ export interface AppConfig {
2
2
  SERVER_URL: string;
3
3
  }
4
4
 
5
+ const DEFAULT_CONFIG: AppConfig = { SERVER_URL: "/api" };
6
+
5
7
  export default class Config {
8
+ private static overrides: Partial<AppConfig> = {};
9
+
6
10
  static async get(): Promise<AppConfig> {
7
- return { SERVER_URL: "/api" };
11
+ return { ...DEFAULT_CONFIG, ...Config.overrides };
12
+ }
13
+
14
+ static set(config: Partial<AppConfig>): void {
15
+ Config.overrides = { ...Config.overrides, ...config };
16
+ }
17
+
18
+ static reset(): void {
19
+ Config.overrides = {};
8
20
  }
9
21
  }
@@ -3,10 +3,28 @@ export class RefreshIntervalService {
3
3
  static readonly DEFAULT = "10000";
4
4
 
5
5
  static get(): string {
6
- return localStorage.getItem(RefreshIntervalService.KEY) ?? RefreshIntervalService.DEFAULT;
6
+ const stored = localStorage.getItem(RefreshIntervalService.KEY);
7
+ if (stored === null) return RefreshIntervalService.DEFAULT;
8
+ return (
9
+ RefreshIntervalService.normalise(stored) ??
10
+ RefreshIntervalService.DEFAULT
11
+ );
7
12
  }
8
13
 
9
14
  static set(value: string): void {
10
- localStorage.setItem(RefreshIntervalService.KEY, value);
15
+ const normalised = RefreshIntervalService.normalise(value);
16
+ if (normalised === null) {
17
+ throw new TypeError(
18
+ `Invalid refresh interval: ${String(value)} (expected a non-negative integer string)`,
19
+ );
20
+ }
21
+ localStorage.setItem(RefreshIntervalService.KEY, normalised);
22
+ }
23
+
24
+ private static normalise(value: string): string | null {
25
+ if (!/^\d+$/.test(value)) return null;
26
+ const numeric = Number(value);
27
+ if (!Number.isSafeInteger(numeric)) return null;
28
+ return String(numeric);
11
29
  }
12
30
  }
@@ -3,10 +3,14 @@ import Config from "./Config";
3
3
 
4
4
  export class UserService {
5
5
  static async isInitialized(): Promise<boolean> {
6
- const config = await Config.get();
7
- const response = await axios.get<{ initialized: boolean }>(
8
- `${config.SERVER_URL}/users/status/initialization`,
9
- );
10
- return response.data.initialized;
6
+ try {
7
+ const config = await Config.get();
8
+ const response = await axios.get<{ initialized: boolean }>(
9
+ `${config.SERVER_URL}/users/status/initialization`,
10
+ );
11
+ return response.data.initialized === true;
12
+ } catch {
13
+ return false;
14
+ }
11
15
  }
12
16
  }
@@ -1,18 +1,41 @@
1
1
  import { acceptHMRUpdate, defineStore } from "pinia";
2
- import { AuthService } from "../services/AuthService";
2
+ import { ref } from "vue";
3
+ import { EventBus, EventTypes } from "../composables/EventBus";
4
+ import { AUTH_TOKEN_KEY, AuthService } from "../services/AuthService";
5
+
6
+ let storageSyncRegistered = false;
7
+
8
+ function registerStorageSync(): void {
9
+ if (storageSyncRegistered) return;
10
+ storageSyncRegistered = true;
11
+ window.addEventListener("storage", (event) => {
12
+ if (event.key === null || event.key === AUTH_TOKEN_KEY) {
13
+ EventBus.emit(EventTypes.AUTH_UPDATED, undefined);
14
+ }
15
+ });
16
+ }
3
17
 
4
18
  export function createAuthenticationStore() {
5
- return defineStore("AuthenticationStore", {
6
- state: () => ({
7
- isAuthenticated: false,
8
- }),
9
-
10
- actions: {
11
- async ensureAuthenticated(): Promise<boolean> {
12
- this.isAuthenticated = await AuthService.isAuthenticated();
13
- return this.isAuthenticated;
14
- },
15
- },
19
+ return defineStore("AuthenticationStore", () => {
20
+ const isAuthenticated = ref(false);
21
+
22
+ async function refreshAuthentication(): Promise<void> {
23
+ isAuthenticated.value = await AuthService.isAuthenticated();
24
+ }
25
+
26
+ async function ensureAuthenticated(): Promise<boolean> {
27
+ await refreshAuthentication();
28
+ return isAuthenticated.value;
29
+ }
30
+
31
+ if (typeof window !== "undefined") {
32
+ EventBus.on(EventTypes.AUTH_UPDATED, () => {
33
+ void refreshAuthentication();
34
+ });
35
+ registerStorageSync();
36
+ }
37
+
38
+ return { isAuthenticated, ensureAuthenticated, refreshAuthentication };
16
39
  });
17
40
  }
18
41