Move dataset review clarification into the assistant workspace and rework the review page into a chat-centric layout with execution rails. Add session-scoped assistant actions for mappings, semantic fields, and SQL preview generation. Introduce optimistic locking for dataset review mutations, propagate session versions through API responses, and mask imported filter values before assistant exposure. Refresh tests, i18n, and spec artifacts to match the new workflow. BREAKING CHANGE: dataset review mutation endpoints now require the X-Session-Version header, and clarification is no longer handled through ClarificationDialog-based flows
145 lines
7.1 KiB
Svelte
145 lines
7.1 KiB
Svelte
<!--[DEF:ProtectedRouteModule:Module] -->
|
|
<!--
|
|
@COMPLEXITY: 3
|
|
@SEMANTICS: auth, route-guard, permission, redirect, session-validation
|
|
@PURPOSE: Enforces authenticated and authorized access before protected route content is rendered.
|
|
@LAYER: UI
|
|
@RELATION: [BINDS_TO] ->[authStore]
|
|
@RELATION: [CALLS] ->[goto]
|
|
@RELATION: [DEPENDS_ON] ->[Permissions]
|
|
@RELATION: [CALLS] ->[fetchApi]
|
|
@INVARIANT: Unauthenticated users are redirected to /login, unauthorized users are redirected to fallbackPath, and protected slot renders only when access is verified.
|
|
@UX_STATE: Idle -> Component mounted, verification not yet started.
|
|
@UX_STATE: Loading -> Spinner is rendered while auth/session/permission validation is in progress.
|
|
@UX_STATE: Error -> Session validation failure triggers logout and /login redirect.
|
|
@UX_STATE: Success -> Protected slot content is rendered for authenticated users with valid access.
|
|
@UX_FEEDBACK: Spinner feedback during Loading and navigation redirect feedback on Error/Unauthorized outcomes.
|
|
@UX_RECOVERY: Re-authenticate via /login after logout; retry occurs automatically on next protected navigation.
|
|
@UX_REACTIVITY: Props are bound via $props; local mutable UI flags use $state; auth store is consumed through Svelte store subscription ($auth).
|
|
@TEST_CONTRACT: [token:user:requiredPermission] -> [redirect:/login | redirect:fallbackPath | render:slot]
|
|
@TEST_SCENARIO: MissingTokenRedirect -> Navigates to /login and suppresses slot render.
|
|
@TEST_SCENARIO: PermissionDeniedRedirect -> Navigates to fallbackPath and suppresses slot render.
|
|
@TEST_SCENARIO: AuthorizedRender -> Renders slot when authenticated and permission passes.
|
|
@TEST_FIXTURE: AuthGuardStateMatrix -> INLINE_JSON
|
|
@TEST_EDGE: missing_field -> user payload missing in store triggers /auth/me fetch, then logout+redirect on failure.
|
|
@TEST_EDGE: invalid_type -> requiredPermission malformed (non-string/null) resolves to denied path and fallback redirect.
|
|
@TEST_EDGE: external_fail -> /auth/me network/API failure triggers logout and /login redirect.
|
|
@TEST_INVARIANT: GuardRedirectPolicy -> VERIFIED_BY: [MissingTokenRedirect, PermissionDeniedRedirect]
|
|
@TEST_INVARIANT: ProtectedRenderGate -> VERIFIED_BY: [AuthorizedRender]
|
|
-->
|
|
|
|
<!--[DEF:ProtectedRoute:Component] -->
|
|
<!--
|
|
@PURPOSE: Wraps protected slot content with session and permission verification guards.
|
|
@PRE: auth store and navigation API are available in runtime; component is mounted in a browser context.
|
|
@POST: Slot renders only when $auth.isAuthenticated and hasRouteAccess are both true.
|
|
@SIDE_EFFECT: Performs /auth/me request, mutates auth store state, emits console instrumentation logs, and executes navigation redirects.
|
|
@DATA_CONTRACT: Input[$props.requiredPermission?: string|null, $props.fallbackPath?: string] -> Output[UIState{isCheckingAccess:boolean, hasRouteAccess:boolean}]
|
|
-->
|
|
|
|
<script>
|
|
import { onMount } from "svelte";
|
|
import { auth } from "../../lib/auth/store";
|
|
import { api } from "../../lib/api";
|
|
import { goto } from "$app/navigation";
|
|
import { hasPermission } from "$lib/auth/permissions.js";
|
|
|
|
const { requiredPermission = null, fallbackPath = "/profile", children } = $props();
|
|
|
|
let hasRouteAccess = $state(false);
|
|
let isCheckingAccess = $state(true);
|
|
|
|
const belief_scope = async (scopeId, run) => {
|
|
console.info(`[${scopeId}][REASON] belief_scope.enter`);
|
|
try {
|
|
return await run();
|
|
} finally {
|
|
console.info(`[${scopeId}][REFLECT] belief_scope.exit`);
|
|
}
|
|
};
|
|
|
|
// [DEF:verifySessionAndAccess:Function]
|
|
/**
|
|
* @PURPOSE: Validates session and optional permission gate before allowing protected content render.
|
|
* @PRE: auth store is initialized and can provide token/user state; navigation is available.
|
|
* @POST: hasRouteAccess=true only when user identity is valid and permission check (if provided) passes.
|
|
* @SIDE_EFFECT: Mutates auth loading/user state, performs API I/O to /auth/me, and may redirect.
|
|
* @DATA_CONTRACT: Input[AuthState, requiredPermission, fallbackPath] -> Output[RouteDecision{login_redirect|fallback_redirect|grant}]
|
|
*/
|
|
async function verifySessionAndAccess() {
|
|
return belief_scope("ProtectedRoute.verifySessionAndAccess", async () => {
|
|
console.info("[ProtectedRoute.verifySessionAndAccess][REASON] Starting route access verification");
|
|
isCheckingAccess = true;
|
|
|
|
try {
|
|
if (!$auth.token) {
|
|
auth.setLoading(false);
|
|
console.info("[ProtectedRoute.verifySessionAndAccess][REFLECT] Missing token, redirecting to /login");
|
|
await goto("/login");
|
|
return;
|
|
}
|
|
|
|
let currentUser = $auth.user;
|
|
if (!currentUser) {
|
|
auth.setLoading(true);
|
|
try {
|
|
const user = await api.fetchApi("/auth/me");
|
|
auth.setUser(user);
|
|
currentUser = user;
|
|
console.info("[ProtectedRoute.verifySessionAndAccess][REASON] Session user hydrated from /auth/me");
|
|
} catch (error) {
|
|
console.warn("[ProtectedRoute.verifySessionAndAccess][EXPLORE] Session validation failed", { error });
|
|
auth.logout();
|
|
await goto("/login");
|
|
return;
|
|
} finally {
|
|
auth.setLoading(false);
|
|
}
|
|
}
|
|
|
|
if (!currentUser) {
|
|
auth.logout();
|
|
console.info("[ProtectedRoute.verifySessionAndAccess][REFLECT] User unresolved, redirecting to /login");
|
|
await goto("/login");
|
|
return;
|
|
}
|
|
|
|
if (requiredPermission && !hasPermission(currentUser, requiredPermission, "READ")) {
|
|
console.info("[ProtectedRoute.verifySessionAndAccess][REFLECT] Permission denied, redirecting to fallback", {
|
|
requiredPermission,
|
|
fallbackPath,
|
|
});
|
|
hasRouteAccess = false;
|
|
await goto(fallbackPath);
|
|
return;
|
|
}
|
|
|
|
hasRouteAccess = true;
|
|
console.info("[ProtectedRoute.verifySessionAndAccess][REFLECT] Access granted");
|
|
} finally {
|
|
isCheckingAccess = false;
|
|
console.info("[ProtectedRoute.verifySessionAndAccess][REFLECT] Verification cycle completed", {
|
|
isCheckingAccess,
|
|
hasRouteAccess,
|
|
});
|
|
}
|
|
});
|
|
}
|
|
// [/DEF:verifySessionAndAccess:Function]
|
|
|
|
onMount(() => {
|
|
void verifySessionAndAccess();
|
|
});
|
|
</script>
|
|
|
|
{#if $auth.loading || isCheckingAccess}
|
|
<div class="flex items-center justify-center min-h-screen">
|
|
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
|
</div>
|
|
{:else if $auth.isAuthenticated && hasRouteAccess}
|
|
{@render children?.()}
|
|
{/if}
|
|
|
|
<!-- [/DEF:ProtectedRoute:Component] -->
|
|
<!-- [/DEF:ProtectedRouteModule:Module] -->
|