This is an automated email from the ASF dual-hosted git repository.

wu-sheng pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/skywalking-horizon-ui.git

commit bf32f086d9dba4227b2ce1c02448f322e998b33a
Author: Wu Sheng <[email protected]>
AuthorDate: Tue May 12 10:17:34 2026 +0800

    ui: vue-query plus pinia auth store with on-401 redirect
---
 apps/ui/src/api/client.ts  | 99 ++++++++++++++++++++++++++++++++++++++++++++++
 apps/ui/src/main.ts        | 29 +++++++++++++-
 apps/ui/src/stores/auth.ts | 85 +++++++++++++++++++++++++++++++++++++++
 3 files changed, 212 insertions(+), 1 deletion(-)

diff --git a/apps/ui/src/api/client.ts b/apps/ui/src/api/client.ts
new file mode 100644
index 0000000..fbc2427
--- /dev/null
+++ b/apps/ui/src/api/client.ts
@@ -0,0 +1,99 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export interface MeResponse {
+  username: string;
+  roles: string[];
+  verbs: string[];
+}
+
+export class BffApiError extends Error {
+  readonly status: number;
+  readonly body: unknown;
+  constructor(status: number, message: string, body: unknown) {
+    super(message);
+    this.name = 'BffApiError';
+    this.status = status;
+    this.body = body;
+  }
+}
+
+type On401 = () => void;
+
+export class BffClient {
+  private on401: On401 | null = null;
+
+  setOn401(fn: On401): void {
+    this.on401 = fn;
+  }
+
+  private async request<T>(
+    method: string,
+    path: string,
+    body?: unknown,
+    headers?: Record<string, string>,
+  ): Promise<T> {
+    const init: RequestInit = {
+      method,
+      credentials: 'include',
+      headers: { ...(body !== undefined ? { 'content-type': 'application/json' 
} : {}), ...headers },
+    };
+    if (body !== undefined) init.body = JSON.stringify(body);
+    const res = await fetch(path, init);
+    if (res.status === 401) {
+      this.on401?.();
+      throw new BffApiError(401, 'unauthenticated', null);
+    }
+    if (!res.ok) {
+      let parsed: unknown = null;
+      try {
+        parsed = await res.json();
+      } catch {
+        parsed = await res.text();
+      }
+      throw new BffApiError(res.status, `${method} ${path} failed 
(${res.status})`, parsed);
+    }
+    if (res.status === 204) return undefined as T;
+    const ct = res.headers.get('content-type') ?? '';
+    if (ct.includes('application/json')) return (await res.json()) as T;
+    return (await res.text()) as unknown as T;
+  }
+
+  // ── auth ─────────────────────────────────────────────────────────────
+  login(username: string, password: string): Promise<MeResponse> {
+    return this.request<MeResponse>('POST', '/api/auth/login', { username, 
password });
+  }
+
+  logout(): Promise<{ status: 'ok' }> {
+    return this.request<{ status: 'ok' }>('POST', '/api/auth/logout');
+  }
+
+  me(): Promise<MeResponse> {
+    return this.request<MeResponse>('GET', '/api/auth/me');
+  }
+
+  // ── cluster / preflight ──────────────────────────────────────────────
+  preflight(): Promise<unknown> {
+    return this.request('GET', '/api/preflight');
+  }
+
+  clusterState(): Promise<unknown> {
+    return this.request('GET', '/api/cluster/state');
+  }
+}
+
+export const bffClient = new BffClient();
diff --git a/apps/ui/src/main.ts b/apps/ui/src/main.ts
index adaaf72..0025101 100644
--- a/apps/ui/src/main.ts
+++ b/apps/ui/src/main.ts
@@ -16,14 +16,41 @@
  */
 import { createApp } from 'vue';
 import { createPinia } from 'pinia';
+import { VueQueryPlugin, QueryClient } from '@tanstack/vue-query';
 
 import App from './App.vue';
 import router from './router';
+import { bffClient } from './api/client';
+import { useAuthStore } from './stores/auth';
 
 import '@skywalking-horizon-ui/design-tokens/tokens.css';
 import './assets/styles/global.css';
 
+const queryClient = new QueryClient({
+  defaultOptions: {
+    queries: {
+      staleTime: 5_000,
+      refetchOnWindowFocus: true,
+      retry: 1,
+    },
+  },
+});
+
 const app = createApp(App);
-app.use(createPinia());
+const pinia = createPinia();
+app.use(pinia);
 app.use(router);
+app.use(VueQueryPlugin, { queryClient });
+
+// Mid-session 401 → clear auth state and bounce to login while preserving the
+// current path so the user can be returned after re-auth.
+bffClient.setOn401(() => {
+  const auth = useAuthStore();
+  auth.$patch({ user: null });
+  const redirect = router.currentRoute.value.fullPath;
+  if (router.currentRoute.value.name !== 'login') {
+    void router.push({ name: 'login', query: { redirect } });
+  }
+});
+
 app.mount('#app');
diff --git a/apps/ui/src/stores/auth.ts b/apps/ui/src/stores/auth.ts
new file mode 100644
index 0000000..35ab1ff
--- /dev/null
+++ b/apps/ui/src/stores/auth.ts
@@ -0,0 +1,85 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { defineStore } from 'pinia';
+import { computed, ref } from 'vue';
+import { BffApiError, bffClient, type MeResponse } from '@/api/client';
+
+export const useAuthStore = defineStore('auth', () => {
+  const user = ref<MeResponse | null>(null);
+  const bootstrapping = ref(true);
+  const loginError = ref<string | null>(null);
+
+  async function bootstrap(): Promise<void> {
+    bootstrapping.value = true;
+    try {
+      user.value = await bffClient.me();
+    } catch {
+      user.value = null;
+    } finally {
+      bootstrapping.value = false;
+    }
+  }
+
+  async function login(username: string, password: string): Promise<boolean> {
+    loginError.value = null;
+    try {
+      user.value = await bffClient.login(username, password);
+      return true;
+    } catch (err) {
+      if (err instanceof BffApiError && err.status === 401) {
+        loginError.value = 'Invalid username or password.';
+      } else {
+        loginError.value = err instanceof Error ? err.message : 'login failed';
+      }
+      user.value = null;
+      return false;
+    }
+  }
+
+  async function logout(): Promise<void> {
+    try {
+      await bffClient.logout();
+    } catch {
+      // swallow — even if logout fails we clear local state
+    }
+    user.value = null;
+  }
+
+  function hasVerb(verb: string): boolean {
+    const grants = user.value?.verbs ?? [];
+    for (const g of grants) {
+      if (g === '*' || g === verb) return true;
+      const [ga, gact] = g.split(':', 2);
+      const [ra, ract] = verb.split(':', 2);
+      if (gact === '*' && ga === ra) return true;
+      if (ga === '*' && gact === ract) return true;
+    }
+    return false;
+  }
+
+  return {
+    user,
+    bootstrapping,
+    loginError,
+    isAuthenticated: computed(() => user.value !== null),
+    bootstrap,
+    login,
+    logout,
+    hasVerb,
+  };
+});

Reply via email to