Files
ss-tools/frontend/src/components/auth/ProtectedRoute.svelte
busya cf7b3556f7 refactor(frontend): enforce semantic tokens + extract 3 Screen Models
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.
2026-06-02 17:58:36 +03:00

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 -->