2026-01-20 03:22:07 +00:00
|
|
|
<script setup lang="ts">
|
|
|
|
|
import { ref, onMounted } 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 orgId = route.params.id 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 loading = ref(false)
|
|
|
|
|
const inviteModalVisible = ref(false)
|
|
|
|
|
const newInviteUrl = ref('')
|
|
|
|
|
const editingDescription = ref(false)
|
|
|
|
|
const newDescription = ref('')
|
|
|
|
|
|
|
|
|
|
const fetchOrganization = async () => {
|
|
|
|
|
loading.value = true
|
|
|
|
|
try {
|
|
|
|
|
const response = await apiClient.get<Organization>(API.organization(orgId))
|
|
|
|
|
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.organizationMembers(orgId))
|
|
|
|
|
members.value = response.data
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('Failed to fetch members:', error)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const fetchInvites = async () => {
|
|
|
|
|
try {
|
|
|
|
|
const response = await apiClient.get<InviteToken[]>(API.organizationInvites(orgId))
|
|
|
|
|
invites.value = response.data
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('Failed to fetch invites:', error)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const fetchRoles = async () => {
|
|
|
|
|
try {
|
|
|
|
|
const response = await apiClient.get<Role[]>(API.organizationRoles(orgId))
|
|
|
|
|
Roles.value = response.data as unknown as Role[]
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('Failed to fetch Roles:', error)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const createInvite = async () => {
|
|
|
|
|
try {
|
|
|
|
|
const response = await apiClient.post<InviteToken>(
|
|
|
|
|
API.organizationCreateInvite(orgId, 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')
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const copyInviteUrl = () => {
|
|
|
|
|
window.navigator.clipboard.writeText(newInviteUrl.value)
|
|
|
|
|
message.success('Invite URL copied to clipboard')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const copyUrl = (url: string) => {
|
|
|
|
|
window.navigator.clipboard.writeText(url)
|
|
|
|
|
message.success('Copied to clipboard')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const revokeInvite = async (token: string) => {
|
|
|
|
|
try {
|
|
|
|
|
await apiClient.delete(API.organizationRevokeInvite(orgId, token))
|
|
|
|
|
message.success('Invite revoked')
|
|
|
|
|
fetchInvites()
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('Failed to revoke invite:', error)
|
|
|
|
|
message.error('Failed to revoke invite')
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const removeMember = async (userId: number) => {
|
|
|
|
|
try {
|
|
|
|
|
await apiClient.post(API.organizationMemberRemove(orgId, userId))
|
|
|
|
|
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(orgId), {
|
|
|
|
|
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 currentUserId = auth.user?.id
|
|
|
|
|
const isOwner = organization.value?.owner?.id === currentUserId
|
|
|
|
|
const myMembership = members.value.find((m) => m.id === currentUserId)
|
|
|
|
|
const isEmployer = myMembership?.is_manager
|
|
|
|
|
|
|
|
|
|
if (!isOwner && !isEmployer) {
|
|
|
|
|
message.error('You do not have permission to manage this organization')
|
|
|
|
|
router.replace(`/organization/${orgId}`)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
<template>
|
|
|
|
|
<div class="page">
|
|
|
|
|
<Spin :spinning="loading" tip="Loading organization...">
|
|
|
|
|
<Card v-if="organization" class="panel" :bordered="false">
|
2026-01-25 17:29:37 +00:00
|
|
|
<div class="header">
|
|
|
|
|
<Typography.Title :level="2">Manage {{ organization.name }}</Typography.Title>
|
2026-01-20 03:22:07 +00:00
|
|
|
<Button type="default" @click="router.push(`/organization/${orgId}`)">
|
|
|
|
|
Back to Organization
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<Tabs>
|
|
|
|
|
<Tabs.TabPane key="details" tab="Details">
|
|
|
|
|
<div class="section">
|
2026-01-25 17:29:37 +00:00
|
|
|
<Typography.Title :level="4" style="color: #ffffff !important">Description</Typography.Title>
|
2026-01-20 03:22:07 +00:00
|
|
|
<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-01-25 17:29:37 +00:00
|
|
|
<Typography.Title :level="4" style="color: #ffffff !important">
|
2026-01-20 03:22:07 +00:00
|
|
|
Members ({{ members.length }})
|
|
|
|
|
</Typography.Title>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<List :data-source="members" :bordered="false">
|
|
|
|
|
<template #renderItem="{ item }">
|
|
|
|
|
<List.Item class="member-item">
|
|
|
|
|
<List.Item.Meta
|
|
|
|
|
:title="`${item.first_name} ${item.last_name}`"
|
|
|
|
|
:description="item.bio || 'No bio provided'"
|
|
|
|
|
/>
|
|
|
|
|
<Space>
|
|
|
|
|
<Button
|
|
|
|
|
v-if="item.id !== organization.owner.id"
|
|
|
|
|
danger
|
|
|
|
|
size="small"
|
|
|
|
|
@click="removeMember(item.id)"
|
|
|
|
|
>
|
|
|
|
|
Remove
|
|
|
|
|
</Button>
|
|
|
|
|
<Tag v-else color="blue">Owner</Tag>
|
|
|
|
|
</Space>
|
|
|
|
|
</List.Item>
|
|
|
|
|
</template>
|
|
|
|
|
</List>
|
|
|
|
|
</div>
|
|
|
|
|
</Tabs.TabPane>
|
|
|
|
|
|
|
|
|
|
<Tabs.TabPane key="invites" tab="Invites">
|
|
|
|
|
<div class="section">
|
|
|
|
|
<div class="section-header">
|
2026-01-25 17:29:37 +00:00
|
|
|
<Typography.Title :level="4" style="color: #ffffff !important">Invite Tokens</Typography.Title>
|
2026-01-20 03:22:07 +00:00
|
|
|
<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.token)"
|
|
|
|
|
>
|
|
|
|
|
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">
|
2026-01-25 17:29:37 +00:00
|
|
|
<Typography.Title :level="4" style="color: #ffffff !important">
|
2026-01-20 03:22:07 +00:00
|
|
|
Roles ({{ Roles.length }})
|
|
|
|
|
</Typography.Title>
|
|
|
|
|
|
|
|
|
|
<List v-if="Roles.length > 0" :data-source="Roles" :bordered="false">
|
|
|
|
|
<template #renderItem="{ item }">
|
|
|
|
|
<List.Item class="Role-item">
|
|
|
|
|
<List.Item.Meta
|
|
|
|
|
:title="item.name"
|
|
|
|
|
:description="item.description || 'No description'"
|
|
|
|
|
/>
|
|
|
|
|
<Tag>{{ item.member_count }} members</Tag>
|
|
|
|
|
</List.Item>
|
|
|
|
|
</template>
|
|
|
|
|
</List>
|
|
|
|
|
<Typography.Paragraph v-else type="secondary">
|
|
|
|
|
No Roles in this organization yet.
|
|
|
|
|
</Typography.Paragraph>
|
|
|
|
|
</div>
|
|
|
|
|
</Tabs.TabPane>
|
|
|
|
|
</Tabs>
|
|
|
|
|
</Card>
|
|
|
|
|
</Spin>
|
|
|
|
|
|
|
|
|
|
<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>
|
|
|
|
|
</div>
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
<style scoped>
|
|
|
|
|
.page {
|
|
|
|
|
padding: 1rem;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-25 17:29:37 +00:00
|
|
|
.header {
|
|
|
|
|
display: flex;
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
align-items: center;
|
|
|
|
|
margin-bottom: 1rem;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-20 03:22:07 +00:00
|
|
|
.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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-25 17:29:37 +00:00
|
|
|
: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) {
|
|
|
|
|
color: #e5e7eb !important;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
:deep(.ant-typography-secondary) {
|
|
|
|
|
color: #cbd5e1 !important;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
:deep(.ant-input-number) {
|
|
|
|
|
background: #111827;
|
|
|
|
|
border-color: #334155;
|
2026-01-20 03:22:07 +00:00
|
|
|
}
|
|
|
|
|
</style>
|