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"
>
-
+
+
+
+
+ {{ authStore.user.display_name }}
+
+
+
+
+
+ Logout
+
+
+
+
+
+
+ Login
+
+
+
+
@@ -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 @@
+
+
+
+
+
+
+
+
+ Sign In
+
+
+
+
+
+
+
+
+
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,