feat: add local user creation

This commit is contained in:
thibaud-leclere 2026-05-06 08:50:30 +02:00
parent 892f69817b
commit d4bbde7deb
5 changed files with 177 additions and 12 deletions

View file

@ -477,6 +477,8 @@
"created_on": "Created On",
"copy_invite_link": "Copy Invite Link",
"copy_link": "Copy Link",
"confirm_password": "Confirm Password",
"create_local_user": "Create Local User",
"date": "Date",
"delete": "Delete",
"delete_user": "Delete User",
@ -498,6 +500,8 @@
"last_active_on": "Last Active",
"load_info_error": "Unable to load user info",
"load_list_error": "Unable to Load Users List",
"local_user_create_failed": "Failed to create local user",
"local_user_created": "Local user created",
"make_admin": "Make Admin",
"name": "Name",
"new_user_added": "New User Added",
@ -508,6 +512,9 @@
"not_found": "User not found",
"pending_invites": "Pending Invites",
"pending_invites_description": "Manage and track pending user invitations with clear status and actions.",
"password": "Password",
"password_min_length": "Password must be at least 12 characters.",
"password_mismatch": "Passwords do not match.",
"remove_admin_privilege": "Remove Admin Privilege",
"remove_admin_status": "Remove Admin Status",
"rename": "Rename",
@ -517,6 +524,9 @@
"show_more": "Show more",
"uid": "UID",
"unnamed": "(Unnamed User)",
"username": "Username",
"username_format": "Username can only contain letters, numbers, dots, dashes, and underscores.",
"username_min_length": "Username must be at least 3 characters.",
"user_not_found": "User not found in the infra!!",
"users": "Users",
"valid_email": "Please enter a valid email address"

View file

@ -20,5 +20,17 @@
"password": "Mot de passe",
"sign_in": "Se connecter",
"username": "Identifiant"
},
"users": {
"confirm_password": "Confirmer le mot de passe",
"create_local_user": "Créer un utilisateur local",
"local_user_create_failed": "Impossible de créer lutilisateur local",
"local_user_created": "Utilisateur local créé",
"password": "Mot de passe",
"password_min_length": "Le mot de passe doit contenir au moins 12 caractères.",
"password_mismatch": "Les mots de passe ne correspondent pas.",
"username": "Identifiant",
"username_format": "Lidentifiant ne peut contenir que des lettres, chiffres, points, tirets et underscores.",
"username_min_length": "Lidentifiant doit contenir au moins 3 caractères."
}
}

View file

@ -227,6 +227,19 @@ export const auth = {
await setInitialUser();
},
createLocalUser: async (
username: string,
password: string,
displayName?: string,
) => {
const res = await authQuery.createLocalUser(
username,
password,
displayName,
);
return res.data;
},
performAuthRefresh: async () => {
const isRefreshSuccess = await refreshToken();

View file

@ -39,6 +39,12 @@ export default {
username,
password,
}),
createLocalUser: (username: string, password: string, displayName?: string) =>
restApi.post('/auth/local/users', {
username,
password,
displayName,
}),
getFirstTimeInfraSetupStatus: () => restApi.get('/site/setup'),
updateFirstTimeInfraSetupStatus: () => restApi.put('/site/setup'),
addOnBoardingConfigs: (config: Record<string, any>) =>

View file

@ -11,6 +11,12 @@
@click="showInviteUserModal = true"
:icon="IconAddUser"
/>
<HoppButtonPrimary
v-if="localAuthEnabled"
:label="t('users.create_local_user')"
@click="showCreateLocalUserModal = true"
:icon="IconKeyRound"
/>
<div class="flex">
<HoppButtonSecondary
outline
@ -224,6 +230,54 @@
@hide-modal="inviteSuccessModal = false"
@copy-invite-link="copyInviteLink"
/>
<HoppSmartModal
v-if="showCreateLocalUserModal"
dialog
:title="t('users.create_local_user')"
@close="hideCreateLocalUserModal"
>
<template #body>
<div class="flex flex-col space-y-4">
<HoppSmartInput
v-model="localUserForm.displayName"
:label="t('users.name')"
input-styles="floating-input"
/>
<HoppSmartInput
v-model="localUserForm.username"
:label="t('users.username')"
input-styles="floating-input"
/>
<HoppSmartInput
v-model="localUserForm.password"
:label="t('users.password')"
input-styles="floating-input"
type="password"
/>
<HoppSmartInput
v-model="localUserForm.confirmPassword"
:label="t('users.confirm_password')"
input-styles="floating-input"
type="password"
/>
</div>
</template>
<template #footer>
<div class="flex justify-end space-x-2">
<HoppButtonSecondary
:label="t('users.cancel')"
outline
filled
@click="hideCreateLocalUserModal"
/>
<HoppButtonPrimary
:label="t('users.create_local_user')"
:loading="creatingLocalUser"
@click="createLocalUser"
/>
</div>
</template>
</HoppSmartModal>
<HoppSmartConfirmModal
:show="confirmUsersToAdmin"
:title="
@ -261,11 +315,12 @@
import { useMutation, useQuery } from '@urql/vue';
import { useTimeAgo } from '@vueuse/core';
import { format } from 'date-fns';
import { computed, onUnmounted, ref, watch } from 'vue';
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { useI18n } from '~/composables/i18n';
import { useToast } from '~/composables/toast';
import { usePagedQuery } from '~/composables/usePagedQuery';
import { auth } from '~/helpers/auth';
import {
DemoteUsersByAdminDocument,
InviteNewUserDocument,
@ -289,11 +344,17 @@ import IconUserCheck from '~icons/lucide/user-check';
import IconUserMinus from '~icons/lucide/user-minus';
import IconAddUser from '~icons/lucide/user-plus';
import IconX from '~icons/lucide/x';
import IconKeyRound from '~icons/lucide/key-round';
// Get Proper Date Formats
const t = useI18n();
const toast = useToast();
onMounted(async () => {
localAuthEnabled.value =
(await auth.getAllowedAuthProviders())?.includes('LOCAL') ?? false;
});
// Time and Date Helpers
const getCreatedDate = (date: string) => format(new Date(date), 'dd-MMMM-yyyy');
const getCreatedTime = (date: string) => format(new Date(date), 'hh:mm a');
@ -321,14 +382,14 @@ const {
UsersListV2Document,
(x) => x.infra.allUsersV2,
usersPerPage,
{ searchString: '', take: usersPerPage, skip: 0 }
{ searchString: '', take: usersPerPage, skip: 0 },
);
// Selected Rows
const selectedRows = ref<UsersListQuery['infra']['allUsers']>([]);
const areAdminsSelected = computed(() =>
selectedRows.value.some((user) => user.isAdmin)
selectedRows.value.some((user) => user.isAdmin),
);
const areNonAdminsSelected = computed(() => {
@ -396,9 +457,9 @@ const finalUsersList = computed(() =>
searchQuery.value.length > 0
? usersList.value.slice(
(page.value - 1) * usersPerPage,
page.value * usersPerPage
page.value * usersPerPage,
)
: usersList.value
: usersList.value,
);
// Spinner
@ -481,6 +542,69 @@ const copyInviteLink = () => {
// Send Invitation through Email
const showInviteUserModal = ref(false);
const sendInvitation = useMutation(InviteNewUserDocument);
const localAuthEnabled = ref(false);
const showCreateLocalUserModal = ref(false);
const creatingLocalUser = ref(false);
const localUserForm = ref({
displayName: '',
username: '',
password: '',
confirmPassword: '',
});
const hideCreateLocalUserModal = () => {
showCreateLocalUserModal.value = false;
localUserForm.value = {
displayName: '',
username: '',
password: '',
confirmPassword: '',
};
};
const createLocalUser = async () => {
const username = localUserForm.value.username.trim();
const password = localUserForm.value.password;
const displayName = localUserForm.value.displayName.trim();
if (username.length < 3) {
toast.error(t('users.username_min_length'));
return;
}
if (!/^[a-zA-Z0-9_.-]+$/.test(username)) {
toast.error(t('users.username_format'));
return;
}
if (password.length < 12) {
toast.error(t('users.password_min_length'));
return;
}
if (password !== localUserForm.value.confirmPassword) {
toast.error(t('users.password_mismatch'));
return;
}
creatingLocalUser.value = true;
try {
await auth.createLocalUser(username, password, displayName || undefined);
toast.success(t('users.local_user_created'));
hideCreateLocalUserModal();
await refetch({
searchString: searchQuery.value,
take: usersPerPage,
skip: (page.value - 1) * usersPerPage,
});
} catch (err) {
console.error(err);
toast.error(t('users.local_user_create_failed'));
} finally {
creatingLocalUser.value = false;
}
};
const sendInvite = async (email: string) => {
const trimmedEmail = email.trim();
@ -538,13 +662,13 @@ const makeUsersToAdmin = async (id: string | null) => {
toast.error(
areMultipleUsersSelected.value
? t('state.users_to_admin_failure')
: t('state.admin_failure')
: t('state.admin_failure'),
);
} else {
toast.success(
areMultipleUsersSelected.value
? t('state.users_to_admin_success')
: t('state.admin_success')
: t('state.admin_success'),
);
usersList.value = usersList.value.map((user) => ({
...user,
@ -573,7 +697,7 @@ const resetConfirmAdminToUser = () => {
};
const areMultipleUsersSelectedToAdmin = computed(
() => selectedRows.value.length > 1
() => selectedRows.value.length > 1,
);
const makeAdminsToUsers = async (id: string | null) => {
@ -591,13 +715,13 @@ const makeAdminsToUsers = async (id: string | null) => {
toast.error(
areMultipleUsersSelected.value
? t('state.remove_admin_from_users_failure')
: t('state.remove_admin_failure')
: t('state.remove_admin_failure'),
);
} else {
toast.success(
areMultipleUsersSelected.value
? t('state.remove_admin_from_users_success')
: t('state.remove_admin_success')
: t('state.remove_admin_success'),
);
usersList.value = usersList.value.map((user) => ({
...user,
@ -627,7 +751,7 @@ const resetConfirmUserDeletion = () => {
};
const areMultipleUsersSelectedForDeletion = computed(
() => selectedRows.value.length > 1
() => selectedRows.value.length > 1,
);
const deleteUsers = async (id: string | null) => {
@ -649,7 +773,7 @@ const deleteUsers = async (id: string | null) => {
handleUserDeletion(deletedUsers);
usersList.value = usersList.value.filter(
(user) => !deletedUserIDs.includes(user.uid)
(user) => !deletedUserIDs.includes(user.uid),
);
selectedRows.value.splice(0, selectedRows.value.length);