@saolabs/client 1.5.0 → 1.6.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/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/src/core/app/Application.d.ts +2 -0
- package/dist/src/core/app/Application.d.ts.map +1 -1
- package/dist/src/core/app/Application.js +3 -0
- package/dist/src/core/app/Application.js.map +1 -1
- package/dist/src/core/bootstrap/providers/CoreServiceProvider.d.ts.map +1 -1
- package/dist/src/core/bootstrap/providers/CoreServiceProvider.js +9 -0
- package/dist/src/core/bootstrap/providers/CoreServiceProvider.js.map +1 -1
- package/dist/src/core/bootstrap/providers/default-providers.js +1 -1
- package/dist/src/core/bootstrap/providers/default-providers.js.map +1 -1
- package/dist/src/core/contracts/ApplicationInterface.d.ts +2 -0
- package/dist/src/core/contracts/ApplicationInterface.d.ts.map +1 -1
- package/dist/src/core/contracts/HelperInterface.d.ts +3 -0
- package/dist/src/core/contracts/HelperInterface.d.ts.map +1 -1
- package/dist/src/core/elements/Html.d.ts +1 -0
- package/dist/src/core/elements/Html.d.ts.map +1 -1
- package/dist/src/core/elements/Html.js +11 -0
- package/dist/src/core/elements/Html.js.map +1 -1
- package/dist/src/core/helpers/ApiClient.d.ts +1 -1
- package/dist/src/core/helpers/ApiClient.d.ts.map +1 -1
- package/dist/src/core/helpers/ApiClient.js +1 -11
- package/dist/src/core/helpers/ApiClient.js.map +1 -1
- package/dist/src/core/helpers/auth.d.ts +4 -0
- package/dist/src/core/helpers/auth.d.ts.map +1 -0
- package/dist/src/core/helpers/auth.js +10 -0
- package/dist/src/core/helpers/auth.js.map +1 -0
- package/dist/src/core/services/AuthClient.d.ts +54 -0
- package/dist/src/core/services/AuthClient.d.ts.map +1 -0
- package/dist/src/core/services/AuthClient.js +155 -0
- package/dist/src/core/services/AuthClient.js.map +1 -0
- package/dist/src/core/services/AuthService.d.ts +70 -0
- package/dist/src/core/services/AuthService.d.ts.map +1 -0
- package/dist/src/core/services/AuthService.js +157 -0
- package/dist/src/core/services/AuthService.js.map +1 -0
- package/dist/src/core/services/HelperService.d.ts +6 -0
- package/dist/src/core/services/HelperService.d.ts.map +1 -1
- package/dist/src/core/services/HelperService.js +22 -1
- package/dist/src/core/services/HelperService.js.map +1 -1
- package/dist/src/core/services/HttpService.d.ts.map +1 -1
- package/dist/src/core/services/HttpService.js +20 -0
- package/dist/src/core/services/HttpService.js.map +1 -1
- package/dist/src/core/services/ReactiveDependency.d.ts +15 -0
- package/dist/src/core/services/ReactiveDependency.d.ts.map +1 -0
- package/dist/src/core/services/ReactiveDependency.js +46 -0
- package/dist/src/core/services/ReactiveDependency.js.map +1 -0
- package/dist/src/core/services/TranslationService.d.ts +2 -0
- package/dist/src/core/services/TranslationService.d.ts.map +1 -1
- package/dist/src/core/services/TranslationService.js +5 -0
- package/dist/src/core/services/TranslationService.js.map +1 -1
- package/dist/src/core/services/index.d.ts +2 -0
- package/dist/src/core/services/index.d.ts.map +1 -1
- package/dist/src/core/services/index.js +2 -0
- package/dist/src/core/services/index.js.map +1 -1
- package/dist/src/core/view/ViewState.d.ts +1 -0
- package/dist/src/core/view/ViewState.d.ts.map +1 -1
- package/dist/src/core/view/ViewState.js +37 -2
- package/dist/src/core/view/ViewState.js.map +1 -1
- package/index.ts +7 -0
- package/package.json +1 -1
- package/src/core/app/Application.ts +6 -1
- package/src/core/bootstrap/providers/CoreServiceProvider.ts +7 -0
- package/src/core/bootstrap/providers/default-providers.ts +1 -1
- package/src/core/contracts/ApplicationInterface.ts +2 -0
- package/src/core/contracts/HelperInterface.ts +3 -0
- package/src/core/elements/Html.ts +11 -0
- package/src/core/helpers/ApiClient.ts +1 -11
- package/src/core/helpers/auth.ts +10 -0
- package/src/core/services/AuthClient.ts +163 -0
- package/src/core/services/AuthService.ts +177 -0
- package/src/core/services/HelperService.ts +23 -1
- package/src/core/services/HttpService.ts +21 -0
- package/src/core/services/ReactiveDependency.ts +46 -0
- package/src/core/services/TranslationService.ts +6 -0
- package/src/core/services/index.ts +2 -0
- package/src/core/view/ViewState.ts +34 -3
|
@@ -25,7 +25,7 @@ export class ApiClient implements APIClientInterface {
|
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
/**
|
|
28
|
-
* Setup default headers
|
|
28
|
+
* Setup default headers. HttpService reads the current CSRF token per request.
|
|
29
29
|
* @private
|
|
30
30
|
*/
|
|
31
31
|
private setupDefaultHeaders(): void {
|
|
@@ -34,16 +34,6 @@ export class ApiClient implements APIClientInterface {
|
|
|
34
34
|
this.http.setHeader('X-Requested-With', 'XMLHttpRequest');
|
|
35
35
|
this.http.setHeader('X-DATA-TYPE', 'json');
|
|
36
36
|
|
|
37
|
-
// Get CSRF token from meta tag (SSR support)
|
|
38
|
-
if (typeof document !== 'undefined') {
|
|
39
|
-
const csrfToken = document.querySelector('meta[name="csrf-token"]');
|
|
40
|
-
if (csrfToken) {
|
|
41
|
-
const token = csrfToken.getAttribute('content');
|
|
42
|
-
if (token) {
|
|
43
|
-
this.http.setHeader('X-CSRF-TOKEN', token);
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
37
|
}
|
|
48
38
|
|
|
49
39
|
init(config: Record<string, any>): void {
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { app } from './app.js';
|
|
2
|
+
import { AuthService } from '../services/AuthService.js';
|
|
3
|
+
import type { ApplicationInterface } from '../contracts/ApplicationInterface.js';
|
|
4
|
+
|
|
5
|
+
/** Same instance exposed by App.Auth and App.Helper.auth(). */
|
|
6
|
+
export function auth(guard?: string | null): AuthService {
|
|
7
|
+
const application = app<ApplicationInterface>();
|
|
8
|
+
if (!application.has('Auth')) application.instance('Auth', AuthService.fromSnapshot(application.shared('auth')));
|
|
9
|
+
return application.get<AuthService>('Auth').guard(guard);
|
|
10
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { AuthService, type AuthSnapshot } from './AuthService.js';
|
|
2
|
+
|
|
3
|
+
export interface AuthProviderInfo { name: string; label: string; url: string }
|
|
4
|
+
export interface AuthClientConfig {
|
|
5
|
+
version: 1;
|
|
6
|
+
guard: string;
|
|
7
|
+
csrfToken: string | null;
|
|
8
|
+
usernameField: string;
|
|
9
|
+
endpoints: { session: string; login: string | null; logout: string };
|
|
10
|
+
providers: AuthProviderInfo[];
|
|
11
|
+
}
|
|
12
|
+
export interface AuthClientOptions {
|
|
13
|
+
fetch?: typeof fetch;
|
|
14
|
+
navigate?: (url: string) => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Same-origin session transport; no dependency on Router, View or a context API. */
|
|
18
|
+
export class AuthClient {
|
|
19
|
+
private config: AuthClientConfig | null = null;
|
|
20
|
+
private queue: Promise<unknown> = Promise.resolve();
|
|
21
|
+
private requests = new Set<AbortController>();
|
|
22
|
+
private disposed = false;
|
|
23
|
+
private generation = 0;
|
|
24
|
+
|
|
25
|
+
constructor(private readonly auth: AuthService, private readonly options: AuthClientOptions = {}) {}
|
|
26
|
+
|
|
27
|
+
configure(config: AuthClientConfig | null): this {
|
|
28
|
+
if (config !== null) {
|
|
29
|
+
if (config.version !== 1 || typeof config.guard !== 'string' || !config.guard
|
|
30
|
+
|| typeof config.usernameField !== 'string' || !config.usernameField
|
|
31
|
+
|| (config.csrfToken !== null && typeof config.csrfToken !== 'string')
|
|
32
|
+
|| !Array.isArray(config.providers)) throw new TypeError('Invalid Auth API configuration.');
|
|
33
|
+
this.localUrl(config.endpoints?.session);
|
|
34
|
+
this.localUrl(config.endpoints?.logout);
|
|
35
|
+
if (config.endpoints.login !== null) this.localUrl(config.endpoints.login);
|
|
36
|
+
for (const provider of config.providers) {
|
|
37
|
+
if (typeof provider.name !== 'string' || !provider.name || typeof provider.label !== 'string') {
|
|
38
|
+
throw new TypeError('Invalid auth provider.');
|
|
39
|
+
}
|
|
40
|
+
this.localUrl(provider.url);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
this.config = config === null ? null : structuredClone(config);
|
|
44
|
+
this.generation++;
|
|
45
|
+
return this;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
get available(): boolean { return this.config !== null && !this.disposed; }
|
|
49
|
+
get csrfToken(): string | null { return this.config?.csrfToken ?? null; }
|
|
50
|
+
get usernameField(): string | null { return this.config?.usernameField ?? null; }
|
|
51
|
+
get providers(): AuthProviderInfo[] { return structuredClone(this.config?.providers ?? []); }
|
|
52
|
+
|
|
53
|
+
login(credentials: Record<string, string | boolean>): Promise<AuthSnapshot> {
|
|
54
|
+
// Do not retain the caller's mutable object while waiting for another operation.
|
|
55
|
+
return this.enqueue('login', { ...credentials });
|
|
56
|
+
}
|
|
57
|
+
logout(): Promise<AuthSnapshot> { return this.enqueue('logout'); }
|
|
58
|
+
refresh(): Promise<AuthSnapshot> { return this.enqueue('session'); }
|
|
59
|
+
|
|
60
|
+
loginUrl(provider: string, options: { next?: string } = {}): string {
|
|
61
|
+
this.assertAvailable();
|
|
62
|
+
const item = this.config!.providers.find(item => item.name === provider);
|
|
63
|
+
if (!item) throw new Error(`Auth provider "${provider}" is not enabled.`);
|
|
64
|
+
const url = this.localUrl(item.url);
|
|
65
|
+
const next = this.localUrl(options.next ?? (window.location.pathname + window.location.search + window.location.hash));
|
|
66
|
+
url.searchParams.set('next', next.pathname + next.search + next.hash);
|
|
67
|
+
return url.href;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** OAuth redirects through the server; callback restores auth via a fresh SSR bootstrap. */
|
|
71
|
+
loginWith(provider: string, options: { next?: string } = {}): void {
|
|
72
|
+
const url = this.loginUrl(provider, options);
|
|
73
|
+
(this.options.navigate ?? (url => window.location.assign(url)))(url);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
private enqueue(action: 'session' | 'login' | 'logout', body?: object): Promise<AuthSnapshot> {
|
|
77
|
+
const work = this.queue.then(async () => {
|
|
78
|
+
this.assertAvailable();
|
|
79
|
+
if (!this.config!.endpoints[action]) throw new Error('Password login is not enabled.');
|
|
80
|
+
const finish = action === 'session' ? () => {} : this.auth.beginClientTransition();
|
|
81
|
+
try {
|
|
82
|
+
// A refresh obtains the cookie/CSRF token when boot ran without a session.
|
|
83
|
+
if (action !== 'session' && !this.config!.csrfToken) await this.request('session');
|
|
84
|
+
return await this.request(action, body);
|
|
85
|
+
} finally { finish(); }
|
|
86
|
+
});
|
|
87
|
+
// A failed login must not poison later logout/refresh operations.
|
|
88
|
+
this.queue = work.catch(() => {});
|
|
89
|
+
return work;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
private async request(action: 'session' | 'login' | 'logout', body?: object): Promise<AuthSnapshot> {
|
|
93
|
+
this.assertAvailable();
|
|
94
|
+
const config = this.config!;
|
|
95
|
+
const url = this.localUrl(config.endpoints[action]!);
|
|
96
|
+
const revision = this.auth.revision;
|
|
97
|
+
const generation = this.generation;
|
|
98
|
+
const controller = new AbortController();
|
|
99
|
+
this.requests.add(controller);
|
|
100
|
+
const timeout = setTimeout(() => controller.abort(), 30000);
|
|
101
|
+
try {
|
|
102
|
+
const headers: Record<string, string> = { Accept: 'application/json', 'X-Requested-With': 'XMLHttpRequest' };
|
|
103
|
+
if (action !== 'session') {
|
|
104
|
+
if (!config.csrfToken) throw new Error('Auth API did not provide a CSRF token.');
|
|
105
|
+
headers['X-CSRF-TOKEN'] = config.csrfToken;
|
|
106
|
+
headers['Content-Type'] = 'application/json';
|
|
107
|
+
}
|
|
108
|
+
const response = await (this.options.fetch ?? fetch)(url.href, {
|
|
109
|
+
method: action === 'session' ? 'GET' : 'POST', headers,
|
|
110
|
+
credentials: 'same-origin', mode: 'same-origin', redirect: 'error', cache: 'no-store',
|
|
111
|
+
signal: controller.signal,
|
|
112
|
+
...(action === 'session' ? {} : {body: JSON.stringify(body ?? {})}),
|
|
113
|
+
});
|
|
114
|
+
if (response.url) this.localUrl(response.url);
|
|
115
|
+
const payload = await response.json();
|
|
116
|
+
controller.signal.throwIfAborted();
|
|
117
|
+
if (!response.ok) {
|
|
118
|
+
throw Object.assign(new Error(typeof payload?.message === 'string' ? payload.message : `Authentication request failed (${response.status}).`),
|
|
119
|
+
{status: response.status, errors: payload?.errors});
|
|
120
|
+
}
|
|
121
|
+
if (this.disposed || generation !== this.generation || revision !== this.auth.revision) {
|
|
122
|
+
throw Object.assign(new Error('Authentication changed while the request was pending.'), {name: 'AbortError'});
|
|
123
|
+
}
|
|
124
|
+
// Validate the next configuration before committing either state or CSRF.
|
|
125
|
+
const next = new AuthClient(this.auth);
|
|
126
|
+
next.configure(payload.authClient);
|
|
127
|
+
if (!payload.authClient) throw new TypeError('Missing Auth API configuration.');
|
|
128
|
+
new AuthService().initialize(payload.auth); // deep validation without emitting or changing current auth
|
|
129
|
+
// Subscribers may immediately send a request. Publish the new CSRF first.
|
|
130
|
+
this.configure(payload.authClient);
|
|
131
|
+
const meta = typeof document === 'undefined' ? null : document.querySelector<HTMLMetaElement>('meta[name="csrf-token"]');
|
|
132
|
+
if (meta && this.config?.csrfToken) meta.content = this.config.csrfToken;
|
|
133
|
+
this.auth.sync(payload.auth, action === 'session' ? 'sync' : action);
|
|
134
|
+
return payload.auth;
|
|
135
|
+
} finally {
|
|
136
|
+
clearTimeout(timeout);
|
|
137
|
+
this.requests.delete(controller);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
private localUrl(value: string): URL {
|
|
142
|
+
if (typeof window === 'undefined' || typeof value !== 'string' || !value.trim()) {
|
|
143
|
+
throw new TypeError('Auth API requires a browser and a same-origin URL.');
|
|
144
|
+
}
|
|
145
|
+
const url = new URL(value, window.location.origin);
|
|
146
|
+
if (url.origin !== window.location.origin || !['http:', 'https:'].includes(url.protocol) || url.username || url.password) {
|
|
147
|
+
throw new TypeError('Auth API URLs must use the current origin.');
|
|
148
|
+
}
|
|
149
|
+
return url;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
private assertAvailable(): void {
|
|
153
|
+
if (!this.available) throw new Error('Auth API is not configured or has been destroyed.');
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
destroy(): void {
|
|
157
|
+
this.disposed = true;
|
|
158
|
+
this.generation++;
|
|
159
|
+
for (const controller of this.requests) controller.abort();
|
|
160
|
+
this.requests.clear();
|
|
161
|
+
this.config = null;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { trackDependency } from './ReactiveDependency.js';
|
|
2
|
+
import { AuthClient } from './AuthClient.js';
|
|
3
|
+
|
|
4
|
+
export type AuthUser = Readonly<{ id: string | number; [key: string]: any }>;
|
|
5
|
+
export interface AuthSnapshot {
|
|
6
|
+
version: 1;
|
|
7
|
+
defaultGuard: string;
|
|
8
|
+
guards: Record<string, { user: AuthUser | null }>;
|
|
9
|
+
}
|
|
10
|
+
export interface AuthChange {
|
|
11
|
+
guard: string;
|
|
12
|
+
user: AuthUser | null;
|
|
13
|
+
previousUser: AuthUser | null;
|
|
14
|
+
source: 'login' | 'logout' | 'sync';
|
|
15
|
+
}
|
|
16
|
+
export type AuthEvent = 'login' | 'logout' | 'change';
|
|
17
|
+
type Listener = (change: AuthChange) => void;
|
|
18
|
+
type Context = {
|
|
19
|
+
defaultGuard: string;
|
|
20
|
+
users: Map<string, AuthUser | null>;
|
|
21
|
+
instances: Map<string, AuthService>;
|
|
22
|
+
listeners: Set<{ guard: string | null; event: AuthEvent; callback: Listener }>;
|
|
23
|
+
revision: number;
|
|
24
|
+
client?: AuthClient;
|
|
25
|
+
transitions: number;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** Client view of server authentication. login/logout record successful server actions; they make no HTTP requests. */
|
|
29
|
+
export class AuthService {
|
|
30
|
+
private readonly context: Context;
|
|
31
|
+
private readonly guardName: string | null;
|
|
32
|
+
|
|
33
|
+
constructor(user: AuthUser | null = null, defaultGuard = 'web', context?: Context, guardName: string | null = null) {
|
|
34
|
+
this.context = context ?? {
|
|
35
|
+
defaultGuard, users: new Map([[defaultGuard, AuthService.copyUser(user)]]),
|
|
36
|
+
instances: new Map(), listeners: new Set(), revision: 0, transitions: 0,
|
|
37
|
+
};
|
|
38
|
+
this.guardName = guardName;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
static fromSnapshot(snapshot?: unknown): AuthService {
|
|
42
|
+
const service = new AuthService();
|
|
43
|
+
if (snapshot !== undefined) service.applySnapshot(snapshot, true);
|
|
44
|
+
return service;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
get revision(): number { return this.context.revision; }
|
|
48
|
+
get client(): AuthClient { return this.context.client ??= new AuthClient(this); }
|
|
49
|
+
get isTransitioning(): boolean { return this.context.transitions > 0; }
|
|
50
|
+
/** Internal transport barrier: page responses cannot overwrite an in-flight login/logout. */
|
|
51
|
+
beginClientTransition(): () => void {
|
|
52
|
+
this.context.revision++;
|
|
53
|
+
this.context.transitions++;
|
|
54
|
+
let finished = false;
|
|
55
|
+
return () => {
|
|
56
|
+
if (finished) return;
|
|
57
|
+
finished = true;
|
|
58
|
+
this.context.transitions--;
|
|
59
|
+
this.context.revision++;
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
private get name(): string { return this.guardName ?? this.context.defaultGuard; }
|
|
63
|
+
|
|
64
|
+
guard(name?: string | null): AuthService {
|
|
65
|
+
if (name == null) return this;
|
|
66
|
+
if (!name.trim()) throw new TypeError('An auth guard must have a name.');
|
|
67
|
+
let guard = this.context.instances.get(name);
|
|
68
|
+
if (!guard) {
|
|
69
|
+
guard = new AuthService(null, this.context.defaultGuard, this.context, name);
|
|
70
|
+
this.context.instances.set(name, guard);
|
|
71
|
+
}
|
|
72
|
+
return guard;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
check(): boolean { return this.user() !== null; }
|
|
76
|
+
guest(): boolean { return !this.check(); }
|
|
77
|
+
id(): string | number | null { return this.user()?.id ?? null; }
|
|
78
|
+
user<T extends AuthUser = AuthUser>(): T | null {
|
|
79
|
+
trackDependency(this);
|
|
80
|
+
return (this.context.users.get(this.name) ?? null) as T | null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Call only after the server accepted login (including registration/OAuth). */
|
|
84
|
+
login(user: AuthUser): this { return this.setUser(user, 'login'); }
|
|
85
|
+
/** Call only after the server confirmed logout or session expiry. */
|
|
86
|
+
logout(): this { return this.setUser(null, 'logout'); }
|
|
87
|
+
|
|
88
|
+
setUser(user: AuthUser | null, source: AuthChange['source'] = 'sync'): this {
|
|
89
|
+
const next = AuthService.copyUser(user);
|
|
90
|
+
const previous = this.user();
|
|
91
|
+
// Even a repeated explicit login/logout invalidates older in-flight responses.
|
|
92
|
+
if (source !== 'sync' || !AuthService.sameUser(previous, next)) this.context.revision++;
|
|
93
|
+
this.context.users.set(this.name, next);
|
|
94
|
+
this.emit(this.name, previous, next, source);
|
|
95
|
+
return this;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Authoritative page-data response. A snapshot replaces guards omitted by the server too. */
|
|
99
|
+
sync(snapshot: unknown, source: AuthChange['source'] = 'sync'): this { return this.applySnapshot(snapshot, false, source); }
|
|
100
|
+
/** Bootstrap only: restore the server session without reporting a new login. */
|
|
101
|
+
initialize(snapshot: unknown): this { return this.applySnapshot(snapshot, true); }
|
|
102
|
+
|
|
103
|
+
on(event: AuthEvent, callback: Listener): () => void {
|
|
104
|
+
const listener = { guard: this.guardName, event, callback };
|
|
105
|
+
this.context.listeners.add(listener);
|
|
106
|
+
return () => { this.context.listeners.delete(listener); };
|
|
107
|
+
}
|
|
108
|
+
subscribe(callback: Listener): () => void { return this.on('change', callback); }
|
|
109
|
+
destroy(): void { this.context.client?.destroy(); this.context.revision++; this.context.listeners.clear(); this.context.instances.clear(); this.context.users.clear(); }
|
|
110
|
+
|
|
111
|
+
private applySnapshot(snapshot: unknown, silent: boolean, source: AuthChange['source'] = 'sync'): this {
|
|
112
|
+
if (!AuthService.isSnapshot(snapshot)) throw new TypeError('Invalid auth snapshot.');
|
|
113
|
+
// Validate everything before replacing any current state.
|
|
114
|
+
const users = new Map(Object.entries(snapshot.guards).map(([name, guard]) => [name, AuthService.copyUser(guard.user)]));
|
|
115
|
+
const previous = this.context.users;
|
|
116
|
+
const previousDefault = this.context.defaultGuard;
|
|
117
|
+
this.context.users = users;
|
|
118
|
+
this.context.defaultGuard = snapshot.defaultGuard;
|
|
119
|
+
if (source !== 'sync' || previousDefault !== snapshot.defaultGuard || [...new Set([...previous.keys(), ...users.keys()])]
|
|
120
|
+
.some(name => !AuthService.sameUser(previous.get(name) ?? null, users.get(name) ?? null))) this.context.revision++;
|
|
121
|
+
if (!silent) {
|
|
122
|
+
for (const name of new Set([...previous.keys(), ...users.keys()])) {
|
|
123
|
+
this.emit(name, previous.get(name) ?? null, users.get(name) ?? null, source);
|
|
124
|
+
}
|
|
125
|
+
if (previousDefault !== snapshot.defaultGuard && AuthService.sameUser(previous.get(snapshot.defaultGuard) ?? null, users.get(snapshot.defaultGuard) ?? null)) {
|
|
126
|
+
this.emit(snapshot.defaultGuard, previous.get(previousDefault) ?? null, users.get(snapshot.defaultGuard) ?? null, source);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return this;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
private emit(guard: string, previousUser: AuthUser | null, user: AuthUser | null, source: AuthChange['source']): void {
|
|
133
|
+
if (AuthService.sameUser(previousUser, user)) return;
|
|
134
|
+
const change = { guard, previousUser, user, source };
|
|
135
|
+
const identityChanged = previousUser?.id !== user?.id;
|
|
136
|
+
const events: AuthEvent[] = [];
|
|
137
|
+
if (identityChanged && previousUser) events.push('logout');
|
|
138
|
+
if (identityChanged && user) events.push('login');
|
|
139
|
+
events.push('change');
|
|
140
|
+
for (const event of events) {
|
|
141
|
+
for (const listener of [...this.context.listeners]) {
|
|
142
|
+
if (!this.context.listeners.has(listener) || listener.event !== event
|
|
143
|
+
|| (listener.guard !== null && listener.guard !== guard)) continue;
|
|
144
|
+
try { listener.callback(change); }
|
|
145
|
+
catch (error) { console.error('[AuthService] Listener error:', error); }
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
static isSnapshot(value: any): value is AuthSnapshot {
|
|
151
|
+
return value?.version === 1 && typeof value.defaultGuard === 'string' && value.defaultGuard.length > 0
|
|
152
|
+
&& value.guards !== null && typeof value.guards === 'object' && !Array.isArray(value.guards)
|
|
153
|
+
&& Object.prototype.hasOwnProperty.call(value.guards, value.defaultGuard)
|
|
154
|
+
&& Object.values(value.guards).every((guard: any) => guard && Object.prototype.hasOwnProperty.call(guard, 'user')
|
|
155
|
+
&& (guard.user === null || (typeof guard.user === 'object' && !Array.isArray(guard.user)
|
|
156
|
+
&& (typeof guard.user.id === 'string' || typeof guard.user.id === 'number'))));
|
|
157
|
+
}
|
|
158
|
+
private static copyUser(user: AuthUser | null): AuthUser | null {
|
|
159
|
+
if (user === null) return null;
|
|
160
|
+
if (!user || Array.isArray(user) || (typeof user.id !== 'string' && typeof user.id !== 'number')
|
|
161
|
+
|| (typeof user.id === 'number' && !Number.isFinite(user.id))) {
|
|
162
|
+
throw new TypeError('An authenticated user must have an id.');
|
|
163
|
+
}
|
|
164
|
+
const copy = structuredClone(user);
|
|
165
|
+
JSON.stringify(copy); // Reject non-JSON user data before changing any state.
|
|
166
|
+
const seen = new WeakSet<object>();
|
|
167
|
+
const freeze = (value: any): void => {
|
|
168
|
+
if (!value || typeof value !== 'object' || seen.has(value)) return;
|
|
169
|
+
seen.add(value);
|
|
170
|
+
for (const item of Object.values(value)) freeze(item);
|
|
171
|
+
Object.freeze(value);
|
|
172
|
+
};
|
|
173
|
+
freeze(copy);
|
|
174
|
+
return copy;
|
|
175
|
+
}
|
|
176
|
+
private static sameUser(a: AuthUser | null, b: AuthUser | null): boolean { return JSON.stringify(a) === JSON.stringify(b); }
|
|
177
|
+
}
|
|
@@ -2,6 +2,7 @@ import { TranslationService, type TranslationConfig } from './TranslationService
|
|
|
2
2
|
import { CollectionProxyInterface, HelperInterface } from "../contracts/HelperInterface.js";
|
|
3
3
|
import { ApplicationInterface } from "../contracts/ApplicationInterface.js";
|
|
4
4
|
import { app } from "../helpers/app.js";
|
|
5
|
+
import { AuthService } from './AuthService.js';
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* HelperService — PHP-compatible helper functions for SaoView runtime.
|
|
@@ -46,6 +47,15 @@ export class HelperService implements HelperInterface {
|
|
|
46
47
|
if (App) this.App = App;
|
|
47
48
|
}
|
|
48
49
|
|
|
50
|
+
auth(guard?: string | null): AuthService {
|
|
51
|
+
const application = this.App ?? app();
|
|
52
|
+
if (!application.has('Auth')) application.instance('Auth', AuthService.fromSnapshot(application.shared('auth')));
|
|
53
|
+
return application.get('Auth').guard(guard);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Compatibility for previously compiled @auth/@guest views. */
|
|
57
|
+
isAuth(guard?: string | null): boolean { return this.auth(guard).check(); }
|
|
58
|
+
|
|
49
59
|
app<T = any>(key?: any, value?: any): T | ApplicationInterface
|
|
50
60
|
{
|
|
51
61
|
return app(key, value);
|
|
@@ -78,13 +88,16 @@ export class HelperService implements HelperInterface {
|
|
|
78
88
|
}
|
|
79
89
|
// Page-data refreshes revisions without resetting the chosen locale.
|
|
80
90
|
this.translations.refresh(config);
|
|
91
|
+
this.syncHeld();
|
|
81
92
|
}
|
|
82
93
|
text(key: string, replace: Record<string, any> | string | null = {}, defaultValue: string | null = null, locale?: string): string {
|
|
83
94
|
if (typeof replace === 'string' && defaultValue === null) defaultValue = replace;
|
|
84
95
|
return this.translations.text(key, replace && typeof replace === 'object' ? replace : {}, defaultValue, locale);
|
|
85
96
|
}
|
|
86
97
|
installLanguage(locale: string, snapshot: import('./TranslationService.js').LanguageSnapshot): boolean {
|
|
87
|
-
|
|
98
|
+
const installed = this.translations.install(locale, snapshot);
|
|
99
|
+
this.syncHeld();
|
|
100
|
+
return installed;
|
|
88
101
|
}
|
|
89
102
|
async loadLanguage(locale: string, load: (locale: string) => Promise<import('./TranslationService.js').LanguageSnapshot>): Promise<boolean> {
|
|
90
103
|
return this.installLanguage(locale, await load(locale));
|
|
@@ -123,6 +136,15 @@ export class HelperService implements HelperInterface {
|
|
|
123
136
|
if (typeof document !== 'undefined') document.documentElement.lang = locale;
|
|
124
137
|
this.App?.Http?.setHeader?.('Accept-Language', locale);
|
|
125
138
|
this.App?.API?.getHttpService?.()?.setHeader?.('Accept-Language', locale);
|
|
139
|
+
this.syncHeld();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Tells the server which dictionaries this app already holds, so page data does not carry them again. */
|
|
143
|
+
private syncHeld(): void {
|
|
144
|
+
const held = this.translations.held();
|
|
145
|
+
if (!held) return;
|
|
146
|
+
this.App?.Http?.setHeader?.('X-Saola-Language', held);
|
|
147
|
+
this.App?.API?.getHttpService?.()?.setHeader?.('X-Saola-Language', held);
|
|
126
148
|
}
|
|
127
149
|
|
|
128
150
|
// ─── Execution ──────────────────────────────────────────────
|
|
@@ -15,6 +15,9 @@
|
|
|
15
15
|
* });
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
+
import { app } from '../helpers/app.js';
|
|
19
|
+
import { AuthService } from './AuthService.js';
|
|
20
|
+
|
|
18
21
|
// ─── Types ──────────────────────────────────────────────────────
|
|
19
22
|
|
|
20
23
|
export interface HttpRequestConfig extends RequestInit {
|
|
@@ -171,10 +174,28 @@ export class HttpService {
|
|
|
171
174
|
}
|
|
172
175
|
if (!group) this.pending.set(requestKey, group = new Set());
|
|
173
176
|
group.add(controller);
|
|
177
|
+
const auth = app.has('Auth') ? app<AuthService>('Auth') : null;
|
|
178
|
+
const authRevision = auth?.revision;
|
|
179
|
+
// Read at send time: session login/logout rotates Laravel's CSRF token.
|
|
180
|
+
if (typeof window !== 'undefined' && urlObj.origin === window.location.origin
|
|
181
|
+
&& !['GET', 'HEAD', 'OPTIONS'].includes(config.method!)) {
|
|
182
|
+
const headers = config.headers ??= {};
|
|
183
|
+
const explicit = Object.keys(headers).some(key => ['x-csrf-token', 'x-xsrf-token'].includes(key.toLowerCase()));
|
|
184
|
+
const token = auth?.client.csrfToken ?? document.querySelector<HTMLMetaElement>('meta[name="csrf-token"]')?.content;
|
|
185
|
+
if (!explicit && token) headers['X-CSRF-TOKEN'] = token;
|
|
186
|
+
}
|
|
174
187
|
const raw = await fetch(fullUrl, config);
|
|
175
188
|
const responseData = await raw.json().catch(() => ({}));
|
|
176
189
|
controller.signal.throwIfAborted();
|
|
177
190
|
|
|
191
|
+
// Only our own server may synchronize auth; an older request cannot undo a newer login/logout.
|
|
192
|
+
if (auth && !auth.isTransitioning && auth.revision === authRevision && typeof window !== 'undefined'
|
|
193
|
+
&& urlObj.origin === window.location.origin
|
|
194
|
+
&& (!raw.url || new URL(raw.url).origin === window.location.origin)
|
|
195
|
+
&& AuthService.isSnapshot(responseData?.auth)) {
|
|
196
|
+
auth.sync(responseData.auth);
|
|
197
|
+
}
|
|
198
|
+
|
|
178
199
|
const viewContext = responseData?.viewContext;
|
|
179
200
|
const currentContext = typeof window !== 'undefined'
|
|
180
201
|
? (window as any).APP_CONFIGS?.view?.systemData?.__context__
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/** A service can participate in computed evaluation without exposing its storage. */
|
|
2
|
+
export interface ReactiveDependency {
|
|
3
|
+
subscribe(callback: () => void): () => void;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
let collecting: Set<ReactiveDependency> | null = null;
|
|
7
|
+
|
|
8
|
+
export function trackDependency(source: ReactiveDependency): void {
|
|
9
|
+
collecting?.add(source);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Reconciles subscriptions after each synchronous evaluation, including branch changes. */
|
|
13
|
+
export class DependencyTracker {
|
|
14
|
+
private subscriptions = new Map<ReactiveDependency, () => void>();
|
|
15
|
+
private destroyed = false;
|
|
16
|
+
|
|
17
|
+
constructor(private readonly invalidate: () => void) {}
|
|
18
|
+
|
|
19
|
+
evaluate<T>(read: () => T): T {
|
|
20
|
+
const previous = collecting;
|
|
21
|
+
const sources = new Set<ReactiveDependency>();
|
|
22
|
+
collecting = sources;
|
|
23
|
+
try { return read(); }
|
|
24
|
+
finally {
|
|
25
|
+
collecting = previous;
|
|
26
|
+
if (!this.destroyed) {
|
|
27
|
+
for (const [source, off] of this.subscriptions) {
|
|
28
|
+
if (!sources.has(source)) { off(); this.subscriptions.delete(source); }
|
|
29
|
+
}
|
|
30
|
+
for (const source of sources) {
|
|
31
|
+
if (!this.subscriptions.has(source)) {
|
|
32
|
+
this.subscriptions.set(source, source.subscribe(() => {
|
|
33
|
+
if (!this.destroyed) this.invalidate();
|
|
34
|
+
}));
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
destroy(): void {
|
|
42
|
+
this.destroyed = true;
|
|
43
|
+
for (const off of this.subscriptions.values()) off();
|
|
44
|
+
this.subscriptions.clear();
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -53,6 +53,12 @@ export class TranslationService {
|
|
|
53
53
|
return this.interpolate(value, replace);
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
/** The catalogs this app holds, as the server reads them back: `<scope>;<locale>=<revision>;…` ('' while none is loaded). */
|
|
57
|
+
held(): string {
|
|
58
|
+
const parts = Object.entries(this.catalogs).map(([locale, snapshot]) => `${locale}=${snapshot.revision}`);
|
|
59
|
+
return this.catalogScope && parts.length ? [this.catalogScope, ...parts].join(';') : '';
|
|
60
|
+
}
|
|
61
|
+
|
|
56
62
|
install(locale: string, snapshot: LanguageSnapshot): boolean {
|
|
57
63
|
if (snapshot.scope !== this.catalogScope) throw new Error('Language snapshot scope differs from the active catalog');
|
|
58
64
|
if (!Number.isSafeInteger(snapshot.revision) || snapshot.revision < 0) throw new Error('Invalid language revision');
|
|
@@ -5,6 +5,8 @@ export * from "./HeadService.js";
|
|
|
5
5
|
export * from "./SectionManager.js";
|
|
6
6
|
export * from "./LoggerService.js";
|
|
7
7
|
export * from './HelperService.js';
|
|
8
|
+
export * from './AuthService.js';
|
|
9
|
+
export * from './AuthClient.js';
|
|
8
10
|
export * from './HttpService.js';
|
|
9
11
|
export * from './MarkerRegistry.js';
|
|
10
12
|
export * from "./StoreService.js";
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import type { ViewControllerInterface } from "../contracts/ViewControllerInterface.js";
|
|
2
2
|
import type { StateManagerInterface, ViewStateInterface, StateItem, StateListener } from "../contracts/ViewStateInterface.js";
|
|
3
3
|
import devtools from "../devtools/hook.js";
|
|
4
|
+
import { DependencyTracker } from '../services/ReactiveDependency.js';
|
|
5
|
+
import { AuthService } from '../services/AuthService.js';
|
|
6
|
+
import { app } from '../helpers/app.js';
|
|
4
7
|
|
|
5
8
|
type MutationChannel = {
|
|
6
9
|
notify: (() => void) | null;
|
|
@@ -188,8 +191,9 @@ export class StateManager implements StateManagerInterface {
|
|
|
188
191
|
return this.useState(value, key)[1];
|
|
189
192
|
}
|
|
190
193
|
|
|
191
|
-
private computedNodes = new Map<string, { deps: string[]; dirty: () => void }>();
|
|
194
|
+
private computedNodes = new Map<string, { deps: string[]; dirty: () => void; tracker: DependencyTracker }>();
|
|
192
195
|
private computedDependents = new Map<string | number, Set<string>>();
|
|
196
|
+
private authUnsubscribe: (() => void) | null = null;
|
|
193
197
|
|
|
194
198
|
/** Invalidate the dependency graph synchronously; evaluation remains lazy. */
|
|
195
199
|
private invalidateComputed(key: string | number, notify = true): void {
|
|
@@ -234,6 +238,7 @@ export class StateManager implements StateManagerInterface {
|
|
|
234
238
|
if (deps.some(dep => reaches(dep))) throw new Error(`[ViewState] Computed dependency cycle: ${key}`);
|
|
235
239
|
const existing = this.states[key] as any;
|
|
236
240
|
if (existing && !existing.__computed__) throw new Error(`[ViewState] State already exists: ${key}`);
|
|
241
|
+
this.computedNodes.get(key)?.tracker.destroy();
|
|
237
242
|
for (const dep of this.computedNodes.get(key)?.deps ?? []) {
|
|
238
243
|
const dependents = this.computedDependents.get(dep);
|
|
239
244
|
dependents?.delete(key);
|
|
@@ -242,6 +247,10 @@ export class StateManager implements StateManagerInterface {
|
|
|
242
247
|
let cache: T;
|
|
243
248
|
let dirty = true;
|
|
244
249
|
let evaluating = false;
|
|
250
|
+
const tracker = new DependencyTracker(() => {
|
|
251
|
+
dirty = true;
|
|
252
|
+
this.enqueueChange(key);
|
|
253
|
+
});
|
|
245
254
|
const slot: any = {
|
|
246
255
|
key,
|
|
247
256
|
__computed__: true,
|
|
@@ -252,7 +261,7 @@ export class StateManager implements StateManagerInterface {
|
|
|
252
261
|
if (dirty) {
|
|
253
262
|
if (evaluating) throw new Error(`[ViewState] Recursive computed read: ${key}`);
|
|
254
263
|
evaluating = true;
|
|
255
|
-
try { cache = fn
|
|
264
|
+
try { cache = tracker.evaluate(fn); dirty = false; }
|
|
256
265
|
finally { evaluating = false; }
|
|
257
266
|
}
|
|
258
267
|
return cache;
|
|
@@ -262,7 +271,7 @@ export class StateManager implements StateManagerInterface {
|
|
|
262
271
|
});
|
|
263
272
|
this.states[key] = slot;
|
|
264
273
|
this.setters[key] = slot.setValue;
|
|
265
|
-
this.computedNodes.set(key, { deps: [...new Set(deps)], dirty: () => { dirty = true; } });
|
|
274
|
+
this.computedNodes.set(key, { deps: [...new Set(deps)], dirty: () => { dirty = true; }, tracker });
|
|
266
275
|
for (const dep of deps) {
|
|
267
276
|
if (!this.computedDependents.has(dep)) this.computedDependents.set(dep, new Set());
|
|
268
277
|
this.computedDependents.get(dep)!.add(key);
|
|
@@ -367,6 +376,21 @@ export class StateManager implements StateManagerInterface {
|
|
|
367
376
|
if (!records) this.subscriptions.set(k, records = new Set());
|
|
368
377
|
records.add(record);
|
|
369
378
|
}
|
|
379
|
+
if (keys.has('@auth') && !this.authUnsubscribe) {
|
|
380
|
+
const application = this.controller?.App ?? app();
|
|
381
|
+
if (!application.has('Auth')) {
|
|
382
|
+
application.instance('Auth', AuthService.fromSnapshot(application.shared('auth')));
|
|
383
|
+
}
|
|
384
|
+
this.authUnsubscribe = (application.get('Auth') as AuthService).subscribe(() => this.enqueueChange('@auth'));
|
|
385
|
+
}
|
|
386
|
+
// Hydration may claim an SSR text node without evaluating its factory.
|
|
387
|
+
// Observed computed values still need their service dependencies connected.
|
|
388
|
+
try {
|
|
389
|
+
for (const k of keys) if (this.computedNodes.has(k)) this.getStateByKey(k);
|
|
390
|
+
} catch (error) {
|
|
391
|
+
this.removeSubscription(record);
|
|
392
|
+
throw error;
|
|
393
|
+
}
|
|
370
394
|
return () => this.removeSubscription(record);
|
|
371
395
|
}
|
|
372
396
|
|
|
@@ -378,6 +402,10 @@ export class StateManager implements StateManagerInterface {
|
|
|
378
402
|
records?.delete(record);
|
|
379
403
|
if (records?.size === 0) this.subscriptions.delete(k);
|
|
380
404
|
}
|
|
405
|
+
if (!this.subscriptions.has('@auth')) {
|
|
406
|
+
this.authUnsubscribe?.();
|
|
407
|
+
this.authUnsubscribe = null;
|
|
408
|
+
}
|
|
381
409
|
}
|
|
382
410
|
|
|
383
411
|
unsubscribe(
|
|
@@ -1079,6 +1107,9 @@ export class StateManager implements StateManagerInterface {
|
|
|
1079
1107
|
cancelAnimationFrame(this.flushRAF);
|
|
1080
1108
|
this.flushRAF = null;
|
|
1081
1109
|
}
|
|
1110
|
+
this.authUnsubscribe?.();
|
|
1111
|
+
this.authUnsubscribe = null;
|
|
1112
|
+
for (const node of this.computedNodes.values()) node.tracker.destroy();
|
|
1082
1113
|
this.computedNodes.clear();
|
|
1083
1114
|
this.computedDependents.clear();
|
|
1084
1115
|
this.versions.clear();
|