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, + }; +});
