Newer
Older
smart-home-server / webclient / src / stores / auth.js
import { ref, computed } from "vue";
import { defineStore } from "pinia";
import { apiGet, apiPost } from "../api/client";
import { setAccessToken, clearAccessToken, getExpiresAt } from "../api/auth";

export const useAuthStore = defineStore("auth", () => {
  const user = ref(null);
  const permissions = ref([]);
  const isLoading = ref(false);
  const refreshTimer = ref(null);

  const isAuthenticated = computed(() => !!user.value);
  const permissionSet = computed(() => new Set(permissions.value));

  function hasPermission(slug) {
    return permissionSet.value.has(slug);
  }

  function hasAnyPermission(slugs) {
    if (!Array.isArray(slugs)) {
      return false;
    }
    return slugs.some((s) => permissionSet.value.has(s));
  }

  function scheduleProactiveRefresh() {
    if (refreshTimer.value) {
      clearTimeout(refreshTimer.value);
      refreshTimer.value = null;
    }
    const expiresAt = getExpiresAt();
    if (!expiresAt) {
      return;
    }
    const delay = expiresAt - Date.now() - 60_000; // refresh 60s before expiry
    if (delay > 0) {
      refreshTimer.value = setTimeout(() => {
        refreshToken().then(() => {
          scheduleProactiveRefresh();
        });
      }, delay);
    } else {
      // Already within 60s of expiry — refresh now
      refreshToken().then(() => {
        scheduleProactiveRefresh();
      });
    }
  }

  function cancelProactiveRefresh() {
    if (refreshTimer.value) {
      clearTimeout(refreshTimer.value);
      refreshTimer.value = null;
    }
  }

  let initPromise = null;

  async function init() {
    if (initPromise) {
      return initPromise;
    }

    isLoading.value = true;
    initPromise = apiGet("/auth/me")
      .then(async (result) => {
        if (result.ok) {
          const payload = result.data?.data || {};
          user.value = payload.user || null;
          permissions.value = payload.permissions || [];
          scheduleProactiveRefresh();
        } else if (result.error?.statusCode === 401) {
          await refreshToken();
          const retry = await apiGet("/auth/me");
          if (retry.ok) {
            const payload = retry.data?.data || {};
            user.value = payload.user || null;
            permissions.value = payload.permissions || [];
            scheduleProactiveRefresh();
          } else {
            user.value = null;
            permissions.value = [];
            clearAccessToken();
            cancelProactiveRefresh();
          }
        } else {
          user.value = null;
          permissions.value = [];
          clearAccessToken();
          cancelProactiveRefresh();
        }
      })
      .catch(() => {
        user.value = null;
        permissions.value = [];
        clearAccessToken();
        cancelProactiveRefresh();
      })
      .finally(() => {
        isLoading.value = false;
      });

    return initPromise;
  }

  async function refreshToken() {
    const result = await apiPost("/auth/refresh");
    if (result.ok) {
      const token = result.data?.data?.access_token || null;
      const expiresIn = result.data?.data?.expires_in || null;
      setAccessToken(token, expiresIn);
      scheduleProactiveRefresh();
    } else {
      clearAccessToken();
      cancelProactiveRefresh();
    }
  }

  async function logout() {
    try {
      await apiPost("/auth/logout");
    } catch {
      // ignore network errors during logout
    }
    user.value = null;
    permissions.value = [];
    clearAccessToken();
    cancelProactiveRefresh();
    window.location.href = "/#/login";
  }

  function redirectToLogin() {
    const returnTo = window.location.href;
    window.location.href = `/auth/login?return_to=${encodeURIComponent(returnTo)}`;
  }

  return {
    user,
    permissions,
    isLoading,
    isAuthenticated,
    hasPermission,
    hasAnyPermission,
    init,
    refreshToken,
    logout,
    redirectToLogin,
  };
});