Skip to content
60 changes: 31 additions & 29 deletions ui/admin/src/components/User/UserFormDialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -99,22 +99,21 @@
color="primary"
variant="outlined"
/>
<v-tooltip location="bottom" class="text-center" :disabled="!emailIsConfirmed">
<v-tooltip location="bottom" class="text-center" :disabled="canChangeStatus">
<template v-slot:activator="{ props }">
<div v-bind="props">
<v-checkbox
v-if="!createUser"
label="User confirmed"
v-model="userConfirmed"
:error-messages="userConfirmedError"
:disabled="emailIsConfirmed"
v-model="isConfirmed"
:disabled="!canChangeStatus"
density="compact"
hide-details
color="primary"
/>
</div>
</template>
<span>You cannot unsubscribe the user's email confirmation</span>
<span>{{ statusTooltipMessage }}</span>
</v-tooltip>
</v-container>
</v-col>
Expand All @@ -135,30 +134,21 @@
</template>

<script setup lang="ts">
import { ref, computed, watch, PropType } from "vue";
import { ref, watch, PropType } from "vue";
import axios, { AxiosError } from "axios";
import * as yup from "yup";
import { useField, useForm } from "vee-validate";
import useUsersStore from "@admin/store/modules/users";
import { IUser } from "@admin/interfaces/IUser";
import useSnackbar from "@/helpers/snackbar";

type UserLocal = {
id?: string;
name?: string;
email?: string;
username?: string;
password?: string;
confirmed?: boolean;
max_namespaces?: number;
};

const props = defineProps({
createUser: {
type: Boolean,
default: false,
},
user: {
type: Object as PropType<UserLocal>,
type: Object as PropType<IUser>,
default: () => ({}),
},
titleCard: {
Expand All @@ -171,10 +161,13 @@ const dialog = ref(false);
const showPassword = ref(false);
const changeNamespaceLimit = ref(false);
const disableNamespaceCreation = ref(false);
const maxNamespaces = ref<number | undefined>(props.user?.max_namespaces || 0);
const emailIsConfirmed = computed(() => props.user?.confirmed);
const maxNamespaces = ref(props.user?.max_namespaces || 0);
const canChangeStatus = props.user.status === "not-confirmed"; // Only allow changing status if the user is not confirmed
const snackbar = useSnackbar();
const userStore = useUsersStore();
const statusTooltipMessage = props.user.status === "invited"
? "You cannot change the status of an invited user."
: "You cannot remove confirmation from a user.";

const { value: name,
errorMessage: nameError,
Expand All @@ -198,25 +191,24 @@ const {
} = useField<string | undefined>("password");

const {
value: userConfirmed,
errorMessage: userConfirmedError,
resetField: resetUserConfirmed,
} = useField<boolean | undefined>("userConfirmed");
value: isConfirmed,
resetField: resetIsConfirmed,
} = useField<boolean | undefined>("isConfirmed");

const resetFormFields = () => {
resetName();
resetEmail();
resetUsername();
resetPassword();
resetUserConfirmed();
resetIsConfirmed();
};

const populateFieldsFromProps = () => {
name.value = props.user?.name;
email.value = props.user?.email;
username.value = props.user?.username;
password.value = undefined;
userConfirmed.value = props.user?.confirmed;
isConfirmed.value = props.user?.status === "confirmed";
maxNamespaces.value = props.user?.max_namespaces || 0;
changeNamespaceLimit.value = props.user?.max_namespaces !== -1;
};
Expand All @@ -237,7 +229,7 @@ const setMaxNamespaces = () => {
maxNamespaces.value = disableNamespaceCreation.value ? 0 : maxNamespaces.value;
};

const { handleSubmit } = useForm<UserLocal>();
const { handleSubmit } = useForm<IUser>();

const handleErrors = (error: AxiosError) => {
if (!error.response?.data) return;
Expand Down Expand Up @@ -280,13 +272,24 @@ const submitUser = async (isCreating: boolean, userData: Record<string, unknown>
}
};

const getStatus = () => {
if (props.createUser) return undefined;

if (canChangeStatus) {
return isConfirmed.value ? "confirmed" : "not-confirmed";
}

return props.user?.status;
};

const prepareUserData = (): Record<string, unknown> => ({
name: name.value,
email: email.value,
username: username.value,
password: password.value || "",
max_namespaces: changeNamespaceLimit.value ? maxNamespaces.value : undefined,
confirmed: !props.createUser ? userConfirmed.value : undefined,
confirmed: !props.createUser ? isConfirmed.value : undefined,
status: getStatus(),
id: !props.createUser ? props.user?.id : undefined,
});

Expand Down Expand Up @@ -315,11 +318,10 @@ watch(disableNamespaceCreation, (newValue) => {

defineExpose({
openDialog,
emailIsConfirmed,
userConfirmed,
password,
name,
email,
username,
isConfirmed,
});
</script>
45 changes: 13 additions & 32 deletions ui/admin/src/components/User/UserList.vue
Original file line number Diff line number Diff line change
Expand Up @@ -29,15 +29,8 @@
<td :namespaces-test="item.namespaces">
{{ item.namespaces }}
</td>
<td v-if="item.confirmed" class="pl-0">
<v-chip class="ma-2" color="success" variant="text" prepend-icon="mdi-checkbox-marked-circle">
Confirmed
</v-chip>
</td>
<td v-else class="pl-0">
<v-chip class="ma-2" color="warning" variant="text" prepend-icon="mdi-alert-circle">
Not confirmed
</v-chip>
<td class="pl-0">
<UserStatusChip :status="item.status" />
</td>

<td>
Expand All @@ -64,17 +57,17 @@
tag="a"
dark
v-bind="props"
@click="loginToken(item)"
@click="loginToken(item.id)"
tabindex="0"
@keyup.enter="loginToken(item)"
@keyup.enter="loginToken(item.id)"
>mdi-login
</v-icon>
</template>
<span>Login</span>
</v-tooltip>

<UserResetPassword
v-if="checkAuthMethods(item as IUser)"
v-if="userPrefersSAMLAuthentication(item.preferences.auth_methods)"
:userId="item.id"
@update="refreshUsers"
/>
Expand All @@ -91,27 +84,15 @@
import { computed, onMounted, ref, watch } from "vue";
import { useRouter } from "vue-router";
import useUsersStore from "@admin/store/modules/users";
import { UserAdminResponse } from "@admin/api/client/api";
import { IUser, UserAuthMethods } from "@admin/interfaces/IUser";
import useAuthStore from "@admin/store/modules/auth";
import useSnackbar from "@/helpers/snackbar";
import DataTable from "../DataTable.vue";
import UserStatusChip from "./UserStatusChip.vue";
import UserFormDialog from "./UserFormDialog.vue";
import UserDelete from "./UserDelete.vue";
import UserResetPassword from "./UserResetPassword.vue";

export interface IUser {
id: string;
auth_methods: Array<string>;
namespaces: number;
confirmed: boolean;
created_at: string;
last_login: string;
name: string;
email: string;
username: string;
password: string;
}

const router = useRouter();
const snackbar = useSnackbar();
const userStore = useUsersStore();
Expand Down Expand Up @@ -151,9 +132,9 @@ const header = [
},
];

const checkAuthMethods = (user: IUser | undefined) => user?.auth_methods
&& user.auth_methods.length === 1
&& user.auth_methods[0] === "saml";
const userPrefersSAMLAuthentication = (authMethods: UserAuthMethods) => (
authMethods && authMethods.length === 1 && authMethods[0] === "saml"
);

onMounted(async () => {
try {
Expand Down Expand Up @@ -205,9 +186,9 @@ watch(itemsPerPage, () => {
getUsers(itemsPerPage.value, page.value);
});

const loginToken = async (user) => {
const loginToken = async (userId: string) => {
try {
const token = await authStore.loginToken(user);
const token = await authStore.loginToken(userId);

const url = `/login?token=${token}`;
window.open(url, "_target");
Expand All @@ -220,7 +201,7 @@ const refreshUsers = async () => {
await userStore.refresh();
};

const redirectToUser = async (user: UserAdminResponse) => {
const redirectToUser = async (user: IUser) => {
router.push({ name: "userDetails", params: { id: user.id } });
};

Expand Down
29 changes: 29 additions & 0 deletions ui/admin/src/components/User/UserStatusChip.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<template>
<v-chip
class="ma-2"
:color="statusChipAttributes.color"
variant="text"
:prepend-icon="statusChipAttributes.icon"
>
{{ statusChipAttributes.label }}
</v-chip>
</template>

<script setup lang="ts">
import { UserStatus } from "@admin/interfaces/IUser";
import { computed } from "vue";

const { status } = defineProps<{
status: UserStatus;
}>();

const validStatuses = ["confirmed", "invited", "not-confirmed"];

const safeStatus = computed(() => validStatuses.includes(status) ? status : "not-confirmed");

const statusChipAttributes = computed(() => ({
confirmed: { color: "success", icon: "mdi-checkbox-marked-circle", label: "Confirmed" },
invited: { color: "warning", icon: "mdi-email-alert", label: "Invited" },
"not-confirmed": { color: "error", icon: "mdi-alert-circle", label: "Not Confirmed" },
}[safeStatus.value]));
</script>
10 changes: 9 additions & 1 deletion ui/admin/src/interfaces/IUser.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
export type UserStatus = "confirmed" | "invited" | "not-confirmed";

export type UserAuthMethods = Array<"saml" | "local">;

export interface IUser {
id: string;
namespaces: number;
confirmed: boolean;
max_namespaces: number;
status: UserStatus;
created_at: string;
last_login: string;
name: string;
email: string;
username: string;
password: string;
preferences: {
auth_methods: UserAuthMethods;
}
}
21 changes: 7 additions & 14 deletions ui/admin/src/store/api/users.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,6 @@
import { adminApi } from "./../../api/http";

type userDataType = {
name: string;
email: string;
username: string;
password: string;
confirmed?: boolean;
max_namespaces?: number;
};
import { IUser } from "@admin/interfaces/IUser";
import { UserAdminRequest } from "@admin/api/client";
import { adminApi } from "@admin/api/http";

const fetchUsers = async (
perPage: number,
Expand All @@ -19,22 +12,22 @@ const getUser = (id: string) => adminApi.getUser(id);

const exportUsers = async (filter: string) => adminApi.exportUsers(filter);

const addUser = (userData: userDataType) => adminApi.createUserAdmin({
const addUser = (userData: IUser) => adminApi.createUserAdmin({
name: userData.name,
email: userData.email,
username: userData.username,
password: userData.password,
max_namespaces: userData.max_namespaces,
});

const putUser = async (id: string, userData: userDataType) => adminApi.adminUpdateUser(id, {
const putUser = async (id: string, userData: IUser) => adminApi.adminUpdateUser(id, {
name: userData.name,
email: userData.email,
username: userData.username,
password: userData.password,
confirmed: userData.confirmed,
status: userData.status,
max_namespaces: userData.max_namespaces,
});
} as UserAdminRequest);

const resetUserPassword = async (id: string) => adminApi.adminResetUserPassword(id);

Expand Down
4 changes: 2 additions & 2 deletions ui/admin/src/store/modules/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,9 @@ export const useAuthStore = defineStore("auth", {
}
},

async loginToken(user: { id: string }) {
async loginToken(userId: string) {
try {
const resp = await getToken(user.id);
const resp = await getToken(userId);
return resp.data.token;
} catch (error) {
this.status = "error";
Expand Down
Loading