Dynavera/site/src/views/OrganizationManage.vue

658 lines
24 KiB
Vue
Raw Normal View History

<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import {
Card,
Typography,
Button,
List,
Space,
Spin,
Input,
message,
Tag,
Modal,
Tabs,
InputNumber,
} from 'ant-design-vue'
import { apiClient, isAxiosError, API } from '../router/api'
import { useUserStore } from '../stores/userStore'
import type { Organization } from '../types/organization'
import type { User } from '../types/user'
import type { InviteToken } from '../types/organization'
import type { Role } from '../types/organization'
const route = useRoute()
const router = useRouter()
const auth = useUserStore()
const organizationUuid = route.params.organizationUuid as string
const organization = ref<Organization | null>(null)
const members = ref<User[]>([])
const invites = ref<InviteToken[]>([])
const newInviteMaxUses = ref<number>(1)
const Roles = ref<Role[]>([])
const memberSearch = ref('')
const roleSearch = ref('')
const loading = ref(false)
const creatingRole = ref(false)
const deletingRoleUuid = ref<string | null>(null)
const roleModalVisible = ref(false)
2026-03-10 19:38:47 +00:00
const roleMembersModalVisible = ref(false)
const selectedRoleForMembers = ref<Role | null>(null)
const selectedRoleMembers = ref<User[]>([])
const createRoleForm = ref({
name: '',
description: '',
})
const inviteModalVisible = ref(false)
const newInviteUrl = ref('')
const editingDescription = ref(false)
const newDescription = ref('')
const filteredMembers = computed(() => {
const query = memberSearch.value.trim().toLowerCase()
if (!query) return members.value
return members.value.filter((member) => {
const fullName = `${member.first_name} ${member.last_name}`.toLowerCase()
return fullName.includes(query)
})
})
const filteredRoles = computed(() => {
const query = roleSearch.value.trim().toLowerCase()
if (!query) return Roles.value
return Roles.value.filter((role) => {
const name = role.name?.toLowerCase() || ''
const description = role.description?.toLowerCase() || ''
return name.includes(query) || description.includes(query)
})
})
const memberEmptyMessage = computed(() => {
if (members.value.length === 0) return 'No members in this organization yet.'
if (memberSearch.value.trim()) return 'No members match your search.'
return 'No members in this organization yet.'
})
const roleEmptyMessage = computed(() => {
if (Roles.value.length === 0) return 'No Roles in this organization yet.'
if (roleSearch.value.trim()) return 'No roles match your search.'
return 'No Roles in this organization yet.'
})
const fetchOrganization = async () => {
loading.value = true
try {
const response = await apiClient.get<Organization>(API.organization.byId(organizationUuid))
organization.value = response.data
newDescription.value = response.data.description
} catch (error) {
console.error('Failed to fetch organization:', error)
message.error('Failed to load organization details')
} finally {
loading.value = false
}
}
const fetchMembers = async () => {
try {
const response = await apiClient.get<User[]>(API.organization.members.list(organizationUuid))
members.value = response.data
} catch (error) {
console.error('Failed to fetch members:', error)
}
}
const fetchInvites = async () => {
try {
2026-03-08 13:19:17 +00:00
const response = await apiClient.get<InviteToken[]>(API.invites.list(organizationUuid))
invites.value = response.data
} catch (error) {
console.error('Failed to fetch invites:', error)
}
}
const fetchRoles = async () => {
try {
2026-03-08 13:19:17 +00:00
const response = await apiClient.get<Role[]>(API.roles.list(organizationUuid))
Roles.value = response.data as unknown as Role[]
} catch (error) {
console.error('Failed to fetch Roles:', error)
}
}
const resetRoleForm = () => {
createRoleForm.value = { name: '', description: '' }
}
const hasDuplicateRoleName = (name: string) =>
Roles.value.some((role) => role.name.trim().toLowerCase() === name.trim().toLowerCase())
const createRole = async () => {
const name = createRoleForm.value.name.trim()
const description = createRoleForm.value.description.trim()
if (!name) {
message.error('Role name is required')
return
}
if (hasDuplicateRoleName(name)) {
message.error('A role with this name already exists')
return
}
creatingRole.value = true
try {
2026-03-08 13:19:17 +00:00
await apiClient.post(API.roles.list(organizationUuid), { name, description })
message.success('Role created successfully')
roleModalVisible.value = false
resetRoleForm()
await fetchRoles()
} catch (error) {
console.error('Failed to create role:', error)
if (isAxiosError(error)) {
message.error(error.response?.data?.error || 'Failed to create role')
} else {
message.error('Failed to create role')
}
} finally {
creatingRole.value = false
}
}
const deleteRole = async (role: Role) => {
Modal.confirm({
title: 'Delete role',
content: `Are you sure you want to delete "${role.name}"?`,
okText: 'Delete',
okType: 'danger',
cancelText: 'Cancel',
onOk: async () => {
deletingRoleUuid.value = role.uuid
try {
2026-03-08 13:19:17 +00:00
await apiClient.delete(API.roles.remove(organizationUuid, role.uuid))
message.success('Role deleted successfully')
await fetchRoles()
} catch (error) {
console.error('Failed to delete role:', error)
if (isAxiosError(error)) {
message.error(error.response?.data?.error || 'Failed to delete role')
} else {
message.error('Failed to delete role')
}
} finally {
deletingRoleUuid.value = null
}
},
})
}
2026-03-10 19:38:47 +00:00
const openRoleMembersModal = (role: Role) => {
selectedRoleForMembers.value = role
selectedRoleMembers.value = Array.isArray(role.members) ? role.members : []
roleMembersModalVisible.value = true
}
const createInvite = async () => {
try {
const response = await apiClient.post<InviteToken>(
2026-03-08 13:19:17 +00:00
API.invites.create(organizationUuid, newInviteMaxUses.value),
)
newInviteUrl.value = response.data.invite_url
inviteModalVisible.value = true
fetchInvites()
} catch (error) {
console.error('Failed to create invite:', error)
message.error('Failed to create invite')
}
}
2026-03-08 13:19:17 +00:00
const fallbackCopyText = (text: string): boolean => {
const textarea = document.createElement('textarea')
textarea.value = text
textarea.setAttribute('readonly', 'true')
textarea.style.position = 'fixed'
textarea.style.opacity = '0'
textarea.style.pointerEvents = 'none'
document.body.appendChild(textarea)
textarea.focus()
textarea.select()
const copied = document.execCommand('copy')
document.body.removeChild(textarea)
return copied
}
2026-03-08 13:19:17 +00:00
const copyToClipboard = async (text: string): Promise<boolean> => {
const safeText = String(text || '').trim()
if (!safeText) return false
if (window.isSecureContext && window.navigator.clipboard?.writeText) {
try {
await window.navigator.clipboard.writeText(safeText)
return true
} catch {
// Fall through to legacy copy for restricted browser contexts.
}
}
return fallbackCopyText(safeText)
}
const copyInviteUrl = async () => {
const copied = await copyToClipboard(newInviteUrl.value)
if (copied) {
message.success('Invite URL copied to clipboard')
return
}
message.error('Could not copy invite URL. Please copy it manually.')
}
const copyUrl = async (url: string) => {
const copied = await copyToClipboard(url)
if (copied) {
message.success('Copied to clipboard')
return
}
message.error('Could not copy URL. Please copy it manually.')
}
const revokeInvite = async (inviteUuid: string) => {
try {
2026-03-08 13:19:17 +00:00
await apiClient.delete(API.invites.revoke(organizationUuid, inviteUuid))
message.success('Invite revoked')
fetchInvites()
} catch (error) {
console.error('Failed to revoke invite:', error)
message.error('Failed to revoke invite')
}
}
const removeMember = async (userUuid: string) => {
try {
await apiClient.post(API.organization.members.remove(organizationUuid, userUuid))
message.success('Member removed')
fetchMembers()
} catch (error) {
console.error('Failed to remove member:', error)
if (isAxiosError(error)) {
message.error(error.response?.data?.error || 'Failed to remove member')
}
}
}
const saveDescription = async () => {
try {
await apiClient.patch(API.organization.byId(organizationUuid), {
description: newDescription.value,
})
message.success('Description updated')
editingDescription.value = false
fetchOrganization()
} catch (error) {
console.error('Failed to update description:', error)
message.error('Failed to update description')
}
}
onMounted(async () => {
await fetchOrganization()
await fetchMembers()
await fetchInvites()
await fetchRoles()
const currentUserUuid = auth.user?.uuid
const isOwner = organization.value?.owner?.uuid === currentUserUuid
const myMembership = members.value.find((member) => member.uuid === currentUserUuid)
const isEmployer = myMembership?.is_manager
if (!isOwner && !isEmployer) {
message.error('You do not have permission to manage this organization')
router.replace(`/organization/${organizationUuid}`)
}
})
</script>
<template>
<div class="page">
<Spin :spinning="loading" tip="Loading organization...">
<Card v-if="organization" class="panel" :bordered="false">
<div class="header">
<Typography.Title :level="2">Manage {{ organization.name }}</Typography.Title>
<Button type="default" @click="router.push(`/organization/${organizationUuid}`)">
Back to Organization
</Button>
</div>
<Tabs>
<Tabs.TabPane key="details" tab="Details">
<div class="section">
2026-03-08 13:19:17 +00:00
<Typography.Title :level="4" style="color: #1f2937 !important">
Description
</Typography.Title>
<div v-if="!editingDescription">
<Typography.Paragraph>
{{ organization.description || 'No description provided' }}
</Typography.Paragraph>
<Button @click="editingDescription = true" size="small">
Edit Description
</Button>
</div>
<div v-else>
<Input.TextArea
v-model:value="newDescription"
:rows="4"
placeholder="Enter organization description"
/>
<Space style="margin-top: 0.5rem">
<Button type="primary" @click="saveDescription">Save</Button>
<Button @click="editingDescription = false">Cancel</Button>
</Space>
</div>
</div>
</Tabs.TabPane>
<Tabs.TabPane key="members" tab="Members">
<div class="section">
<div class="section-header">
2026-03-08 13:19:17 +00:00
<Typography.Title :level="4" style="color: #1f2937 !important">
Members ({{ filteredMembers.length }})
</Typography.Title>
<Input
v-model:value="memberSearch"
allow-clear
class="search-input"
placeholder="Search members by name"
style="max-width: 280px"
/>
</div>
<List
v-if="filteredMembers.length > 0"
:data-source="filteredMembers"
:bordered="false"
>
<template #renderItem="{ item }">
<List.Item class="member-item">
<List.Item.Meta
:title="`${item.first_name} ${item.last_name}`"
:description="item.email_address"
/>
<Space>
<Tag v-if="item.uuid === organization.owner.uuid" color="blue">
Owner
</Tag>
<Tag v-else :color="item.is_manager ? 'purple' : 'default'">
{{ item.is_manager ? 'Manager' : 'Member' }}
</Tag>
<Button
v-if="item.uuid !== organization.owner.uuid"
danger
size="small"
@click="removeMember(item.uuid)"
>
Remove
</Button>
</Space>
</List.Item>
</template>
</List>
<Typography.Paragraph v-else type="secondary">
{{ memberEmptyMessage }}
</Typography.Paragraph>
</div>
</Tabs.TabPane>
<Tabs.TabPane key="invites" tab="Invites">
<div class="section">
<div class="section-header">
2026-03-08 13:19:17 +00:00
<Typography.Title :level="4" style="color: #1f2937 !important">
Invite Tokens
</Typography.Title>
<Space>
<InputNumber v-model:value="newInviteMaxUses" :min="1" />
<Button type="primary" @click="createInvite">
Create Invite
</Button>
</Space>
</div>
<List
v-if="invites.length > 0"
:data-source="invites"
:bordered="false"
>
<template #renderItem="{ item }">
<List.Item class="invite-item">
<List.Item.Meta
:title="`Created by ${item.created_by.first_name} ${item.created_by.last_name}`"
:description="`Expires: ${new Date(item.expires_at).toLocaleDateString()}`"
/>
<Space>
<Tag :color="item.is_valid ? 'green' : 'red'">
{{ item.is_valid ? 'Valid' : 'Expired' }}
</Tag>
<Tag class="white-tag">
Uses: {{ item.uses || 0 }} /
{{ item.max_uses || 1 }}
</Tag>
<Button size="small" @click="copyUrl(item.invite_url)">
Copy URL
</Button>
<Button
danger
size="small"
@click="revokeInvite(item.uuid)"
>
Revoke
</Button>
</Space>
</List.Item>
</template>
</List>
<Typography.Paragraph v-else type="secondary">
No active invites. Create one to invite new members.
</Typography.Paragraph>
</div>
</Tabs.TabPane>
<Tabs.TabPane key="Roles" tab="Roles">
<div class="section">
<div class="section-header">
2026-03-08 13:19:17 +00:00
<Typography.Title :level="4" style="color: #1f2937 !important">
Roles ({{ filteredRoles.length }})
</Typography.Title>
<Space>
<Input
v-model:value="roleSearch"
allow-clear
class="search-input"
placeholder="Search roles by name or description"
style="width: 300px"
/>
<Button type="primary" @click="roleModalVisible = true">
Create Role
</Button>
</Space>
</div>
<List
v-if="filteredRoles.length > 0"
:data-source="filteredRoles"
:bordered="false"
>
<template #renderItem="{ item }">
<List.Item class="Role-item">
<List.Item.Meta
:title="item.name"
:description="item.description || 'No description'"
/>
<Space>
<Tag>{{ item.member_count }} members</Tag>
2026-03-10 19:38:47 +00:00
<Button size="small" @click="openRoleMembersModal(item)">
View Members
</Button>
<Button
danger
size="small"
:loading="deletingRoleUuid === item.uuid"
@click="deleteRole(item)"
>
Delete
</Button>
</Space>
</List.Item>
</template>
</List>
<Typography.Paragraph v-else type="secondary">
{{ roleEmptyMessage }}
</Typography.Paragraph>
</div>
</Tabs.TabPane>
</Tabs>
</Card>
</Spin>
<Modal
v-model:open="roleModalVisible"
title="Create Role"
ok-text="Create"
cancel-text="Cancel"
:ok-button-props="{ loading: creatingRole }"
@ok="createRole"
@cancel="resetRoleForm"
>
<div style="display: flex; flex-direction: column; gap: 0.75rem">
<Input
v-model:value="createRoleForm.name"
placeholder="Role name"
:maxlength="100"
@pressEnter="createRole"
/>
<Input.TextArea
v-model:value="createRoleForm.description"
placeholder="Role description"
:rows="4"
:maxlength="1000"
/>
</div>
</Modal>
<Modal
v-model:open="inviteModalVisible"
title="Invite Created"
@ok="inviteModalVisible = false"
>
<div>
<Typography.Paragraph>
Share this URL with people you want to invite:
</Typography.Paragraph>
<Input
:value="newInviteUrl"
readonly
@click="copyInviteUrl"
style="cursor: pointer"
/>
<Button type="primary" block style="margin-top: 1rem" @click="copyInviteUrl">
Copy to Clipboard
</Button>
</div>
</Modal>
2026-03-10 19:38:47 +00:00
<Modal
v-model:open="roleMembersModalVisible"
:title="`Members in ${selectedRoleForMembers?.name || 'Role'}`"
:footer="null"
>
<List
v-if="selectedRoleMembers.length > 0"
:data-source="selectedRoleMembers"
:bordered="false"
>
<template #renderItem="{ item }">
<List.Item>
<List.Item.Meta
:title="`${item.first_name} ${item.last_name}`"
:description="item.email_address"
/>
<Tag :color="item.is_manager ? 'purple' : 'default'">
{{ item.is_manager ? 'Manager' : 'Member' }}
</Tag>
</List.Item>
</template>
</List>
<Typography.Paragraph v-else type="secondary">
No members assigned to this role yet.
</Typography.Paragraph>
</Modal>
</div>
</template>
<style scoped>
.page {
padding: 1rem;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.section {
margin: 2rem 0;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.white-tag {
background-color: #ffffff !important;
color: #000000 !important;
}
:deep(.ant-typography),
:deep(.ant-typography p),
:deep(.ant-typography span),
:deep(.ant-typography h4),
:deep(.ant-list-item),
:deep(.ant-list-item-meta-title),
:deep(.ant-list-item-meta-description),
:deep(.ant-tabs-tab),
:deep(.ant-input-number),
:deep(.ant-input-number-input) {
2026-03-08 13:19:17 +00:00
color: #1f2937 !important;
}
:deep(.ant-typography-secondary) {
2026-03-08 13:19:17 +00:00
color: #6b7280 !important;
}
:deep(.ant-input-number) {
2026-03-08 13:19:17 +00:00
background: #ffffff;
border-color: #d0d8e2;
}
:deep(.search-input) {
2026-03-08 13:19:17 +00:00
background: #ffffff !important;
border-color: #d0d8e2 !important;
color: #1f2937 !important;
}
:deep(.search-input::placeholder) {
2026-03-08 13:19:17 +00:00
color: #6b7280 !important;
}
:deep(.search-input::selection) {
2026-03-08 13:19:17 +00:00
background: #dbeafe !important;
color: #1f2937 !important;
}
</style>