mirror of
https://gitcode.com/JianFeeeee/webui4frpc.git
synced 2026-10-03 15:43:59 +00:00
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:
101
web/src/App.vue
101
web/src/App.vue
@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user