feat(cloud): remove static credentials and add host console
Tests / Test No test results found

This commit is contained in:
2026-07-13 19:45:53 +08:00
parent efeb3eb926
commit c162c2501b
61 changed files with 3118 additions and 1221 deletions
-174
View File
@@ -1,174 +0,0 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from "vue";
import { LoaderCircle, RefreshCw, UserPlus } from "@lucide/vue";
import {
CloudApiError,
createUser,
listUsers,
resetUserPassword,
revokeUserSessions,
updateUser,
} from "../api";
import type { CloudUser, UserRole } from "../types";
const users = ref<CloudUser[]>([]);
const loading = ref(false);
const error = ref("");
const success = ref("");
const showCreate = ref(false);
const submitting = ref(false);
const passwordInputs = reactive<Record<string, string>>({});
const form = reactive({
username: "",
display_name: "",
role: "viewer" as UserRole,
password: "",
});
const roles: UserRole[] = ["viewer", "operator", "admin"];
function clearCreatePassword() {
form.password = "";
}
async function refresh() {
loading.value = true;
error.value = "";
try {
users.value = (await listUsers()).items;
} catch (err) {
error.value = describeError(err, "failed to load users");
} finally {
loading.value = false;
}
}
async function submitCreate() {
error.value = "";
success.value = "";
if (!form.username.trim() || !form.display_name.trim() || !form.password) {
error.value = "username, display name, role, and password are required";
return;
}
submitting.value = true;
try {
const created = await createUser({
username: form.username.trim(),
display_name: form.display_name.trim(),
role: form.role,
password: form.password,
});
form.username = "";
form.display_name = "";
form.role = "viewer";
clearCreatePassword();
showCreate.value = false;
success.value = `${created.username} was created and must change their password`;
await refresh();
} catch (err) {
clearCreatePassword();
error.value = describeError(err, "failed to create user");
} finally {
submitting.value = false;
}
}
async function saveUser(user: CloudUser) {
error.value = "";
success.value = "";
try {
const updated = await updateUser(user.id, {
role: user.role,
enabled: user.enabled,
display_name: user.display_name,
});
replaceUser(updated);
success.value = `updated ${updated.username}`;
} catch (err) {
error.value = describeError(err, "failed to update user");
await refresh();
}
}
async function resetPassword(user: CloudUser) {
const password = passwordInputs[user.id] || "";
if (!password) {
error.value = "enter a temporary password first";
return;
}
error.value = "";
try {
const updated = await resetUserPassword(user.id, password);
passwordInputs[user.id] = "";
replaceUser(updated);
success.value = `reset password for ${updated.username}`;
} catch (err) {
passwordInputs[user.id] = "";
error.value = describeError(err, "failed to reset password");
}
}
async function revokeSessions(user: CloudUser) {
error.value = "";
try {
await revokeUserSessions(user.id);
success.value = `revoked sessions for ${user.username}`;
} catch (err) {
error.value = describeError(err, "failed to revoke sessions");
}
}
function replaceUser(updated: CloudUser) {
users.value = users.value.map((user) => (user.id === updated.id ? updated : user));
}
function describeError(err: unknown, fallback: string): string {
return err instanceof CloudApiError || err instanceof Error ? err.message : fallback;
}
onMounted(refresh);
</script>
<template>
<div>
<div class="toolbar">
<h2>Users</h2>
<button :disabled="loading" @click="refresh"><RefreshCw :size="14" /> Refresh</button>
<button class="primary" @click="showCreate = !showCreate"><UserPlus :size="14" /> {{ showCreate ? "Close form" : "Create user" }}</button>
<span v-if="loading" class="muted"><LoaderCircle :size="14" class="loader" /> loading</span>
</div>
<div v-if="error" class="notice error" style="margin-bottom: 12px">{{ error }}</div>
<div v-if="success" class="notice success" style="margin-bottom: 12px">{{ success }}</div>
<div v-if="showCreate" class="panel">
<h3>Create user</h3>
<form @submit.prevent="submitCreate">
<div class="form-grid">
<div><label for="user-name">Username</label><input id="user-name" v-model="form.username" autocomplete="off" /></div>
<div><label for="display-name">Display name</label><input id="display-name" v-model="form.display_name" autocomplete="name" /></div>
<div><label for="user-role">Role</label><select id="user-role" v-model="form.role"><option v-for="role in roles" :key="role" :value="role">{{ role }}</option></select></div>
<div><label for="user-password">Initial password</label><input id="user-password" v-model="form.password" type="password" autocomplete="new-password" /></div>
</div>
<div class="toolbar" style="margin-top: 12px"><button class="primary" type="submit" :disabled="submitting">Create</button></div>
</form>
</div>
<div class="panel">
<table v-if="users.length">
<thead><tr><th>User</th><th>Role</th><th>Status</th><th>Last login</th><th>Actions</th></tr></thead>
<tbody>
<tr v-for="user in users" :key="user.id">
<td><strong>{{ user.display_name }}</strong><div class="dim">{{ user.username }}</div></td>
<td><select v-model="user.role"><option v-for="role in roles" :key="role" :value="role">{{ role }}</option></select></td>
<td><label><input v-model="user.enabled" type="checkbox" /> enabled</label><div v-if="user.must_change_password" class="dim">password change required</div></td>
<td class="dim">{{ user.last_login_at || "never" }}</td>
<td>
<div class="toolbar" style="margin: 0"><button @click="saveUser(user)">Save</button><input v-model="passwordInputs[user.id]" type="password" placeholder="temporary password" autocomplete="new-password" /><button @click="resetPassword(user)">Reset password</button><button @click="revokeSessions(user)">Revoke sessions</button></div>
</td>
</tr>
</tbody>
</table>
<div v-else class="muted">No user accounts found.</div>
</div>
</div>
</template>