@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.
Files changed (31) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/LICENSE +21 -0
  3. package/README.md +3 -0
  4. package/app/components/DeleteAccountModal.vue +139 -0
  5. package/app/components/OrganizationMembers.vue +298 -0
  6. package/app/components/OrganizationSettings.vue +315 -0
  7. package/app/components/OrganizationSwitcher.vue +166 -0
  8. package/app/components/organization/DetailsCard.vue +84 -0
  9. package/app/components/organization/InvitationsCard.vue +112 -0
  10. package/app/components/organization/InviteMemberModal.vue +101 -0
  11. package/app/components/organization/MembersCard.vue +108 -0
  12. package/app/pages/my-organizations.vue +125 -0
  13. package/app/pages/settings/index.vue +248 -0
  14. package/app/pages/settings/organization.vue +171 -0
  15. package/app/pages/settings/security.vue +195 -0
  16. package/app/pages/settings.vue +101 -0
  17. package/app/plugins/account-organizations-feature.ts +3 -0
  18. package/i18n/locales/en.json +208 -0
  19. package/i18n/locales/nl.json +208 -0
  20. package/nuxt.config.ts +11 -0
  21. package/package.json +51 -0
  22. package/portal.manifest.mjs +6 -0
  23. package/server/api/organizations/[id]/index.get.ts +54 -0
  24. package/server/api/organizations/[id]/invitations/[invitationId]/delete.post.ts +42 -0
  25. package/server/api/organizations/[id]/invitations/[invitationId]/resend.post.ts +91 -0
  26. package/server/api/organizations/[id]/invitations/[invitationId].patch.ts +18 -0
  27. package/server/api/organizations/[id]/invitations.get.ts +49 -0
  28. package/server/api/organizations/accept-invitation.post.ts +131 -0
  29. package/server/api/organizations/get-invitation.get.ts +73 -0
  30. package/server/api/profile.patch.ts +99 -0
  31. package/shared/feature.ts +24 -0
@@ -0,0 +1,171 @@
1
+ <script setup lang="ts">
2
+ import type {
3
+ Organization,
4
+ OrganizationInvitationsResponse,
5
+ OrganizationMemberWithUser,
6
+ ApiError,
7
+ MemberRole
8
+ } from '@nuxt-customer-portal/core/shared/types/index'
9
+ import { authClient } from '@nuxt-customer-portal/core/app/utils/auth-client'
10
+ import { canViewOrganizationDirectory as canViewDirectory } from '@nuxt-customer-portal/core/shared/feature-registry'
11
+
12
+ const userStore = useUserStore()
13
+ const { activeOrganizationId } = storeToRefs(userStore)
14
+ const { hasPermission } = userStore
15
+
16
+ const { t } = useI18n()
17
+
18
+ useSeoMeta({
19
+ title: () => t('settings.organization')
20
+ })
21
+
22
+ const loading = ref(true)
23
+ const error = ref('')
24
+ const organization = ref<Organization | null>(null)
25
+ const members = ref<OrganizationMemberWithUser[]>([])
26
+ const invitations = ref<OrganizationInvitationsResponse>([])
27
+ const showEditModal = ref(false)
28
+ const userOrganizationRole = ref<MemberRole | null>(null)
29
+ const canViewOrganizationDirectory = computed(() => canViewDirectory(userOrganizationRole.value))
30
+
31
+ // Load organization details
32
+ const loadOrganization = async () => {
33
+ if (!activeOrganizationId.value) {
34
+ loading.value = false
35
+ organization.value = null
36
+ userOrganizationRole.value = null
37
+ members.value = []
38
+ invitations.value = []
39
+ return
40
+ }
41
+ try {
42
+ loading.value = true
43
+ error.value = ''
44
+ userOrganizationRole.value = null
45
+ const { data: roleData, error: roleError } = await authClient.organization.getActiveMemberRole({
46
+ query: { organizationId: activeOrganizationId.value }
47
+ })
48
+ if (roleError) {
49
+ throw roleError
50
+ }
51
+ userOrganizationRole.value = (roleData?.role as MemberRole | undefined) ?? null
52
+ organization.value = await $fetch<Organization>(`/api/organizations/${activeOrganizationId.value}`)
53
+ if (organization.value && canViewOrganizationDirectory.value) {
54
+ await loadMembers()
55
+ await loadInvitations()
56
+ }
57
+ } catch (err) {
58
+ const apiError = err as ApiError
59
+ error.value = apiError.message || t('organization.settings.errors.loadFailed')
60
+ } finally {
61
+ loading.value = false
62
+ }
63
+ }
64
+
65
+ // Load members
66
+ const loadMembers = async () => {
67
+ if (!organization.value) {
68
+ return
69
+ }
70
+
71
+ try {
72
+ const { listMembers } = useOrganization()
73
+ const result = await listMembers(organization.value.id)
74
+ if (result.error) {
75
+ throw result.error
76
+ }
77
+ const data = result.data
78
+ members.value = Array.isArray(data)
79
+ ? (data as OrganizationMemberWithUser[])
80
+ : ((data?.members || []) as OrganizationMemberWithUser[])
81
+ } catch (err) {
82
+ console.error('Failed to load members:', err)
83
+ members.value = []
84
+ }
85
+ }
86
+
87
+ // Load invitations
88
+ const loadInvitations = async () => {
89
+ if (!organization.value) {
90
+ return
91
+ }
92
+
93
+ try {
94
+ invitations.value = await $fetch<OrganizationInvitationsResponse>(
95
+ `/api/organizations/${organization.value.id}/invitations`
96
+ )
97
+ } catch (err) {
98
+ console.error('Failed to load invitations:', err)
99
+ invitations.value = []
100
+ }
101
+ }
102
+
103
+ const handleOrganizationUpdated = async () => {
104
+ showEditModal.value = false
105
+ await userStore.refreshOrganizations()
106
+ await loadOrganization()
107
+ }
108
+
109
+ watch(
110
+ activeOrganizationId,
111
+ (id) => {
112
+ if (id) {
113
+ void loadOrganization()
114
+ } else {
115
+ loading.value = false
116
+ organization.value = null
117
+ userOrganizationRole.value = null
118
+ members.value = []
119
+ invitations.value = []
120
+ }
121
+ },
122
+ { immediate: true }
123
+ )
124
+ </script>
125
+
126
+ <template>
127
+ <div>
128
+ <!-- Loading State -->
129
+ <div v-if="loading" class="text-center py-8">
130
+ <UIcon name="i-lucide-loader-2" class="w-8 h-8 animate-spin mx-auto" />
131
+ <p class="text-gray-600 dark:text-gray-400 mt-2">
132
+ {{ t('organization.members.loading') }}
133
+ </p>
134
+ </div>
135
+
136
+ <!-- Error State -->
137
+ <UAlert v-else-if="error" color="error" :title="error" variant="outline" />
138
+
139
+ <!-- Organization Details -->
140
+ <div v-else-if="organization && hasPermission('organization', 'read')" class="space-y-6">
141
+ <OrganizationDetailsCard
142
+ :organization="organization"
143
+ :role="userOrganizationRole"
144
+ :can-edit="userOrganizationRole === 'owner'"
145
+ :editing="showEditModal"
146
+ @edit="showEditModal = true"
147
+ >
148
+ <template #edit>
149
+ <OrganizationSettings @updated="handleOrganizationUpdated" @canceled="showEditModal = false" />
150
+ </template>
151
+ </OrganizationDetailsCard>
152
+
153
+ <OrganizationMembersCard
154
+ v-if="canViewOrganizationDirectory"
155
+ :organization-id="organization.id"
156
+ :members="members"
157
+ :loading="loading"
158
+ :can-manage="true"
159
+ @refresh="loadMembers"
160
+ />
161
+ <OrganizationInvitationsCard
162
+ v-if="canViewOrganizationDirectory"
163
+ :organization-id="organization.id"
164
+ :invitations="invitations"
165
+ :loading="loading"
166
+ :can-manage="true"
167
+ @refresh="loadInvitations"
168
+ />
169
+ </div>
170
+ </div>
171
+ </template>
@@ -0,0 +1,195 @@
1
+ <script setup lang="ts">
2
+ import * as z from 'zod'
3
+ import type { FormError, FormSubmitEvent } from '@nuxt/ui'
4
+ import { authClient } from '@nuxt-customer-portal/core/app/utils/auth-client'
5
+
6
+ const { t } = useI18n()
7
+ const toast = useToast()
8
+
9
+ useSeoMeta({
10
+ title: () => t('settings.security')
11
+ })
12
+
13
+ const passwordSchema = z
14
+ .object({
15
+ currentPassword: z
16
+ .string()
17
+ .min(1, t('security.password.validation.currentPasswordRequired'))
18
+ .min(8, t('login.validation.passwordMinLength')),
19
+ newPassword: z
20
+ .string()
21
+ .min(1, t('security.password.validation.newPasswordRequired'))
22
+ .min(8, t('login.validation.passwordMinLength'))
23
+ })
24
+ .refine((data) => data.currentPassword !== data.newPassword, {
25
+ message: t('security.password.validation.passwordsMustBeDifferent'),
26
+ path: ['newPassword']
27
+ })
28
+
29
+ type PasswordSchema = z.output<typeof passwordSchema>
30
+
31
+ const password = reactive<Partial<PasswordSchema>>({
32
+ currentPassword: '',
33
+ newPassword: ''
34
+ })
35
+
36
+ const userStore = useUserStore()
37
+ const { changePasswordAllowed } = storeToRefs(userStore)
38
+
39
+ const isLoading = ref(false)
40
+ const showDeleteModal = ref(false)
41
+
42
+ const validate = (state: Partial<PasswordSchema>): FormError[] => {
43
+ const errors: FormError[] = []
44
+ if (state.currentPassword && state.newPassword && state.currentPassword === state.newPassword) {
45
+ errors.push({ name: 'newPassword', message: t('security.password.validation.passwordsMustBeDifferentShort') })
46
+ }
47
+ return errors
48
+ }
49
+
50
+ const handleSubmit = async (event: FormSubmitEvent<PasswordSchema>) => {
51
+ if (!changePasswordAllowed.value) {
52
+ return
53
+ }
54
+
55
+ isLoading.value = true
56
+
57
+ try {
58
+ const { error } = await authClient.changePassword({
59
+ currentPassword: event.data.currentPassword,
60
+ newPassword: event.data.newPassword,
61
+ revokeOtherSessions: false // Keep other sessions active
62
+ })
63
+
64
+ if (error) {
65
+ throw error
66
+ }
67
+
68
+ toast.add({
69
+ title: t('common.success'),
70
+ description: t('security.password.success'),
71
+ color: 'success'
72
+ })
73
+
74
+ // Reset form
75
+ password.currentPassword = ''
76
+ password.newPassword = ''
77
+ } catch (err: unknown) {
78
+ let errorMessage = t('security.password.error')
79
+
80
+ if (err instanceof Error) {
81
+ errorMessage = err.message
82
+ } else if (err && typeof err === 'object' && 'message' in err) {
83
+ errorMessage = String(err.message)
84
+ }
85
+
86
+ toast.add({
87
+ title: t('common.error'),
88
+ description: errorMessage,
89
+ color: 'error'
90
+ })
91
+ } finally {
92
+ isLoading.value = false
93
+ }
94
+ }
95
+
96
+ const isDeleteRequested = ref(false)
97
+ const handleDeleteSuccess = () => {
98
+ // Account deletion handled in modal (redirects to home)
99
+ showDeleteModal.value = false
100
+ isDeleteRequested.value = true
101
+ }
102
+
103
+ const handleDeleteError = (message: string) => {
104
+ // Error already shown in modal via toast
105
+ console.error('Account deletion error:', message)
106
+ }
107
+ </script>
108
+
109
+ <template>
110
+ <div>
111
+ <AppCard
112
+ v-if="changePasswordAllowed"
113
+ :title="t('security.password.title')"
114
+ :description="t('security.password.description')"
115
+ >
116
+ <UForm
117
+ :schema="passwordSchema"
118
+ :state="password"
119
+ :validate="validate"
120
+ class="flex flex-col gap-4 max-w-xs"
121
+ @submit="handleSubmit"
122
+ >
123
+ <UFormField :name="'currentPassword'" :label="t('security.password.currentPassword')">
124
+ <UInput
125
+ v-model="password.currentPassword"
126
+ type="password"
127
+ :placeholder="t('security.password.currentPasswordPlaceholder')"
128
+ class="w-full"
129
+ :disabled="isLoading"
130
+ />
131
+ </UFormField>
132
+
133
+ <UFormField :name="'newPassword'" :label="t('security.password.newPassword')">
134
+ <UInput
135
+ v-model="password.newPassword"
136
+ type="password"
137
+ :placeholder="t('security.password.newPasswordPlaceholder')"
138
+ class="w-full"
139
+ :disabled="isLoading"
140
+ />
141
+ </UFormField>
142
+
143
+ <UButton
144
+ :label="t('security.password.updateButton')"
145
+ class="w-fit"
146
+ type="submit"
147
+ :loading="isLoading"
148
+ :disabled="isLoading"
149
+ />
150
+ </UForm>
151
+ </AppCard>
152
+
153
+ <UCard
154
+ :title="t('security.account.title')"
155
+ :description="t('security.account.description')"
156
+ class="bg-linear-to-tl from-error/10 from-5% to-default mt-8"
157
+ variant="subtle"
158
+ >
159
+ <template #header>
160
+ <div>
161
+ <h2 class="text-xl font-semibold text-gray-900 dark:text-white">
162
+ {{ t('security.account.title') }}
163
+ </h2>
164
+ <h6 class="text-sm text-gray-600 dark:text-gray-400">
165
+ {{ t('security.account.description') }}
166
+ </h6>
167
+ </div>
168
+ </template>
169
+ <template #footer>
170
+ <UButton
171
+ :label="t('security.account.deleteButton')"
172
+ :disabled="isDeleteRequested"
173
+ color="error"
174
+ @click="showDeleteModal = true"
175
+ />
176
+ </template>
177
+ <UAlert
178
+ v-if="isDeleteRequested"
179
+ color="warning"
180
+ :title="t('security.account.deleteRequested')"
181
+ :description="t('security.account.deleteRequestedDescription')"
182
+ variant="outline"
183
+ />
184
+ </UCard>
185
+
186
+ <!-- Delete Account Modal -->
187
+ <DeleteAccountModal
188
+ v-if="showDeleteModal"
189
+ v-model:open="showDeleteModal"
190
+ :requires-password="changePasswordAllowed"
191
+ @success="handleDeleteSuccess"
192
+ @error="handleDeleteError"
193
+ />
194
+ </div>
195
+ </template>
@@ -0,0 +1,101 @@
1
+ <script setup lang="ts">
2
+ import type { NavigationMenuItem } from '@nuxt/ui'
3
+
4
+ const { t } = useI18n()
5
+ const route = useRoute()
6
+ const router = useRouter()
7
+
8
+ // Mobile breakpoint detection
9
+ const breakpoints = useBreakpoints({
10
+ mobile: 768
11
+ })
12
+ const isMobile = breakpoints.smaller('mobile')
13
+
14
+ const links = [
15
+ [
16
+ {
17
+ label: t('settings.profile'),
18
+ icon: 'i-lucide-user',
19
+ to: '/settings',
20
+ exact: true
21
+ },
22
+ {
23
+ label: t('settings.organization'),
24
+ icon: 'i-lucide-users',
25
+ to: '/settings/organization'
26
+ },
27
+ {
28
+ label: t('settings.security'),
29
+ icon: 'i-lucide-shield',
30
+ to: '/settings/security'
31
+ }
32
+ ]
33
+ ] satisfies NavigationMenuItem[][]
34
+
35
+ const flatLinks = computed(() => links[0] ?? [])
36
+
37
+ const activeLink = computed(() => {
38
+ const items = flatLinks.value
39
+ if (!items.length) {
40
+ return undefined
41
+ }
42
+ const found = items.find((item) => {
43
+ const to = String(item.to || '')
44
+ if (!to) {
45
+ return false
46
+ }
47
+ // `/settings` should not match `/settings/*` (profile tab only on the root route)
48
+ if (to === '/settings') {
49
+ return route.path === to
50
+ }
51
+ return route.path === to || route.path.startsWith(`${to}/`)
52
+ })
53
+ return found || items[0]
54
+ })
55
+
56
+ useSeoMeta({
57
+ title: () => activeLink.value?.label || t('settings.title')
58
+ })
59
+
60
+ const mobileMenuItems = computed(() => [
61
+ flatLinks.value.map((item) => ({
62
+ label: item.label,
63
+ icon: item.icon,
64
+ onSelect: () => router.push(String(item.to))
65
+ }))
66
+ ])
67
+ </script>
68
+
69
+ <template>
70
+ <div class="h-[calc(100dvh-var(--ui-header-height))] min-h-0 overflow-y-auto bg-muted py-8">
71
+ <UContainer>
72
+ <div class="mb-4">
73
+ <UDropdownMenu
74
+ v-if="isMobile"
75
+ :items="mobileMenuItems"
76
+ :content="{ align: 'start', collisionPadding: 12 }"
77
+ :ui="{
78
+ // Match the trigger width (our button is full-width on mobile)
79
+ content: 'w-(--reka-dropdown-menu-trigger-width) bg-elevated border border-primary/30 shadow-xl rounded-lg',
80
+ // Improve contrast/visibility on dark backgrounds
81
+ item: 'text-highlighted data-highlighted:before:bg-elevated/80',
82
+ itemLabel: 'text-highlighted font-medium',
83
+ itemLeadingIcon: 'text-primary',
84
+ itemDescription: 'text-muted'
85
+ }"
86
+ >
87
+ <UButton color="neutral" variant="outline" class="w-full justify-between">
88
+ <span class="flex items-center gap-2 min-w-0">
89
+ <UIcon v-if="activeLink?.icon" :name="activeLink.icon" class="size-4 shrink-0" />
90
+ <span class="truncate">{{ activeLink?.label }}</span>
91
+ </span>
92
+ <UIcon name="i-lucide-chevron-down" class="size-4 opacity-60 shrink-0" />
93
+ </UButton>
94
+ </UDropdownMenu>
95
+
96
+ <UNavigationMenu v-else :items="links" highlight />
97
+ </div>
98
+ <NuxtPage />
99
+ </UContainer>
100
+ </div>
101
+ </template>
@@ -0,0 +1,3 @@
1
+ import { accountOrganizationsFeature } from '../../shared/feature'
2
+
3
+ export default defineNuxtPlugin(() => usePortalFeatures().registerFeature(accountOrganizationsFeature))
@@ -0,0 +1,208 @@
1
+ {
2
+ "settings": {
3
+ "title": "Settings",
4
+ "profile": "Profile",
5
+ "organization": "Organization",
6
+ "security": "Security"
7
+ },
8
+ "security": {
9
+ "password": {
10
+ "title": "Password",
11
+ "description": "Confirm your current password before setting a new one.",
12
+ "currentPassword": "Current Password",
13
+ "currentPasswordPlaceholder": "Enter your current password",
14
+ "newPassword": "New Password",
15
+ "newPasswordPlaceholder": "Enter your new password",
16
+ "updateButton": "Update Password",
17
+ "validation": {
18
+ "currentPasswordRequired": "Current password is required",
19
+ "newPasswordRequired": "New password is required",
20
+ "passwordsMustBeDifferent": "New password must be different from current password",
21
+ "passwordsMustBeDifferentShort": "Passwords must be different"
22
+ },
23
+ "success": "Password changed successfully",
24
+ "error": "Failed to change password"
25
+ },
26
+ "account": {
27
+ "title": "Terminate Account",
28
+ "description": "No longer want to use our service? You can delete your account here. This action is not reversible. All information related to this account will be deleted permanently.",
29
+ "deleteButton": "Delete account",
30
+ "deleteRequested": "Account Deletion Requested",
31
+ "deleteRequestedDescription": "We have sent you a verification email. Please check your email and click the link to complete the account deletion process.",
32
+ "delete": {
33
+ "confirmTitle": "Confirm Account Deletion",
34
+ "confirmMessage": "Are you sure you want to delete your account? This action cannot be undone.",
35
+ "passwordRequired": "Please enter your password to confirm account deletion",
36
+ "passwordPlaceholder": "Enter your password",
37
+ "deleteButton": "Delete Account",
38
+ "cancelButton": "Cancel",
39
+ "error": "Failed to delete account",
40
+ "emailVerificationSent": "Verification email sent. Please check your email to complete account deletion."
41
+ }
42
+ }
43
+ },
44
+ "myOrganizations": {
45
+ "title": "My Organizations",
46
+ "description": "Manage your organizations and switch between them"
47
+ },
48
+ "organization": {
49
+ "details": {
50
+ "title": "Organization details",
51
+ "name": "Name",
52
+ "slug": "Slug",
53
+ "created": "Created"
54
+ },
55
+ "emailProvider": {
56
+ "title": "Email provider",
57
+ "description": "Configure this organization's Resend account for invoices, reminders, and other transactional email.",
58
+ "configured": "Resend is configured",
59
+ "notConfigured": "Resend is not configured",
60
+ "required": "Transactional email is blocked until an organization owner configures a key.",
61
+ "masked": "Active API key ending in {suffix}",
62
+ "verifiedDomains": "Verified sending domains",
63
+ "keyLabel": "Resend API key",
64
+ "replaceLabel": "New Resend API key",
65
+ "configure": "Configure and validate",
66
+ "replace": "Validate and replace",
67
+ "checkAgain": "Check domains again",
68
+ "remove": "Remove key",
69
+ "saved": "Resend API key saved",
70
+ "documentation": "Resend domain verification guide",
71
+ "invalid": "The Resend API key could not be validated",
72
+ "invalidDescription": "Check that the key is active and belongs to the intended Resend account.",
73
+ "permissionRequired": "This Resend key is send-only",
74
+ "permissionRequiredDescription": "Create a Full access API key in Resend. Domain access is required to verify that this organization may use its configured sender address.",
75
+ "checkFailed": "Could not check Resend domains",
76
+ "removed": "Resend API key removed",
77
+ "statusFailed": "Email provider status could not be loaded",
78
+ "statusFailedDescription": "Check the server and database migration before configuring a key.",
79
+ "removeTitle": "Remove Resend API key?",
80
+ "removeDescription": "Invoices and reminders cannot be sent until a new organization key is configured."
81
+ },
82
+ "settings": {
83
+ "editButton": "Edit organization",
84
+ "updateSuccess": "Organization updated successfully",
85
+ "nameLabel": "Organization Name",
86
+ "namePlaceholder": "Organization name",
87
+ "officialCompanyNameLabel": "Official company name",
88
+ "officialCompanyNamePlaceholder": "Company name as registered",
89
+ "officialCompanyNameHint": "The official name under which the organization is registered.",
90
+ "slugLabel": "Organization Slug",
91
+ "slugPlaceholder": "organization-slug",
92
+ "slugHint": "URL-friendly identifier for your organization",
93
+ "slugChecking": "Checking availability...",
94
+ "slugAvailable": "Slug is available",
95
+ "slugUnavailable": "Slug is already in use",
96
+ "slugError": "Unable to check slug availability",
97
+ "logoLabel": "Organization Logo",
98
+ "logoChoose": "Choose logo",
99
+ "logoRemove": "Remove logo",
100
+ "avatarLogoLabel": "Avatar logo",
101
+ "avatarLogoHint": "A compact logo for organization lists. Prefer a square image (PNG, JPG, GIF, or WebP, up to 2 MB).",
102
+ "invoiceLogoHint": "Upload the organization logo as PNG, JPG, GIF, or WebP.",
103
+ "logoHint": "Upload a PNG, JPG, or GIF. Recommended size: 280×160.",
104
+ "ownerOnly": "Only organization owners can edit name or slug.",
105
+ "errors": {
106
+ "loadFailed": "Failed to load organization",
107
+ "updateFailed": "Failed to update organization",
108
+ "slugTaken": "Organization slug is already taken"
109
+ },
110
+ "validation": {
111
+ "nameRequired": "Organization name is required",
112
+ "officialCompanyNameRequired": "Official company name is required",
113
+ "slugRequired": "Organization slug is required",
114
+ "slugInvalid": "Slug must be lowercase letters, numbers, and hyphens only",
115
+ "logoInvalid": "Logo must be a valid image",
116
+ "logoTooLarge": "Logo must be smaller than 2 MB"
117
+ }
118
+ },
119
+ "members": {
120
+ "loading": "Loading organization data...",
121
+ "title": "Members",
122
+ "empty": "No members yet",
123
+ "role": "Role",
124
+ "remove": "Remove member",
125
+ "removeSuccess": "Member removed successfully",
126
+ "roles": {
127
+ "owner": "Owner",
128
+ "admin": "Admin",
129
+ "member": "Member"
130
+ },
131
+ "confirmRemove": {
132
+ "title": "Remove member?",
133
+ "message": "Are you sure you want to remove {name} from this organization?",
134
+ "confirm": "Remove"
135
+ },
136
+ "invitations": {
137
+ "title": "Invitations",
138
+ "empty": "No pending invitations",
139
+ "expires": "Expires",
140
+ "resend": "Resend",
141
+ "delete": "Delete",
142
+ "inviteButton": "Invite Member",
143
+ "modalTitle": "Invite Member",
144
+ "emailLabel": "Email Address",
145
+ "emailPlaceholder": "Enter the email address of the member",
146
+ "roleLabel": "Role",
147
+ "roleMember": "Member",
148
+ "roleAdmin": "Admin",
149
+ "sendButton": "Send Invitation",
150
+ "sendSuccess": "Invitation sent successfully",
151
+ "cancel": "Cancel",
152
+ "resendSuccess": "Invitation resent successfully",
153
+ "deleteSuccess": "Invitation deleted successfully"
154
+ },
155
+ "errors": {
156
+ "sendInvitationFailed": "Failed to send invitation",
157
+ "resendFailed": "Failed to resend invitation",
158
+ "cancelFailed": "Failed to cancel invitation",
159
+ "removeFailed": "Failed to remove member"
160
+ },
161
+ "confirm": {
162
+ "cancelInvitation": "Are you sure you want to cancel this invitation?"
163
+ },
164
+ "validation": {
165
+ "emailInvalid": "Invalid email address",
166
+ "roleRequired": "Role is required"
167
+ }
168
+ }
169
+ },
170
+ "profile": {
171
+ "badges": {
172
+ "active": "Active"
173
+ },
174
+ "buttons": {
175
+ "setActive": "Set active"
176
+ },
177
+ "description": "Manage your personal information and profile settings",
178
+ "fields": {
179
+ "email": "Email",
180
+ "emailDescription": "Your email address cannot be changed",
181
+ "name": "Name",
182
+ "nameDescription": "Your display name",
183
+ "namePlaceholder": "Enter your name",
184
+ "profilePicture": "Profile Picture",
185
+ "profilePictureDescription": "JPG, GIF or PNG. 1MB Max.",
186
+ "chooseAvatar": "Choose"
187
+ },
188
+ "messages": {
189
+ "error": "Error",
190
+ "failedToActivateOrganization": "Failed to activate organization",
191
+ "organizationActivated": "Organization activated successfully",
192
+ "profileUpdated": "Your profile has been updated successfully",
193
+ "success": "Success",
194
+ "updateFailed": "Failed to update profile"
195
+ },
196
+ "sections": {
197
+ "organizations": "My organizations",
198
+ "profileInfo": "Profile Information"
199
+ },
200
+ "title": "Profile Settings",
201
+ "validation": {
202
+ "atLeastOneField": "At least one field (name or image) must be provided",
203
+ "imageInvalidUrl": "Image must be a valid URL",
204
+ "nameMaxLength": "Name must be less than 255 characters",
205
+ "nameRequired": "Name is required"
206
+ }
207
+ }
208
+ }