💄 Improved UI regarding Login

This commit is contained in:
andreas.dinauer 2025-11-09 00:00:33 +01:00
parent 40e2721b82
commit 2930460102
34 changed files with 367 additions and 74 deletions

View File

@ -1,3 +1,5 @@
import axios from "axios";
export class User
{
username?: string;
@ -5,6 +7,40 @@ export class User
password?: string;
roles?: string[];
initial?: boolean;
static get(onSuccess: (users: User[]) => void)
{
axios.get(ApiConfig.getHttpBase() + '/users', {
headers: {
Authorization: "Bearer " + requireToken()
}
})
.then((response) => {
onSuccess(response.data);
});
}
static create(user: UserCreation, onSuccess: () => void)
{
axios.post(ApiConfig.getHttpBase() + '/users', user, {
headers: {
Authorization: "Bearer " + requireToken()
}
})
.then(() => {
onSuccess();
});
}
}
export class UserCreation
{
firstname?: string;
lastname?: string;
email?: string;
password?: string;
username?: string;
role: string = "USER";
}
export function hasAnyRole(user: User | undefined, requiredRoles: string[])

View File

@ -2,6 +2,7 @@
<div>
<p class="grid-element">{{ configMap.metadata.name }}</p>
<p class="grid-element">{{ configMap.metadata.namespace }}</p>
<p class="grid-element">{{ calcAge(configMap.metadata.creationTimestamp) }}</p>
<p class="grid-element"><span v-if="configMap.data">{{ Object.keys(configMap.data).length }}</span><span v-else>-</span></p>
<div class="grid-element">
<ActionButton>delete</ActionButton>
@ -11,6 +12,7 @@
<script setup lang="ts">
import type { ConfigMap } from '~/classes/ConfigMap';
import { calcAge } from '~/classes/Pod';
defineProps<{
configMap: ConfigMap

View File

@ -1,6 +1,7 @@
<template>
<div>
<p class="grid-element">{{ namespace.metadata.name }}</p>
<p class="grid-element">{{ calcAge(namespace.metadata.creationTimestamp) }}</p>
<div class="grid-element">
<ActionButton>delete</ActionButton>
</div>
@ -9,6 +10,7 @@
<script setup lang="ts">
import type { Namespace } from '~/classes/Namespace';
import { calcAge } from '~/classes/Pod';
defineProps<{
namespace: Namespace

View File

@ -1,15 +1,18 @@
<template>
<SidebarTemplate>
<div class="nav">
<NuxtLink class="namespace" to="/account/settings">Account</NuxtLink>
<NuxtLink class="namespace" to="/account/users">Users</NuxtLink>
<NuxtLink class="namespace" to="/account/password">Password</NuxtLink>
<NuxtLink class="namespace" to="/account/settings/password">Password</NuxtLink>
<NuxtLink class="namespace" to="/account/settings/users">Users</NuxtLink>
</div>
<div class="left-center">
<UiIcon>account_circle</UiIcon>
<p>{{ user.username }}</p>
</div>
</SidebarTemplate>
</template>
<script setup lang="ts">
const user = requireUser();
</script>
<style scoped>

View File

@ -17,6 +17,7 @@
justify-content: space-between;
gap: 0.5rem;
height: 100%;
min-width: 10rem;
}
.namespace, .resources {
padding: 0.35rem 0.5rem;

View File

@ -1,7 +1,7 @@
<template>
<div class="content-l">
<h2>Account</h2>
<div class="content-m">
<div class="content-m tile-l">
<h3>Passwort ändern</h3>
<div class="col-2">
<UiInput label="Passwort">

View File

@ -6,7 +6,7 @@
<script setup lang="ts">
import type { Dataset } from './Dataset';
import { TimeScale, LinearScale, LineController, PointElement, LineElement, Chart, type ChartConfiguration } from 'chart.js';
import { TimeScale, LinearScale, LineController, PointElement, LineElement, Chart, Filler, type ChartConfiguration } from 'chart.js';
import 'chartjs-adapter-moment';
Chart.register(TimeScale)
@ -14,6 +14,7 @@ Chart.register(LinearScale)
Chart.register(PointElement)
Chart.register(LineController)
Chart.register(LineElement)
Chart.register(Filler)
const id = Math.random().toString().replaceAll(".", "");

View File

@ -1,8 +1,9 @@
<template>
<div>
<p class="grid-element" v-if="deployment.metadata">{{ deployment.metadata.name }}</p>
<p class="grid-element" v-if="deployment.metadata">{{ deployment.metadata.namespace }}</p>
<p class="grid-element" v-if="deployment.spec">{{ deployment.spec.replicas }}</p>
<p class="grid-element">{{ deployment.metadata.name }}</p>
<p class="grid-element">{{ deployment.metadata.namespace }}</p>
<p class="grid-element">{{ calcAge(deployment.metadata.creationTimestamp) }}</p>
<p class="grid-element">{{ deployment.spec.replicas }}</p>
<div class="grid-element action-buttons">
<ActionButton>delete</ActionButton>
<ActionButton>autorenew</ActionButton>
@ -15,6 +16,7 @@
<script setup lang="ts">
import { Deployment } from '~/classes/Deployment';
import RescaleDeploymentPopup from '../RescaleDeploymentPopup.vue';
import { calcAge } from '~/classes/Pod';
defineProps<{
deployment: Deployment

View File

@ -1,7 +1,8 @@
<template>
<div @click="() => showViewPopup = true">
<p class="grid-element">{{ ingress.metadata.name }}</p>
<p class="grid-element cursor">{{ ingress.metadata.name }}</p>
<p class="grid-element">{{ ingress.metadata.namespace }}</p>
<p class="grid-element">{{ calcAge(ingress.metadata.creationTimestamp) }}</p>
<p class="grid-element">{{ ingress.spec.ingressClassName }}</p>
<p class="grid-element">{{ ingress.spec.rules.length }}</p>
<div class="grid-element action-buttons">
@ -14,6 +15,7 @@
<script setup lang="ts">
import type { Ingress } from '~/classes/Ingress';
import IngressViewPopup from './view/IngressViewPopup.vue';
import { calcAge } from '~/classes/Pod';
defineProps<{
ingress: Ingress

View File

@ -4,6 +4,7 @@
<div class="header">
<p>Name</p>
<p>Namespace</p>
<p>Age</p>
<p>Entries</p>
<p>Aktionen</p>
</div>
@ -28,6 +29,6 @@ onUnmounted(() => {
<style scoped>
.config-map-container {
display: grid;
grid-template-columns: 1fr 1fr 1fr auto;
grid-template-columns: auto auto auto 1fr auto;
}
</style>

View File

@ -4,6 +4,7 @@
<div class="header">
<p>Name</p>
<p>Namespace</p>
<p>Age</p>
<p>Replicas</p>
<p>Aktionen</p>
</div>
@ -28,6 +29,6 @@ onUnmounted(() => {
<style scoped>
.deployment-container {
grid-template-columns: auto 1fr 1fr auto;
grid-template-columns: auto auto auto 1fr auto;
}
</style>

View File

@ -4,6 +4,7 @@
<div class="header">
<p>Name</p>
<p>Namespace</p>
<p>Age</p>
<p>Ingress Class Name</p>
<p>Rules</p>
<p>Actions</p>
@ -28,6 +29,6 @@ onUnmounted(() => {
<style scoped>
.ingress-container {
grid-template-columns: 1fr 1fr 1fr 1fr auto;
grid-template-columns: auto auto auto auto 1fr auto;
}
</style>

View File

@ -3,6 +3,7 @@
<div class="resource-container namespace-container">
<div class="header">
<p>Name</p>
<p>Age</p>
<p>Actions</p>
</div>
<NamespaceComponent :namespace="namespace" v-for="namespace, index in namespaces" class="resource" :class="{ even: index % 2 }"></NamespaceComponent>
@ -26,6 +27,6 @@ onUnmounted(() => {
<style scoped>
.namespace-container {
grid-template-columns: 1fr auto;
grid-template-columns: auto 1fr auto;
}
</style>

View File

@ -4,7 +4,7 @@
<div class="header">
<p>Name</p>
<p>Namespace</p>
<p>Alter</p>
<p>Age</p>
<p>Storage Class</p>
<p>Access Modes</p>
<p>Aktionen</p>

View File

@ -3,7 +3,7 @@
<div class="resource-container persistent-volume-container">
<div class="header">
<p>Name</p>
<p>Alter</p>
<p>Age</p>
<p>Aktionen</p>
</div>
<PersistentVolumeComponent v-for="persistentVolume, index in persistentVolumes" :persistent-volume="persistentVolume" class="resource" :class="{ even: index % 2 }"></PersistentVolumeComponent>

View File

@ -4,7 +4,7 @@
<div class="header">
<p>Pod</p>
<p>Namespace</p>
<p>Alter</p>
<p>Age</p>
<p>Node</p>
<p>Containers</p>
<p>Status</p>

View File

@ -7,7 +7,7 @@
<UiButton v-if="false" icon="add" class="extra-small" reverse @click="() => secretAddComponent.open()">Add</UiButton>
</div>
<p>Namespace</p>
<p>Alter</p>
<p>Age</p>
<p>Aktionen</p>
</div>
<SecretComponent v-for="secret, index in secrets" :secret="secret" class="resource" :class="{ even: index % 2 }"></SecretComponent>

View File

@ -4,6 +4,7 @@
<div class="header">
<p>Service</p>
<p>Namespace</p>
<p>Age</p>
<p>Type</p>
<p>Aktionen</p>
</div>
@ -27,6 +28,6 @@ onUnmounted(() => {
<style>
.service-container {
grid-template-columns: auto auto 1fr auto;
grid-template-columns: auto auto auto 1fr auto;
}
</style>

View File

@ -2,7 +2,7 @@
<div class="resource">
<p class="grid-element">{{ secret.metadata.name }}</p>
<p class="grid-element">{{ secret.metadata.namespace }}</p>
<p class="grid-element">-</p>
<p class="grid-element">{{ calcAge(secret.metadata.creationTimestamp) }}</p>
<div class="grid-element action-buttons">
<ActionButton>edit</ActionButton>
<ActionButton>delete</ActionButton>
@ -11,6 +11,7 @@
</template>
<script setup lang="ts">
import { calcAge } from '~/classes/Pod';
import type { Secret } from '~/classes/Secret';
defineProps<{

View File

@ -1,17 +1,18 @@
<template>
<div>
<p class="grid-element" v-if="service.metadata">{{ service.metadata.name }}</p>
<p class="grid-element" v-if="service.metadata">{{ service.metadata.namespace }}</p>
<p class="grid-element">{{ service.metadata.name }}</p>
<p class="grid-element">{{ service.metadata.namespace }}</p>
<p class="grid-element">{{ calcAge(service.metadata.creationTimestamp) }}</p>
<p class="grid-element" v-if="service.spec">{{ service.spec.type }}</p>
<div class="grid-element">
<ActionButton v-if="hasAnyRole(getUser(), ['admin', 'maintainer'])">delete</ActionButton>
<ActionButton>delete</ActionButton>
</div>
</div>
</template>
<script setup lang="ts">
import { calcAge } from '~/classes/Pod';
import type { Service } from '~/classes/Service';
import { hasAnyRole } from '~/classes/User';
defineProps<{
service: Service

13
components/ui/Prompt.ts Normal file
View File

@ -0,0 +1,13 @@
export class Prompt
{
constructor (
public text: string,
public type: PromptType
) {}
}
export enum PromptType
{
ERROR = "error",
SUCCESS = "success"
}

View File

@ -37,6 +37,7 @@ const props = defineProps<{
white-space: nowrap;
border-radius: 0.25rem;
font-weight: 500;
font-size: 1rem;
}
.button.square {
padding: 0;

27
components/ui/UiError.vue Normal file
View File

@ -0,0 +1,27 @@
<template>
<div class="spaced-center padding-m error" v-if="error">
<p>{{ error }}</p>
<UiIcon class="pointer" @click="() => emit('close')">close</UiIcon>
</div>
</template>
<script setup lang="ts">
defineProps<{
error?: string
}>();
const emit = defineEmits<{
(e: 'close'): void
}>();
</script>
<style scoped>
.error {
background-color: rgb(218, 57, 57);
border-radius: 0.25rem;
height: 2.5rem;
}
.error * {
color: white;
}
</style>

34
components/ui/UiPompt.vue Normal file
View File

@ -0,0 +1,34 @@
<template>
<div class="spaced-center padding-m prompt" v-if="prompt" :class="prompt.type">
<p>{{ prompt.text }}</p>
<UiIcon class="pointer" @click="() => emit('close')">close</UiIcon>
</div>
</template>
<script setup lang="ts">
import type { Prompt } from './Prompt';
defineProps<{
prompt?: Prompt
}>();
const emit = defineEmits<{
(e: 'close'): void
}>();
</script>
<style scoped>
.prompt {
border-radius: 0.25rem;
height: 2.5rem;
}
.error {
background-color: rgb(218, 57, 57);
}
.success {
background-color: rgb(43, 161, 49);
}
.prompt * {
color: white;
}
</style>

View File

@ -7,7 +7,7 @@
<div class="left-center">
<NuxtLink to="/account/inspect/nodes/_all">Inspect</NuxtLink>
<NuxtLink to="/account/monitorings/nodes">Monitorings</NuxtLink>
<NuxtLink to="/account/settings">Settings</NuxtLink>
<NuxtLink to="/account/settings/password">Settings</NuxtLink>
<p class="pointer" @click="logout()">Logout</p>
</div>
</div>

View File

@ -1,10 +1,7 @@
<template>
<div class="monitorings-page">
<SidebarTemplate>
<div class="content-l">
<h2>Kubooboo</h2>
<NuxtLink class="resources" to="/account/monitorings/nodes">Nodes</NuxtLink>
</div>
</SidebarTemplate>
<NuxtPage></NuxtPage>
</div>

View File

@ -1,7 +1,7 @@
<template>
<div class="settings-page">
<SettingsSidebar></SettingsSidebar>
<div>
<div class="padding-l">
<NuxtPage></NuxtPage>
</div>
</div>

View File

@ -1,13 +0,0 @@
<template>
<div>
</div>
</template>
<script setup lang="ts">
</script>
<style scoped>
</style>

View File

@ -1,11 +1,46 @@
<template>
<div>
<div class="content-l">
<h2>Change Password</h2>
<UiPompt :prompt="prompt" @close="prompt = undefined"></UiPompt>
<div class="col-2 tile-l">
<UiInput label="New Password" required>
<input type="password" v-model="password">
</UiInput>
<UiInput label="Repeat Password" required>
<input type="password" v-model="repeatPassword">
</UiInput>
</div>
<div class="center">
<UiButton class="width-6rem" icon="change_circle" reverse @click="() => change()">Change</UiButton>
</div>
</div>
</template>
<script setup lang="ts">
import { Prompt, PromptType } from '~/components/ui/Prompt';
import { changePassword } from '~/requests/user';
import UiPompt from '~/components/ui/UiPompt.vue';
const password = ref('');
const repeatPassword = ref('');
const prompt: Ref<Prompt | undefined> = ref(undefined);
function change()
{
if (password.value && password.value === repeatPassword.value)
{
changePassword(requireUser().username, password.value, () => {
password.value = '';
repeatPassword.value = '';
prompt.value = new Prompt("Password changed successfully.", PromptType.SUCCESS);
});
}
else
{
prompt.value = new Prompt("Passwords don't match.", PromptType.ERROR);
throw new Error("Passwords don't match.");
}
}
</script>
<style scoped>

View File

@ -1,13 +0,0 @@
<template>
<div>
</div>
</template>
<script setup lang="ts">
</script>
<style scoped>
</style>

View File

@ -0,0 +1,65 @@
<template>
<div class="content-l">
<h2>Add User</h2>
<UiError :error="error" @close="error = undefined"></UiError>
<div class="col-2 tile-l">
<UiInput label="First Name">
<input type="text" v-model="user.firstname">
</UiInput>
<UiInput label="Last Name">
<input type="text" v-model="user.lastname">
</UiInput>
</div>
<div class="col-2 tile-l">
<UiInput label="Username" required>
<input type="text" v-model="user.username">
</UiInput>
<UiInput label="E-Mail" required>
<input type="text" v-model="user.email">
</UiInput>
</div>
<div class="col-2 tile-l">
<UiInput label="Role" required>
<select v-model="user.role">
<option value="USER">User</option>
<option value="ADMIN">Admin</option>
</select>
</UiInput>
<UiInput label="Password" required>
<input type="password" v-model="user.password">
</UiInput>
</div>
<div class="center">
<UiButton class="width-6rem" @click="() => useRouter().push('/account/settings/users')">Zurück</UiButton>
<UiButton class="width-6rem" @click="() => create()">Add</UiButton>
</div>
</div>
</template>
<script setup lang="ts">
import { User, UserCreation } from '~/classes/User';
import UiError from '~/components/ui/UiError.vue';
const user = ref(new UserCreation());
const error: Ref<string | undefined> = ref(undefined);
function create()
{
const _user = user.value;
if (_user.email && _user.username && _user.role && _user.password)
{
User.create(user.value, () => {
useRouter().push('/account/settings/users');
});
}
else
{
error.value = "Please fill out all required fields.";
throw new Error("Invalid user object.");
}
}
</script>
<style scoped>
</style>

View File

@ -0,0 +1,65 @@
<template>
<div class="content-l">
<div class="spaced-center">
<h2>Users</h2>
<UiButton icon="person_add" reverse @click="() => useRouter().push('/account/settings/users/add')">Add</UiButton>
</div>
<div class="content-l">
<div class="user-container">
<div class="contents">
<h3 class="gray">Username</h3>
<h3 class="gray">E-Mail</h3>
<h3 class="gray">Roles</h3>
</div>
<div v-for="user in users" class="contents user-row">
<p>{{ user.username }}</p>
<p>{{ Optional.ofNullable(user.email).orElse("-") }}</p>
<p v-if="user.roles">{{ user.roles.join(", ") }}</p>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { User } from '~/classes/User';
const users: Ref<User[] | undefined> = ref(undefined);
onMounted(() => {
User.get((_users) => {
users.value = _users;
});
});
</script>
<style scoped>
.user-container {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
row-gap: 0.5rem;
}
.user-container > :first-child > * {
margin-bottom: 0.5rem;
}
.contents {
display: contents;
}
.contents > * {
padding: 0.5rem;
}
.gray {
background-color: var(--shade-light);
}
.user-row > * {
background-color: var(--tile-color);
}
.contents > :first-child {
border-top-left-radius: 0.25rem;
border-bottom-left-radius: 0.25rem;
}
.contents > :last-child {
border-top-right-radius: 0.5rem;
border-bottom-right-radius: 0.25rem;
}
</style>

View File

@ -1,17 +1,22 @@
<template>
<div class="login-wrapper">
<div class="tile-l login-window">
<h2>Kubooboo</h2>
<img class="logo" src="@/assets/transparent_logo.png" alt="">
<form class="login content-xl" @submit.prevent="doLogin()">
<div class="tile-l content-l login-window">
<div class="content-l">
<h2 class="center">Kubooboo</h2>
<div class="center">
<img class="logo" src="@/assets/transparent_logo.png" alt=""></img>
</div>
<div class="center">
<h1>Login</h1>
</div>
</div>
<form class="login content-l" @submit.prevent="doLogin()">
<div class="content-l">
<UiInput label="Username">
<UiError :error="error" @close="() => { error = undefined }"></UiError>
<UiInput label="Username" required>
<input type="text" v-model="loginCredentials.username">
</UiInput>
<UiInput label="Passwort">
<UiInput label="Passwort" required>
<input type="password" v-model="loginCredentials.password">
</UiInput>
</div>
@ -30,11 +35,15 @@ import { login } from '~/requests/login';
import { jwtDecode } from 'jwt-decode';
import { getUser } from '~/requests/user';
import { Session } from '~/classes/Session';
import UiError from '~/components/ui/UiError.vue';
const loginCredentials = ref(new User());
const error: Ref<string | undefined> = ref(undefined);
const loading = ref(false);
function doLogin()
{
if (loginCredentials.value.username && loginCredentials.value.password)
{
loading.value = true;
login(loginCredentials.value, (token: string) => {
@ -43,8 +52,23 @@ function doLogin()
setSessionCookie(new Session(user, token), decode.exp as number);
useRouter().push('/account/inspect/nodes/_all');
});
}, (code?: string) => {
if (code === 'user_not_found')
{
error.value = "User does not exist.";
}
if (code === 'wrong_password')
{
error.value = "Invalid password.";
}
loading.value = false;
});
}
else
{
throw new Error("Cannot send invalid form.");
}
}
function setSessionCookie(session: Session, exp: number)
{

View File

@ -1,11 +1,13 @@
import axios from "axios";
import axios, { AxiosError, type AxiosResponse } from "axios";
import type { User } from "~/classes/User";
export function login(user: User, onSuccess: (token: string) => void)
export function login(user: User, onSuccess: (token: string) => void, onError: (code?: string) => void)
{
axios.post<string>(ApiConfig.getHttpBase() + '/login', user)
.then((response) => {
onSuccess(response.data);
})
.catch();
.catch((error: AxiosError<string, any>) => {
onError(error.response?.data);
});
}