Color tokens: 3658→0 violations across 186 .svelte/.svelte.ts files. All raw Tailwind colors (bg-blue-*, text-gray-*, border-red-*, etc.) replaced with semantic tokens from tailwind.config.js in 5 perl passes. Model extraction (11→8 oversized pages): - LLMReportModel.svelte.ts: LLM report page 413→221 lines - DatasetDetailModel.svelte.ts: dataset detail page 416→218 lines - DatasetsHubModel.svelte.ts: datasets hub page 468→246 lines Tests: 14 assertions updated (color classes + model refs). Build: 1 duplicate class:text-primary fix in ValidationTaskForm. All 699/699 tests pass.
146 lines
7.4 KiB
Svelte
146 lines
7.4 KiB
Svelte
<!-- #region ProtectedRoute [C:3] [TYPE Component] [SEMANTICS auth, route-guard, permission, session, redirect] -->
|
|
<!-- @BRIEF Component component: components/auth/ProtectedRoute.svelte -->
|
|
<!-- @LAYER UI -->
|
|
<!--
|
|
@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 ->[EXT:frontend:authStore]
|
|
@RELATION CALLS ->[EXT:frontend:goto]
|
|
@RELATION DEPENDS_ON ->[PermissionsModule]
|
|
@RELATION CALLS ->[EXT:frontend: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]
|
|
-->
|
|
|
|
<!-- @INVARIANT All routes except login gated behind authenticated session and RBAC check. -->
|
|
<!--
|
|
@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 lang="ts">
|
|
import { onMount } from "svelte";
|
|
import { auth } from "../../lib/auth/store.svelte.js";
|
|
import { api } from "../../lib/api";
|
|
import { goto } from "$app/navigation";
|
|
import { ROUTES } from "$lib/routes";
|
|
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`);
|
|
}
|
|
};
|
|
|
|
// #region verifySessionAndAccess:Function [TYPE 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(ROUTES.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(ROUTES.login());
|
|
return;
|
|
} finally {
|
|
auth.setLoading(false);
|
|
}
|
|
}
|
|
|
|
if (!currentUser) {
|
|
auth.logout();
|
|
console.info("[ProtectedRoute.verifySessionAndAccess][REFLECT] User unresolved, redirecting to /login");
|
|
await goto(ROUTES.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,
|
|
});
|
|
}
|
|
});
|
|
}
|
|
// #endregion 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-primary-ring"></div>
|
|
</div>
|
|
{:else if $auth.isAuthenticated && hasRouteAccess}
|
|
{@render children?.()}
|
|
{/if}
|
|
|
|
<!-- #endregion ProtectedRoute -->
|