33800 Docs

← Retour

Pattern Auth Qwik avec plugin@auth.ts

Problème résolu

Le flash d'UI "non-connecté → connecté" quand on utilise useVisibleTask$ pour vérifier l'auth.

AVANT (problématique):
┌──────────────────────────────────────────────────────────────┐
│ 1. SSR render → loading:true, isAuthenticated:false          │
│ 2. HTML envoyé au navigateur (page non-connecté ou spinner)  │
│ 3. Hydration client                                          │
│ 4. useVisibleTask$ → fetch /api/auth/me avec cookie          │
│ 5. Réponse → isAuthenticated:true → FLASH vers connecté      │
└──────────────────────────────────────────────────────────────┘

APRÈS (corrigé):
┌──────────────────────────────────────────────────────────────┐
│ 1. plugin@auth.ts (onRequest) → lit cookie → appel API       │
│ 2. SSR render avec isAuthenticated déjà correct              │
│ 3. HTML envoyé (page correcte dès le départ)                 │
│ 4. Hydration client (pas de flash)                           │
└──────────────────────────────────────────────────────────────┘

Architecture

src/routes/
├── plugin@auth.ts      # S'exécute en PREMIER pour toutes les routes
├── layout.tsx          # routeLoader$ pour exposer sharedMap aux composants
└── index.tsx           # Utilise useContext(AuthContext)

Fichiers à créer/modifier

1. plugin@auth.ts (nouveau)

import type { RequestHandler } from '@builder.io/qwik-city';

const API_URL = process.env.PUBLIC_API_URL || 'https://api.example.com';

export interface UserSession {
  id: string;
  email: string;
}

export const onRequest: RequestHandler = async (requestEvent) => {
  const { cookie, sharedMap } = requestEvent;

  // Récupérer le cookie httpOnly
  const accessToken = cookie.get('access_token');

  if (!accessToken?.value) {
    sharedMap.set('user', null);
    sharedMap.set('isAuthenticated', false);
    return;
  }

  // Appeler l'API côté serveur avec le token
  try {
    const res = await fetch(`${API_URL}/api/auth/me`, {
      headers: {
        'Authorization': `Bearer ${accessToken.value}`,
        'Content-Type': 'application/json'
      }
    });

    const data = await res.json();

    if (data.success && data.user) {
      sharedMap.set('user', data.user);
      sharedMap.set('isAuthenticated', true);
    } else {
      sharedMap.set('user', null);
      sharedMap.set('isAuthenticated', false);
    }
  } catch (e) {
    sharedMap.set('user', null);
    sharedMap.set('isAuthenticated', false);
  }
};

2. layout.tsx (modifié)

import { component$, Slot, useContextProvider, createContextId, useSignal } from '@builder.io/qwik';
import { routeLoader$, useNavigate, Link } from '@builder.io/qwik-city';
import type { UserSession } from './plugin@auth';

export interface AuthState {
  user: UserSession | null;
  isAuthenticated: boolean;
}

export const AuthContext = createContextId<AuthState>('auth-context');

// routeLoader$ pour récupérer l'auth depuis sharedMap
export const useAuthLoader = routeLoader$<AuthState>(({ sharedMap }) => {
  const user = sharedMap.get('user') as UserSession | null;
  const isAuthenticated = sharedMap.get('isAuthenticated') as boolean;
  return {
    user: user || null,
    isAuthenticated: isAuthenticated || false
  };
});

export default component$(() => {
  const authData = useAuthLoader();

  // Signal pour gérer logout dynamique dans la session
  const loggedOut = useSignal(false);

  const authState: AuthState = {
    user: loggedOut.value ? null : authData.value.user,
    isAuthenticated: loggedOut.value ? false : authData.value.isAuthenticated
  };

  useContextProvider(AuthContext, authState);

  // ... reste du composant
});

3. Dans les pages (utilisation)

import { useContext } from '@builder.io/qwik';
import { AuthContext } from './layout';

export default component$(() => {
  const auth = useContext(AuthContext);

  // PAS de loading state - l'auth est déjà disponible
  if (!auth.isAuthenticated) {
    return <LoginPrompt />;
  }

  return <Dashboard user={auth.user} />;
});

Points clés

  1. plugin@auth.ts s'exécute AVANT tout le reste (même les onRequest des layouts)
  2. sharedMap est accessible partout côté serveur (onRequest, routeLoader$, server$)
  3. routeLoader$ expose les données au composant pour le SSR
  4. Pas de useVisibleTask$ pour le chargement initial de l'auth
  5. useVisibleTask$ uniquement pour les actions client (logout dans la session)

Pour protéger des routes (redirect si non auth)

// Dans le layout d'une section protégée (ex: /admin/layout.tsx)
export const onRequest: RequestHandler = async ({ sharedMap, redirect }) => {
  const isAuthenticated = sharedMap.get('isAuthenticated');
  if (!isAuthenticated) {
    throw redirect(302, '/auth/login');
  }
};

Accès dans server$

import { server$ } from '@builder.io/qwik-city';

const getData = server$(async function() {
  const user = this.sharedMap.get('user');
  const isAuthenticated = this.sharedMap.get('isAuthenticated');

  if (!isAuthenticated) {
    throw new Error('Unauthorized');
  }

  // ... logique avec user
});

Référence