@@ -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