Files
collective-lists/apps/web/src/routes/+layout.svelte
Oier Bravo Urtasun 286f59225f feat(fase-12): nav gating + redirect guard + realtime publication
UI side:
  * DesktopSidebar / BottomTabBar filter their entries by $enabledSections,
    tag each entry with data-section, and BottomTabBar exposes a
    data-section-count attribute so the grid is observable from tests.
  * "lists" is force-shown unconditionally — the §12.3.4 always-one-
    landing rule covers the edge case where every layer says OFF.
  * (app)/+layout.svelte adds a $effect that, when the URL matches a
    disabled section (/tasks, /notes, /search), goto's /lists and shows
    a transient `section_disabled_for_collective` toast.
  * Root +layout.svelte exposes the supabase singleton on window.__sb
    in dev so the new Playwright spec can patch rows without a parallel
    client; dead-code-eliminated in production builds.

DB side:
  * Migration 023 grows by ALTER PUBLICATION supabase_realtime ADD TABLE
    public.users + public.collectives + REPLICA IDENTITY FULL on both.
    Without this membership the features.ts subscriptions get zero
    events and SV-02 (collective toggle → realtime → member's nav
    updates) silently fails. Caught while running the spec.
  * pgTAP 015 grows from 16 to 20 assertions to cover publication
    membership + REPLICA IDENTITY for both new realtime tables.

Paraglide messages: section_disabled_for_collective, the visibility
section titles + blurbs, section_label_* per SectionKey. Both en + es.

Playwright tests/e2e/section-visibility.test.ts (SV-01..SV-03):
  SV-01 user toggle → nav reflects → /tasks redirects to /lists
  SV-02 admin collective toggle → member sees it disappear in realtime
  SV-03 collective ON beats user OFF — per-collective resolution

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 04:10:19 +02:00

202 lines
7.1 KiB
Svelte

<script lang="ts">
import '../app.css';
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { getSupabase } from '$lib/supabase';
import { currentUser, authLoading } from '$lib/stores/auth';
import { userCollectives, currentCollective } from '$lib/stores/collective';
import {
loadCurrentUserFeatures,
subscribeCollectiveFeatures,
teardownFeatureSubscriptions
} from '$lib/stores/features';
import type { FeatureFlags } from '@colectivo/types';
import { ParaglideJS } from '@inlang/paraglide-sveltekit';
import { i18n } from '$lib/i18n';
import { initTheme } from '$lib/theme';
import { setLanguageTag } from '$lib/paraglide/runtime';
import { detectLanguage } from '$lib/utils/accept-language';
onMount(() => {
// Fase 9.1: hydrate the theme stores from localStorage and start
// listening for OS prefers-color-scheme changes. The inline script
// in app.html has already set <html data-theme> synchronously to
// avoid FOUC — initTheme just builds the JS-side state on top.
initTheme();
// Dev-only: expose the supabase client on window so Playwright tests
// can drive PostgREST through it without re-creating a parallel
// client. Production never sees this (import.meta.env.DEV is
// dead-code-eliminated by Vite at build time).
if (import.meta.env.DEV) {
(window as unknown as { __sb?: unknown }).__sb = getSupabase();
}
// Fase 6.1: register the PWA service worker. `@vite-pwa/sveltekit` does
// not auto-register — the virtual module is a no-op unless called.
void import('virtual:pwa-register').then(({ registerSW }) => {
registerSW({ immediate: true });
});
const supabase = getSupabase();
const {
data: { subscription }
} = supabase.auth.onAuthStateChange((event, session) => {
currentUser.set(session?.user ?? null);
authLoading.set(false);
if (session) {
// Defer out of the onAuthStateChange callback so the internal auth
// lock is fully released before we make any PostgREST query.
// Without this, queries hang on `getAccessToken() -> initializePromise`
// in some browser environments (e.g. Playwright headless Chromium).
setTimeout(async () => {
await loadUserCollectives(session.user.id);
// Fase 12.2: load the user's feature_flags + subscribe to
// realtime updates (own row). Best-effort: a failure here
// only degrades to default-ON for every section.
try {
await loadCurrentUserFeatures(session.user.id);
} catch {
/* non-fatal */
}
// Fase 10.8: auto-detect language for genuinely new users.
// Best-effort, runs after every auth event so newly-seeded
// (post-Keycloak-self-registration) rows pick up the user's
// browser preference instead of the seed default 'en'.
await maybeBootstrapLanguage(session.user.id);
if (event === 'SIGNED_IN') {
// Post-login: send the user where they belong.
// Only redirect when coming from the callback or the root — not on
// token refreshes or when the user is already on a real route.
const path = $page.url.pathname;
if (path === '/auth/callback' || path === '/') {
// If the user came in via /invitation/[token] and had to
// log in first, resume that flow instead of going to
// /lists or /onboarding.
const pendingToken = sessionStorage.getItem('pendingInvitationToken');
if (pendingToken) {
sessionStorage.removeItem('pendingInvitationToken');
goto(`/invitation/${pendingToken}`);
} else if ($userCollectives.length === 0) {
goto('/onboarding');
} else {
goto('/lists');
}
}
}
}, 0);
}
});
// Fase 12.2: keep the active-collective realtime subscription in sync.
// Subscribing here (after onAuthStateChange) rather than at the auth
// callback level means it follows the user across collective switches
// without needing a sign-in event. Tear down on layout destroy too.
const collectiveUnsub = currentCollective.subscribe((c) => {
if (c) void subscribeCollectiveFeatures(c.id);
});
return () => {
subscription.unsubscribe();
collectiveUnsub();
void teardownFeatureSubscriptions();
};
});
/**
* Fase 10.8 — Accept-Language detection for first-time users.
*
* The Paraglide stack is compile-time and the OIDC dance lives outside
* SvelteKit, so we can't sniff the real Accept-Language header server-
* side here without standing up a hooks.server.ts that the auth flow
* never traverses. Instead we use `navigator.languages` (the browser's
* Accept-Language proxy) on the client right after the first SIGNED_IN.
*
* Idempotency: we only UPDATE when the row was just created (within
* 60s of now) AND `language` still equals the seed default ('en'). This
* means a Spanish-browser user lands in Spanish on first run; an
* established user who later switched themselves to English stays
* English; existing users are not retroactively re-detected (plan
* §10.8.5 — respect their previous explicit choice).
*/
async function maybeBootstrapLanguage(userId: string): Promise<void> {
const supabase = getSupabase();
const { data } = await supabase
.from('users')
.select('language, created_at')
.eq('id', userId)
.single();
if (!data) return;
const ageMs = Date.now() - new Date(data.created_at).getTime();
const isFreshAccount = ageMs < 60_000;
if (!isFreshAccount) return;
if (data.language !== 'en') return;
const langs =
(typeof navigator !== 'undefined' &&
(navigator.languages?.length ? Array.from(navigator.languages) : [navigator.language])) ||
[];
const detected = detectLanguage(langs);
if (detected === 'en') return;
await supabase.from('users').update({ language: detected }).eq('id', userId);
setLanguageTag(detected);
}
async function loadUserCollectives(userId: string): Promise<void> {
const supabase = getSupabase();
const { data } = await supabase
.from('collective_members')
.select(
'collective_id, role, collectives(id, name, emoji, created_at, feature_flags)'
)
.eq('user_id', userId);
if (!data) return;
type CollectiveRow = {
id: string;
name: string;
emoji: string;
created_at: string;
feature_flags: FeatureFlags;
};
const collectives = data
.map((row) => {
const c = row.collectives as
| { id: string; name: string; emoji: string; created_at: string; feature_flags: FeatureFlags | null }
| null;
return c
? {
id: c.id,
name: c.name,
emoji: c.emoji,
created_at: c.created_at,
feature_flags: (c.feature_flags ?? {}) as FeatureFlags
}
: null;
})
.filter((c): c is CollectiveRow => c !== null);
userCollectives.set(collectives);
// Restore last active collective from localStorage, or default to first
const savedId = localStorage.getItem('activeCollectiveId');
const active = collectives.find((c) => c.id === savedId) ?? collectives[0] ?? null;
currentCollective.set(active);
if (active) localStorage.setItem('activeCollectiveId', active.id);
}
</script>
<ParaglideJS {i18n}>
<slot />
</ParaglideJS>