@svadmin/core 0.50.0 → 0.53.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/package.json +21 -4
- package/src/access-control-contract.ts +250 -0
- package/src/adapters/casbin.ts +20 -14
- package/src/adapters/casl.ts +22 -19
- package/src/admin-tool.ts +7 -7
- package/src/audit-contract.ts +105 -0
- package/src/audit.ts +88 -128
- package/src/auth-hooks.svelte.ts +593 -261
- package/src/auth-mutation-contract.ts +143 -0
- package/src/auth-query-contract.ts +97 -0
- package/src/captured-mutation.svelte.ts +45 -0
- package/src/chatProvider.svelte.ts +1 -1
- package/src/command-contract.ts +195 -0
- package/src/context.svelte.ts +217 -205
- package/src/contract-context.svelte.ts +37 -0
- package/src/contract-form.svelte.ts +566 -0
- package/src/create-contract.ts +56 -0
- package/src/create-hooks.svelte.ts +347 -0
- package/src/create-many-contract.ts +90 -0
- package/src/create-many-hooks.svelte.ts +396 -0
- package/src/data-transfer.svelte.ts +1 -215
- package/src/defined-options.ts +50 -0
- package/src/delete-contract.ts +50 -0
- package/src/delete-hooks.svelte.ts +403 -0
- package/src/delete-many-contract.ts +60 -0
- package/src/delete-many-hooks.svelte.ts +402 -0
- package/src/enterprise.ts +4 -4
- package/src/export-format.ts +16 -14
- package/src/export-hooks.svelte.ts +191 -0
- package/src/feature-gate-contract.ts +53 -0
- package/src/hook-utils.svelte.ts +53 -105
- package/src/hooks.svelte.ts +19 -776
- package/src/http.ts +88 -87
- package/src/i18n.svelte.ts +64 -2
- package/src/import-contract.ts +106 -0
- package/src/import-hooks.svelte.ts +310 -0
- package/src/index.ts +77 -30
- package/src/inferencer-graphql.ts +7 -6
- package/src/inferencer.ts +9 -0
- package/src/infinite-query.svelte.ts +228 -0
- package/src/invalidation-contract.ts +26 -0
- package/src/live-sse.ts +91 -127
- package/src/live-subscription.svelte.ts +130 -0
- package/src/live-transport.ts +127 -0
- package/src/live-websocket.ts +85 -118
- package/src/live.svelte.ts +66 -68
- package/src/notification.svelte.ts +17 -15
- package/src/permission-catalog.ts +121 -0
- package/src/permission-hints-contract.ts +48 -0
- package/src/permissions.svelte.ts +112 -59
- package/src/plain-data.ts +61 -0
- package/src/provider-bundle.ts +85 -79
- package/src/provider-response.ts +35 -0
- package/src/query-hooks.svelte.ts +241 -288
- package/src/query-invalidation.ts +172 -0
- package/src/query-keys.ts +221 -110
- package/src/query-session.svelte.ts +220 -0
- package/src/query-snapshot.ts +144 -0
- package/src/reactive-projection.ts +53 -0
- package/src/record-decoder.ts +63 -0
- package/src/resource-binding.svelte.ts +19 -0
- package/src/resource-contract.ts +595 -0
- package/src/resource-mutation-types.ts +30 -0
- package/src/resource-schemas.ts +371 -0
- package/src/resource-transport-config.ts +156 -0
- package/src/route-parsing.ts +11 -11
- package/src/router-provider.ts +64 -59
- package/src/router.ts +8 -3
- package/src/routing-hooks.svelte.ts +90 -62
- package/src/schema-validation.ts +92 -0
- package/src/schema.ts +13 -0
- package/src/select-options.ts +95 -0
- package/src/select-query.svelte.ts +191 -0
- package/src/session-query.svelte.ts +103 -0
- package/src/strict-command-hooks.svelte.ts +333 -0
- package/src/strict-hooks.svelte.ts +198 -0
- package/src/table-hooks.svelte.ts +180 -174
- package/src/table-state.svelte.ts +117 -117
- package/src/task-contract.ts +87 -0
- package/src/task-hooks.svelte.ts +190 -180
- package/src/task-provider.ts +115 -0
- package/src/task-subscription.ts +132 -0
- package/src/theme.svelte.ts +155 -154
- package/src/toast.svelte.ts +38 -37
- package/src/types.ts +68 -110
- package/src/unsafe.ts +7 -0
- package/src/update-contract.ts +65 -0
- package/src/update-hooks.svelte.ts +347 -0
- package/src/update-many-contract.ts +79 -0
- package/src/update-many-hooks.svelte.ts +373 -0
- package/src/url-sync.ts +11 -10
- package/src/useCan.ts +96 -31
- package/src/useParsed.svelte.ts +18 -14
- package/src/useStepsForm.svelte.ts +123 -63
- package/src/utility-hooks.svelte.ts +101 -158
- package/src/form-hooks.svelte.ts +0 -889
- package/src/mutation-hooks.svelte.ts +0 -561
- package/src/schema-form.ts +0 -31
package/src/auth-hooks.svelte.ts
CHANGED
|
@@ -1,171 +1,320 @@
|
|
|
1
|
-
|
|
2
|
-
//
|
|
3
|
-
// Uses module-level $state — no component init-time constraints.
|
|
1
|
+
import { definedOptions } from './defined-options';
|
|
2
|
+
// Component-scoped auth calls with validated results and provider-scoped session revisions.
|
|
4
3
|
|
|
5
4
|
import { captureAdminContext } from './context.svelte';
|
|
6
5
|
import type { AdminContextAccessor } from './context.svelte';
|
|
7
6
|
import { notifyWithProvider } from './notification.svelte';
|
|
8
|
-
import { t,
|
|
9
|
-
import type { AuthActionResult,
|
|
7
|
+
import { t,useTranslation } from './i18n.svelte';
|
|
8
|
+
import type { AuthActionResult,CheckResult,Identity,AuthProvider } from './types';
|
|
10
9
|
import { useQueryClient } from '@tanstack/svelte-query';
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
10
|
+
import { untrack } from 'svelte';
|
|
11
|
+
import { createSubscriber } from 'svelte/reactivity';
|
|
12
|
+
import { parseQueryKey } from './query-keys';
|
|
13
|
+
import { decodePermissionHints, hasPermissionHint, PermissionHintsError, type PermissionHints } from './permission-hints-contract';
|
|
14
|
+
import {
|
|
15
|
+
AuthQueryError, decodeAuthCheck, decodeIdentity, AuthErrorHandlingError, decodeAuthErrorResult,
|
|
16
|
+
type AuthErrorHandlingResult,
|
|
17
|
+
} from './auth-query-contract';
|
|
18
|
+
import {
|
|
19
|
+
AuthMutationError, authMutationFailure, decodeAuthAction, prepareAuthMutation,
|
|
20
|
+
type AuthMutationArgs, type AuthMutationMethod,
|
|
21
|
+
} from './auth-mutation-contract';
|
|
22
|
+
import { navigateWithProvider } from './router';
|
|
23
|
+
|
|
24
|
+
let _logoutVersion=$state(0);
|
|
25
|
+
interface AuthSession {
|
|
26
|
+
readonly cacheId: number;
|
|
27
|
+
readonly observe: () => void;
|
|
28
|
+
readonly changed: () => void;
|
|
29
|
+
version: number;
|
|
30
|
+
logout: number;
|
|
31
|
+
intent: number;
|
|
32
|
+
liveRevision: number;
|
|
33
|
+
liveState: 'ready' | 'changing' | 'signed-out' | 'uncertain';
|
|
34
|
+
inFlight: number;
|
|
35
|
+
}
|
|
36
|
+
let sessions=$state.raw(new WeakMap<AuthProvider, AuthSession>());
|
|
37
|
+
let nextAuthMutationInstanceId=0;
|
|
38
|
+
let nextAuthSessionId=0;
|
|
14
39
|
export function getLogoutVersion() { return _logoutVersion; }
|
|
15
|
-
export function resetLogoutVersion() {
|
|
40
|
+
export function resetLogoutVersion() {
|
|
41
|
+
_logoutVersion=0;
|
|
42
|
+
sessions=new WeakMap<AuthProvider, AuthSession>();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function authSession(provider: AuthProvider | null) {
|
|
46
|
+
if(!provider) return { cacheId: 0, observe: () => {}, changed: () => {},
|
|
47
|
+
version: 0, logout: 0, intent: 0, liveRevision: 0, liveState: 'ready' as const, inFlight: 0 };
|
|
48
|
+
const existing=sessions.get(provider);
|
|
49
|
+
if(existing) return existing;
|
|
50
|
+
let update: (() => void) | undefined;
|
|
51
|
+
// A lazily created session may originate inside a derived expression.
|
|
52
|
+
const observe=createSubscriber(notify => { update=notify; return () => { update=undefined; }; });
|
|
53
|
+
const session=$state<AuthSession>({ cacheId: ++nextAuthSessionId, observe, changed: () => update?.(),
|
|
54
|
+
version: 0, logout: 0, intent: 0, liveRevision: 0, liveState: 'ready', inFlight: 0 });
|
|
55
|
+
sessions.set(provider,session);
|
|
56
|
+
return session;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function setLiveSession(provider: AuthProvider, state: AuthSession['liveState']) {
|
|
60
|
+
const session=authSession(provider);
|
|
61
|
+
session.liveRevision++;
|
|
62
|
+
session.liveState=state;
|
|
63
|
+
session.changed();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function beginLiveSessionChange(provider: AuthProvider, intent: number) {
|
|
67
|
+
const session=authSession(provider);
|
|
68
|
+
session.inFlight++;
|
|
69
|
+
setLiveSession(provider,'changing');
|
|
70
|
+
return () => {
|
|
71
|
+
session.inFlight--;
|
|
72
|
+
session.changed();
|
|
73
|
+
// An older request finishing last leaves the effective remote principal uncertain.
|
|
74
|
+
if(authSession(provider)===session && session.inFlight===0 && session.intent!==intent) {
|
|
75
|
+
setLiveSession(provider,'uncertain');
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Read-only capability snapshot; consumers cannot mutate the underlying auth session. */
|
|
81
|
+
export function captureAuthLiveScope(provider: AuthProvider | null) {
|
|
82
|
+
if(!provider) return Object.freeze({ cacheKey: 'anonymous', available: true, isCurrent: () => true });
|
|
83
|
+
const session=authSession(provider);
|
|
84
|
+
session.observe();
|
|
85
|
+
const revision=session.liveRevision;
|
|
86
|
+
const available=session.liveState==='ready' && session.inFlight===0;
|
|
87
|
+
return Object.freeze({
|
|
88
|
+
cacheKey: `auth:${session.cacheId}:${revision}`,
|
|
89
|
+
available,
|
|
90
|
+
isCurrent: () => available && authSession(provider)===session && session.liveRevision===revision &&
|
|
91
|
+
session.liveState==='ready' && session.inFlight===0,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function rejectedLiveState(previous: AuthSession['liveState']): AuthSession['liveState'] {
|
|
96
|
+
return previous==='ready' || previous==='signed-out' ? previous : 'uncertain';
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function advanceAuthSession(provider: AuthProvider, logout: boolean, liveState: AuthSession['liveState']='ready'): number {
|
|
100
|
+
const session=authSession(provider);
|
|
101
|
+
session.version++;
|
|
102
|
+
setLiveSession(provider,logout ? 'signed-out' : liveState);
|
|
103
|
+
if(logout) {
|
|
104
|
+
session.logout++;
|
|
105
|
+
_logoutVersion++;
|
|
106
|
+
}
|
|
107
|
+
return session.version;
|
|
108
|
+
}
|
|
16
109
|
|
|
17
110
|
// ─── Mutate Factory ─────────────────────────────────────────────
|
|
18
111
|
|
|
19
|
-
interface CreateAuthMutationOptions {
|
|
20
|
-
method:
|
|
21
|
-
successMessage?: string
|
|
22
|
-
errorMessage?: string
|
|
23
|
-
onSuccess?: (result: AuthActionResult,
|
|
112
|
+
interface CreateAuthMutationOptions<M extends AuthMutationMethod> {
|
|
113
|
+
method: M;
|
|
114
|
+
successMessage?: string|null;
|
|
115
|
+
errorMessage?: string|false;
|
|
116
|
+
onSuccess?: (result: AuthActionResult,navigate: (path: string) => Promise<void>) => void|Promise<void>;
|
|
24
117
|
}
|
|
25
118
|
|
|
26
119
|
export interface AuthNotificationOptions {
|
|
27
120
|
/** Set to false when the page owns the success state. */
|
|
28
|
-
successNotification?: string
|
|
29
|
-
errorNotification?: string
|
|
121
|
+
successNotification?: string|false;
|
|
122
|
+
errorNotification?: string|false;
|
|
30
123
|
}
|
|
31
124
|
|
|
32
125
|
function resolveSuccessMessage(
|
|
33
|
-
configured: string
|
|
126
|
+
configured: string|false|undefined,
|
|
34
127
|
defaultMessage: string,
|
|
35
|
-
): string
|
|
36
|
-
return configured
|
|
128
|
+
): string|null {
|
|
129
|
+
return configured===false? null:configured??defaultMessage;
|
|
37
130
|
}
|
|
38
131
|
|
|
39
|
-
function createAuthMutation(options: CreateAuthMutationOptions) {
|
|
40
|
-
const mutationInstanceId
|
|
41
|
-
const adminContext
|
|
42
|
-
const i18n
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
132
|
+
function createAuthMutation<M extends AuthMutationMethod>(options: CreateAuthMutationOptions<M>) {
|
|
133
|
+
const mutationInstanceId=nextAuthMutationInstanceId++;
|
|
134
|
+
const adminContext=captureAdminContext();
|
|
135
|
+
const i18n=useTranslation();
|
|
136
|
+
interface Pending {
|
|
137
|
+
epoch: number;
|
|
138
|
+
provider: AuthProvider | null;
|
|
139
|
+
tenant: string | number | undefined;
|
|
140
|
+
authVersion: number;
|
|
141
|
+
intent: number;
|
|
142
|
+
previousLive: AuthSession['liveState'];
|
|
143
|
+
changedLive: boolean;
|
|
144
|
+
}
|
|
145
|
+
let pending=$state.raw<Pending | null>(null);
|
|
146
|
+
let mutationEpoch=0;
|
|
147
|
+
let disposed=false;
|
|
148
|
+
|
|
149
|
+
const activeTenantIdentity=() => adminContext.tenantCacheKey?.__svadminTenant;
|
|
150
|
+
function current(value: Pending): boolean {
|
|
151
|
+
return !disposed && value.epoch === mutationEpoch
|
|
152
|
+
&& value.provider === adminContext.authProvider
|
|
153
|
+
&& value.tenant === activeTenantIdentity()
|
|
154
|
+
&& value.intent === authSession(value.provider).intent
|
|
155
|
+
&& value.authVersion === authSession(value.provider).version;
|
|
58
156
|
}
|
|
59
157
|
|
|
60
|
-
async function mutate(params
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
158
|
+
async function mutate(...[params]: AuthMutationArgs<M>): Promise<AuthActionResult> {
|
|
159
|
+
if(disposed) return authMutationFailure('AUTH_RESULT_SUPERSEDED');
|
|
160
|
+
const provider=adminContext.authProvider;
|
|
161
|
+
const epoch=++mutationEpoch;
|
|
162
|
+
const started: Pending={
|
|
163
|
+
epoch, provider, tenant: activeTenantIdentity(),
|
|
164
|
+
authVersion: authSession(provider).version, intent: authSession(provider).intent,
|
|
165
|
+
previousLive: authSession(provider).liveState, changedLive: false,
|
|
166
|
+
};
|
|
167
|
+
pending=started;
|
|
168
|
+
const router=adminContext.routerProvider;
|
|
169
|
+
const notificationProvider=adminContext.notificationProvider;
|
|
170
|
+
const eventKey=`auth:${mutationInstanceId}:${options.method}:${epoch}`;
|
|
171
|
+
const sendNotification=(notification: Parameters<typeof notifyWithProvider>[0]) => {
|
|
172
|
+
try {
|
|
173
|
+
notifyWithProvider(notification,notificationProvider);
|
|
174
|
+
} catch {
|
|
175
|
+
// A presentation failure cannot change an already completed authentication operation.
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
const failure=(code: AuthMutationError['code']) => {
|
|
179
|
+
const result=authMutationFailure(code);
|
|
180
|
+
if(current(started)) {
|
|
181
|
+
if(provider && started.changedLive) {
|
|
182
|
+
setLiveSession(provider,code==='AUTH_REJECTED' ? rejectedLiveState(started.previousLive) : 'uncertain');
|
|
183
|
+
}
|
|
184
|
+
pending=null;
|
|
185
|
+
if(options.errorMessage!==false) {
|
|
78
186
|
sendNotification({
|
|
79
|
-
type: '
|
|
80
|
-
message: options.
|
|
81
|
-
key: `${eventKey}:
|
|
187
|
+
type: 'error',
|
|
188
|
+
message: typeof options.errorMessage==='string' ? options.errorMessage : i18n.t('common.operationFailed'),
|
|
189
|
+
key: `${eventKey}:error`,
|
|
82
190
|
});
|
|
83
191
|
}
|
|
84
|
-
if (options.onSuccess) await options.onSuccess(result, adminContext);
|
|
85
|
-
} else {
|
|
86
|
-
const msg = result.error?.message ?? (typeof options.errorMessage === 'string' ? options.errorMessage : i18n.t('common.operationFailed'));
|
|
87
|
-
if (options.errorMessage !== false) {
|
|
88
|
-
sendNotification({ type: 'error', message: msg, key: `${eventKey}:error` });
|
|
89
|
-
}
|
|
90
192
|
}
|
|
91
193
|
return result;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
|
|
194
|
+
};
|
|
195
|
+
let invoke: () => Promise<unknown>;
|
|
196
|
+
try {
|
|
197
|
+
if(!provider) return failure('AUTH_METHOD_UNAVAILABLE');
|
|
198
|
+
invoke=prepareAuthMutation(provider,options.method,params);
|
|
199
|
+
} catch(err) {
|
|
200
|
+
return failure(err instanceof AuthMutationError ? err.code : 'INVALID_AUTH_INPUT');
|
|
201
|
+
}
|
|
202
|
+
if(!current(started)) return authMutationFailure('AUTH_RESULT_SUPERSEDED');
|
|
203
|
+
let releaseLive: (() => void) | undefined;
|
|
204
|
+
if(provider && options.method!=='forgotPassword') {
|
|
205
|
+
started.intent=++authSession(provider).intent;
|
|
206
|
+
started.changedLive=true;
|
|
207
|
+
releaseLive=beginLiveSessionChange(provider,started.intent);
|
|
208
|
+
}
|
|
209
|
+
let response: unknown;
|
|
210
|
+
try {
|
|
211
|
+
response=await invoke();
|
|
212
|
+
} catch {
|
|
213
|
+
return current(started) ? failure('AUTH_REQUEST_FAILED') : authMutationFailure('AUTH_RESULT_SUPERSEDED');
|
|
214
|
+
} finally {
|
|
215
|
+
releaseLive?.();
|
|
216
|
+
}
|
|
217
|
+
if(!current(started)) return authMutationFailure('AUTH_RESULT_SUPERSEDED');
|
|
218
|
+
let result: AuthActionResult;
|
|
219
|
+
try {
|
|
220
|
+
result=decodeAuthAction(response);
|
|
221
|
+
} catch {
|
|
222
|
+
return failure('INVALID_AUTH_RESULT');
|
|
223
|
+
}
|
|
224
|
+
if(!current(started)) return authMutationFailure('AUTH_RESULT_SUPERSEDED');
|
|
225
|
+
if(!result.success) return failure('AUTH_REJECTED');
|
|
226
|
+
pending=null;
|
|
227
|
+
if(provider && options.method!=='forgotPassword') {
|
|
228
|
+
started.authVersion=advanceAuthSession(provider,options.method==='logout',
|
|
229
|
+
options.method==='login' ? 'ready' : rejectedLiveState(started.previousLive));
|
|
230
|
+
}
|
|
231
|
+
try {
|
|
232
|
+
if(options.successMessage) {
|
|
95
233
|
sendNotification({
|
|
96
|
-
type: '
|
|
97
|
-
message: msg,
|
|
98
|
-
key: `auth:${mutationInstanceId}:${String(options.method)}:${epoch}:error`,
|
|
234
|
+
type: 'success', message: options.successMessage, key: `${eventKey}:success`,
|
|
99
235
|
});
|
|
100
236
|
}
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
237
|
+
if(current(started) && options.onSuccess) {
|
|
238
|
+
await options.onSuccess(result,async path => {
|
|
239
|
+
await navigateWithProvider(router,path,undefined,
|
|
240
|
+
() => current(started) && router===adminContext.routerProvider);
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
} catch {
|
|
244
|
+
// Navigation is a separate side effect; do not report the server write as failed.
|
|
104
245
|
}
|
|
246
|
+
return current(started) ? result : authMutationFailure('AUTH_RESULT_SUPERSEDED');
|
|
105
247
|
}
|
|
106
248
|
|
|
107
|
-
if
|
|
249
|
+
if(typeof window!=='undefined') {
|
|
108
250
|
$effect(() => {
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
observedProvider = provider;
|
|
113
|
-
observedTenantIdentity = tenantIdentity;
|
|
114
|
-
mutationEpoch++;
|
|
115
|
-
isLoading = false;
|
|
116
|
-
}
|
|
117
|
-
return () => {
|
|
118
|
-
mutationEpoch++;
|
|
119
|
-
};
|
|
251
|
+
void adminContext.authProvider;
|
|
252
|
+
void activeTenantIdentity();
|
|
253
|
+
return () => { ++mutationEpoch; };
|
|
120
254
|
});
|
|
255
|
+
$effect(() => () => { disposed=true; ++mutationEpoch; });
|
|
121
256
|
}
|
|
122
257
|
|
|
123
258
|
return {
|
|
124
259
|
mutate,
|
|
125
|
-
get isLoading() { return
|
|
260
|
+
get isLoading() { return pending !== null && current(pending); },
|
|
126
261
|
};
|
|
127
262
|
}
|
|
128
263
|
|
|
129
264
|
// ─── useLogin ─────────────────────────────────────────────────
|
|
130
265
|
|
|
131
|
-
export function useLogin(opts?: AuthNotificationOptions
|
|
266
|
+
export function useLogin(opts?: AuthNotificationOptions&{ errorMessage?: string|false }) {
|
|
132
267
|
return createAuthMutation({
|
|
133
268
|
method: 'login',
|
|
134
|
-
successMessage: resolveSuccessMessage(opts?.successNotification,
|
|
135
|
-
errorMessage: opts?.errorNotification
|
|
136
|
-
onSuccess: async (result,
|
|
269
|
+
successMessage: resolveSuccessMessage(opts?.successNotification,t('common.operationSuccess')),
|
|
270
|
+
errorMessage: opts?.errorNotification??opts?.errorMessage??t('common.loginFailed'),
|
|
271
|
+
onSuccess: async (result,navigate) => { await navigate(result.redirectTo??'/'); }
|
|
137
272
|
});
|
|
138
273
|
}
|
|
139
274
|
|
|
140
275
|
// ─── useLogout ────────────────────────────────────────────────
|
|
141
276
|
|
|
142
277
|
export function useLogout() {
|
|
143
|
-
|
|
278
|
+
const context=captureAdminContext();
|
|
279
|
+
let queryClient: ReturnType<typeof useQueryClient>|undefined;
|
|
144
280
|
try {
|
|
145
|
-
queryClient
|
|
281
|
+
queryClient=useQueryClient();
|
|
146
282
|
} catch {
|
|
147
283
|
// Auth hooks remain usable without TanStack Query; cache clearing is best-effort.
|
|
148
284
|
}
|
|
149
285
|
return createAuthMutation({
|
|
150
286
|
method: 'logout',
|
|
151
287
|
successMessage: null,
|
|
152
|
-
onSuccess: async (result,
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
await adminContext.navigate(result.redirectTo ?? '/login');
|
|
288
|
+
onSuccess: async (result,navigate) => {
|
|
289
|
+
clearAuthQueries(queryClient,context.authProvider);
|
|
290
|
+
await navigate(result.redirectTo??'/login');
|
|
156
291
|
}
|
|
157
292
|
});
|
|
158
293
|
}
|
|
159
294
|
|
|
295
|
+
/** @internal Preserve caches with a proven different authentication owner. */
|
|
296
|
+
export function clearAuthQueries(client: ReturnType<typeof useQueryClient> | undefined, provider: AuthProvider | null): void {
|
|
297
|
+
if(!client) return;
|
|
298
|
+
client.getMutationCache().clear();
|
|
299
|
+
const prefix=provider ? `auth:${authSession(provider).cacheId}:` : 'anonymous';
|
|
300
|
+
client.removeQueries({ predicate: query => {
|
|
301
|
+
const params=parseQueryKey(query.queryKey)?.params;
|
|
302
|
+
const tag: unknown=typeof params==='object' && params!==null
|
|
303
|
+
? Object.getOwnPropertyDescriptor(params,'authSession')?.value : undefined;
|
|
304
|
+
// Untagged caches have no provable session owner and retain conservative logout eviction.
|
|
305
|
+
return typeof tag!=='string' || (provider ? tag.startsWith(prefix) : tag===prefix);
|
|
306
|
+
} });
|
|
307
|
+
}
|
|
308
|
+
|
|
160
309
|
// ─── useRegister ──────────────────────────────────────────────
|
|
161
310
|
|
|
162
311
|
export function useRegister(opts?: AuthNotificationOptions) {
|
|
163
312
|
return createAuthMutation({
|
|
164
313
|
method: 'register',
|
|
165
|
-
successMessage: resolveSuccessMessage(opts?.successNotification,
|
|
166
|
-
errorMessage: opts?.errorNotification,
|
|
167
|
-
onSuccess: async (result,
|
|
168
|
-
if
|
|
314
|
+
successMessage: resolveSuccessMessage(opts?.successNotification,t('auth.registerSuccess')),
|
|
315
|
+
...definedOptions({ errorMessage: opts?.errorNotification }),
|
|
316
|
+
onSuccess: async (result,navigate) => {
|
|
317
|
+
if(result.redirectTo) await navigate(result.redirectTo);
|
|
169
318
|
}
|
|
170
319
|
});
|
|
171
320
|
}
|
|
@@ -175,8 +324,8 @@ export function useRegister(opts?: AuthNotificationOptions) {
|
|
|
175
324
|
export function useForgotPassword(opts?: AuthNotificationOptions) {
|
|
176
325
|
return createAuthMutation({
|
|
177
326
|
method: 'forgotPassword',
|
|
178
|
-
successMessage: resolveSuccessMessage(opts?.successNotification,
|
|
179
|
-
errorMessage: opts?.errorNotification,
|
|
327
|
+
successMessage: resolveSuccessMessage(opts?.successNotification,t('auth.resetLinkSent')),
|
|
328
|
+
...definedOptions({ errorMessage: opts?.errorNotification }),
|
|
180
329
|
});
|
|
181
330
|
}
|
|
182
331
|
|
|
@@ -185,10 +334,10 @@ export function useForgotPassword(opts?: AuthNotificationOptions) {
|
|
|
185
334
|
export function useUpdatePassword(opts?: AuthNotificationOptions) {
|
|
186
335
|
return createAuthMutation({
|
|
187
336
|
method: 'updatePassword',
|
|
188
|
-
successMessage: resolveSuccessMessage(opts?.successNotification,
|
|
189
|
-
errorMessage: opts?.errorNotification,
|
|
190
|
-
onSuccess: async (result,
|
|
191
|
-
if
|
|
337
|
+
successMessage: resolveSuccessMessage(opts?.successNotification,t('common.operationSuccess')),
|
|
338
|
+
...definedOptions({ errorMessage: opts?.errorNotification }),
|
|
339
|
+
onSuccess: async (result,navigate) => {
|
|
340
|
+
if(result.redirectTo) await navigate(result.redirectTo);
|
|
192
341
|
}
|
|
193
342
|
});
|
|
194
343
|
}
|
|
@@ -198,10 +347,10 @@ export function useUpdatePassword(opts?: AuthNotificationOptions) {
|
|
|
198
347
|
export function useUpdateIdentity(opts?: AuthNotificationOptions) {
|
|
199
348
|
return createAuthMutation({
|
|
200
349
|
method: 'updateIdentity',
|
|
201
|
-
successMessage: resolveSuccessMessage(opts?.successNotification,
|
|
202
|
-
errorMessage: opts?.errorNotification,
|
|
203
|
-
onSuccess: async (result,
|
|
204
|
-
if
|
|
350
|
+
successMessage: resolveSuccessMessage(opts?.successNotification,t('common.operationSuccess')),
|
|
351
|
+
...definedOptions({ errorMessage: opts?.errorNotification }),
|
|
352
|
+
onSuccess: async (result,navigate) => {
|
|
353
|
+
if(result.redirectTo) await navigate(result.redirectTo);
|
|
205
354
|
}
|
|
206
355
|
});
|
|
207
356
|
}
|
|
@@ -209,131 +358,160 @@ export function useUpdateIdentity(opts?: AuthNotificationOptions) {
|
|
|
209
358
|
export function useUpdateProfile(opts?: AuthNotificationOptions) {
|
|
210
359
|
return createAuthMutation({
|
|
211
360
|
method: 'updateProfile',
|
|
212
|
-
successMessage: resolveSuccessMessage(opts?.successNotification,
|
|
213
|
-
errorMessage: opts?.errorNotification,
|
|
214
|
-
onSuccess: async (result,
|
|
215
|
-
if
|
|
361
|
+
successMessage: resolveSuccessMessage(opts?.successNotification,t('common.operationSuccess')),
|
|
362
|
+
...definedOptions({ errorMessage: opts?.errorNotification }),
|
|
363
|
+
onSuccess: async (result,navigate) => {
|
|
364
|
+
if(result.redirectTo) await navigate(result.redirectTo);
|
|
216
365
|
}
|
|
217
366
|
});
|
|
218
367
|
}
|
|
219
368
|
|
|
220
369
|
// ─── useGetIdentity ──────────────────────────────────────────
|
|
221
370
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
provider.getIdentity().then(identity => {
|
|
240
|
-
if (epoch !== requestEpoch) return;
|
|
241
|
-
data = identity;
|
|
242
|
-
isLoading = false;
|
|
243
|
-
}).catch(err => {
|
|
244
|
-
if (epoch !== requestEpoch) return;
|
|
245
|
-
error = err instanceof Error ? err : new Error(String(err));
|
|
246
|
-
isLoading = false;
|
|
247
|
-
console.warn('[svadmin] useGetIdentity failed:', err);
|
|
248
|
-
});
|
|
371
|
+
function createAuthQuery<T>(options: {
|
|
372
|
+
empty: T;
|
|
373
|
+
withoutProvider: T;
|
|
374
|
+
request: (provider: AuthProvider) => Promise<unknown>;
|
|
375
|
+
decode: (value: unknown) => T;
|
|
376
|
+
invalidCode: 'INVALID_AUTH_IDENTITY' | 'INVALID_AUTH_CHECK';
|
|
377
|
+
onValidated?: (provider: AuthProvider, data: T, liveRevision: number) => void;
|
|
378
|
+
}) {
|
|
379
|
+
const adminContext=captureAdminContext();
|
|
380
|
+
interface Snapshot {
|
|
381
|
+
provider: AuthProvider | null;
|
|
382
|
+
tenant: string | number | undefined;
|
|
383
|
+
logout: number;
|
|
384
|
+
authVersion: number;
|
|
385
|
+
data: T;
|
|
386
|
+
isLoading: boolean;
|
|
387
|
+
error: AuthQueryError | null;
|
|
249
388
|
}
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
389
|
+
let snapshot=$state.raw<Snapshot | null>(null);
|
|
390
|
+
let epoch=0;
|
|
391
|
+
let disposed=false;
|
|
392
|
+
let observedProvider=adminContext.authProvider;
|
|
393
|
+
let observedLogoutVersion=authSession(observedProvider).logout;
|
|
394
|
+
|
|
395
|
+
function current(value: Snapshot | null): value is Snapshot {
|
|
396
|
+
return !disposed && value !== null
|
|
397
|
+
&& value.provider === adminContext.authProvider
|
|
398
|
+
&& value.tenant === adminContext.tenantCacheKey?.__svadminTenant
|
|
399
|
+
&& value.authVersion === authSession(value.provider).version
|
|
400
|
+
&& value.logout === authSession(value.provider).logout;
|
|
253
401
|
}
|
|
254
402
|
|
|
255
|
-
function
|
|
256
|
-
|
|
257
|
-
|
|
403
|
+
async function fetch(): Promise<void> {
|
|
404
|
+
if(disposed) return;
|
|
405
|
+
const provider=adminContext.authProvider;
|
|
406
|
+
const liveRevision=authSession(provider).liveRevision;
|
|
407
|
+
const request=++epoch;
|
|
408
|
+
const started: Snapshot={
|
|
409
|
+
provider, tenant: adminContext.tenantCacheKey?.__svadminTenant,
|
|
410
|
+
logout: authSession(provider).logout, authVersion: authSession(provider).version,
|
|
411
|
+
data: provider ? options.empty : options.withoutProvider,
|
|
412
|
+
isLoading: provider !== null && typeof window !== 'undefined', error: null,
|
|
413
|
+
};
|
|
414
|
+
snapshot=started;
|
|
415
|
+
if(!started.isLoading || !provider) return;
|
|
416
|
+
const fail=(code: AuthQueryError['code']) => {
|
|
417
|
+
if(request !== epoch || !current(started)) return;
|
|
418
|
+
snapshot={ ...started, isLoading: false, error: new AuthQueryError(code) };
|
|
419
|
+
};
|
|
420
|
+
let response: unknown;
|
|
421
|
+
try {
|
|
422
|
+
response=await options.request(provider);
|
|
423
|
+
} catch {
|
|
424
|
+
fail('AUTH_QUERY_FAILED');
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
try {
|
|
428
|
+
const data=options.decode(response);
|
|
429
|
+
if(request !== epoch || !current(started)) return;
|
|
430
|
+
options.onValidated?.(provider,data,liveRevision);
|
|
431
|
+
snapshot={ ...started, data, isLoading: false };
|
|
432
|
+
} catch {
|
|
433
|
+
fail(options.invalidCode);
|
|
434
|
+
}
|
|
258
435
|
}
|
|
259
436
|
|
|
260
|
-
if
|
|
437
|
+
if(typeof window!=='undefined') {
|
|
261
438
|
$effect(() => {
|
|
262
|
-
const provider
|
|
263
|
-
|
|
264
|
-
const
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
439
|
+
const provider=adminContext.authProvider;
|
|
440
|
+
const tenant=adminContext.tenantCacheKey?.__svadminTenant;
|
|
441
|
+
const logout=authSession(provider).logout;
|
|
442
|
+
const authVersion=authSession(provider).version;
|
|
443
|
+
if(provider===observedProvider && logout!==observedLogoutVersion) {
|
|
444
|
+
observedLogoutVersion=logout;
|
|
445
|
+
++epoch;
|
|
446
|
+
snapshot={
|
|
447
|
+
provider, tenant, logout, authVersion,
|
|
448
|
+
data: provider ? options.empty : options.withoutProvider, isLoading: false, error: null,
|
|
449
|
+
};
|
|
271
450
|
return;
|
|
272
451
|
}
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
};
|
|
452
|
+
observedProvider=provider;
|
|
453
|
+
observedLogoutVersion=logout;
|
|
454
|
+
untrack(() => { void fetch(); });
|
|
455
|
+
return () => { ++epoch; };
|
|
278
456
|
});
|
|
457
|
+
$effect(() => () => { disposed=true; ++epoch; });
|
|
279
458
|
}
|
|
280
459
|
|
|
281
460
|
return {
|
|
282
|
-
get data() {
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
461
|
+
get data() {
|
|
462
|
+
if(current(snapshot)) return snapshot.data;
|
|
463
|
+
return !disposed && !adminContext.authProvider ? options.withoutProvider : options.empty;
|
|
464
|
+
},
|
|
465
|
+
get isLoading() {
|
|
466
|
+
return current(snapshot) ? snapshot.isLoading
|
|
467
|
+
: !disposed && adminContext.authProvider !== null && typeof window !== 'undefined';
|
|
468
|
+
},
|
|
469
|
+
get error() { return current(snapshot) ? snapshot.error : null; },
|
|
470
|
+
refetch: fetch,
|
|
286
471
|
};
|
|
287
472
|
}
|
|
288
473
|
|
|
474
|
+
export function useGetIdentity() {
|
|
475
|
+
return createAuthQuery<Readonly<Identity> | null>({
|
|
476
|
+
empty: null,
|
|
477
|
+
withoutProvider: null,
|
|
478
|
+
request: provider => {
|
|
479
|
+
const method=provider.getIdentity;
|
|
480
|
+
if(typeof method !== 'function') throw new AuthQueryError('AUTH_QUERY_FAILED');
|
|
481
|
+
return method.call(provider);
|
|
482
|
+
},
|
|
483
|
+
decode: decodeIdentity,
|
|
484
|
+
invalidCode: 'INVALID_AUTH_IDENTITY',
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
|
|
289
488
|
// ─── useIsAuthenticated ──────────────────────────────────────
|
|
290
489
|
|
|
291
490
|
export function useIsAuthenticated() {
|
|
292
|
-
const
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
}
|
|
310
|
-
isLoading = true;
|
|
311
|
-
provider.check().then((result: CheckResult) => {
|
|
312
|
-
isAuthenticated = result.authenticated;
|
|
313
|
-
checkResult = result;
|
|
314
|
-
isLoading = false;
|
|
315
|
-
}).catch(() => {
|
|
316
|
-
isAuthenticated = false;
|
|
317
|
-
checkResult = { authenticated: false };
|
|
318
|
-
isLoading = false;
|
|
319
|
-
});
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
// Initial check
|
|
323
|
-
check();
|
|
324
|
-
|
|
325
|
-
if (typeof window !== 'undefined') {
|
|
326
|
-
$effect(() => {
|
|
327
|
-
void _logoutVersion;
|
|
328
|
-
isAuthenticated = false;
|
|
329
|
-
});
|
|
330
|
-
}
|
|
491
|
+
const query=createAuthQuery<Readonly<CheckResult> | null>({
|
|
492
|
+
empty: null,
|
|
493
|
+
withoutProvider: Object.freeze({ authenticated: true }),
|
|
494
|
+
request: provider => {
|
|
495
|
+
const method=provider.check;
|
|
496
|
+
if(typeof method !== 'function') throw new AuthQueryError('AUTH_QUERY_FAILED');
|
|
497
|
+
return method.call(provider);
|
|
498
|
+
},
|
|
499
|
+
decode: decodeAuthCheck,
|
|
500
|
+
invalidCode: 'INVALID_AUTH_CHECK',
|
|
501
|
+
onValidated(provider,data,revision) {
|
|
502
|
+
const session=authSession(provider);
|
|
503
|
+
if(data && session.liveRevision===revision && session.liveState!=='changing' && session.inFlight===0) {
|
|
504
|
+
setLiveSession(provider,data.authenticated ? 'ready' : 'signed-out');
|
|
505
|
+
}
|
|
506
|
+
},
|
|
507
|
+
});
|
|
331
508
|
|
|
332
509
|
return {
|
|
333
|
-
get isAuthenticated() { return
|
|
334
|
-
get isLoading() { return isLoading; },
|
|
335
|
-
get data() { return
|
|
336
|
-
|
|
510
|
+
get isAuthenticated() { return query.data?.authenticated === true; },
|
|
511
|
+
get isLoading() { return query.isLoading; },
|
|
512
|
+
get data() { return query.data; },
|
|
513
|
+
get error() { return query.error; },
|
|
514
|
+
refetch: query.refetch,
|
|
337
515
|
};
|
|
338
516
|
}
|
|
339
517
|
|
|
@@ -345,28 +523,130 @@ export function useIsAuthenticated() {
|
|
|
345
523
|
* If it returns { redirectTo }, navigates there.
|
|
346
524
|
*/
|
|
347
525
|
export function useOnError() {
|
|
348
|
-
const adminContext
|
|
526
|
+
const adminContext=captureAdminContext();
|
|
527
|
+
let queryClient: ReturnType<typeof useQueryClient> | undefined;
|
|
528
|
+
try {
|
|
529
|
+
queryClient=useQueryClient();
|
|
530
|
+
} catch {
|
|
531
|
+
// Standalone auth hooks may not have a query cache.
|
|
532
|
+
}
|
|
533
|
+
let epoch=0;
|
|
534
|
+
let disposed=false;
|
|
535
|
+
$effect(() => {
|
|
536
|
+
void adminContext.authProvider;
|
|
537
|
+
void adminContext.tenantCacheKey?.__svadminTenant;
|
|
538
|
+
return () => { ++epoch; };
|
|
539
|
+
});
|
|
540
|
+
$effect(() => () => { disposed=true; ++epoch; });
|
|
349
541
|
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
542
|
+
return {
|
|
543
|
+
mutate(error: unknown): Promise<AuthErrorHandlingResult> {
|
|
544
|
+
const request=++epoch;
|
|
545
|
+
return handleAuthError(error,adminContext,
|
|
546
|
+
() => !disposed && request===epoch, () => clearAuthQueries(queryClient,adminContext.authProvider));
|
|
547
|
+
},
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/** Shared validated delegate for auth hooks and data-hook errors. */
|
|
552
|
+
export async function handleAuthError(
|
|
553
|
+
error: unknown,
|
|
554
|
+
adminContext: AdminContextAccessor,
|
|
555
|
+
isActive: () => boolean = () => true,
|
|
556
|
+
onLogout: () => void = () => {},
|
|
557
|
+
origin?: ReturnType<typeof captureAuthLiveScope>,
|
|
558
|
+
isExplicitlyCancelled: () => boolean = () => false,
|
|
559
|
+
): Promise<AuthErrorHandlingResult> {
|
|
560
|
+
const provider=adminContext.authProvider;
|
|
561
|
+
const tenant=adminContext.tenantCacheKey?.__svadminTenant;
|
|
562
|
+
const router=adminContext.routerProvider;
|
|
563
|
+
let version=authSession(provider).version;
|
|
564
|
+
let intent=authSession(provider).intent;
|
|
565
|
+
let previousLive: AuthSession['liveState'] | undefined;
|
|
566
|
+
let ownsLogout=false;
|
|
567
|
+
// A delegate owns its checked logout after the session begins transitioning.
|
|
568
|
+
const current=() => (isActive() || (ownsLogout && !isExplicitlyCancelled()))
|
|
569
|
+
&& (ownsLogout || origin===undefined || origin.isCurrent()) && provider===adminContext.authProvider
|
|
570
|
+
&& tenant===adminContext.tenantCacheKey?.__svadminTenant && version===authSession(provider).version
|
|
571
|
+
&& intent===authSession(provider).intent
|
|
572
|
+
&& router===adminContext.routerProvider;
|
|
573
|
+
const fail=(code: AuthErrorHandlingError['code'], rejected=false): AuthErrorHandlingResult => {
|
|
574
|
+
if(provider && previousLive!==undefined && current()) {
|
|
575
|
+
// A failed data-query logout must not automatically restart the unauthorized read.
|
|
576
|
+
setLiveSession(provider,rejected && origin===undefined ? rejectedLiveState(previousLive) : 'uncertain');
|
|
355
577
|
}
|
|
578
|
+
return { status: 'failed', error: new AuthErrorHandlingError(code) };
|
|
579
|
+
};
|
|
580
|
+
if(!current()) return { status: 'superseded' };
|
|
581
|
+
let response: unknown;
|
|
582
|
+
let invokeLogout: (() => Promise<unknown>) | undefined;
|
|
583
|
+
try {
|
|
584
|
+
const method=provider?.onError;
|
|
585
|
+
if(method===undefined) {
|
|
586
|
+
const descriptor=typeof error==='object' && error!==null
|
|
587
|
+
? Object.getOwnPropertyDescriptor(error,'statusCode') : undefined;
|
|
588
|
+
const status: unknown=descriptor && 'value' in descriptor ? descriptor.value : undefined;
|
|
589
|
+
if(status!==401) return { status: 'ignored' };
|
|
590
|
+
await navigateWithProvider(router,'/login',undefined,current);
|
|
591
|
+
return { status: current() ? 'handled' : 'superseded' };
|
|
592
|
+
}
|
|
593
|
+
if(typeof method!=='function' || !provider) return fail('AUTH_ERROR_HANDLER_FAILED');
|
|
356
594
|
try {
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
await adminContext.navigate(result.redirectTo ?? '/login');
|
|
361
|
-
} else if (result.redirectTo) {
|
|
362
|
-
await adminContext.navigate(result.redirectTo);
|
|
363
|
-
}
|
|
364
|
-
} catch (err) {
|
|
365
|
-
console.warn('[svadmin] useOnError failed:', err);
|
|
595
|
+
invokeLogout=prepareAuthMutation(provider,'logout',undefined);
|
|
596
|
+
} catch {
|
|
597
|
+
// A missing logout method matters only if the validated handler requests logout.
|
|
366
598
|
}
|
|
599
|
+
if(!current()) return { status: 'superseded' };
|
|
600
|
+
response=await method.call(provider,error);
|
|
601
|
+
} catch {
|
|
602
|
+
return current() ? fail('AUTH_ERROR_HANDLER_FAILED') : { status: 'superseded' };
|
|
367
603
|
}
|
|
368
|
-
|
|
369
|
-
|
|
604
|
+
if(!current()) return { status: 'superseded' };
|
|
605
|
+
let result: ReturnType<typeof decodeAuthErrorResult>;
|
|
606
|
+
try {
|
|
607
|
+
result=decodeAuthErrorResult(response);
|
|
608
|
+
} catch {
|
|
609
|
+
return fail('INVALID_AUTH_ERROR_RESULT');
|
|
610
|
+
}
|
|
611
|
+
if(!current()) return { status: 'superseded' };
|
|
612
|
+
if(result.logout) {
|
|
613
|
+
if(!invokeLogout || !provider) return fail('AUTH_LOGOUT_FAILED');
|
|
614
|
+
previousLive=authSession(provider).liveState;
|
|
615
|
+
// The checked origin hands ownership to this delegate's own logout intent.
|
|
616
|
+
ownsLogout=true;
|
|
617
|
+
intent=++authSession(provider).intent;
|
|
618
|
+
const releaseLive=beginLiveSessionChange(provider,intent);
|
|
619
|
+
let logout: unknown;
|
|
620
|
+
try {
|
|
621
|
+
logout=await invokeLogout();
|
|
622
|
+
} catch {
|
|
623
|
+
return current() ? fail('AUTH_LOGOUT_FAILED') : { status: 'superseded' };
|
|
624
|
+
} finally {
|
|
625
|
+
releaseLive();
|
|
626
|
+
}
|
|
627
|
+
if(!current()) return { status: 'superseded' };
|
|
628
|
+
try {
|
|
629
|
+
if(!decodeAuthAction(logout).success) return fail('AUTH_LOGOUT_FAILED',true);
|
|
630
|
+
} catch {
|
|
631
|
+
return fail('AUTH_LOGOUT_FAILED');
|
|
632
|
+
}
|
|
633
|
+
if(!current()) return { status: 'superseded' };
|
|
634
|
+
version=advanceAuthSession(provider,true);
|
|
635
|
+
try {
|
|
636
|
+
onLogout();
|
|
637
|
+
} catch {
|
|
638
|
+
// A cache observer cannot undo a confirmed server logout.
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
const redirect=result.redirectTo ?? (result.logout ? '/login' : undefined);
|
|
642
|
+
if(redirect!==undefined) {
|
|
643
|
+
try {
|
|
644
|
+
await navigateWithProvider(router,redirect,undefined,current);
|
|
645
|
+
} catch {
|
|
646
|
+
return current() ? fail('AUTH_ERROR_HANDLER_FAILED') : { status: 'superseded' };
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
return { status: current() ? 'handled' : 'superseded' };
|
|
370
650
|
}
|
|
371
651
|
|
|
372
652
|
// ─── usePermissions ──────────────────────────────────────────
|
|
@@ -389,52 +669,104 @@ export function useOnError() {
|
|
|
389
669
|
* </script>
|
|
390
670
|
* ```
|
|
391
671
|
*/
|
|
392
|
-
export function usePermissions
|
|
393
|
-
const adminContext
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
672
|
+
export function usePermissions() {
|
|
673
|
+
const adminContext=captureAdminContext();
|
|
674
|
+
interface Snapshot {
|
|
675
|
+
provider: AuthProvider | null;
|
|
676
|
+
tenant: string | number | undefined;
|
|
677
|
+
logout: number;
|
|
678
|
+
authVersion: number;
|
|
679
|
+
hints: PermissionHints;
|
|
680
|
+
isLoading: boolean;
|
|
681
|
+
error: PermissionHintsError | null;
|
|
682
|
+
}
|
|
683
|
+
let snapshot=$state.raw<Snapshot | null>(null);
|
|
684
|
+
let epoch=0;
|
|
685
|
+
let disposed=false;
|
|
686
|
+
let observedProvider=adminContext.authProvider;
|
|
687
|
+
let observedLogoutVersion=authSession(observedProvider).logout;
|
|
688
|
+
let version=$state(0);
|
|
689
|
+
|
|
690
|
+
function current(value: Snapshot | null): value is Snapshot {
|
|
691
|
+
return !disposed && value !== null
|
|
692
|
+
&& value.provider === adminContext.authProvider
|
|
693
|
+
&& value.tenant === adminContext.tenantCacheKey?.__svadminTenant
|
|
694
|
+
&& value.authVersion === authSession(value.provider).version
|
|
695
|
+
&& value.logout === authSession(value.provider).logout;
|
|
413
696
|
}
|
|
414
697
|
|
|
415
|
-
|
|
416
|
-
|
|
698
|
+
async function fetch(): Promise<void> {
|
|
699
|
+
if(disposed) return;
|
|
700
|
+
const provider=adminContext.authProvider;
|
|
701
|
+
const request=++epoch;
|
|
702
|
+
const started: Snapshot={
|
|
703
|
+
provider, tenant: adminContext.tenantCacheKey?.__svadminTenant,
|
|
704
|
+
logout: authSession(provider).logout, authVersion: authSession(provider).version,
|
|
705
|
+
hints: null, isLoading: true, error: null,
|
|
706
|
+
};
|
|
707
|
+
snapshot=started;
|
|
708
|
+
const fail=(code: PermissionHintsError['code']) => {
|
|
709
|
+
if(request!==epoch || !current(started)) return;
|
|
710
|
+
snapshot={ ...started, isLoading: false, error: new PermissionHintsError(code) };
|
|
711
|
+
};
|
|
712
|
+
let response: unknown;
|
|
713
|
+
try {
|
|
714
|
+
const getPermissions=provider?.getPermissions;
|
|
715
|
+
if(getPermissions===undefined) {
|
|
716
|
+
snapshot={ ...started, isLoading: false };
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
if(typeof getPermissions!=='function') {
|
|
720
|
+
fail('PERMISSION_HINTS_FAILED');
|
|
721
|
+
return;
|
|
722
|
+
}
|
|
723
|
+
response=await getPermissions.call(provider);
|
|
724
|
+
} catch {
|
|
725
|
+
fail('PERMISSION_HINTS_FAILED');
|
|
726
|
+
return;
|
|
727
|
+
}
|
|
728
|
+
try {
|
|
729
|
+
const hints=decodePermissionHints(response);
|
|
730
|
+
if(request!==epoch || !current(started)) return;
|
|
731
|
+
snapshot={ ...started, hints, isLoading: false };
|
|
732
|
+
version++;
|
|
733
|
+
} catch {
|
|
734
|
+
fail('INVALID_PERMISSION_HINTS');
|
|
735
|
+
}
|
|
417
736
|
}
|
|
418
737
|
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
738
|
+
$effect(() => {
|
|
739
|
+
const provider=adminContext.authProvider;
|
|
740
|
+
const tenant=adminContext.tenantCacheKey?.__svadminTenant;
|
|
741
|
+
const logout=authSession(provider).logout;
|
|
742
|
+
const authVersion=authSession(provider).version;
|
|
743
|
+
if(provider===observedProvider && logout!==observedLogoutVersion) {
|
|
744
|
+
observedLogoutVersion=logout;
|
|
745
|
+
++epoch;
|
|
746
|
+
snapshot={ provider, tenant, logout, authVersion, hints: null, isLoading: false, error: null };
|
|
747
|
+
return;
|
|
748
|
+
}
|
|
749
|
+
observedProvider=provider;
|
|
750
|
+
observedLogoutVersion=logout;
|
|
751
|
+
untrack(() => { void fetch(); });
|
|
752
|
+
return () => { ++epoch; };
|
|
753
|
+
});
|
|
754
|
+
$effect(() => () => { disposed=true; ++epoch; });
|
|
755
|
+
|
|
756
|
+
const raw=(): PermissionHints => current(snapshot) ? snapshot.hints : null;
|
|
757
|
+
const hasFn=(permission: string): boolean => hasPermissionHint(raw(),permission);
|
|
426
758
|
|
|
427
759
|
return {
|
|
428
|
-
get isLoading() { return isLoading; },
|
|
429
|
-
get error() { return error; },
|
|
760
|
+
get isLoading() { return current(snapshot) ? snapshot.isLoading : !disposed && typeof window!=='undefined'; },
|
|
761
|
+
get error() { return current(snapshot) ? snapshot.error : null; },
|
|
430
762
|
get version() { return version; },
|
|
431
|
-
get raw() { return
|
|
763
|
+
get raw() { return raw(); },
|
|
432
764
|
refetch: fetch,
|
|
433
765
|
|
|
434
766
|
/** Check if a specific permission string exists */
|
|
435
767
|
has: hasFn,
|
|
436
768
|
|
|
437
769
|
/** Check resource:action style permission */
|
|
438
|
-
can: (resource: string,
|
|
770
|
+
can: (resource: string,action: string): boolean => hasFn(`${resource}:${action}`),
|
|
439
771
|
};
|
|
440
772
|
}
|