feat(cloud-console): add user authentication and administration
This commit is contained in:
@@ -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