feat: session-cookie login/logout + three-tier roles (superadmin/admin/viewer) with read-only UI + remove pink theme

- cookie-based auth (/login /logout) replacing Basic Auth for UI, enabling logout
- roles: superadmin (account management only), admin (full except accounts), viewer/audit (read-only status+cluster, export logs)
- readonly accounts hide edit buttons (added remote node, group ops, canvas layout/save/import, cluster manage, install) instead of greying them
- auditors see status+cluster only; ordinary admins lose the accounts nav; last-admin guard covers superadmin
- remove pink theme entirely (switcher, [data-theme=pink], leftover localStorage), keep white/blue
This commit is contained in:
2026-08-20 09:08:07 +08:00
parent b39bd427fa
commit f29ec81e4a
27 changed files with 1195 additions and 724 deletions

View File

@ -10,7 +10,8 @@
<i class="blob b1"></i><i class="blob b2"></i><i class="blob b3"></i>
</div>
<div class="app-shell">
<LoginView v-if="authReady && !isAuthed" />
<div v-else-if="authReady" class="app-shell">
<aside class="sidebar">
<div class="sb-brand">
<img class="sb-logo" src="/favicon.svg" alt="webui4frpc" />
@ -37,11 +38,11 @@
<div class="sb-identity" v-if="authReady">
<span class="id-name">{{ authName || '匿名' }}</span>
<span class="id-level" :class="authLevel">{{ levelLabel }}</span>
<button class="sb-logout" title="退出登录" @click="onLogout">退出</button>
</div>
<div class="sb-theme" role="group" aria-label="主题">
<button class="sw white" :class="{ on: theme === 'white' }" title="白色" @click="setTheme('white')" />
<button class="sw blue" :class="{ on: theme === 'blue' }" title="蓝色" @click="setTheme('blue')" />
<button class="sw pink" :class="{ on: theme === 'pink' }" title="粉色" @click="setTheme('pink')" />
</div>
</div>
</aside>
@ -59,31 +60,45 @@
</main>
</div>
</div>
<div v-else class="boot">加载中…</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
import CanvasView from './views/CanvasView.vue'
import SettingsView from './views/SettingsView.vue'
import StatusView from './views/StatusView.vue'
import ClusterView from './views/ClusterView.vue'
import UsersView from './views/UsersView.vue'
import { authLevel, authName, authReady, fetchMe, isAdmin } from './auth'
import LoginView from './views/LoginView.vue'
import { authLevel, authName, authReady, canWrite, fetchMe, isAuthed, isSuperAdmin, logout } from './auth'
type ViewKey = 'canvas' | 'settings' | 'status' | 'cluster' | 'users'
const view = ref<ViewKey>('status')
onMounted(() => { fetchMe() })
const levelLabel = computed(() =>
authLevel.value === 'admin' ? '管理员' : authLevel.value === 'write' ? '写' : '只读',
)
// logout clears the session cookie and drops back to the login page.
async function onLogout() {
await logout()
view.value = 'status'
}
// ---- theme switcher (white default / blue / pink) ----
const levelLabel = computed(() => {
switch (authLevel.value) {
case 'superadmin': return '超级管理员'
case 'admin': return '管理员'
case 'write': return '写'
default: return '只读'
}
})
// ---- theme switcher (white default / blue) ----
// The data-theme attribute is applied pre-mount by main.ts (no flash); here we
// only mirror it so the active swatch highlights, and update it on click.
type Theme = 'white' | 'blue' | 'pink'
const VALID_THEMES: Theme[] = ['white', 'blue', 'pink']
type Theme = 'white' | 'blue'
const VALID_THEMES: Theme[] = ['white', 'blue']
const theme = ref<Theme>(
(VALID_THEMES as string[]).includes(document.documentElement.getAttribute('data-theme') || '')
? (document.documentElement.getAttribute('data-theme') as Theme)
@ -95,20 +110,32 @@ const setTheme = (t: Theme) => {
localStorage.setItem('w4f-theme', t)
}
const allNav: { key: ViewKey; label: string; icon: string; adminOnly?: boolean }[] = [
const allNav: { key: ViewKey; label: string; icon: string; writeOnly?: boolean; superAdminOnly?: boolean }[] = [
{ key: 'status', label: '状态', icon: '<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 3v18h18"/><path d="m19 9-5 5-4-4-3 3"/></svg>' },
{ key: 'canvas', label: '连接配置', icon: '<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="6" r="3"/><circle cx="18" cy="6" r="3"/><circle cx="12" cy="18" r="3"/><path d="M8.5 7.5 16 16M15.5 7.5 8 16"/></svg>' },
{ key: 'settings', label: '设置', icon: '<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>' },
{ key: 'canvas', label: '连接配置', writeOnly: true, icon: '<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="6" r="3"/><circle cx="18" cy="6" r="3"/><circle cx="12" cy="18" r="3"/><path d="M8.5 7.5 16 16M15.5 7.5 8 16"/></svg>' },
{ key: 'settings', label: '设置', writeOnly: true, icon: '<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>' },
{ key: 'cluster', label: '集群', icon: '<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="5" r="2"/><circle cx="5" cy="19" r="2"/><circle cx="19" cy="19" r="2"/><path d="M12 7v4m0 0-5 6m5-6 5 6"/></svg>' },
{ key: 'users', label: '账号与密钥', adminOnly: true, icon: '<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>' },
{ key: 'users', label: '账号与密钥', superAdminOnly: true, icon: '<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>' },
]
// nav filters admin-only entries (账号与密钥) until /me resolves to admin.
// Until authReady, the users entry is hidden so a viewer never sees it flash.
// nav is filtered by role:
// - canvas/settings need write (viewers/auditors never see them),
// - users needs superadmin (ordinary admins cannot manage accounts),
// - status + cluster are visible to everyone (auditors view + export logs).
const nav = computed(() =>
allNav.filter((n) => !n.adminOnly || (authReady.value && isAdmin.value)),
allNav.filter((n) => {
if (n.superAdminOnly) return authReady.value && isSuperAdmin.value
if (n.writeOnly) return canWrite.value
return true
}),
)
// When the account level drops a page (e.g. a viewer logs in while the canvas
// was open), fall back to the first visible page so we never render a hidden one.
watch(nav, (items) => {
if (!items.some((n) => n.key === view.value)) view.value = 'status'
})
const currentLabel = computed(() => nav.value.find((n) => n.key === view.value)?.label ?? '')
</script>
@ -221,8 +248,8 @@ const currentLabel = computed(() => nav.value.find((n) => n.key === view.value)?
.sb-identity {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
justify-content: flex-start;
gap: 6px;
padding: 8px 10px;
margin-bottom: 12px;
border-radius: 10px;
@ -231,6 +258,7 @@ const currentLabel = computed(() => nav.value.find((n) => n.key === view.value)?
font-size: 12.5px;
}
.sb-identity .id-name {
margin-right: auto;
font-weight: 600;
color: var(--w4f-fg);
overflow: hidden;
@ -250,6 +278,10 @@ const currentLabel = computed(() => nav.value.find((n) => n.key === view.value)?
color: #fff;
background: linear-gradient(135deg, var(--w4f-primary), var(--w4f-secondary));
}
.sb-identity .id-level.superadmin {
color: #fff;
background: linear-gradient(135deg, var(--w4f-secondary), var(--w4f-primary));
}
.sb-identity .id-level.write {
color: var(--w4f-warning);
background: var(--w4f-warning-50);
@ -257,6 +289,36 @@ const currentLabel = computed(() => nav.value.find((n) => n.key === view.value)?
.sb-identity .id-level.read {
color: var(--w4f-muted);
}
.sb-logout {
flex: 0 0 auto;
padding: 3px 9px;
border-radius: 7px;
border: 1px solid var(--w4f-line-strong);
background: transparent;
color: var(--w4f-muted);
font-size: 11px;
font-weight: 600;
font-family: inherit;
cursor: pointer;
transition: color 0.15s var(--w4f-ease), border-color 0.15s var(--w4f-ease),
background 0.15s var(--w4f-ease);
}
.sb-logout:hover {
color: var(--w4f-danger);
border-color: var(--w4f-danger);
background: var(--w4f-danger-50);
}
/* ---------- boot splash (shown while /me resolves) ---------- */
.boot {
position: fixed;
inset: 0;
display: grid;
place-items: center;
color: var(--w4f-muted);
font-family: var(--w4f-font);
font-size: 13px;
}
/* ---------- theme switcher ---------- */
.sb-theme { display: flex; gap: 6px; margin-bottom: 12px; }
@ -270,7 +332,6 @@ const currentLabel = computed(() => nav.value.find((n) => n.key === view.value)?
/* swatch fills are FIXED (represent each theme's identity), not theme-driven */
.sb-theme .sw.white { background: linear-gradient(135deg, #ffffff, #cdd4e0); }
.sb-theme .sw.blue { background: linear-gradient(135deg, #3b82f6, #06b6d4); }
.sb-theme .sw.pink { background: linear-gradient(135deg, #ff7fac, #f33b7c); }
/* ---------- main ---------- */
.main {