diff --git a/server/SHServ/Integrations/GAuth/AuthControllerTrait.php b/server/SHServ/Integrations/GAuth/AuthControllerTrait.php index 95dfb4a..1756ce9 100644 --- a/server/SHServ/Integrations/GAuth/AuthControllerTrait.php +++ b/server/SHServ/Integrations/GAuth/AuthControllerTrait.php @@ -14,7 +14,7 @@ */ protected function require_auth(): ?string { - if (!isset($_SESSION['shserv_auth_token'])) { + if (!$this->resolve_user()) { return $this->utils()->response_error('unauthenticated', [], [], 401); } return null; @@ -30,7 +30,7 @@ return $authError; } - $user = $this->get_current_user(); + $user = $this->resolve_user(); if (!$user) { return $this->utils()->response_error('unauthenticated', [], [], 401); } @@ -44,18 +44,70 @@ } /** - * Get current user data from session. + * Resolve current user from session or Bearer token. + */ + protected function resolve_user(): ?array + { + // 1. Session-based auth + $sessionUserId = $_SESSION['shserv_user_id'] ?? null; + if ($sessionUserId) { + return $this->load_user_by_id((int) $sessionUserId); + } + + // 2. Bearer token auth + $bearer = $this->get_bearer_token(); + if ($bearer) { + return $this->resolve_user_by_bearer($bearer); + } + + return null; + } + + /** + * Extract Bearer token from Authorization header. + */ + protected function get_bearer_token(): ?string + { + $header = $_SERVER['HTTP_AUTHORIZATION'] ?? ''; + if (str_starts_with($header, 'Bearer ')) { + return substr($header, 7); + } + return null; + } + + /** + * Resolve user by OAuth access token via local session table. + */ + protected function resolve_user_by_bearer(string $token): ?array + { + $tb = app()->thin_builder; + $result = $tb->select('shserv_sessions', ['user_id'], [['access_token', '=', $token]]); + if ($result) { + return $this->load_user_by_id((int) $result[0]['user_id']); + } + return null; + } + + /** + * Load user row from shserv_users by local ID. + */ + protected function load_user_by_id(int $userId): ?array + { + $tb = app()->thin_builder; + $result = $tb->select( + 'shserv_users', + ['id', 'gauth_user_id', 'email', 'display_name', 'avatar_url', 'system_role', 'status'], + [['id', '=', $userId]] + ); + return $result ? $result[0] : null; + } + + /** + * Get current user data (session or Bearer). */ protected function get_current_user(): ?array { - $userId = $_SESSION['shserv_user_id'] ?? null; - if (!$userId) { - return null; - } - - $tb = app()->thin_builder; - $result = $tb->select('shserv_users', ['id', 'gauth_user_id', 'email', 'display_name', 'avatar_url', 'system_role', 'status'], [['id', '=', $userId]]); - return $result ? $result[0] : null; + return $this->resolve_user(); } /** @@ -63,7 +115,7 @@ */ protected function get_current_permissions(): array { - $user = $this->get_current_user(); + $user = $this->resolve_user(); if (!$user) { return []; } diff --git a/webclient/src/api/auth.js b/webclient/src/api/auth.js new file mode 100644 index 0000000..8196716 --- /dev/null +++ b/webclient/src/api/auth.js @@ -0,0 +1,24 @@ +let _accessToken = null; + +/** + * Set the current OAuth access token for Bearer authentication. + * @param {string|null} token + */ +export function setAccessToken(token) { + _accessToken = token || null; +} + +/** + * Get the current access token. + * @returns {string|null} + */ +export function getAccessToken() { + return _accessToken; +} + +/** + * Clear the stored access token. + */ +export function clearAccessToken() { + _accessToken = null; +} diff --git a/webclient/src/api/client.js b/webclient/src/api/client.js index b54fab5..cdfa312 100644 --- a/webclient/src/api/client.js +++ b/webclient/src/api/client.js @@ -1,4 +1,5 @@ import { requestHttp } from "./http"; +import { clearAccessToken } from "./auth"; function makeError(type, message, extra = {}) { return { @@ -13,6 +14,14 @@ const { response, data, meta } = await requestHttp(method, path, body, options); if (!response.ok) { + if (response.status === 401) { + clearAccessToken(); + const isLoginPage = window.location.hash.includes("/login"); + if (!isLoginPage) { + window.location.href = `/auth/login?return_to=${encodeURIComponent(window.location.hash || "#/")}`; + } + } + return { ok: false, error: makeError("http_error", `HTTP ${response.status}`, { diff --git a/webclient/src/api/http.js b/webclient/src/api/http.js index fb70dfd..c01dda4 100644 --- a/webclient/src/api/http.js +++ b/webclient/src/api/http.js @@ -1,3 +1,5 @@ +import { getAccessToken } from "./auth"; + const DEFAULT_TIMEOUT_MS = Number(import.meta.env.VITE_API_TIMEOUT_MS || 10000); function buildQuery(params) { @@ -48,6 +50,11 @@ ...(options.headers || {}), }; + const token = getAccessToken(); + if (token) { + headers["Authorization"] = `Bearer ${token}`; + } + const init = { method, headers, diff --git a/webclient/src/app/main.js b/webclient/src/app/main.js index 17f597f..28658a0 100644 --- a/webclient/src/app/main.js +++ b/webclient/src/app/main.js @@ -3,6 +3,7 @@ import App from "./App.vue"; import { router } from "../router"; import { useGlobalErrorHandler } from "../composables/useGlobalErrorHandler"; +import { useAuthStore } from "../stores/auth.js"; import "@phosphor-icons/web/regular"; import "@phosphor-icons/web/fill"; import "../styles/main.css"; @@ -11,4 +12,11 @@ useGlobalErrorHandler(app); -app.use(createPinia()).use(router).mount("#app"); +const pinia = createPinia(); +app.use(pinia).use(router); + +// Initialize auth before mounting so router guards have valid state +const authStore = useAuthStore(); +authStore.init().finally(() => { + app.mount("#app"); +}); diff --git a/webclient/src/components/layout/AppShell.vue b/webclient/src/components/layout/AppShell.vue index 869dcd1..fefde8f 100644 --- a/webclient/src/components/layout/AppShell.vue +++ b/webclient/src/components/layout/AppShell.vue @@ -9,7 +9,33 @@ :current="pageTitle" > @@ -17,11 +43,14 @@ + + diff --git a/webclient/src/components/layout/__tests__/AppShell.spec.js b/webclient/src/components/layout/__tests__/AppShell.spec.js index c3ee40f..aeff95c 100644 --- a/webclient/src/components/layout/__tests__/AppShell.spec.js +++ b/webclient/src/components/layout/__tests__/AppShell.spec.js @@ -1,29 +1,51 @@ import { describe, it, expect } from "vitest"; import { mount } from "@vue/test-utils"; +import { createPinia } from "pinia"; +import { createRouter, createWebHashHistory } from "vue-router"; import AppShell from "../AppShell.vue"; +import { useAuthStore } from "../../../stores/auth.js"; + +function createTestRouter() { + return createRouter({ + history: createWebHashHistory(), + routes: [{ path: "/", name: "home", component: { template: "
Home
" } }], + }); +} describe("AppShell", () => { - it("renders brand name", () => { - const wrapper = mount(AppShell, { - slots: { default: "Content" }, + function mountShell(options = {}) { + const pinia = createPinia(); + const authStore = useAuthStore(pinia); + authStore.$patch({ + user: { id: 1, display_name: "Test" }, + permissions: [ + "areas.view", "areas.manage", + "devices.view", "devices.scan", "devices.setup", "devices.control", "devices.edit", "devices.delete", + "scripts.view", "scripts.run", "scripts.edit", + "firmware.view", "firmware.upload", + ], }); + const router = createTestRouter(); + return mount(AppShell, { + global: { + plugins: [pinia, router], + }, + slots: { default: options.slot || "Content" }, + }); + } + it("renders brand name", () => { + const wrapper = mountShell({ slot: "Content" }); expect(wrapper.text()).toContain("SHSERV WEB CLIENT"); }); it("renders slot content", () => { - const wrapper = mount(AppShell, { - slots: { default: "

Page Content

" }, - }); - + const wrapper = mountShell({ slot: "

Page Content

" }); expect(wrapper.text()).toContain("Page Content"); }); it("renders navigation items with icons", () => { - const wrapper = mount(AppShell, { - slots: { default: "" }, - }); - + const wrapper = mountShell(); expect(wrapper.text()).toContain("Favorites"); expect(wrapper.text()).toContain("Areas"); expect(wrapper.text()).toContain("Devices"); diff --git a/webclient/src/composables/usePermission.js b/webclient/src/composables/usePermission.js new file mode 100644 index 0000000..66d6596 --- /dev/null +++ b/webclient/src/composables/usePermission.js @@ -0,0 +1,11 @@ +import { useAuthStore } from "../stores/auth.js"; + +export function usePermission() { + const authStore = useAuthStore(); + + return { + has: (slug) => authStore.hasPermission(slug), + hasAny: (slugs) => authStore.hasAnyPermission(slugs), + permissions: authStore.permissions, + }; +} diff --git a/webclient/src/features/auth/pages/LoginPage.vue b/webclient/src/features/auth/pages/LoginPage.vue new file mode 100644 index 0000000..df541be --- /dev/null +++ b/webclient/src/features/auth/pages/LoginPage.vue @@ -0,0 +1,50 @@ + + + + + diff --git a/webclient/src/router/index.js b/webclient/src/router/index.js index c3d88dd..a1ff4f4 100644 --- a/webclient/src/router/index.js +++ b/webclient/src/router/index.js @@ -1,7 +1,42 @@ import { createRouter, createWebHashHistory } from "vue-router"; import { routes } from "./routes"; +import { useAuthStore } from "../stores/auth.js"; export const router = createRouter({ history: createWebHashHistory(), routes, }); + +router.beforeEach((to, from, next) => { + const authStore = useAuthStore(); + + // Allow public routes unconditionally + if (to.meta?.public) { + if (to.name === "login" && authStore.isAuthenticated) { + next({ name: "areas-favorites" }); + return; + } + next(); + return; + } + + // Require authentication + if (!authStore.isAuthenticated) { + if (authStore.isLoading) { + // Wait briefly then re-evaluate (should not happen if init() runs before mount) + next({ name: "login" }); + return; + } + next({ name: "login" }); + return; + } + + // Check route-level permission + const required = to.meta?.permission; + if (required && !authStore.hasPermission(required)) { + next({ name: "areas-favorites" }); + return; + } + + next(); +}); diff --git a/webclient/src/router/routes.js b/webclient/src/router/routes.js index 63c423a..6ff3902 100644 --- a/webclient/src/router/routes.js +++ b/webclient/src/router/routes.js @@ -9,6 +9,7 @@ import ScriptsScopesPage from "../features/scripts/pages/ScriptsScopesPage.vue"; import ScriptDetailPage from "../features/scripts/pages/ScriptDetailPage.vue"; import FirmwaresListPage from "../features/firmwares/pages/FirmwaresListPage.vue"; +import LoginPage from "../features/auth/pages/LoginPage.vue"; export const routes = [ { @@ -16,59 +17,76 @@ redirect: "/areas/favorites", }, { + path: "/login", + name: "login", + component: LoginPage, + meta: { public: true }, + }, + { path: "/areas/favorites", name: "areas-favorites", component: AreaFavoritesPage, + meta: { permission: "areas.view" }, }, { path: "/areas/tree", name: "areas-tree", component: AreaTreePage, + meta: { permission: "areas.view" }, }, { path: "/areas/:id", name: "area-detail", component: AreaDetailPage, + meta: { permission: "areas.view" }, }, { path: "/devices", name: "devices", component: DevicesListPage, + meta: { permission: "devices.view" }, }, { path: "/devices/scanning", name: "devices-scanning", component: DevicesScanningPage, + meta: { permission: "devices.scan" }, }, { path: "/devices/:id", name: "device-detail", component: DeviceDetailPage, + meta: { permission: "devices.view" }, }, { path: "/scripts/actions", name: "scripts-actions", component: ScriptsActionsPage, + meta: { permission: "scripts.run" }, }, { path: "/scripts/regular", name: "scripts-regular", component: ScriptsRegularPage, + meta: { permission: "scripts.view" }, }, { path: "/scripts/scopes", name: "scripts-scopes", component: ScriptsScopesPage, + meta: { permission: "scripts.view" }, }, { path: "/scripts/:type(actions|regular|scopes)/:id", name: "script-detail", component: ScriptDetailPage, + meta: { permission: "scripts.view" }, }, { path: "/firmwares", name: "firmwares", component: FirmwaresListPage, + meta: { permission: "firmware.view" }, }, { path: "/:pathMatch(.*)*", diff --git a/webclient/src/stores/auth.js b/webclient/src/stores/auth.js new file mode 100644 index 0000000..9bdf50d --- /dev/null +++ b/webclient/src/stores/auth.js @@ -0,0 +1,86 @@ +import { ref, computed } from "vue"; +import { defineStore } from "pinia"; +import { apiGet, apiPost } from "../api/client"; +import { setAccessToken, clearAccessToken } from "../api/auth"; + +export const useAuthStore = defineStore("auth", () => { + const user = ref(null); + const permissions = ref([]); + const isLoading = ref(false); + + 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)); + } + + async function init() { + isLoading.value = true; + try { + const result = await apiGet("/auth/me"); + if (result.ok) { + const payload = result.data?.data || {}; + user.value = payload.user || null; + permissions.value = payload.permissions || []; + await refreshToken(); + } else { + user.value = null; + permissions.value = []; + clearAccessToken(); + } + } catch { + user.value = null; + permissions.value = []; + clearAccessToken(); + } finally { + isLoading.value = false; + } + } + + async function refreshToken() { + const result = await apiPost("/auth/refresh"); + if (result.ok) { + setAccessToken(result.data?.data?.access_token || null); + } else { + clearAccessToken(); + } + } + + async function logout() { + try { + await apiPost("/auth/logout"); + } catch { + // ignore network errors during logout + } + user.value = null; + permissions.value = []; + clearAccessToken(); + window.location.href = "/auth/login"; + } + + function redirectToLogin() { + const returnTo = window.location.hash || "#/"; + window.location.href = `/auth/login?return_to=${encodeURIComponent(returnTo)}`; + } + + return { + user, + permissions, + isLoading, + isAuthenticated, + hasPermission, + hasAnyPermission, + init, + refreshToken, + logout, + redirectToLogin, + }; +}); diff --git a/webclient/src/test/mocks/handlers.js b/webclient/src/test/mocks/handlers.js index e5c5eaf..53923ea 100644 --- a/webclient/src/test/mocks/handlers.js +++ b/webclient/src/test/mocks/handlers.js @@ -6,6 +6,29 @@ const url = new URL(request.url); const path = url.searchParams.get("path"); + if (path === "/auth/me") { + return HttpResponse.json({ + status: true, + data: { + user: { + id: 1, + gauth_user_id: "u_123", + email: "test@example.com", + display_name: "Test User", + avatar_url: "", + system_role: "admin", + status: "active", + }, + permissions: [ + "areas.view", "areas.manage", + "devices.view", "devices.scan", "devices.setup", "devices.control", "devices.edit", "devices.delete", + "scripts.view", "scripts.run", "scripts.edit", + "firmware.view", "firmware.upload", + ], + }, + }); + } + if (path === "/api/v1/areas/list") { return HttpResponse.json({ status: true, @@ -230,6 +253,20 @@ const path = url.searchParams.get("path"); const body = await request.json().catch(() => ({})); + if (path === "/auth/logout") { + return HttpResponse.json({ status: true }); + } + + if (path === "/auth/refresh") { + return HttpResponse.json({ + status: true, + data: { + access_token: "mock_access_token_12345", + expires_in: 900, + }, + }); + } + if (path === "/api/v1/areas/new-area") { return HttpResponse.json({ status: true,