@nuxt-customer-portal/organizations 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +11 -0
- package/LICENSE +21 -0
- package/README.md +3 -0
- package/app/components/DeleteAccountModal.vue +139 -0
- package/app/components/OrganizationMembers.vue +298 -0
- package/app/components/OrganizationSettings.vue +315 -0
- package/app/components/OrganizationSwitcher.vue +166 -0
- package/app/components/organization/DetailsCard.vue +84 -0
- package/app/components/organization/InvitationsCard.vue +112 -0
- package/app/components/organization/InviteMemberModal.vue +101 -0
- package/app/components/organization/MembersCard.vue +108 -0
- package/app/pages/my-organizations.vue +125 -0
- package/app/pages/settings/index.vue +248 -0
- package/app/pages/settings/organization.vue +171 -0
- package/app/pages/settings/security.vue +195 -0
- package/app/pages/settings.vue +101 -0
- package/app/plugins/account-organizations-feature.ts +3 -0
- package/i18n/locales/en.json +208 -0
- package/i18n/locales/nl.json +208 -0
- package/nuxt.config.ts +11 -0
- package/package.json +51 -0
- package/portal.manifest.mjs +6 -0
- package/server/api/organizations/[id]/index.get.ts +54 -0
- package/server/api/organizations/[id]/invitations/[invitationId]/delete.post.ts +42 -0
- package/server/api/organizations/[id]/invitations/[invitationId]/resend.post.ts +91 -0
- package/server/api/organizations/[id]/invitations/[invitationId].patch.ts +18 -0
- package/server/api/organizations/[id]/invitations.get.ts +49 -0
- package/server/api/organizations/accept-invitation.post.ts +131 -0
- package/server/api/organizations/get-invitation.get.ts +73 -0
- package/server/api/profile.patch.ts +99 -0
- package/shared/feature.ts +24 -0
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import * as z from 'zod'
|
|
3
|
+
import type { FormSubmitEvent } from '@nuxt/ui'
|
|
4
|
+
import { useOrganization } from '@nuxt-customer-portal/core/app/composables/useOrganization'
|
|
5
|
+
|
|
6
|
+
const props = defineProps<{
|
|
7
|
+
organizationId: string
|
|
8
|
+
}>()
|
|
9
|
+
|
|
10
|
+
const emit = defineEmits<{
|
|
11
|
+
success: []
|
|
12
|
+
error: [message: string]
|
|
13
|
+
}>()
|
|
14
|
+
|
|
15
|
+
const open = defineModel<boolean>('open', { default: false })
|
|
16
|
+
const { t } = useI18n()
|
|
17
|
+
const toast = useToast()
|
|
18
|
+
const { inviteMember } = useOrganization()
|
|
19
|
+
|
|
20
|
+
const formSchema = computed(() =>
|
|
21
|
+
z.object({
|
|
22
|
+
email: z.string().email(t('organization.members.validation.emailInvalid')),
|
|
23
|
+
role: z.enum(['member', 'admin'], { error: t('organization.members.validation.roleRequired') })
|
|
24
|
+
})
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
const form = reactive({
|
|
28
|
+
email: '',
|
|
29
|
+
role: 'member' as 'member' | 'admin'
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
const inviting = ref(false)
|
|
33
|
+
|
|
34
|
+
const handleSubmit = async (event: FormSubmitEvent<z.output<typeof formSchema.value>>) => {
|
|
35
|
+
try {
|
|
36
|
+
inviting.value = true
|
|
37
|
+
|
|
38
|
+
const result = await inviteMember(event.data.email, props.organizationId, event.data.role)
|
|
39
|
+
|
|
40
|
+
if (result.error) {
|
|
41
|
+
throw new Error(result.error.message || t('organization.members.errors.sendInvitationFailed'))
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
toast.add({
|
|
45
|
+
title: t('common.success'),
|
|
46
|
+
description: t('organization.members.invitations.sendSuccess'),
|
|
47
|
+
color: 'success'
|
|
48
|
+
})
|
|
49
|
+
open.value = false
|
|
50
|
+
emit('success')
|
|
51
|
+
} catch (err: unknown) {
|
|
52
|
+
const errorMessage = err instanceof Error ? err.message : t('organization.members.errors.sendInvitationFailed')
|
|
53
|
+
toast.add({
|
|
54
|
+
title: t('common.error'),
|
|
55
|
+
description: errorMessage,
|
|
56
|
+
color: 'error'
|
|
57
|
+
})
|
|
58
|
+
emit('error', errorMessage)
|
|
59
|
+
} finally {
|
|
60
|
+
inviting.value = false
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
</script>
|
|
64
|
+
|
|
65
|
+
<template>
|
|
66
|
+
<UModal v-model:open="open" :title="t('organization.members.invitations.modalTitle')" :ui="{ footer: 'justify-end' }">
|
|
67
|
+
<template #body>
|
|
68
|
+
<UForm :state="form" :schema="formSchema" class="space-y-4" @submit="handleSubmit">
|
|
69
|
+
<UFormField name="email" :label="t('organization.members.invitations.emailLabel')" required>
|
|
70
|
+
<UInput
|
|
71
|
+
v-model="form.email"
|
|
72
|
+
type="email"
|
|
73
|
+
:placeholder="t('organization.members.invitations.emailPlaceholder')"
|
|
74
|
+
size="lg"
|
|
75
|
+
class="w-full"
|
|
76
|
+
/>
|
|
77
|
+
</UFormField>
|
|
78
|
+
<UFormField name="role" :label="t('organization.members.invitations.roleLabel')" required>
|
|
79
|
+
<USelect
|
|
80
|
+
v-model="form.role"
|
|
81
|
+
:items="[
|
|
82
|
+
{ label: t('organization.members.invitations.roleMember'), value: 'member' },
|
|
83
|
+
{ label: t('organization.members.invitations.roleAdmin'), value: 'admin' }
|
|
84
|
+
]"
|
|
85
|
+
value-key="value"
|
|
86
|
+
size="lg"
|
|
87
|
+
class="w-full"
|
|
88
|
+
/>
|
|
89
|
+
</UFormField>
|
|
90
|
+
<div class="flex flex-col sm:flex-row gap-4 justify-end pt-4">
|
|
91
|
+
<UButton type="button" variant="outline" class="flex-1 sm:flex-none" @click="open = false">
|
|
92
|
+
{{ t('organization.members.invitations.cancel') }}
|
|
93
|
+
</UButton>
|
|
94
|
+
<UButton type="submit" :loading="inviting" color="primary" class="flex-1 sm:flex-none">
|
|
95
|
+
{{ t('organization.members.invitations.sendButton') }}
|
|
96
|
+
</UButton>
|
|
97
|
+
</div>
|
|
98
|
+
</UForm>
|
|
99
|
+
</template>
|
|
100
|
+
</UModal>
|
|
101
|
+
</template>
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { OrganizationMemberWithUser } from '@nuxt-customer-portal/core/shared/types/index'
|
|
3
|
+
|
|
4
|
+
const props = withDefaults(
|
|
5
|
+
defineProps<{
|
|
6
|
+
organizationId: string
|
|
7
|
+
members: OrganizationMemberWithUser[]
|
|
8
|
+
loading?: boolean
|
|
9
|
+
canManage?: boolean
|
|
10
|
+
}>(),
|
|
11
|
+
{
|
|
12
|
+
loading: false,
|
|
13
|
+
canManage: false
|
|
14
|
+
}
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
const emit = defineEmits<{ refresh: [] }>()
|
|
18
|
+
const { t } = useI18n()
|
|
19
|
+
const { currentUser } = storeToRefs(useUserStore())
|
|
20
|
+
const { removeMember } = useOrganization()
|
|
21
|
+
const toast = useToast()
|
|
22
|
+
const selectedMember = ref<OrganizationMemberWithUser | null>(null)
|
|
23
|
+
const showRemoveModal = ref(false)
|
|
24
|
+
const removing = ref(false)
|
|
25
|
+
|
|
26
|
+
const requestRemove = (member: OrganizationMemberWithUser) => {
|
|
27
|
+
selectedMember.value = member
|
|
28
|
+
showRemoveModal.value = true
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const handleRemove = async () => {
|
|
32
|
+
if (!selectedMember.value) {
|
|
33
|
+
return
|
|
34
|
+
}
|
|
35
|
+
try {
|
|
36
|
+
removing.value = true
|
|
37
|
+
const result = await removeMember(selectedMember.value.user.email, props.organizationId)
|
|
38
|
+
if (result.error) {
|
|
39
|
+
throw result.error
|
|
40
|
+
}
|
|
41
|
+
toast.add({ title: t('common.success'), description: t('organization.members.removeSuccess'), color: 'success' })
|
|
42
|
+
emit('refresh')
|
|
43
|
+
} catch (error) {
|
|
44
|
+
const message = error instanceof Error ? error.message : t('organization.members.errors.removeFailed')
|
|
45
|
+
toast.add({ title: t('common.error'), description: message, color: 'error' })
|
|
46
|
+
} finally {
|
|
47
|
+
removing.value = false
|
|
48
|
+
selectedMember.value = null
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
</script>
|
|
52
|
+
|
|
53
|
+
<template>
|
|
54
|
+
<UCard>
|
|
55
|
+
<template #header>
|
|
56
|
+
<h2 class="text-xl font-semibold">{{ t('organization.members.title') }} ({{ members.length }})</h2>
|
|
57
|
+
</template>
|
|
58
|
+
|
|
59
|
+
<div v-if="loading" class="py-8 text-center text-muted">{{ t('organization.members.loading') }}</div>
|
|
60
|
+
<div v-else-if="members.length === 0" class="py-8 text-center text-muted">
|
|
61
|
+
{{ t('organization.members.empty') }}
|
|
62
|
+
</div>
|
|
63
|
+
<div v-else class="divide-y divide-default">
|
|
64
|
+
<div
|
|
65
|
+
v-for="member in members"
|
|
66
|
+
:key="member.id"
|
|
67
|
+
class="flex flex-wrap items-center justify-between gap-3 py-4 first:pt-0 last:pb-0"
|
|
68
|
+
>
|
|
69
|
+
<div class="flex min-w-0 items-center gap-3">
|
|
70
|
+
<UAvatar :src="member.user.image || undefined" :alt="member.user.name || member.user.email" size="md" />
|
|
71
|
+
<div class="min-w-0">
|
|
72
|
+
<p class="truncate font-medium">{{ member.user.name || member.user.email }}</p>
|
|
73
|
+
<p class="truncate text-sm text-muted">{{ member.user.email }}</p>
|
|
74
|
+
</div>
|
|
75
|
+
</div>
|
|
76
|
+
<div class="flex items-center gap-2">
|
|
77
|
+
<UBadge
|
|
78
|
+
:color="member.role === 'owner' ? 'primary' : member.role === 'admin' ? 'info' : 'neutral'"
|
|
79
|
+
variant="soft"
|
|
80
|
+
>
|
|
81
|
+
{{ t(`organization.members.roles.${Array.isArray(member.role) ? member.role[0] : member.role}`) }}
|
|
82
|
+
</UBadge>
|
|
83
|
+
<UButton
|
|
84
|
+
v-if="canManage && member.userId !== currentUser?.id && member.role !== 'owner'"
|
|
85
|
+
icon="i-lucide-user-minus"
|
|
86
|
+
color="error"
|
|
87
|
+
variant="ghost"
|
|
88
|
+
size="sm"
|
|
89
|
+
:aria-label="t('organization.members.remove')"
|
|
90
|
+
@click="requestRemove(member)"
|
|
91
|
+
/>
|
|
92
|
+
</div>
|
|
93
|
+
</div>
|
|
94
|
+
</div>
|
|
95
|
+
|
|
96
|
+
<ConfirmationModal
|
|
97
|
+
v-if="showRemoveModal && selectedMember"
|
|
98
|
+
v-model:open="showRemoveModal"
|
|
99
|
+
title="organization.members.confirmRemove.title"
|
|
100
|
+
message="organization.members.confirmRemove.message"
|
|
101
|
+
:message-params="{ name: selectedMember.user.name || selectedMember.user.email }"
|
|
102
|
+
confirm-text="organization.members.confirmRemove.confirm"
|
|
103
|
+
confirm-color="error"
|
|
104
|
+
:loading="removing"
|
|
105
|
+
@confirm="handleRemove"
|
|
106
|
+
/>
|
|
107
|
+
</UCard>
|
|
108
|
+
</template>
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { Organization } from '@nuxt-customer-portal/core/shared/types/index'
|
|
3
|
+
|
|
4
|
+
const { t } = useI18n()
|
|
5
|
+
|
|
6
|
+
const userStore = useUserStore()
|
|
7
|
+
const { activeOrganizationId, myOrganizations } = storeToRefs(userStore)
|
|
8
|
+
const { setActiveOrganizationId } = userStore
|
|
9
|
+
const toast = useToast()
|
|
10
|
+
|
|
11
|
+
// Type for organization with role
|
|
12
|
+
type OrganizationWithRole = Organization & { role?: string | null }
|
|
13
|
+
|
|
14
|
+
if (!userStore.isAdmin) {
|
|
15
|
+
throw createError({ statusCode: 403, message: 'Access denied. You must be an admin to view this page.' })
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Page metadata
|
|
19
|
+
useSeoMeta({
|
|
20
|
+
title: t('myOrganizations.title'),
|
|
21
|
+
description: t('myOrganizations.description')
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
const isActiveOrganization = (organizationId: string) => {
|
|
25
|
+
if (!activeOrganizationId.value) {
|
|
26
|
+
return false
|
|
27
|
+
}
|
|
28
|
+
return activeOrganizationId.value === organizationId
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const handleSetActive = async (organizationId: string) => {
|
|
32
|
+
try {
|
|
33
|
+
await setActiveOrganizationId(organizationId)
|
|
34
|
+
toast.add({
|
|
35
|
+
title: t('profile.messages.success'),
|
|
36
|
+
description: t('profile.messages.organizationActivated'),
|
|
37
|
+
color: 'success'
|
|
38
|
+
})
|
|
39
|
+
} catch (error) {
|
|
40
|
+
console.error('Error setting active organization:', error)
|
|
41
|
+
toast.add({
|
|
42
|
+
title: t('profile.messages.error'),
|
|
43
|
+
description: t('profile.messages.failedToActivateOrganization'),
|
|
44
|
+
color: 'error'
|
|
45
|
+
})
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
</script>
|
|
49
|
+
|
|
50
|
+
<template>
|
|
51
|
+
<div class="min-h-screen bg-gray-50 dark:bg-gray-900">
|
|
52
|
+
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
|
53
|
+
<!-- Page Header -->
|
|
54
|
+
<div class="mb-8">
|
|
55
|
+
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">
|
|
56
|
+
{{ $t('myOrganizations.title') }}
|
|
57
|
+
</h1>
|
|
58
|
+
<p class="mt-2 hidden text-sm text-gray-600 dark:text-gray-400 sm:block">
|
|
59
|
+
{{ $t('myOrganizations.description') }}
|
|
60
|
+
</p>
|
|
61
|
+
</div>
|
|
62
|
+
|
|
63
|
+
<UCard>
|
|
64
|
+
<template #header>
|
|
65
|
+
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">
|
|
66
|
+
{{ $t('profile.sections.organizations') }}
|
|
67
|
+
</h2>
|
|
68
|
+
</template>
|
|
69
|
+
<div v-if="myOrganizations === null || myOrganizations === undefined">Loading...</div>
|
|
70
|
+
<div v-else-if="myOrganizations.length === 0">No organizations found.</div>
|
|
71
|
+
<div v-else class="space-y-6">
|
|
72
|
+
<NuxtLink
|
|
73
|
+
v-for="organization in myOrganizations"
|
|
74
|
+
:key="organization.id"
|
|
75
|
+
:to="`/admin/organizations/${organization.slug}?from=my-organizations`"
|
|
76
|
+
class="flex items-start justify-between p-4 rounded-lg border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors cursor-pointer"
|
|
77
|
+
>
|
|
78
|
+
<div class="flex-1">
|
|
79
|
+
<div class="flex items-center justify-between mb-1">
|
|
80
|
+
<div class="flex items-center gap-2">
|
|
81
|
+
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">{{ organization.name }}</h3>
|
|
82
|
+
<UBadge
|
|
83
|
+
v-if="(organization as OrganizationWithRole).role"
|
|
84
|
+
:color="
|
|
85
|
+
(organization as OrganizationWithRole).role === 'owner'
|
|
86
|
+
? 'primary'
|
|
87
|
+
: (organization as OrganizationWithRole).role === 'admin'
|
|
88
|
+
? 'info'
|
|
89
|
+
: 'neutral'
|
|
90
|
+
"
|
|
91
|
+
variant="soft"
|
|
92
|
+
>
|
|
93
|
+
{{
|
|
94
|
+
String((organization as OrganizationWithRole).role)
|
|
95
|
+
.charAt(0)
|
|
96
|
+
.toUpperCase() + String((organization as OrganizationWithRole).role).slice(1)
|
|
97
|
+
}}
|
|
98
|
+
</UBadge>
|
|
99
|
+
</div>
|
|
100
|
+
<div class="flex items-center gap-2" @click.stop>
|
|
101
|
+
<UBadge v-if="isActiveOrganization(organization.id)" color="success" variant="subtle">
|
|
102
|
+
{{ $t('profile.badges.active') }}
|
|
103
|
+
</UBadge>
|
|
104
|
+
<UButton
|
|
105
|
+
v-else
|
|
106
|
+
size="sm"
|
|
107
|
+
variant="outline"
|
|
108
|
+
:loading="userStore.isLoading"
|
|
109
|
+
@click="handleSetActive(organization.id)"
|
|
110
|
+
>
|
|
111
|
+
{{ $t('profile.buttons.setActive') }}
|
|
112
|
+
</UButton>
|
|
113
|
+
</div>
|
|
114
|
+
</div>
|
|
115
|
+
<p class="text-sm text-gray-600 dark:text-gray-400">{{ organization.slug }}</p>
|
|
116
|
+
<p class="text-xs text-gray-500 mt-1">
|
|
117
|
+
{{ $t('common.createdAt') }}: {{ new Date(organization.createdAt).toLocaleDateString() }}
|
|
118
|
+
</p>
|
|
119
|
+
</div>
|
|
120
|
+
</NuxtLink>
|
|
121
|
+
</div>
|
|
122
|
+
</UCard>
|
|
123
|
+
</div>
|
|
124
|
+
</div>
|
|
125
|
+
</template>
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import * as z from 'zod'
|
|
3
|
+
import type { FormSubmitEvent } from '@nuxt/ui'
|
|
4
|
+
|
|
5
|
+
const { t } = useI18n()
|
|
6
|
+
|
|
7
|
+
const userStore = useUserStore()
|
|
8
|
+
const { currentUser } = storeToRefs(userStore)
|
|
9
|
+
const { setCurrentUser } = userStore
|
|
10
|
+
const toast = useToast()
|
|
11
|
+
|
|
12
|
+
const fileRef = ref<HTMLInputElement>()
|
|
13
|
+
|
|
14
|
+
// Zod schema for profile form validation
|
|
15
|
+
const schema = z
|
|
16
|
+
.object({
|
|
17
|
+
name: z
|
|
18
|
+
.string()
|
|
19
|
+
.trim()
|
|
20
|
+
.min(1, t('profile.validation.nameRequired'))
|
|
21
|
+
.max(255, t('profile.validation.nameMaxLength')),
|
|
22
|
+
image: z
|
|
23
|
+
.union([z.string().url(t('profile.validation.imageInvalidUrl')), z.literal('').transform(() => null), z.null()])
|
|
24
|
+
.optional()
|
|
25
|
+
})
|
|
26
|
+
.refine((data) => data.name !== undefined || data.image !== undefined, {
|
|
27
|
+
message: t('profile.validation.atLeastOneField')
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
type Schema = z.output<typeof schema>
|
|
31
|
+
|
|
32
|
+
// Form state
|
|
33
|
+
const form = reactive<Partial<Schema>>({
|
|
34
|
+
name: '',
|
|
35
|
+
image: ''
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
const isLoading = ref(false)
|
|
39
|
+
const isDirty = ref(false)
|
|
40
|
+
const selectedFile = ref<File | null>(null)
|
|
41
|
+
|
|
42
|
+
// Initialize form with current user data
|
|
43
|
+
watchEffect(() => {
|
|
44
|
+
if (currentUser.value) {
|
|
45
|
+
form.name = currentUser.value.name || ''
|
|
46
|
+
form.image = currentUser.value.image || ''
|
|
47
|
+
isDirty.value = false
|
|
48
|
+
}
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
// Watch for form changes
|
|
52
|
+
watch(
|
|
53
|
+
() => [form.name, form.image],
|
|
54
|
+
() => {
|
|
55
|
+
if (currentUser.value) {
|
|
56
|
+
const nameChanged = form.name !== (currentUser.value.name || '')
|
|
57
|
+
const imageChanged = form.image !== (currentUser.value.image || '')
|
|
58
|
+
isDirty.value = nameChanged || imageChanged
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
{ deep: true }
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
// Page metadata
|
|
65
|
+
useSeoMeta({
|
|
66
|
+
title: t('profile.title'),
|
|
67
|
+
description: t('profile.description')
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
const handleSubmit = async (event: FormSubmitEvent<Schema>) => {
|
|
71
|
+
if (!currentUser.value) {
|
|
72
|
+
return
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
isLoading.value = true
|
|
76
|
+
try {
|
|
77
|
+
let imageUrl = event.data.image === null || event.data.image === '' ? null : event.data.image
|
|
78
|
+
|
|
79
|
+
// If a file was selected, convert it to base64 data URL
|
|
80
|
+
if (selectedFile.value) {
|
|
81
|
+
const reader = new FileReader()
|
|
82
|
+
imageUrl = await new Promise<string>((resolve, reject) => {
|
|
83
|
+
reader.onload = () => resolve(reader.result as string)
|
|
84
|
+
reader.onerror = reject
|
|
85
|
+
reader.readAsDataURL(selectedFile.value!)
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const response = await $fetch<{
|
|
90
|
+
success: boolean
|
|
91
|
+
message: string
|
|
92
|
+
user?: {
|
|
93
|
+
id: string
|
|
94
|
+
name: string
|
|
95
|
+
email: string
|
|
96
|
+
image: string | null
|
|
97
|
+
}
|
|
98
|
+
}>('/api/profile', {
|
|
99
|
+
method: 'PATCH',
|
|
100
|
+
body: {
|
|
101
|
+
name: event.data.name?.trim(),
|
|
102
|
+
image: imageUrl
|
|
103
|
+
}
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
if (response.success && response.user) {
|
|
107
|
+
// Update the user store with new data
|
|
108
|
+
setCurrentUser({
|
|
109
|
+
...currentUser.value,
|
|
110
|
+
name: response.user.name,
|
|
111
|
+
image: response.user.image || undefined
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
toast.add({
|
|
115
|
+
title: t('profile.messages.success'),
|
|
116
|
+
description: t('profile.messages.profileUpdated'),
|
|
117
|
+
color: 'success'
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
isDirty.value = false
|
|
121
|
+
selectedFile.value = null
|
|
122
|
+
// Revoke the blob URL if it was created
|
|
123
|
+
if (form.image && form.image.startsWith('blob:')) {
|
|
124
|
+
URL.revokeObjectURL(form.image)
|
|
125
|
+
}
|
|
126
|
+
// Update form.image with the saved URL
|
|
127
|
+
form.image = response.user.image || ''
|
|
128
|
+
}
|
|
129
|
+
} catch (error: unknown) {
|
|
130
|
+
console.error('Error updating profile:', error)
|
|
131
|
+
const errorMessage =
|
|
132
|
+
(error as { data?: { message?: string }; message?: string })?.data?.message ||
|
|
133
|
+
(error as { message?: string })?.message ||
|
|
134
|
+
t('profile.messages.updateFailed')
|
|
135
|
+
toast.add({
|
|
136
|
+
title: t('profile.messages.error'),
|
|
137
|
+
description: errorMessage,
|
|
138
|
+
color: 'error'
|
|
139
|
+
})
|
|
140
|
+
} finally {
|
|
141
|
+
isLoading.value = false
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const handleReset = () => {
|
|
146
|
+
if (currentUser.value) {
|
|
147
|
+
// Revoke blob URL if it exists
|
|
148
|
+
if (form.image && form.image.startsWith('blob:')) {
|
|
149
|
+
URL.revokeObjectURL(form.image)
|
|
150
|
+
}
|
|
151
|
+
form.name = currentUser.value.name || ''
|
|
152
|
+
form.image = currentUser.value.image || ''
|
|
153
|
+
isDirty.value = false
|
|
154
|
+
selectedFile.value = null
|
|
155
|
+
if (fileRef.value) {
|
|
156
|
+
fileRef.value.value = ''
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function onFileChange(e: Event) {
|
|
162
|
+
const input = e.target as HTMLInputElement
|
|
163
|
+
|
|
164
|
+
if (!input.files?.length) {
|
|
165
|
+
return
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const file = input.files[0]!
|
|
169
|
+
|
|
170
|
+
// Revoke previous blob URL if it exists
|
|
171
|
+
if (form.image && form.image.startsWith('blob:')) {
|
|
172
|
+
URL.revokeObjectURL(form.image)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
selectedFile.value = file
|
|
176
|
+
|
|
177
|
+
// Create preview URL
|
|
178
|
+
form.image = URL.createObjectURL(file)
|
|
179
|
+
isDirty.value = true
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function onFileClick() {
|
|
183
|
+
fileRef.value?.click()
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Cleanup blob URLs on unmount
|
|
187
|
+
onUnmounted(() => {
|
|
188
|
+
if (form.image && form.image.startsWith('blob:')) {
|
|
189
|
+
URL.revokeObjectURL(form.image)
|
|
190
|
+
}
|
|
191
|
+
})
|
|
192
|
+
</script>
|
|
193
|
+
|
|
194
|
+
<template>
|
|
195
|
+
<AppCard class="mb-8" :title="$t('profile.sections.profileInfo')">
|
|
196
|
+
<UForm :state="form" :schema="schema" class="space-y-6" @submit="handleSubmit">
|
|
197
|
+
<!-- Profile Picture Section -->
|
|
198
|
+
<UFormField
|
|
199
|
+
:label="$t('profile.fields.profilePicture')"
|
|
200
|
+
:description="$t('profile.fields.profilePictureDescription')"
|
|
201
|
+
name="image"
|
|
202
|
+
class="flex max-sm:flex-col justify-between sm:items-center gap-4"
|
|
203
|
+
>
|
|
204
|
+
<div class="flex flex-wrap items-center gap-3">
|
|
205
|
+
<UAvatar
|
|
206
|
+
:src="form.image || undefined"
|
|
207
|
+
:alt="form.name || currentUser?.email || 'User'"
|
|
208
|
+
:text="form.name ? form.name.charAt(0).toUpperCase() : currentUser?.email?.charAt(0).toUpperCase() || 'U'"
|
|
209
|
+
size="xl"
|
|
210
|
+
class="ring-2 ring-gray-200 dark:ring-gray-700"
|
|
211
|
+
/>
|
|
212
|
+
<UButton :label="$t('profile.fields.chooseAvatar')" color="primary" variant="outline" @click="onFileClick" />
|
|
213
|
+
<input ref="fileRef" type="file" class="hidden" accept=".jpg, .jpeg, .png, .gif" @change="onFileChange" />
|
|
214
|
+
</div>
|
|
215
|
+
</UFormField>
|
|
216
|
+
|
|
217
|
+
<UFormField
|
|
218
|
+
:label="$t('profile.fields.name')"
|
|
219
|
+
:description="$t('profile.fields.nameDescription')"
|
|
220
|
+
name="name"
|
|
221
|
+
required
|
|
222
|
+
>
|
|
223
|
+
<UInput
|
|
224
|
+
v-model="form.name"
|
|
225
|
+
type="text"
|
|
226
|
+
:placeholder="$t('profile.fields.namePlaceholder')"
|
|
227
|
+
icon="i-lucide-user"
|
|
228
|
+
class="w-full"
|
|
229
|
+
required
|
|
230
|
+
/>
|
|
231
|
+
</UFormField>
|
|
232
|
+
|
|
233
|
+
<UFormField :label="$t('profile.fields.email')" :description="$t('profile.fields.emailDescription')" name="email">
|
|
234
|
+
<UInput :model-value="currentUser?.email || ''" type="email" disabled icon="i-lucide-mail" class="w-full" />
|
|
235
|
+
</UFormField>
|
|
236
|
+
|
|
237
|
+
<!-- Form Actions -->
|
|
238
|
+
<div class="flex items-center justify-end gap-3 pt-4 border-t border-gray-200 dark:border-gray-700">
|
|
239
|
+
<UButton type="button" variant="ghost" color="neutral" :disabled="!isDirty || isLoading" @click="handleReset">
|
|
240
|
+
{{ $t('common.reset') }}
|
|
241
|
+
</UButton>
|
|
242
|
+
<UButton type="submit" :loading="isLoading" :disabled="!isDirty">
|
|
243
|
+
{{ $t('common.save') }}
|
|
244
|
+
</UButton>
|
|
245
|
+
</div>
|
|
246
|
+
</UForm>
|
|
247
|
+
</AppCard>
|
|
248
|
+
</template>
|