feat: Integrate official drive_provider, update user profile features & UI improvements
All checks were successful
CI / container-job (push) Successful in 41s

- adonisrc.ts: Load official drive_provider and unload custom driver_provider.
- packages.json: Add @headlessui/vue dependency for tab components.
- AvatarController.ts: Rewrite avatar generation logic to always return the same avatar per user.
- auth/UserController.ts: Add profile and profileUpdate methods to support user profile editing.
- Submitter/datasetController.ts & app/models/file.ts: Adapt code to use the official drive_provider.
- app/models/user.ts: Introduce “isAdmin” getter.
- config/drive.ts: Create new configuration for the official drive_provider.
- providers/vinejs_provider.ts: Adapt allowedExtensions control to use provided options or database enabled extensions.
- resource/js/app.ts: Load default Head and Link components.
- resources/js/menu.ts: Add settings-profile.edit menu point.
- resources/js/Components/action-message.vue: Add new component for improved user feedback after form submissions.
- New avatar-input.vue component: Enable profile picture selection.
- Components/CardBox.vue: Alter layout to optionally show HeaderIcon in title bar.
- FormControl.vue: Define a readonly prop for textareas.
- Improve overall UI with updates to NavBar.vue, UserAvatar.vue, UserAvatarCurrentUser.vue, and add v-model support to password-meter.vue.
- Remove profile editing logic from AccountInfo.vue and introduce new profile components (show.vue, update-password-form.vue, update-profile-information.vue).
- app.edge: Modify page (add @inertiaHead tag) for better meta management.
- routes.ts: Add new routes for editing user profiles.
- General npm updates.
This commit is contained in:
Kaimbacher 2025-02-27 16:24:25 +01:00
parent a41b091214
commit 36cd7a757b
34 changed files with 1396 additions and 407 deletions

View file

@ -1,5 +1,5 @@
<script lang="ts" setup>
import { Head, usePage } from '@inertiajs/vue3';
import { usePage } from '@inertiajs/vue3';
import { mdiAccountKey, mdiSquareEditOutline, mdiAlertBoxOutline } from '@mdi/js';
import { computed, ComputedRef } from 'vue';
import LayoutAuthenticated from '@/Layouts/LayoutAuthenticated.vue';

View file

@ -12,7 +12,7 @@ import {
mdiAsterisk,
mdiFormTextboxPassword,
mdiArrowLeftBoldOutline,
mdiAlertBoxOutline,
mdiAlertBoxOutline,
} from '@mdi/js';
import SectionMain from '@/Components/SectionMain.vue';
import CardBox from '@/Components/CardBox.vue';
@ -36,16 +36,16 @@ import PasswordMeter from '@/Components/SimplePasswordMeter/password-meter.vue';
const emit = defineEmits(['confirm', 'update:confirmation'])
const enabled = ref(false);
const handleScore = (score: number) => {
if (score >= 4){
enabled.value = true;
} else {
enabled.value = false;
}
// strengthLabel.value = scoreLabel;
// score.value = scoreValue;
};
// const enabled = ref(false);
// const handleScore = (score: number) => {
// if (score >= 4) {
// enabled.value = true;
// } else {
// enabled.value = false;
// }
// // strengthLabel.value = scoreLabel;
// // score.value = scoreValue;
// };
defineProps({
// user will be returned from controller action
@ -82,20 +82,20 @@ defineProps({
// };
const passwordForm = useForm({
old_password: '',
new_password: '',
confirm_password: '',
});
const passwordSubmit = async () => {
await passwordForm.post(stardust.route('account.password.store'), {
preserveScroll: true,
onSuccess: () => {
// console.log(resp);
passwordForm.reset();
},
});
};
// const passwordForm = useForm({
// old_password: '',
// new_password: '',
// confirm_password: '',
// });
// const passwordSubmit = async () => {
// await passwordForm.post(stardust.route('account.password.store'), {
// preserveScroll: true,
// onSuccess: () => {
// // console.log(resp);
// passwordForm.reset();
// },
// });
// };
const flash: Ref<any> = computed(() => {
return usePage().props.flash;
@ -139,40 +139,10 @@ const flash: Ref<any> = computed(() => {
{{ $page.props.flash.message }}
</NotificationBar> -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- <div class="grid grid-cols-1 lg:grid-cols-1 gap-6"> -->
<div class="grid grid-cols-1 gap-6">
<!-- password form -->
<!-- <CardBox title="Edit Profile" :icon="mdiAccountCircle" form @submit.prevent="profileForm.post(route('admin.account.info.store'))"> -->
<!-- <CardBox title="Edit Profile" :icon="mdiAccountCircle" form @submit.prevent="profileSubmit()">
<FormField label="Login" help="Required. Your login name" :class="{ 'text-red-400': errors.login }">
<FormControl v-model="profileForm.login" v-bind:icon="mdiAccount" name="login" required :error="errors.login">
<div class="text-red-400 text-sm" v-if="errors.login">
{{ errors.login }}
</div>
</FormControl>
</FormField>
<FormField label="Email" help="Required. Your e-mail" :class="{ 'text-red-400': errors.email }">
<FormControl v-model="profileForm.email" :icon="mdiMail" type="email" name="email" required :error="errors.email">
<div class="text-red-400 text-sm" v-if="errors.email">
{{ errors.email }}
</div>
</FormControl>
</FormField>
<template #footer>
<BaseButtons>
<BaseButton color="info" type="submit" label="Submit" />
</BaseButtons>
</template>
</CardBox> -->
<!-- password form -->
<!-- <CardBox title="Change Password" :icon="mdiLock" form @submit.prevent="passwordForm.post(route('admin.account.password.store'), {
preserveScroll: true,
onSuccess: () => passwordForm.reset(),
}) "> -->
<CardBox id="passwordForm" title="Change Password" :icon="mdiLock" form @submit.prevent="passwordSubmit()">
<!-- <CardBox id="passwordForm" title="Change Password" :icon="mdiLock" form
@submit.prevent="passwordSubmit()">
<FormValidationErrors v-bind:errors="errors" />
<FormField label="Current password" help="Required. Your current password"
@ -186,22 +156,15 @@ const flash: Ref<any> = computed(() => {
</FormField>
<BaseDivider />
<!-- <FormField label="New password" help="Required. New password"
:class="{ 'text-red-400': passwordForm.errors.new_password }">
<FormControl v-model="passwordForm.new_password" :icon="mdiFormTextboxPassword" name="new_password"
type="password" required :error="passwordForm.errors.new_password">
<div class="text-red-400 text-sm" v-if="passwordForm.errors.new_password">
{{ passwordForm.errors.new_password }}
</div>
</FormControl>
</FormField> -->
<PasswordMeter v-model:password="passwordForm.new_password" :errors="passwordForm.errors" @score="handleScore" />
<PasswordMeter v-model="passwordForm.new_password" :errors="passwordForm.errors"
@score="handleScore" />
<FormField label="Confirm password" help="Required. New password one more time"
:class="{ 'text-red-400': passwordForm.errors.confirm_password }">
<FormControl v-model="passwordForm.confirm_password" :icon="mdiFormTextboxPassword"
name="confirm_password" type="password" required :error="passwordForm.errors.confirm_password">
name="confirm_password" type="password" required
:error="passwordForm.errors.confirm_password">
<div class="text-red-400 text-sm" v-if="passwordForm.errors.confirm_password">
{{ passwordForm.errors.confirm_password }}
</div>
@ -219,16 +182,17 @@ const flash: Ref<any> = computed(() => {
<template #footer>
<BaseButtons>
<BaseButton type="submit" color="info" label="Change password" :disabled="passwordForm.processing == true || enabled == false" />
<BaseButton type="submit" color="info" label="Change password"
:disabled="passwordForm.processing == true || enabled == false" />
</BaseButtons>
</template>
</CardBox>
</CardBox> -->
<PersonalTotpSettings :twoFactorEnabled="twoFactorEnabled" :backupState="backupState">
</PersonalTotpSettings>
<!-- <PersonalSettings :state="backupState"/> -->
<PersonalTotpSettings :twoFactorEnabled="twoFactorEnabled" :backupState="backupState">
</PersonalTotpSettings>
<!-- <PersonalSettings :state="backupState"/> -->
<!-- <CardBox v-if="!props.twoFactorEnabled" title="Two-Factor Authentication" :icon="mdiInformation" form
@submit.prevent="enableTwoFactorAuthentication()">
@ -248,7 +212,7 @@ const flash: Ref<any> = computed(() => {
</template>
</CardBox> -->
</div>
</SectionMain>

View file

@ -16,6 +16,7 @@ import {
// import { containerMaxW } from '@/config.js'; // "xl:max-w-6xl xl:mx-auto"
// import * as chartConfig from '@/Components/Charts/chart.config.js';
import LineChart from '@/Components/Charts/LineChart.vue';
import UserCard from '@/Components/unused/UserCard.vue';
import SectionMain from '@/Components/SectionMain.vue';
import CardBoxWidget from '@/Components/CardBoxWidget.vue';
import CardBox from '@/Components/CardBox.vue';
@ -134,6 +135,8 @@ const datasets = computed(() => mainService.datasets);
</div>
</div>
<UserCard />
<SectionBannerStarOnGitHub />
<SectionTitleLineWithButton :icon="mdiChartPie" title="Trends overview: Publications per month" />

View file

@ -0,0 +1,135 @@
<script lang="ts" setup>
import FormControl from '@/Components/FormControl.vue';
import FormField from '@/Components/FormField.vue';
import ActionMessage from '@/Components/action-message.vue'
import BaseButton from '@/Components/BaseButton.vue';
import BaseButtons from '@/Components/BaseButtons.vue';
import { useForm, usePage } from '@inertiajs/vue3';
import { ref, Ref, computed } from 'vue';
import { stardust } from '@eidellev/adonis-stardust/client';
import CardBox from '@/Components/CardBox.vue';
import { mdiLock } from '@mdi/js';
import PasswordMeter from '@/Components/SimplePasswordMeter/password-meter.vue';
// import BaseDivider from '@/Components/BaseDivider.vue';
// const errors: Ref<any> = computed(() => {
// return usePage().props.errors;
// });
const flash: Ref<any> = computed(() => {
return usePage().props.flash;
});
const newPasswordInput: Ref<typeof FormControl | null> = ref(null);
const oldPasswordInput: Ref<typeof FormControl | null> = ref(null);
const enabled = ref(false);
const handleScore = (score: number) => {
if (score >= 4) {
enabled.value = true;
} else {
enabled.value = false;
}
// strengthLabel.value = scoreLabel;
// score.value = scoreValue;
};
const form = useForm({
old_password: '',
new_password: '',
confirm_password: '',
});
const updatePassword = async () => {
await form.put(stardust.route('settings.password.update'), {
preserveScroll: true,
onSuccess: () => {
form.reset();
},
onError: () => {
if (form.errors.new_password) {
form.reset('new_password', 'confirm_password');
enabled.value = false;
// newPasswordInput.value.focus();
// newPasswordInput.value?.focus();
}
if (form.errors.old_password) {
form.reset('old_password');
// oldPasswordInput.value?.focus();
}
},
});
};
</script>
<template>
<!-- <div class="p-7 text-gray-900 bg-white rounded-lg border border-gray-100 shadow dark:border-gray-600 dark:bg-secondary-dark dark:text-white"> -->
<!-- <div class="mb-4">
<h3 class="text-dark text-md">{{ 'Update Password' }}</h3>
</div> -->
<CardBox id="passwordForm" title="Change Password" :icon="mdiLock" form :show-header-icon="false">
<FormField label="Current password" help="Required. Your current password"
:class="{ 'text-red-400': form.errors.old_password }">
<FormControl label="Current Password" id="current_password" ref="oldPasswordInput"
:placeholder="'Please Enter Current Password'" v-model="form.old_password"
:error="form.errors.old_password" type="password" class="block w-full" autocomplete="current-password">
<div class="text-red-400 text-sm" v-if="form.errors.old_password">
{{ form.errors.old_password }}
</div>
</FormControl>
</FormField>
<!-- <div class="col-span-6 sm:col-span-4"> -->
<!-- <FormControl label="New Password" id="password" :placeholder="'Please Enter New Password'"
ref="newPasswordInput" v-model="form.new_password" type="password" class="block w-full"
autocomplete="new-password" :error="form.errors.new_password">
<div class="text-red-400 text-sm" v-if="form.errors.new_password">
{{ form.errors.new_password }}
</div>
</FormControl> -->
<PasswordMeter ref="newPasswordInput" v-model="form.new_password" :errors="form.errors"
@score="handleScore" />
<!-- </div> -->
<FormField label="Confirm password" help="Required. New password one more time"
:class="{ 'text-red-400': form.errors.confirm_password }">
<FormControl label="Confirm Password" :placeholder="'Please Enter Confirm Password'" id="confirm_password"
v-model="form.confirm_password" type="password" class="block w-full" autocomplete="new-password"
:error="form.errors.confirm_password">
<div class="text-red-400 text-sm" v-if="form.errors.confirm_password">
{{ form.errors.confirm_password }}
</div>
</FormControl>
</FormField>
<!-- <BaseDivider /> -->
<template #footer>
<div class="flex items-center justify-end gap-3 mt-5">
<ActionMessage v-if="flash.message" :on="form.recentlySuccessful" color="success">
{{ flash.message }}
</ActionMessage>
<ActionMessage v-if="flash.warning" :on="form.recentlySuccessful" color="warning">
{{ flash.warning }}
</ActionMessage>
<BaseButtons>
<BaseButton type="submit" color="info" label="Change password" @click.prevent="updatePassword()"
:disabled="form.processing == true || enabled == false" />
</BaseButtons>
</div>
</template>
</CardBox>
</template>

View file

@ -0,0 +1,179 @@
<script lang="ts" setup>
import { computed, Ref, ref } from 'vue';
import { useForm, usePage } from '@inertiajs/vue3';
import ActionMessage from '@/Components/action-message.vue'
import FormControl from '@/Components/FormControl.vue';
import FormField from '@/Components/FormField.vue';
import CardBox from '@/Components/CardBox.vue';
import { mdiAccount } from '@mdi/js';
import BaseButton from '@/Components/BaseButton.vue';
import BaseButtons from '@/Components/BaseButtons.vue'
import { stardust } from '@eidellev/adonis-stardust/client';
import AvatarInput from '@/Components/avatar-input.vue';
const props = defineProps({
user: {
type: Object,
required: true,
},
defaultUrl: {
type: String,
required: false,
},
});
const errors: Ref<any> = computed(() => {
return usePage().props.errors || {}
});
const flash: Ref<any> = computed(() => {
return usePage().props.flash;
});
const fullName = computed(() => `${props.user.first_name} ${props.user.last_name}`);
const recentlyHasError = ref(false);
const form = useForm({
first_name: props.user.first_name,
last_name: props.user.last_name,
login: props.user.login,
email: props.user.email,
// mobile: `${props.user.mobile}`,
avatar: undefined as File | undefined,
});
// const verificationLinkSent = ref(null);
const avatarInput: Ref<HTMLInputElement | null> = ref(null);
const updateProfileInformation = () => {
// if (avatarInput.value) {
// form.avatar = avatarInput.value?.files ? avatarInput.value.files[0] : undefined;
// }
form.put(stardust.route('settings.profile.update', [props.user.id]), {
errorBag: 'updateProfileInformation',
preserveScroll: true,
onSuccess: () => {
// clearPhotoFileInput();
},
onError: () => {
if (form.errors.avatar) {
if (avatarInput.value) {
avatarInput.value.value = '';
}
}
recentlyHasError.value = true
setTimeout(() => {
recentlyHasError.value = false
}, 5000)
},
});
};
// const sendEmailVerification = () => {
// verificationLinkSent.value = true;
// };
</script>
<template>
<CardBox id="passwordForm" title="Basic Info" :icon="mdiAccount" form :show-header-icon="false">
<!-- <FormValidationErrors v-bind:errors="errors" /> -->
<AvatarInput class="h-24 w-24 rounded-full" v-model="form.avatar" ref="avatarInput"
:default-src="defaultUrl ? defaultUrl : '/api/avatar?name=' + fullName + '&size=50'">
</AvatarInput>
<div class="text-red-400 text-sm" v-if="errors.avatar && Array.isArray(errors.avatar)">
{{ errors.avatar.join(', ') }}
</div>
<FormField label="First Name" :class="{ 'text-red-400': form.errors.first_name }">
<FormControl id="first_name" label="First Name" v-model="form.first_name" :error="form.errors.first_name"
type="text" :placeholder="'First Name'" class="w-full" autocomplete="first_name">
<div class="text-red-400 text-sm" v-if="errors.first_name && Array.isArray(errors.first_name)">
{{ errors.first_name.join(', ') }}
</div>
</FormControl>
</FormField>
<FormField label="Last Name" :class="{ 'text-red-400': form.errors.last_name }">
<FormControl id="last_name" label="Last Name" v-model="form.last_name" :error="form.errors.last_name"
type="text" :placeholder="'Last Name'" class="w-full" autocomplete="last_name">
<div class="text-red-400 text-sm" v-if="errors.last_name && Array.isArray(errors.last_name)">
{{ errors.last_name.join(', ') }}
</div>
</FormControl>
</FormField>
<FormField label="Username" :class="{ 'text-red-400': form.errors.login }">
<FormControl id="username" label="Username" v-model="form.login" class="w-full"
:is-read-only="!user.is_admin">
<div class="text-red-400 text-sm" v-if="errors.login && Array.isArray(errors.login)">
{{ errors.login.join(', ') }}
</div>
</FormControl>
</FormField>
<FormField label="Enter Email">
<FormControl v-model="form.email" type="text" placeholder="Email" :errors="form.errors.email"
:is-read-only="!user.is_admin">
<div class="text-red-400 text-sm" v-if="errors.email && Array.isArray(errors.email)">
{{ errors.email.join(', ') }}
</div>
</FormControl>
</FormField>
<!-- Email -->
<!-- <div>
<FormControl label="Email" id="email" v-model="form.email" :readonly="!user.is_super_admin"
:disabled="!user.is_super_admin" class="w-full" />
<div v-if="user.email_verified_at === null">
<p class="text-sm mt-2">
{{ 'Your email address is unverified.' }}
<Link :href="route('verification.send')" method="post" as="button"
class="underline text-gray-600 hover:text-gray-900" @click.prevent="sendEmailVerification">
{{ 'Click here to re-send the verification email.' }}
</Link>
</p>
<div v-show="verificationLinkSent" class="mt-2 font-medium text-sm text-green-600">
{{ 'A new verification link has been sent to your email address.' }}
</div>
</div>
</div> -->
<!-- <div class="relative">
<FormControl label="Mobile" id="mobile" class="w-full" input-class="w-full pl-5" v-model="form.mobile"
:readonly="!user.is_super_admin" :disabled="!user.is_super_admin" />
<span class="absolute top-9 left-0 inline-flex items-center ml-2 font-bold text-secondary-light">+</span>
</div> -->
<!-- <div class="col-span-2 flex justify-end items-center mt-5"> -->
<template #footer>
<div class="flex items-center justify-end gap-3 ">
<ActionMessage :on="recentlyHasError" color="warning">
<ul class="list-disc list-inside space-y-2 text-sm">
<li v-for="(messages, field) in errors" :key="field" class="flex flex-col">
<span class="font-semibold capitalize text-gray-700 dark:text-gray-300">{{ field }}:</span>
<span class="text-red-600 dark:text-red-400 ml-4">{{ messages.join(', ') }}</span>
</li>
</ul>
</ActionMessage>
<ActionMessage v-if="flash.message" :on="form.recentlySuccessful" color="success">
{{ flash.message }}
</ActionMessage>
<ActionMessage v-if="flash.warning" :on="form.recentlySuccessful" color="warning">
{{ flash.warning }}
</ActionMessage>
<BaseButtons>
<BaseButton type="submit" color="info" label="Save Changes"
@click.prevent="updateProfileInformation" :disabled="form.processing" />
</BaseButtons>
</div>
</template>
</CardBox>
<!-- </div>
</div> -->
</template>

View file

@ -0,0 +1,139 @@
<script lang="ts" setup>
/*=========================================================================================
File Name: show profile
----------------------------------------------------------------------------------------
Author: Arno Kaimbacher
Author URL: https://jakint.at/
==========================================================================================*/
// import { useUrlSearchParams } from '@vueuse/core';
// import { usePage } from '@inertiajs/vue3';
import { TabGroup, TabList, Tab, TabPanels, TabPanel } from '@headlessui/vue';
import { ref, computed } from 'vue';
// import IconRounded from '@/Components/IconRounded.vue';
// import AdminLayout from '@/Layouts/AdminLayout.vue';
// import AppLayout from '@/Layouts/AppLayout.vue';
import LayoutAuthenticated from '@/Layouts/LayoutAuthenticated.vue';
import UpdatePasswordForm from '@/Pages/profile/partials/update-password-form.vue';
import UpdateProfileInformationForm from '@/Pages/profile/partials/update-profile-information-form.vue';
import { usePage } from '@inertiajs/vue3';
import SectionMain from '@/Components/SectionMain.vue';
import SectionTitleLineWithButton from '@/Components/SectionTitleLineWithButton.vue';
import { mdiAccount, mdiArrowLeftBoldOutline, mdiFormTextboxPassword } from '@mdi/js';
import { stardust } from '@eidellev/adonis-stardust/client';
import BaseButton from '@/Components/BaseButton.vue';
import BaseIcon from '@/Components/BaseIcon.vue';
defineProps({
user: {
type: Object,
required: true,
},
defaultUrl: {
type: String,
required: true,
},
});
const tabs = [
{ id: 1, title: 'My Profile', icon: mdiAccount },
{ id: 2, title: 'Change Password', icon: mdiFormTextboxPassword },
];
// const params = useUrlSearchParams('history');
const selectedTab = ref(0);
function changeTab(index: number) {
selectedTab.value = index;
}
const user = computed(() => usePage().props.user);
// const sessions = computed(() => usePage().props.sessions);
// const Layout = computed(() => (user.value.is_super_admin ? AdminLayout : AppLayout));
</script>
<template>
<Component :is="LayoutAuthenticated">
<Head title="Profile"></Head>
<SectionMain>
<SectionTitleLineWithButton :icon="mdiAccount" title="Profile" main>
<BaseButton :route-name="stardust.route('dashboard')" :icon="mdiArrowLeftBoldOutline" label="Back"
color="white" rounded-full small />
</SectionTitleLineWithButton>
<!-- <NotificationBar v-if="flash.message" color="success" :icon="mdiAlertBoxOutline">
{{ flash.message }}
</NotificationBar> -->
<div class="">
<TabGroup :selectedIndex="selectedTab" @change="changeTab">
<TabList class="flex space-x-7 p-1">
<Tab v-for="(tab, index) in tabs" as="template" :key="index" v-slot="{ selected }">
<button :class="[
'inline-flex items-center justify-center font-semibold focus:outline-none disabled:opacity-25 pb-2',
selected
? 'text-dark border-b-2 border-primary transition-all duration-500 inline-block'
: 'text-light',
]">
<!-- <IconRounded :name="tab.icon" class="h-5 mr-3" /> -->
<BaseIcon :path="tab.icon" class="flex-none" w="w-12" />
{{ tab.title }}
</button>
</Tab>
</TabList>
<transition v-show="selectedTab >= 0" enter-active-class="transition duration-100 ease-out"
enter-from-class="transform scale-95 opacity-0" enter-to-class="transform scale-100 opacity-100"
leave-active-class="transition duration-75 ease-in"
leave-from-class="transform scale-100 opacity-100"
leave-to-class="transform scale-95 opacity-0">
<TabPanels class="mt-2">
<TabPanel :key="Date.now().toString() + 1" :class="[]">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<UpdateProfileInformationForm class="p-5" :user="user" :default-url="defaultUrl" />
</div>
</TabPanel>
<TabPanel :key="Date.now().toString() + 3" :class="[]">
<!-- <div class="grid grid-cols-1 md:grid-cols-2 gap-x-5 gap-y-7 items-start"> -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<UpdatePasswordForm class="p-5" />
</div>
</TabPanel>
</TabPanels>
</transition>
</TabGroup>
</div>
</SectionMain>
</Component>
</template>
<style>
.cool-link::after {
content: '';
display: block;
width: 0;
height: 2px;
background: #fda92d;
transition: width 0.3s;
}
.cool-link:hover::after {
width: 100%;
}
</style>
<!-- <script>
import { defineComponent } from 'vue';
export default defineComponent({
layout: false,
});
</script> -->