172 lines
5.2 KiB
Vue
172 lines
5.2 KiB
Vue
<script setup lang="ts">
|
|
import { computed, onMounted, onUnmounted, ref } from "vue";
|
|
import type { Component } from "vue";
|
|
import {
|
|
Boxes,
|
|
ListChecks,
|
|
LogOut,
|
|
MonitorSmartphone,
|
|
Puzzle,
|
|
SlidersHorizontal,
|
|
UsersRound,
|
|
} from "@lucide/vue";
|
|
import {
|
|
AUTH_INVALID_EVENT,
|
|
getCurrentUser,
|
|
logout,
|
|
} from "./api";
|
|
import { hasScope } from "./permissions";
|
|
import type { CloudUser } from "./types";
|
|
import LoginScreen from "./views/LoginScreen.vue";
|
|
import PasswordChangeScreen from "./views/PasswordChangeScreen.vue";
|
|
import TasksView from "./views/TasksView.vue";
|
|
import DevicesView from "./views/DevicesView.vue";
|
|
import PluginsView from "./views/PluginsView.vue";
|
|
import UsersView from "./views/UsersView.vue";
|
|
import LlmProvidersView from "./views/LlmProvidersView.vue";
|
|
|
|
type ViewId = "tasks" | "devices" | "plugins" | "users" | "providers";
|
|
|
|
const activeView = ref<ViewId>("tasks");
|
|
const currentUser = ref<CloudUser | null>(null);
|
|
const loading = ref(true);
|
|
const authMessage = ref("");
|
|
|
|
const canAdminPlugins = computed(
|
|
() =>
|
|
currentUser.value?.scopes.includes("*") ||
|
|
currentUser.value?.scopes.includes("plugins:admin"),
|
|
);
|
|
const canSubmitTasks = computed(
|
|
() =>
|
|
currentUser.value?.scopes.includes("*") ||
|
|
currentUser.value?.scopes.includes("tasks:submit"),
|
|
);
|
|
const canAdminUsers = computed(
|
|
() => Boolean(
|
|
currentUser.value?.scopes.includes("*") ||
|
|
currentUser.value?.scopes.includes("users:admin")),
|
|
);
|
|
const canAdminGovernance = computed(
|
|
() => Boolean(
|
|
currentUser.value?.scopes.includes("*") ||
|
|
currentUser.value?.scopes.includes("governance:admin")),
|
|
);
|
|
const canAdminProviders = computed(() => hasScope(currentUser.value, "llm-providers:admin"));
|
|
const isAuthenticated = computed(() => currentUser.value !== null);
|
|
const currentUserLabel = computed(() =>
|
|
currentUser.value ? `${currentUser.value.display_name} (${currentUser.value.role})` : "",
|
|
);
|
|
const mustChangePassword = computed(() => currentUser.value?.must_change_password ?? false);
|
|
const navItems = computed<{ id: ViewId; label: string; icon: Component }[]>(() => {
|
|
const items: { id: ViewId; label: string; icon: Component }[] = [
|
|
{ id: "tasks", label: "Tasks", icon: ListChecks },
|
|
{ id: "devices", label: "Devices", icon: MonitorSmartphone },
|
|
{ id: "plugins", label: "Plugins", icon: Puzzle },
|
|
];
|
|
if (canAdminUsers.value || canAdminGovernance.value) {
|
|
items.push({ id: "users", label: "Users & limits", icon: UsersRound });
|
|
}
|
|
if (canAdminProviders.value) {
|
|
items.push({ id: "providers", label: "LLM providers", icon: SlidersHorizontal });
|
|
}
|
|
return items;
|
|
});
|
|
|
|
async function initializeAuthentication() {
|
|
loading.value = true;
|
|
currentUser.value = null;
|
|
try {
|
|
currentUser.value = await getCurrentUser();
|
|
} catch {
|
|
// A missing session is the normal initial state.
|
|
}
|
|
loading.value = false;
|
|
}
|
|
|
|
async function onAuthenticated() {
|
|
authMessage.value = "";
|
|
await initializeAuthentication();
|
|
}
|
|
|
|
function onAuthInvalid() {
|
|
currentUser.value = null;
|
|
authMessage.value = "your session expired. sign in again.";
|
|
}
|
|
|
|
async function signOut() {
|
|
try {
|
|
if (currentUser.value) await logout();
|
|
} catch {
|
|
// Local state must still be cleared when the already-expired session rejects logout.
|
|
}
|
|
currentUser.value = null;
|
|
authMessage.value = "";
|
|
}
|
|
|
|
function onPasswordChanged() {
|
|
currentUser.value = null;
|
|
authMessage.value = "password changed. sign in with the new password.";
|
|
}
|
|
|
|
onMounted(() => {
|
|
void initializeAuthentication();
|
|
window.addEventListener(AUTH_INVALID_EVENT, onAuthInvalid as EventListener);
|
|
});
|
|
|
|
onUnmounted(() => {
|
|
window.removeEventListener(AUTH_INVALID_EVENT, onAuthInvalid as EventListener);
|
|
});
|
|
|
|
const activeComponent = computed(() => {
|
|
switch (activeView.value) {
|
|
case "devices":
|
|
return DevicesView;
|
|
case "plugins":
|
|
return PluginsView;
|
|
case "users":
|
|
return UsersView;
|
|
case "providers":
|
|
return LlmProvidersView;
|
|
default:
|
|
return TasksView;
|
|
}
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<div v-if="loading" class="token-screen"><p>Checking session…</p></div>
|
|
<LoginScreen v-else-if="!isAuthenticated" :message="authMessage" @authenticated="onAuthenticated" />
|
|
<PasswordChangeScreen
|
|
v-else-if="mustChangePassword"
|
|
@changed="onPasswordChanged"
|
|
@sign-out="signOut"
|
|
/>
|
|
<div v-else class="app-shell">
|
|
<nav class="app-nav">
|
|
<h1><Boxes :size="14" /> Cloud Console</h1>
|
|
<button
|
|
v-for="item in navItems"
|
|
:key="item.id"
|
|
:class="{ active: activeView === item.id }"
|
|
@click="activeView = item.id"
|
|
>
|
|
<component :is="item.icon" :size="14" /> {{ item.label }}
|
|
</button>
|
|
<div class="spacer" />
|
|
<div class="dim">{{ currentUserLabel }}</div>
|
|
<button @click="signOut"><LogOut :size="14" /> Sign out</button>
|
|
</nav>
|
|
<main class="app-main">
|
|
<PluginsView v-if="activeView === 'plugins'" :can-admin="canAdminPlugins" />
|
|
<UsersView
|
|
v-else-if="activeView === 'users'"
|
|
:can-admin-users="canAdminUsers"
|
|
:can-admin-governance="canAdminGovernance"
|
|
/>
|
|
<LlmProvidersView v-else-if="activeView === 'providers'" :can-admin="canAdminProviders" />
|
|
<component v-else :is="activeComponent" :can-submit="canSubmitTasks" />
|
|
</main>
|
|
</div>
|
|
</template>
|