@@ -7,6 +7,7 @@ import {
|
||||
LogOut,
|
||||
MonitorSmartphone,
|
||||
Puzzle,
|
||||
UsersRound,
|
||||
} from "@lucide/vue";
|
||||
import {
|
||||
AUTH_INVALID_EVENT,
|
||||
@@ -19,8 +20,9 @@ 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";
|
||||
|
||||
type ViewId = "tasks" | "devices" | "plugins";
|
||||
type ViewId = "tasks" | "devices" | "plugins" | "users";
|
||||
|
||||
const activeView = ref<ViewId>("tasks");
|
||||
const currentUser = ref<CloudUser | null>(null);
|
||||
@@ -37,6 +39,16 @@ 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 isAuthenticated = computed(() => currentUser.value !== null);
|
||||
const currentUserLabel = computed(() =>
|
||||
currentUser.value ? `${currentUser.value.display_name} (${currentUser.value.role})` : "",
|
||||
@@ -48,6 +60,9 @@ const navItems = computed<{ id: ViewId; label: string; icon: Component }[]>(() =
|
||||
{ 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 });
|
||||
}
|
||||
return items;
|
||||
});
|
||||
|
||||
@@ -102,6 +117,8 @@ const activeComponent = computed(() => {
|
||||
return DevicesView;
|
||||
case "plugins":
|
||||
return PluginsView;
|
||||
case "users":
|
||||
return UsersView;
|
||||
default:
|
||||
return TasksView;
|
||||
}
|
||||
@@ -133,6 +150,11 @@ const activeComponent = computed(() => {
|
||||
</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"
|
||||
/>
|
||||
<component v-else :is="activeComponent" :can-submit="canSubmitTasks" />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type {
|
||||
CloudUser,
|
||||
DeviceRecord,
|
||||
HostGovernancePolicy,
|
||||
HostTokenUsageSummary,
|
||||
HostRecord,
|
||||
PluginRecord,
|
||||
PluginRegistrationPayload,
|
||||
@@ -8,6 +10,8 @@ import type {
|
||||
TaskListResponse,
|
||||
TaskSubmissionPayload,
|
||||
TaskStatus,
|
||||
UserListResponse,
|
||||
UserSubmissionPolicy,
|
||||
} from "./types";
|
||||
|
||||
const configuredBaseUrl = import.meta.env.VITE_CLOUD_API_BASE_URL as
|
||||
@@ -119,6 +123,87 @@ export function changePassword(
|
||||
});
|
||||
}
|
||||
|
||||
export function listUsers(): Promise<UserListResponse> {
|
||||
return request<UserListResponse>("/v1/users?limit=100&offset=0");
|
||||
}
|
||||
|
||||
export function createUser(payload: {
|
||||
username: string;
|
||||
display_name: string;
|
||||
role: "viewer" | "operator" | "admin";
|
||||
password: string;
|
||||
}): Promise<CloudUser> {
|
||||
return request<CloudUser>("/v1/users", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function updateUser(
|
||||
userId: string,
|
||||
payload: { display_name?: string; role?: "viewer" | "operator" | "admin"; enabled?: boolean },
|
||||
): Promise<CloudUser> {
|
||||
return request<CloudUser>(`/v1/users/${encodeURIComponent(userId)}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function resetUserPassword(userId: string, password: string): Promise<CloudUser> {
|
||||
return request<CloudUser>(`/v1/users/${encodeURIComponent(userId)}/password`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ password }),
|
||||
});
|
||||
}
|
||||
|
||||
export function revokeUserSessions(userId: string): Promise<void> {
|
||||
return request<void>(`/v1/users/${encodeURIComponent(userId)}/sessions`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
export function getUserSubmissionPolicy(userId: string): Promise<UserSubmissionPolicy> {
|
||||
return request<UserSubmissionPolicy>(
|
||||
`/v1/users/${encodeURIComponent(userId)}/submission-policy`,
|
||||
);
|
||||
}
|
||||
|
||||
export function updateUserSubmissionPolicy(
|
||||
userId: string,
|
||||
payload: Omit<UserSubmissionPolicy, "user_id" | "revision" | "updated_at"> & {
|
||||
expected_revision?: number;
|
||||
},
|
||||
): Promise<UserSubmissionPolicy> {
|
||||
return request<UserSubmissionPolicy>(
|
||||
`/v1/users/${encodeURIComponent(userId)}/submission-policy`,
|
||||
{ method: "PUT", body: JSON.stringify(payload) },
|
||||
);
|
||||
}
|
||||
|
||||
export function getHostGovernancePolicy(hostId: string): Promise<HostGovernancePolicy> {
|
||||
return request<HostGovernancePolicy>(
|
||||
`/v1/hosts/${encodeURIComponent(hostId)}/governance-policy`,
|
||||
);
|
||||
}
|
||||
|
||||
export function updateHostGovernancePolicy(
|
||||
hostId: string,
|
||||
payload: Omit<HostGovernancePolicy, "host_id" | "revision" | "updated_at"> & {
|
||||
expected_revision?: number;
|
||||
},
|
||||
): Promise<HostGovernancePolicy> {
|
||||
return request<HostGovernancePolicy>(
|
||||
`/v1/hosts/${encodeURIComponent(hostId)}/governance-policy`,
|
||||
{ method: "PUT", body: JSON.stringify(payload) },
|
||||
);
|
||||
}
|
||||
|
||||
export function getHostTokenUsage(hostId: string): Promise<HostTokenUsageSummary> {
|
||||
return request<HostTokenUsageSummary>(
|
||||
`/v1/hosts/${encodeURIComponent(hostId)}/token-usage`,
|
||||
);
|
||||
}
|
||||
|
||||
export function listTasks(options?: {
|
||||
status?: TaskStatus;
|
||||
limit?: number;
|
||||
|
||||
@@ -319,6 +319,19 @@ tr.row-selected {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.form-grid input:not([type="checkbox"]),
|
||||
.form-grid select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.form-grid .actions {
|
||||
align-self: end;
|
||||
}
|
||||
|
||||
.policy-grid select {
|
||||
min-height: 92px;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
@@ -96,3 +96,41 @@ export interface CloudUser {
|
||||
updated_at: string;
|
||||
last_login_at: string | null;
|
||||
}
|
||||
|
||||
export interface UserListResponse {
|
||||
items: CloudUser[];
|
||||
limit: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
export interface DeviceTarget {
|
||||
host_id: string;
|
||||
device_id: string;
|
||||
}
|
||||
|
||||
export interface UserSubmissionPolicy {
|
||||
user_id: string;
|
||||
revision: number;
|
||||
submission_enabled: boolean;
|
||||
allowed_host_ids: string[] | null;
|
||||
allowed_device_targets: DeviceTarget[] | null;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface HostGovernancePolicy {
|
||||
host_id: string;
|
||||
revision: number;
|
||||
self_submission_enabled: boolean;
|
||||
max_active_tasks: number | null;
|
||||
daily_token_budget: number | null;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface HostTokenUsageSummary {
|
||||
host_id: string;
|
||||
usage_day: string;
|
||||
daily_token_budget: number | null;
|
||||
used_tokens: number;
|
||||
reserved_tokens: number;
|
||||
remaining_tokens: number | null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,380 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import {
|
||||
CloudApiError,
|
||||
createUser,
|
||||
getHostGovernancePolicy,
|
||||
getHostTokenUsage,
|
||||
getUserSubmissionPolicy,
|
||||
listDevices,
|
||||
listHosts,
|
||||
listUsers,
|
||||
resetUserPassword,
|
||||
revokeUserSessions,
|
||||
updateUser,
|
||||
updateUserSubmissionPolicy,
|
||||
updateHostGovernancePolicy,
|
||||
} from "../api";
|
||||
import type {
|
||||
CloudUser,
|
||||
DeviceRecord,
|
||||
HostRecord,
|
||||
HostTokenUsageSummary,
|
||||
UserRole,
|
||||
} from "../types";
|
||||
|
||||
const props = defineProps<{ canAdminUsers: boolean; canAdminGovernance: boolean }>();
|
||||
|
||||
const users = ref<CloudUser[]>([]);
|
||||
const hosts = ref<HostRecord[]>([]);
|
||||
const devices = ref<DeviceRecord[]>([]);
|
||||
const selectedUserId = ref("");
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const errorMessage = ref("");
|
||||
const successMessage = ref("");
|
||||
|
||||
const createUsername = ref("");
|
||||
const createDisplayName = ref("");
|
||||
const createRole = ref<UserRole>("operator");
|
||||
const createPassword = ref("");
|
||||
const selectedRole = ref<UserRole>("viewer");
|
||||
const selectedEnabled = ref(true);
|
||||
const resetPassword = ref("");
|
||||
|
||||
const submissionEnabled = ref(true);
|
||||
const restrictHosts = ref(false);
|
||||
const allowedHostIds = ref<string[]>([]);
|
||||
const restrictDevices = ref(false);
|
||||
const allowedDeviceKeys = ref<string[]>([]);
|
||||
const policyRevision = ref<number | null>(null);
|
||||
const selectedHostId = ref("");
|
||||
const hostPolicyRevision = ref<number | null>(null);
|
||||
const hostSelfSubmissionEnabled = ref(true);
|
||||
const hostMaxActiveTasks = ref("");
|
||||
const hostDailyTokenBudget = ref("");
|
||||
const hostUsage = ref<HostTokenUsageSummary | null>(null);
|
||||
|
||||
const selectedUser = computed(
|
||||
() => users.value.find((user) => user.id === selectedUserId.value) ?? null,
|
||||
);
|
||||
const deviceKey = (device: Pick<DeviceRecord, "host_id" | "device_id">) =>
|
||||
`${device.host_id}\u0000${device.device_id}`;
|
||||
|
||||
function showError(error: unknown, fallback: string) {
|
||||
successMessage.value = "";
|
||||
errorMessage.value = error instanceof Error ? error.message : fallback;
|
||||
}
|
||||
|
||||
function selectUser(user: CloudUser) {
|
||||
selectedUserId.value = user.id;
|
||||
selectedRole.value = user.role;
|
||||
selectedEnabled.value = user.enabled;
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
loading.value = true;
|
||||
errorMessage.value = "";
|
||||
try {
|
||||
const requests: Promise<unknown>[] = [];
|
||||
if (props.canAdminUsers) requests.push(listUsers());
|
||||
if (props.canAdminGovernance) requests.push(listHosts(), listDevices());
|
||||
const results = await Promise.all(requests);
|
||||
let index = 0;
|
||||
if (props.canAdminUsers) {
|
||||
users.value = (results[index++] as { items: CloudUser[] }).items;
|
||||
if (!selectedUserId.value && users.value[0]) selectUser(users.value[0]);
|
||||
}
|
||||
if (props.canAdminGovernance) {
|
||||
hosts.value = results[index++] as HostRecord[];
|
||||
devices.value = results[index++] as DeviceRecord[];
|
||||
if (!selectedHostId.value && hosts.value[0]) {
|
||||
selectedHostId.value = hosts.value[0].host_id;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
showError(error, "failed to load user administration data");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadHostPolicy() {
|
||||
if (!props.canAdminGovernance || !selectedHostId.value) return;
|
||||
errorMessage.value = "";
|
||||
try {
|
||||
const [policy, usage] = await Promise.all([
|
||||
getHostGovernancePolicy(selectedHostId.value),
|
||||
getHostTokenUsage(selectedHostId.value),
|
||||
]);
|
||||
hostUsage.value = usage;
|
||||
hostPolicyRevision.value = policy.revision;
|
||||
hostSelfSubmissionEnabled.value = policy.self_submission_enabled;
|
||||
hostMaxActiveTasks.value = policy.max_active_tasks?.toString() ?? "";
|
||||
hostDailyTokenBudget.value = policy.daily_token_budget?.toString() ?? "";
|
||||
} catch (error) {
|
||||
if (error instanceof CloudApiError && error.status === 404) {
|
||||
hostPolicyRevision.value = null;
|
||||
hostSelfSubmissionEnabled.value = true;
|
||||
hostMaxActiveTasks.value = "";
|
||||
hostDailyTokenBudget.value = "";
|
||||
hostUsage.value = await getHostTokenUsage(selectedHostId.value);
|
||||
return;
|
||||
}
|
||||
showError(error, "failed to load Host policy");
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPolicy() {
|
||||
if (!props.canAdminGovernance || !selectedUserId.value) return;
|
||||
errorMessage.value = "";
|
||||
try {
|
||||
const policy = await getUserSubmissionPolicy(selectedUserId.value);
|
||||
submissionEnabled.value = policy.submission_enabled;
|
||||
restrictHosts.value = policy.allowed_host_ids !== null;
|
||||
allowedHostIds.value = policy.allowed_host_ids ?? [];
|
||||
restrictDevices.value = policy.allowed_device_targets !== null;
|
||||
allowedDeviceKeys.value = (policy.allowed_device_targets ?? []).map(deviceKey);
|
||||
policyRevision.value = policy.revision;
|
||||
} catch (error) {
|
||||
if (error instanceof CloudApiError && error.status === 404) {
|
||||
submissionEnabled.value = true;
|
||||
restrictHosts.value = false;
|
||||
allowedHostIds.value = [];
|
||||
restrictDevices.value = false;
|
||||
allowedDeviceKeys.value = [];
|
||||
policyRevision.value = null;
|
||||
return;
|
||||
}
|
||||
showError(error, "failed to load submission policy");
|
||||
}
|
||||
}
|
||||
|
||||
async function createAccount() {
|
||||
saving.value = true;
|
||||
errorMessage.value = "";
|
||||
try {
|
||||
const user = await createUser({
|
||||
username: createUsername.value.trim(),
|
||||
display_name: createDisplayName.value.trim(),
|
||||
role: createRole.value,
|
||||
password: createPassword.value,
|
||||
});
|
||||
createUsername.value = "";
|
||||
createDisplayName.value = "";
|
||||
successMessage.value = `created ${user.username}`;
|
||||
await refresh();
|
||||
selectUser(user);
|
||||
} catch (error) {
|
||||
showError(error, "failed to create user");
|
||||
} finally {
|
||||
createPassword.value = "";
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveUser() {
|
||||
if (!selectedUser.value) return;
|
||||
saving.value = true;
|
||||
try {
|
||||
await updateUser(selectedUser.value.id, {
|
||||
role: selectedRole.value,
|
||||
enabled: selectedEnabled.value,
|
||||
});
|
||||
successMessage.value = "user updated";
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
showError(error, "failed to update user");
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function resetPasswordForUser() {
|
||||
if (!selectedUser.value || !resetPassword.value) return;
|
||||
saving.value = true;
|
||||
try {
|
||||
await resetUserPassword(selectedUser.value.id, resetPassword.value);
|
||||
successMessage.value = "password reset; existing sessions were revoked";
|
||||
} catch (error) {
|
||||
showError(error, "failed to reset password");
|
||||
} finally {
|
||||
resetPassword.value = "";
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function revokeSessions() {
|
||||
if (!selectedUser.value) return;
|
||||
saving.value = true;
|
||||
try {
|
||||
await revokeUserSessions(selectedUser.value.id);
|
||||
successMessage.value = "sessions revoked";
|
||||
} catch (error) {
|
||||
showError(error, "failed to revoke sessions");
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function savePolicy() {
|
||||
if (!selectedUserId.value) {
|
||||
errorMessage.value = "select or enter a user id before saving a policy";
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
try {
|
||||
const selectedDevices = new Set(allowedDeviceKeys.value);
|
||||
const policy = await updateUserSubmissionPolicy(selectedUserId.value, {
|
||||
submission_enabled: submissionEnabled.value,
|
||||
expected_revision: policyRevision.value ?? 0,
|
||||
allowed_host_ids: restrictHosts.value ? allowedHostIds.value : null,
|
||||
allowed_device_targets: restrictDevices.value
|
||||
? devices.value
|
||||
.filter((device) => selectedDevices.has(deviceKey(device)))
|
||||
.map((device) => ({ host_id: device.host_id, device_id: device.device_id }))
|
||||
: null,
|
||||
});
|
||||
policyRevision.value = policy.revision;
|
||||
successMessage.value = `submission policy saved (revision ${policy.revision})`;
|
||||
} catch (error) {
|
||||
showError(error, "failed to save submission policy");
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function parseOptionalPositive(value: string, label: string): number | null {
|
||||
if (!value.trim()) return null;
|
||||
const parsed = Number(value);
|
||||
if (!Number.isInteger(parsed) || parsed < 1) {
|
||||
throw new Error(`${label} must be a positive integer`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
async function saveHostPolicy() {
|
||||
if (!selectedHostId.value) return;
|
||||
saving.value = true;
|
||||
try {
|
||||
const policy = await updateHostGovernancePolicy(selectedHostId.value, {
|
||||
self_submission_enabled: hostSelfSubmissionEnabled.value,
|
||||
max_active_tasks: parseOptionalPositive(hostMaxActiveTasks.value, "max active tasks"),
|
||||
daily_token_budget: parseOptionalPositive(hostDailyTokenBudget.value, "daily token budget"),
|
||||
expected_revision: hostPolicyRevision.value ?? 0,
|
||||
});
|
||||
hostPolicyRevision.value = policy.revision;
|
||||
hostUsage.value = await getHostTokenUsage(selectedHostId.value);
|
||||
successMessage.value = `Host policy saved (revision ${policy.revision})`;
|
||||
} catch (error) {
|
||||
showError(error, "failed to save Host policy");
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
watch(selectedUserId, () => {
|
||||
if (selectedUser.value) {
|
||||
selectedRole.value = selectedUser.value.role;
|
||||
selectedEnabled.value = selectedUser.value.enabled;
|
||||
}
|
||||
void loadPolicy();
|
||||
});
|
||||
watch(selectedHostId, () => {
|
||||
void loadHostPolicy();
|
||||
});
|
||||
|
||||
onMounted(() => void refresh());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="toolbar">
|
||||
<h2>Users & task limits</h2>
|
||||
<button :disabled="loading" @click="refresh">Refresh</button>
|
||||
<span v-if="loading" class="muted">loading…</span>
|
||||
</div>
|
||||
<div v-if="errorMessage" class="notice error">{{ errorMessage }}</div>
|
||||
<div v-if="successMessage" class="notice success">{{ successMessage }}</div>
|
||||
|
||||
<div v-if="canAdminUsers" class="panel">
|
||||
<h3>Create user</h3>
|
||||
<form class="form-grid" @submit.prevent="createAccount">
|
||||
<label>Username <input v-model="createUsername" required /></label>
|
||||
<label>Display name <input v-model="createDisplayName" required /></label>
|
||||
<label>Role <select v-model="createRole"><option value="viewer">Viewer</option><option value="operator">Operator</option><option value="admin">Admin</option></select></label>
|
||||
<label>Password <input v-model="createPassword" type="password" required /></label>
|
||||
<div class="field-full actions"><button class="primary" :disabled="saving">Create user</button></div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div v-if="canAdminUsers" class="panel">
|
||||
<h3>Accounts</h3>
|
||||
<table v-if="users.length">
|
||||
<thead><tr><th>Username</th><th>Role</th><th>Status</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="user in users" :key="user.id" :class="{ 'row-selected': user.id === selectedUserId }">
|
||||
<td>{{ user.display_name }} <span class="dim">({{ user.username }})</span></td>
|
||||
<td>{{ user.role }}</td>
|
||||
<td>{{ user.enabled ? "enabled" : "disabled" }}</td>
|
||||
<td><button @click="selectUser(user)">Manage</button></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p v-else class="muted">No accounts found.</p>
|
||||
</div>
|
||||
|
||||
<div v-if="canAdminGovernance && !canAdminUsers" class="panel">
|
||||
<label>User ID <input v-model="selectedUserId" placeholder="Cloud user id" /></label>
|
||||
<p class="muted">Use an existing user id to manage its task-submission policy.</p>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedUser && canAdminUsers" class="panel">
|
||||
<h3>Account: {{ selectedUser.username }}</h3>
|
||||
<div class="form-grid">
|
||||
<label>Role <select v-model="selectedRole"><option value="viewer">Viewer</option><option value="operator">Operator</option><option value="admin">Admin</option></select></label>
|
||||
<label><input v-model="selectedEnabled" type="checkbox" /> Enabled</label>
|
||||
<div class="field-full actions"><button :disabled="saving" @click="saveUser">Save account</button></div>
|
||||
<label>Password reset <input v-model="resetPassword" type="password" placeholder="New temporary password" /></label>
|
||||
<div class="actions"><button :disabled="saving || !resetPassword" @click="resetPasswordForUser">Reset password</button><button :disabled="saving" @click="revokeSessions">Revoke sessions</button></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="canAdminGovernance && selectedUserId" class="panel">
|
||||
<h3>Task-submission policy <span class="dim">{{ policyRevision === null ? "not configured" : `revision ${policyRevision}` }}</span></h3>
|
||||
<p class="muted">An unrestricted policy allows ordinary scope-authorized submissions. Enabling a restriction requires an explicit target.</p>
|
||||
<label><input v-model="submissionEnabled" type="checkbox" /> Allow this user to submit tasks</label>
|
||||
<div class="form-grid policy-grid">
|
||||
<label><input v-model="restrictHosts" type="checkbox" /> Restrict Hosts</label>
|
||||
<select v-model="allowedHostIds" multiple :disabled="!restrictHosts">
|
||||
<option v-for="host in hosts" :key="host.host_id" :value="host.host_id">{{ host.host_id }}</option>
|
||||
</select>
|
||||
<label><input v-model="restrictDevices" type="checkbox" /> Restrict Devices</label>
|
||||
<select v-model="allowedDeviceKeys" multiple :disabled="!restrictDevices">
|
||||
<option v-for="device in devices" :key="deviceKey(device)" :value="deviceKey(device)">{{ device.host_id }} / {{ device.device_id }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="actions"><button class="primary" :disabled="saving" @click="savePolicy">Save policy</button></div>
|
||||
</div>
|
||||
|
||||
<div v-if="canAdminGovernance" class="panel">
|
||||
<h3>Host operational limits</h3>
|
||||
<p class="muted">Daily token budgets apply when this Host uses Cloud planner transport. Each decision reserves the Cloud-configured conservative ceiling before calling a provider, so a usable budget must cover that ceiling. Direct provider transport remains unmetered until it is moved behind the proxy.</p>
|
||||
<div class="form-grid">
|
||||
<label>Host
|
||||
<select v-model="selectedHostId">
|
||||
<option v-for="host in hosts" :key="host.host_id" :value="host.host_id">{{ host.host_id }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label><input v-model="hostSelfSubmissionEnabled" type="checkbox" /> Allow edge self-submission</label>
|
||||
<label>Maximum active tasks <input v-model="hostMaxActiveTasks" inputmode="numeric" placeholder="Unlimited" /></label>
|
||||
<label>Daily Cloud-proxy token budget <input v-model="hostDailyTokenBudget" inputmode="numeric" placeholder="Unlimited" /></label>
|
||||
</div>
|
||||
<div class="actions"><button class="primary" :disabled="saving || !selectedHostId" @click="saveHostPolicy">Save Host policy {{ hostPolicyRevision === null ? "" : `(revision ${hostPolicyRevision})` }}</button></div>
|
||||
<p v-if="hostUsage" class="muted">
|
||||
{{ hostUsage.usage_day }}: used {{ hostUsage.used_tokens }}, reserved {{ hostUsage.reserved_tokens }},
|
||||
remaining {{ hostUsage.remaining_tokens ?? "unmetered" }} tokens.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user