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>

Reply via email to