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
+5 -12
View File
@@ -6,10 +6,6 @@ expiring, revocable `HttpOnly` session cookie, while the frontend sends the
separate CSRF cookie value on writes. The browser never stores the session
secret in JavaScript.
The login screen also offers **Use API token** for existing break-glass or
automation credentials. That token is held only in `sessionStorage`; it remains
compatible with the existing scoped `CLOUD_PUBLIC_CREDENTIALS_JSON` model.
## Prerequisites
- Node.js 20+
@@ -22,7 +18,7 @@ Accounts have fixed roles:
- `viewer`: task, device/host, and plugin read views
- `operator`: viewer access plus task submission APIs
- `admin`: all API scopes and the Console Users view
- `admin`: all API scopes
## Local development
@@ -48,10 +44,7 @@ The Console uses `credentials: include`. `401` returns to the login screen;
## Production
`npm run build` type-checks and creates `dist/`. The repository Dockerfile
already builds this bundle into `/app/console-static`; `compose.yaml` and
`compose.deploy.yaml` mount it at the same-origin `/console/` route. No CORS
configuration is required in that deployment shape.
Administrators can create users, assign roles, enable/disable accounts, reset
temporary passwords, and revoke sessions. All password inputs are cleared from
the UI after a create/reset request succeeds or fails.
already builds this bundle into `/app/console-static` and configures the Cloud
API to serve it at the same-origin `/console/` route. No CORS configuration is
required in that deployment shape. Use `device-cloud-admin` for account
provisioning and recovery.
+11 -28
View File
@@ -7,13 +7,10 @@ import {
LogOut,
MonitorSmartphone,
Puzzle,
Users,
} from "@lucide/vue";
import {
AUTH_INVALID_EVENT,
clearStoredToken,
getCurrentUser,
hasTokenMode,
logout,
} from "./api";
import type { CloudUser } from "./types";
@@ -22,26 +19,23 @@ 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" | "users";
type ViewId = "tasks" | "devices" | "plugins";
const activeView = ref<ViewId>("tasks");
const currentUser = ref<CloudUser | null>(null);
const tokenMode = ref(false);
const loading = ref(true);
const authMessage = ref("");
const isAdmin = computed(
() => currentUser.value?.scopes.includes("*") || currentUser.value?.scopes.includes("users:admin"),
);
const canAdminPlugins = computed(
() =>
tokenMode.value ||
currentUser.value?.scopes.includes("*") ||
currentUser.value?.scopes.includes("plugins:admin"),
);
const isAuthenticated = computed(() => currentUser.value !== null || tokenMode.value);
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 }[] = [
@@ -49,20 +43,16 @@ const navItems = computed<{ id: ViewId; label: string; icon: Component }[]>(() =
{ id: "devices", label: "Devices", icon: MonitorSmartphone },
{ id: "plugins", label: "Plugins", icon: Puzzle },
];
if (isAdmin.value) items.push({ id: "users", label: "Users", icon: Users });
return items;
});
async function initializeAuthentication() {
loading.value = true;
currentUser.value = null;
tokenMode.value = hasTokenMode();
if (!tokenMode.value) {
try {
currentUser.value = await getCurrentUser();
} catch {
// A missing session is the normal initial state.
}
try {
currentUser.value = await getCurrentUser();
} catch {
// A missing session is the normal initial state.
}
loading.value = false;
}
@@ -74,8 +64,7 @@ async function onAuthenticated() {
function onAuthInvalid() {
currentUser.value = null;
tokenMode.value = false;
authMessage.value = "your session expired or credentials were rejected. sign in again.";
authMessage.value = "your session expired. sign in again.";
}
async function signOut() {
@@ -84,15 +73,12 @@ async function signOut() {
} catch {
// Local state must still be cleared when the already-expired session rejects logout.
}
clearStoredToken();
currentUser.value = null;
tokenMode.value = false;
authMessage.value = "";
}
function onPasswordChanged() {
currentUser.value = null;
tokenMode.value = false;
authMessage.value = "password changed. sign in with the new password.";
}
@@ -111,8 +97,6 @@ const activeComponent = computed(() => {
return DevicesView;
case "plugins":
return PluginsView;
case "users":
return UsersView;
default:
return TasksView;
}
@@ -139,12 +123,11 @@ const activeComponent = computed(() => {
<component :is="item.icon" :size="14" /> {{ item.label }}
</button>
<div class="spacer" />
<div class="dim">{{ currentUser ? `${currentUser.display_name} (${currentUser.role})` : "API token" }}</div>
<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' && isAdmin" />
<component v-else :is="activeComponent" />
</main>
</div>
+2 -24
View File
@@ -3,12 +3,9 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
AUTH_INVALID_EVENT,
clearStoredToken,
getStoredToken,
listDevices,
login,
registerPlugin,
storeToken,
} from "./api";
function response(payload: unknown, status = 200): Response {
@@ -20,14 +17,12 @@ function response(payload: unknown, status = 200): Response {
describe("Cloud Console API authentication", () => {
beforeEach(() => {
clearStoredToken();
document.cookie = "amcp_csrf=; Max-Age=0; path=/";
vi.stubGlobal("fetch", vi.fn());
});
afterEach(() => {
vi.unstubAllGlobals();
clearStoredToken();
});
it("uses credentialed account login without a bearer header", async () => {
@@ -79,34 +74,17 @@ describe("Cloud Console API authentication", () => {
);
});
it("keeps the explicit compatibility bearer-token path", async () => {
storeToken("compatibility-token");
vi.mocked(fetch).mockResolvedValueOnce(response([]));
await listDevices();
expect(fetch).toHaveBeenCalledWith(
expect.stringMatching(/\/v1\/devices$/),
expect.objectContaining({
headers: expect.objectContaining({ Authorization: "Bearer compatibility-token" }),
}),
);
});
it("clears authentication only on 401, not on 403", async () => {
storeToken("compatibility-token");
it("invalidates the session only on 401", async () => {
const invalidated = vi.fn();
window.addEventListener(AUTH_INVALID_EVENT, invalidated);
vi.mocked(fetch).mockResolvedValueOnce(response({ detail: "unauthorized" }, 401));
await expect(listDevices()).rejects.toMatchObject({ status: 401 });
expect(getStoredToken()).toBeNull();
expect(invalidated).toHaveBeenCalledTimes(1);
storeToken("compatibility-token");
vi.mocked(fetch).mockResolvedValueOnce(response({ detail: "forbidden" }, 403));
await expect(listDevices()).rejects.toMatchObject({ status: 403 });
expect(getStoredToken()).toBe("compatibility-token");
expect(invalidated).toHaveBeenCalledTimes(1);
window.removeEventListener(AUTH_INVALID_EVENT, invalidated);
});
});
+3 -77
View File
@@ -7,9 +7,6 @@ import type {
TaskAttempt,
TaskListResponse,
TaskStatus,
UserCreatePayload,
UserListResponse,
UserUpdatePayload,
} from "./types";
const configuredBaseUrl = import.meta.env.VITE_CLOUD_API_BASE_URL as
@@ -21,7 +18,6 @@ export const API_BASE_URL = (configuredBaseUrl || window.location.origin).replac
"",
);
const TOKEN_STORAGE_KEY = "cloudConsole.bearerToken";
const CSRF_COOKIE_NAME = "amcp_csrf";
export const AUTH_INVALID_EVENT = "cloud-console:auth-invalid";
@@ -35,47 +31,19 @@ export class CloudApiError extends Error {
}
}
export function getStoredToken(): string | null {
try {
return sessionStorage.getItem(TOKEN_STORAGE_KEY);
} catch {
return null;
}
}
export function storeToken(token: string): void {
sessionStorage.setItem(TOKEN_STORAGE_KEY, token);
}
export function clearStoredToken(): void {
sessionStorage.removeItem(TOKEN_STORAGE_KEY);
}
export function hasTokenMode(): boolean {
return getStoredToken() !== null;
}
interface RequestInitLike {
method?: string;
body?: string | null;
headers?: Record<string, string>;
allowAnonymous?: boolean;
sessionOnly?: boolean;
}
async function request<T>(path: string, init: RequestInitLike = {}): Promise<T> {
const token = init.sessionOnly ? null : getStoredToken();
if (!init.allowAnonymous && !token && !init.sessionOnly) {
// Session mode is permitted, so a missing token is not itself an error.
}
const method = init.method || "GET";
const headers: Record<string, string> = {
Accept: "application/json",
...init.headers,
};
if (token) {
headers.Authorization = `Bearer ${token}`;
} else if (["POST", "PUT", "PATCH", "DELETE"].includes(method)) {
if (["POST", "PUT", "PATCH", "DELETE"].includes(method)) {
const csrfToken = getCookie(CSRF_COOKIE_NAME);
if (csrfToken) headers["X-CSRF-Token"] = csrfToken;
}
@@ -89,7 +57,6 @@ async function request<T>(path: string, init: RequestInitLike = {}): Promise<T>
credentials: "include",
});
if (response.status === 401) {
if (token) clearStoredToken();
window.dispatchEvent(new CustomEvent(AUTH_INVALID_EVENT));
throw new CloudApiError(401, await responseDetail(response, "authentication expired"));
}
@@ -127,17 +94,15 @@ export function login(username: string, password: string): Promise<CloudUser> {
return request<CloudUser>("/v1/auth/login", {
method: "POST",
body: JSON.stringify({ username, password }),
allowAnonymous: true,
sessionOnly: true,
});
}
export function getCurrentUser(): Promise<CloudUser> {
return request<CloudUser>("/v1/auth/me", { sessionOnly: true });
return request<CloudUser>("/v1/auth/me");
}
export function logout(): Promise<void> {
return request<void>("/v1/auth/logout", { method: "POST", sessionOnly: true });
return request<void>("/v1/auth/logout", { method: "POST" });
}
export function changePassword(
@@ -150,7 +115,6 @@ export function changePassword(
current_password: currentPassword,
new_password: newPassword,
}),
sessionOnly: true,
});
}
@@ -189,41 +153,3 @@ export function registerPlugin(payload: PluginRegistrationPayload): Promise<Plug
body: JSON.stringify(payload),
});
}
export function listUsers(options?: {
limit?: number;
offset?: number;
}): Promise<UserListResponse> {
const params = new URLSearchParams({
limit: String(options?.limit ?? 50),
offset: String(options?.offset ?? 0),
});
return request<UserListResponse>(`/v1/users?${params.toString()}`);
}
export function createUser(payload: UserCreatePayload): Promise<CloudUser> {
return request<CloudUser>("/v1/users", {
method: "POST",
body: JSON.stringify(payload),
});
}
export function updateUser(userId: string, payload: UserUpdatePayload): 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",
});
}
-19
View File
@@ -83,22 +83,3 @@ export interface CloudUser {
updated_at: string;
last_login_at: string | null;
}
export interface UserListResponse {
items: CloudUser[];
limit: number;
offset: number;
}
export interface UserCreatePayload {
username: string;
display_name: string;
role: UserRole;
password: string;
}
export interface UserUpdatePayload {
display_name?: string;
role?: UserRole;
enabled?: boolean;
}
+2 -31
View File
@@ -1,14 +1,12 @@
<script setup lang="ts">
import { ref } from "vue";
import { API_BASE_URL, CloudApiError, login, storeToken } from "../api";
import { API_BASE_URL, CloudApiError, login } from "../api";
defineProps<{ message?: string }>();
const emit = defineEmits<{ (e: "authenticated"): void }>();
const useToken = ref(false);
const username = ref("");
const password = ref("");
const token = ref("");
const error = ref("");
const submitting = ref(false);
@@ -31,17 +29,6 @@ async function submitLogin() {
}
}
function submitToken() {
const value = token.value.trim();
if (!value) {
error.value = "paste a bearer token issued by the cloud control plane";
return;
}
storeToken(value);
token.value = "";
error.value = "";
emit("authenticated");
}
</script>
<template>
@@ -55,29 +42,13 @@ function submitToken() {
</div>
<div v-if="error" class="notice error" style="margin-bottom: 16px">{{ error }}</div>
<form v-if="!useToken" @submit.prevent="submitLogin">
<form @submit.prevent="submitLogin">
<label for="username">Username</label>
<input id="username" v-model="username" autocomplete="username" />
<label for="password" style="margin-top: 12px">Password</label>
<input id="password" v-model="password" type="password" autocomplete="current-password" />
<div class="actions">
<button class="primary" type="submit" :disabled="submitting">Sign in</button>
<button type="button" @click="useToken = true">Use API token</button>
</div>
</form>
<form v-else @submit.prevent="submitToken">
<label for="token">Bearer token</label>
<textarea
id="token"
v-model="token"
autocomplete="off"
spellcheck="false"
placeholder="paste a break-glass or compatibility token"
></textarea>
<div class="actions">
<button class="primary" type="submit">Connect</button>
<button type="button" @click="useToken = false">Use account login</button>
</div>
</form>
</div>
-51
View File
@@ -1,51 +0,0 @@
<script setup lang="ts">
import { ref } from "vue";
import { API_BASE_URL, storeToken } from "../api";
defineProps<{ rejectionMessage?: string }>();
const emit = defineEmits<{ (e: "submitted"): void }>();
const token = ref("");
const error = ref("");
function submit() {
const trimmed = token.value.trim();
if (!trimmed) {
error.value = "paste a bearer token issued by the cloud control plane";
return;
}
storeToken(trimmed);
error.value = "";
emit("submitted");
}
</script>
<template>
<div class="token-screen">
<h1>Cloud Console</h1>
<p>
Paste an operator bearer token scoped to the Cloud Control Plane at
<code>{{ API_BASE_URL }}</code>. The token is held in
<code>sessionStorage</code> only close this tab to discard it.
</p>
<div v-if="rejectionMessage" class="notice error" style="margin-bottom: 16px">
{{ rejectionMessage }}
</div>
<form @submit.prevent="submit">
<label for="token">Bearer token</label>
<textarea
id="token"
v-model="token"
autocomplete="off"
spellcheck="false"
placeholder="paste a token scoped at least to tasks:read, pool:read, plugins:read"
></textarea>
<div v-if="error" class="notice error" style="margin-top: 12px">
{{ error }}
</div>
<div class="actions">
<button class="primary" type="submit">Connect</button>
</div>
</form>
</div>
</template>
-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>