@devopsplaybook.io/common-web 0.5.0 → 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 +27 -0
- package/README.md +59 -0
- package/package.json +1 -1
- package/services/AuthService.ts +11 -3
- package/services/AxiosAuthInterceptor.ts +55 -0
- package/services/Config.ts +13 -1
- package/services/RefreshIntervalService.ts +20 -2
- package/services/UserService.ts +9 -5
- package/stores/AuthenticationStore.ts +35 -12
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
package/services/AuthService.ts
CHANGED
|
@@ -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
|
-
|
|
60
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/services/Config.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/services/UserService.ts
CHANGED
|
@@ -3,10 +3,14 @@ import Config from "./Config";
|
|
|
3
3
|
|
|
4
4
|
export class UserService {
|
|
5
5
|
static async isInitialized(): Promise<boolean> {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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 {
|
|
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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
|