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 aaf8f4c337082e7676f948d1848766830232b298 Author: Wu Sheng <[email protected]> AuthorDate: Tue May 12 10:18:34 2026 +0800 ui: login view with route guard and sign-out --- apps/ui/src/App.vue | 4 +- apps/ui/src/components/shell/AppSidebar.vue | 25 +++- apps/ui/src/router/index.ts | 34 ++++- apps/ui/src/views/auth/LoginView.vue | 191 ++++++++++++++++++++++++++++ 4 files changed, 245 insertions(+), 9 deletions(-) diff --git a/apps/ui/src/App.vue b/apps/ui/src/App.vue index 944e2c4..6da8e2d 100644 --- a/apps/ui/src/App.vue +++ b/apps/ui/src/App.vue @@ -15,9 +15,9 @@ limitations under the License. --> <script setup lang="ts"> -import AppShell from '@/components/shell/AppShell.vue'; +import { RouterView } from 'vue-router'; </script> <template> - <AppShell /> + <RouterView /> </template> diff --git a/apps/ui/src/components/shell/AppSidebar.vue b/apps/ui/src/components/shell/AppSidebar.vue index 0d63822..8fdc9be 100644 --- a/apps/ui/src/components/shell/AppSidebar.vue +++ b/apps/ui/src/components/shell/AppSidebar.vue @@ -16,8 +16,16 @@ --> <script setup lang="ts"> import { ref } from 'vue'; -import { RouterLink, useRoute } from 'vue-router'; +import { RouterLink, useRoute, useRouter } from 'vue-router'; import Icon, { type IconName } from '@/components/icons/Icon.vue'; +import { useAuthStore } from '@/stores/auth'; + +const auth = useAuthStore(); +const router = useRouter(); +async function signOut(): Promise<void> { + await auth.logout(); + await router.push({ name: 'login' }); +} // Phase 2 will replace this stub with real getMenuItems / listLayers data. const layers = ref([ @@ -124,11 +132,18 @@ const admin: NavRow[] = [ </nav> <div class="sw-side-foot"> - <div class="sw-avatar">SW</div> - <div style="line-height: 1.2"> - <div style="color: var(--sw-fg-0); font-weight: 600">guest</div> - <div>not signed in</div> + <div class="sw-avatar"> + {{ auth.user?.username ? auth.user.username.slice(0, 2).toUpperCase() : '?' }} + </div> + <div style="line-height: 1.2; flex: 1; min-width: 0; overflow: hidden"> + <div style="color: var(--sw-fg-0); font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap"> + {{ auth.user?.username ?? 'guest' }} + </div> + <div>{{ auth.user?.roles?.join(' · ') ?? 'not signed in' }}</div> </div> + <button v-if="auth.isAuthenticated" class="sw-btn is-icon" title="Sign out" @click="signOut"> + <Icon name="share" :size="12" /> + </button> </div> </aside> </template> diff --git a/apps/ui/src/router/index.ts b/apps/ui/src/router/index.ts index e5898dc..3ba0a0b 100644 --- a/apps/ui/src/router/index.ts +++ b/apps/ui/src/router/index.ts @@ -15,16 +15,46 @@ * limitations under the License. */ import { createRouter, createWebHistory } from 'vue-router'; +import { useAuthStore } from '@/stores/auth'; const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), routes: [ + { + path: '/login', + name: 'login', + component: () => import('@/views/auth/LoginView.vue'), + meta: { public: true }, + }, { path: '/', - name: 'landing', - component: () => import('@/views/landing/LandingView.vue'), + component: () => import('@/components/shell/AppShell.vue'), + children: [ + { + path: '', + name: 'home', + component: () => import('@/views/landing/LandingView.vue'), + }, + ], }, ], }); +let bootstrapped = false; + +router.beforeEach(async (to) => { + const auth = useAuthStore(); + if (!bootstrapped) { + await auth.bootstrap(); + bootstrapped = true; + } + const isPublic = to.meta.public === true; + if (!isPublic && !auth.isAuthenticated) { + return { name: 'login', query: { redirect: to.fullPath } }; + } + if (to.name === 'login' && auth.isAuthenticated) { + return { path: '/' }; + } +}); + export default router; diff --git a/apps/ui/src/views/auth/LoginView.vue b/apps/ui/src/views/auth/LoginView.vue new file mode 100644 index 0000000..dc46775 --- /dev/null +++ b/apps/ui/src/views/auth/LoginView.vue @@ -0,0 +1,191 @@ +<!-- + 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. +--> +<script setup lang="ts"> +import { ref } from 'vue'; +import { useRoute, useRouter } from 'vue-router'; +import Icon from '@/components/icons/Icon.vue'; +import { useAuthStore } from '@/stores/auth'; + +const auth = useAuthStore(); +const router = useRouter(); +const route = useRoute(); + +const username = ref(''); +const password = ref(''); +const submitting = ref(false); + +async function submit(): Promise<void> { + if (submitting.value) return; + submitting.value = true; + try { + const ok = await auth.login(username.value, password.value); + if (ok) { + const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/'; + await router.push(redirect); + } + } finally { + submitting.value = false; + } +} +</script> + +<template> + <div class="login-wrap"> + <form class="login-card" @submit.prevent="submit"> + <div class="brand"> + <div class="brand-mark"><Icon name="sky" :size="20" /></div> + <div> + <div class="brand-title">SkyWalking</div> + <div class="brand-sub">Horizon UI</div> + </div> + </div> + + <label class="field"> + <span>Username</span> + <input + v-model="username" + type="text" + name="username" + autocomplete="username" + autofocus + required + /> + </label> + + <label class="field"> + <span>Password</span> + <input + v-model="password" + type="password" + name="password" + autocomplete="current-password" + required + /> + </label> + + <div v-if="auth.loginError" class="error">{{ auth.loginError }}</div> + + <button class="sw-btn is-primary submit" type="submit" :disabled="submitting"> + {{ submitting ? 'Signing in…' : 'Sign in' }} + </button> + + <div class="foot"> + Local + LDAP auth. OIDC and SSO are out of scope for v1. + </div> + </form> + </div> +</template> + +<style scoped> +.login-wrap { + min-height: 100vh; + display: grid; + place-items: center; + background: + radial-gradient(1200px 600px at 20% 10%, rgba(249, 115, 22, 0.06), transparent 60%), + radial-gradient(900px 500px at 100% 90%, rgba(168, 85, 247, 0.06), transparent 60%), + var(--sw-bg-0); +} +.login-card { + width: 360px; + background: var(--sw-bg-1); + border: 1px solid var(--sw-line); + border-radius: 10px; + padding: 24px 24px 18px; + box-shadow: 0 24px 60px -24px rgba(0, 0, 0, 0.6); +} +.brand { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 22px; +} +.brand-mark { + width: 36px; + height: 36px; + display: grid; + place-items: center; + border-radius: 8px; + background: linear-gradient(135deg, var(--sw-accent) 0%, #d946ef 110%); + color: #fff; + box-shadow: + inset 0 0 0 1px rgba(255, 255, 255, 0.05), + 0 12px 28px -10px var(--sw-accent); +} +.brand-title { + font-size: 15px; + font-weight: 600; + letter-spacing: -0.01em; +} +.brand-sub { + font-size: 11px; + color: var(--sw-fg-2); +} +.field { + display: block; + margin-bottom: 12px; +} +.field span { + display: block; + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--sw-fg-2); + margin-bottom: 6px; +} +.field input { + width: 100%; + height: 32px; + padding: 0 10px; + background: var(--sw-bg-2); + border: 1px solid var(--sw-line-2); + border-radius: 6px; + color: var(--sw-fg-0); + font: inherit; + font-size: 13px; + outline: none; + transition: border-color 0.1s; +} +.field input:focus { + border-color: var(--sw-accent-line); +} +.error { + margin: 8px 0 12px; + padding: 8px 10px; + background: var(--sw-err-soft); + color: #f87171; + border: 1px solid rgba(239, 68, 68, 0.3); + border-radius: 6px; + font-size: 12px; +} +.submit { + width: 100%; + height: 34px; + margin-top: 6px; + font-size: 13px; +} +.submit:disabled { + opacity: 0.6; + cursor: not-allowed; +} +.foot { + margin-top: 14px; + font-size: 11px; + color: var(--sw-fg-3); + text-align: center; +} +</style>
