feat(cloud-console): add user authentication and administration
This commit is contained in:
+91
-56
@@ -7,110 +7,145 @@ import {
|
||||
LogOut,
|
||||
MonitorSmartphone,
|
||||
Puzzle,
|
||||
Users,
|
||||
} from "@lucide/vue";
|
||||
import {
|
||||
TOKEN_INVALID_EVENT,
|
||||
AUTH_INVALID_EVENT,
|
||||
clearStoredToken,
|
||||
getStoredToken,
|
||||
getCurrentUser,
|
||||
hasTokenMode,
|
||||
logout,
|
||||
} from "./api";
|
||||
import TokenScreen from "./views/TokenScreen.vue";
|
||||
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";
|
||||
|
||||
type ViewId = "tasks" | "devices" | "plugins";
|
||||
|
||||
const navItems: { id: ViewId; label: string; icon: Component }[] = [
|
||||
{ id: "tasks", label: "Tasks", icon: ListChecks },
|
||||
{ id: "devices", label: "Devices", icon: MonitorSmartphone },
|
||||
{ id: "plugins", label: "Plugins", icon: Puzzle },
|
||||
];
|
||||
type ViewId = "tasks" | "devices" | "plugins" | "users";
|
||||
|
||||
const activeView = ref<ViewId>("tasks");
|
||||
const tokenRejectedMessage = ref("");
|
||||
const hasToken = ref(false);
|
||||
const currentUser = ref<CloudUser | null>(null);
|
||||
const tokenMode = ref(false);
|
||||
const loading = ref(true);
|
||||
const authMessage = ref("");
|
||||
|
||||
function refreshTokenState() {
|
||||
hasToken.value = getStoredToken() !== null;
|
||||
}
|
||||
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 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 (isAdmin.value) items.push({ id: "users", label: "Users", icon: Users });
|
||||
return items;
|
||||
});
|
||||
|
||||
function onTokenInvalid() {
|
||||
hasToken.value = false;
|
||||
tokenRejectedMessage.value =
|
||||
"the cloud api rejected the stored token (401/403). paste a new token to continue.";
|
||||
}
|
||||
|
||||
function onStorage(event: StorageEvent) {
|
||||
if (event.key === null) {
|
||||
// Tab-wide sessionStorage clear (some browsers fire this on logout).
|
||||
refreshTokenState();
|
||||
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.
|
||||
}
|
||||
}
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
function signOut() {
|
||||
async function onAuthenticated() {
|
||||
authMessage.value = "";
|
||||
await initializeAuthentication();
|
||||
}
|
||||
|
||||
function onAuthInvalid() {
|
||||
currentUser.value = null;
|
||||
tokenMode.value = false;
|
||||
authMessage.value = "your session expired or credentials were rejected. 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.
|
||||
}
|
||||
clearStoredToken();
|
||||
hasToken.value = false;
|
||||
tokenRejectedMessage.value = "";
|
||||
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.";
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
refreshTokenState();
|
||||
window.addEventListener(TOKEN_INVALID_EVENT, onTokenInvalid as EventListener);
|
||||
window.addEventListener("storage", onStorage as EventListener);
|
||||
void initializeAuthentication();
|
||||
window.addEventListener(AUTH_INVALID_EVENT, onAuthInvalid as EventListener);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener(TOKEN_INVALID_EVENT, onTokenInvalid as EventListener);
|
||||
window.removeEventListener("storage", onStorage as EventListener);
|
||||
window.removeEventListener(AUTH_INVALID_EVENT, onAuthInvalid as EventListener);
|
||||
});
|
||||
|
||||
const activeComponent = computed(() => {
|
||||
switch (activeView.value) {
|
||||
case "tasks":
|
||||
return TasksView;
|
||||
case "devices":
|
||||
return DevicesView;
|
||||
case "plugins":
|
||||
return PluginsView;
|
||||
case "users":
|
||||
return UsersView;
|
||||
default:
|
||||
return TasksView;
|
||||
}
|
||||
return TasksView;
|
||||
});
|
||||
|
||||
function onTokenSubmitted() {
|
||||
tokenRejectedMessage.value = "";
|
||||
refreshTokenState();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TokenScreen
|
||||
v-if="!hasToken"
|
||||
:rejection-message="tokenRejectedMessage"
|
||||
@submitted="onTokenSubmitted"
|
||||
<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>
|
||||
<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 }}
|
||||
<component :is="item.icon" :size="14" /> {{ item.label }}
|
||||
</button>
|
||||
<div class="spacer" />
|
||||
<button @click="signOut">
|
||||
<LogOut :size="14" />
|
||||
Clear token
|
||||
</button>
|
||||
<div class="dim">{{ currentUser ? `${currentUser.display_name} (${currentUser.role})` : "API token" }}</div>
|
||||
<button @click="signOut"><LogOut :size="14" /> Sign out</button>
|
||||
</nav>
|
||||
<main class="app-main">
|
||||
<component :is="activeComponent" />
|
||||
<PluginsView v-if="activeView === 'plugins'" :can-admin="canAdminPlugins" />
|
||||
<UsersView v-else-if="activeView === 'users' && isAdmin" />
|
||||
<component v-else :is="activeComponent" />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
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 {
|
||||
return new Response(JSON.stringify(payload), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
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 () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce(
|
||||
response({
|
||||
id: "user-a",
|
||||
username: "admin",
|
||||
display_name: "Administrator",
|
||||
role: "admin",
|
||||
enabled: true,
|
||||
must_change_password: false,
|
||||
scopes: ["*"],
|
||||
created_at: "2026-01-01T00:00:00+00:00",
|
||||
updated_at: "2026-01-01T00:00:00+00:00",
|
||||
last_login_at: null,
|
||||
}),
|
||||
);
|
||||
|
||||
await login("admin", "correct-horse-battery-staple");
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/\/v1\/auth\/login$/),
|
||||
expect.objectContaining({
|
||||
credentials: "include",
|
||||
headers: expect.not.objectContaining({ Authorization: expect.any(String) }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses CSRF proof for session-authenticated writes", async () => {
|
||||
document.cookie = "amcp_csrf=csrf-value; path=/";
|
||||
vi.mocked(fetch).mockResolvedValueOnce(
|
||||
response({ name: "demo", version: "1", entry_point_kind: "tool", target: "m:t", wired: false }),
|
||||
);
|
||||
|
||||
await registerPlugin({
|
||||
name: "demo",
|
||||
version: "1",
|
||||
entry_point_kind: "tool",
|
||||
target: "m:t",
|
||||
});
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/\/v1\/plugins$/),
|
||||
expect.objectContaining({
|
||||
credentials: "include",
|
||||
headers: expect.objectContaining({ "X-CSRF-Token": "csrf-value" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
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");
|
||||
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");
|
||||
window.removeEventListener(AUTH_INVALID_EVENT, invalidated);
|
||||
});
|
||||
});
|
||||
+129
-46
@@ -1,4 +1,5 @@
|
||||
import type {
|
||||
CloudUser,
|
||||
DeviceRecord,
|
||||
HostRecord,
|
||||
PluginRecord,
|
||||
@@ -6,21 +7,24 @@ import type {
|
||||
TaskAttempt,
|
||||
TaskListResponse,
|
||||
TaskStatus,
|
||||
UserCreatePayload,
|
||||
UserListResponse,
|
||||
UserUpdatePayload,
|
||||
} from "./types";
|
||||
|
||||
const configuredBaseUrl = import.meta.env.VITE_CLOUD_API_BASE_URL as
|
||||
| string
|
||||
| undefined;
|
||||
// Same-origin Docker deployments (CLOUD_CONSOLE_STATIC_DIR) serve this SPA
|
||||
// straight off the Cloud API, so without a build-time override the API lives
|
||||
// at whatever host the browser loaded the page from, not a hardcoded IP.
|
||||
export const API_BASE_URL = (
|
||||
configuredBaseUrl || window.location.origin
|
||||
).replace(/\/$/, "");
|
||||
|
||||
export const API_BASE_URL = (configuredBaseUrl || window.location.origin).replace(
|
||||
/\/$/,
|
||||
"",
|
||||
);
|
||||
|
||||
const TOKEN_STORAGE_KEY = "cloudConsole.bearerToken";
|
||||
const CSRF_COOKIE_NAME = "amcp_csrf";
|
||||
|
||||
export const TOKEN_INVALID_EVENT = "cloud-console:token-invalid";
|
||||
export const AUTH_INVALID_EVENT = "cloud-console:auth-invalid";
|
||||
|
||||
export class CloudApiError extends Error {
|
||||
readonly status: number;
|
||||
@@ -47,64 +51,109 @@ 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 = getStoredToken();
|
||||
if (!token) {
|
||||
throw new CloudApiError(401, "no bearer token stored");
|
||||
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",
|
||||
Authorization: `Bearer ${token}`,
|
||||
...init.headers,
|
||||
};
|
||||
if (token) {
|
||||
headers.Authorization = `Bearer ${token}`;
|
||||
} else if (["POST", "PUT", "PATCH", "DELETE"].includes(method)) {
|
||||
const csrfToken = getCookie(CSRF_COOKIE_NAME);
|
||||
if (csrfToken) headers["X-CSRF-Token"] = csrfToken;
|
||||
}
|
||||
if (init.body !== undefined && init.body !== null) {
|
||||
headers["Content-Type"] = "application/json";
|
||||
}
|
||||
const response = await fetch(`${API_BASE_URL}${path}`, {
|
||||
method: init.method || "GET",
|
||||
method,
|
||||
body: init.body ?? null,
|
||||
headers,
|
||||
credentials: "include",
|
||||
});
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
clearStoredToken();
|
||||
window.dispatchEvent(new CustomEvent(TOKEN_INVALID_EVENT));
|
||||
let detail = "token rejected by cloud api";
|
||||
try {
|
||||
const payload = (await response.json()) as { detail?: unknown };
|
||||
if (typeof payload.detail === "string") {
|
||||
detail = payload.detail;
|
||||
}
|
||||
} catch {
|
||||
// fall back to the default detail
|
||||
}
|
||||
throw new CloudApiError(response.status, detail);
|
||||
if (response.status === 401) {
|
||||
if (token) clearStoredToken();
|
||||
window.dispatchEvent(new CustomEvent(AUTH_INVALID_EVENT));
|
||||
throw new CloudApiError(401, await responseDetail(response, "authentication expired"));
|
||||
}
|
||||
if (!response.ok) {
|
||||
let message = `${response.status} ${response.statusText}`;
|
||||
try {
|
||||
const payload = (await response.json()) as { detail?: unknown };
|
||||
if (typeof payload.detail === "string") {
|
||||
message = payload.detail;
|
||||
} else if (payload.detail) {
|
||||
message = JSON.stringify(payload.detail);
|
||||
}
|
||||
} catch {
|
||||
message = await response.text().catch(() => message);
|
||||
}
|
||||
throw new CloudApiError(response.status, message);
|
||||
}
|
||||
if (response.status === 204) {
|
||||
return undefined as T;
|
||||
throw new CloudApiError(
|
||||
response.status,
|
||||
await responseDetail(response, `${response.status} ${response.statusText}`),
|
||||
);
|
||||
}
|
||||
if (response.status === 204) return undefined as T;
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
async function responseDetail(response: Response, fallback: string): Promise<string> {
|
||||
try {
|
||||
const payload = (await response.json()) as { detail?: unknown };
|
||||
if (typeof payload.detail === "string") return payload.detail;
|
||||
if (payload.detail) return JSON.stringify(payload.detail);
|
||||
} catch {
|
||||
// Preserve the fallback for empty or non-JSON responses.
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function getCookie(name: string): string | null {
|
||||
const prefix = `${encodeURIComponent(name)}=`;
|
||||
for (const part of document.cookie.split(";")) {
|
||||
const value = part.trim();
|
||||
if (value.startsWith(prefix)) return decodeURIComponent(value.slice(prefix.length));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
export function logout(): Promise<void> {
|
||||
return request<void>("/v1/auth/logout", { method: "POST", sessionOnly: true });
|
||||
}
|
||||
|
||||
export function changePassword(
|
||||
currentPassword: string,
|
||||
newPassword: string,
|
||||
): Promise<void> {
|
||||
return request<void>("/v1/auth/password", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
current_password: currentPassword,
|
||||
new_password: newPassword,
|
||||
}),
|
||||
sessionOnly: true,
|
||||
});
|
||||
}
|
||||
|
||||
export function listTasks(options?: {
|
||||
status?: TaskStatus;
|
||||
limit?: number;
|
||||
@@ -119,9 +168,7 @@ export function listTasks(options?: {
|
||||
}
|
||||
|
||||
export function getTaskAttempts(taskId: string): Promise<TaskAttempt[]> {
|
||||
return request<TaskAttempt[]>(
|
||||
`/v1/tasks/${encodeURIComponent(taskId)}/attempts`,
|
||||
);
|
||||
return request<TaskAttempt[]>(`/v1/tasks/${encodeURIComponent(taskId)}/attempts`);
|
||||
}
|
||||
|
||||
export function listDevices(): Promise<DeviceRecord[]> {
|
||||
@@ -136,11 +183,47 @@ export function listPlugins(): Promise<PluginRecord[]> {
|
||||
return request<PluginRecord[]>("/v1/plugins");
|
||||
}
|
||||
|
||||
export function registerPlugin(
|
||||
payload: PluginRegistrationPayload,
|
||||
): Promise<PluginRecord> {
|
||||
export function registerPlugin(payload: PluginRegistrationPayload): Promise<PluginRecord> {
|
||||
return request<PluginRecord>("/v1/plugins", {
|
||||
method: "POST",
|
||||
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",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -293,9 +293,14 @@ tr.row-selected {
|
||||
font-family: ui-monospace, SFMono-Regular, "Cascadia Code", Consolas, monospace;
|
||||
}
|
||||
|
||||
.token-screen input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.token-screen .actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
|
||||
@@ -68,3 +68,37 @@ export interface PluginRegistrationPayload {
|
||||
entry_point_kind: PluginEntryPointKind;
|
||||
target: string;
|
||||
}
|
||||
|
||||
export type UserRole = "viewer" | "operator" | "admin";
|
||||
|
||||
export interface CloudUser {
|
||||
id: string;
|
||||
username: string;
|
||||
display_name: string;
|
||||
role: UserRole;
|
||||
enabled: boolean;
|
||||
must_change_password: boolean;
|
||||
scopes: string[];
|
||||
created_at: string;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { API_BASE_URL, CloudApiError, login, storeToken } 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);
|
||||
|
||||
async function submitLogin() {
|
||||
error.value = "";
|
||||
if (!username.value.trim() || !password.value) {
|
||||
error.value = "username and password are required";
|
||||
return;
|
||||
}
|
||||
submitting.value = true;
|
||||
try {
|
||||
await login(username.value.trim(), password.value);
|
||||
password.value = "";
|
||||
emit("authenticated");
|
||||
} catch (err) {
|
||||
password.value = "";
|
||||
error.value = err instanceof CloudApiError ? err.message : "sign in failed";
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
<div class="token-screen">
|
||||
<h1>Cloud Console</h1>
|
||||
<p>
|
||||
Sign in to the Cloud Control Plane at <code>{{ API_BASE_URL }}</code>.
|
||||
</p>
|
||||
<div v-if="message" class="notice error" style="margin-bottom: 16px">
|
||||
{{ message }}
|
||||
</div>
|
||||
<div v-if="error" class="notice error" style="margin-bottom: 16px">{{ error }}</div>
|
||||
|
||||
<form v-if="!useToken" @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>
|
||||
</template>
|
||||
@@ -0,0 +1,54 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { CloudApiError, changePassword } from "../api";
|
||||
|
||||
const emit = defineEmits<{ (e: "changed"): void; (e: "signOut"): void }>();
|
||||
const currentPassword = ref("");
|
||||
const newPassword = ref("");
|
||||
const confirmation = ref("");
|
||||
const error = ref("");
|
||||
const submitting = ref(false);
|
||||
|
||||
async function submit() {
|
||||
error.value = "";
|
||||
if (newPassword.value !== confirmation.value) {
|
||||
error.value = "new password confirmation does not match";
|
||||
return;
|
||||
}
|
||||
submitting.value = true;
|
||||
try {
|
||||
await changePassword(currentPassword.value, newPassword.value);
|
||||
currentPassword.value = "";
|
||||
newPassword.value = "";
|
||||
confirmation.value = "";
|
||||
emit("changed");
|
||||
} catch (err) {
|
||||
currentPassword.value = "";
|
||||
newPassword.value = "";
|
||||
confirmation.value = "";
|
||||
error.value = err instanceof CloudApiError ? err.message : "password change failed";
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="token-screen">
|
||||
<h1>Change password</h1>
|
||||
<p>Your administrator requires you to replace this temporary password.</p>
|
||||
<div v-if="error" class="notice error" style="margin-bottom: 16px">{{ error }}</div>
|
||||
<form @submit.prevent="submit">
|
||||
<label for="current-password">Current password</label>
|
||||
<input id="current-password" v-model="currentPassword" type="password" autocomplete="current-password" />
|
||||
<label for="new-password" style="margin-top: 12px">New password</label>
|
||||
<input id="new-password" v-model="newPassword" type="password" autocomplete="new-password" />
|
||||
<label for="confirm-password" style="margin-top: 12px">Confirm new password</label>
|
||||
<input id="confirm-password" v-model="confirmation" type="password" autocomplete="new-password" />
|
||||
<div class="actions">
|
||||
<button class="primary" type="submit" :disabled="submitting">Change password</button>
|
||||
<button type="button" @click="emit('signOut')">Sign out</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
@@ -7,6 +7,8 @@ import type {
|
||||
PluginRecord,
|
||||
} from "../types";
|
||||
|
||||
defineProps<{ canAdmin?: boolean }>();
|
||||
|
||||
const loading = ref(false);
|
||||
const errorMessage = ref("");
|
||||
const plugins = ref<PluginRecord[]>([]);
|
||||
@@ -99,7 +101,7 @@ onMounted(refresh);
|
||||
<RefreshCw :size="14" />
|
||||
Refresh
|
||||
</button>
|
||||
<button class="primary" @click="showForm = !showForm">
|
||||
<button v-if="canAdmin" class="primary" @click="showForm = !showForm">
|
||||
<Plus :size="14" />
|
||||
{{ showForm ? "Close form" : "Register plugin" }}
|
||||
</button>
|
||||
@@ -111,7 +113,7 @@ onMounted(refresh);
|
||||
<div v-if="errorMessage" class="notice error">{{ errorMessage }}</div>
|
||||
<div v-if="formSuccess" class="notice success">{{ formSuccess }}</div>
|
||||
|
||||
<div class="panel" v-if="showForm">
|
||||
<div class="panel" v-if="showForm && canAdmin">
|
||||
<h3>Register a plugin</h3>
|
||||
<p class="dim">
|
||||
The cloud api requires the <code>plugins:admin</code> scope for this
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user