Added organization management, consolidated views and styling
This commit is contained in:
parent
eaba88decd
commit
48615c10a4
14 changed files with 711 additions and 201 deletions
|
|
@ -54,3 +54,42 @@ pre {
|
|||
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New',
|
||||
monospace;
|
||||
}
|
||||
|
||||
:root {
|
||||
--view-max-width: 1200px;
|
||||
--view-panel-background: #0f172a;
|
||||
--view-panel-border: #1f2937;
|
||||
--view-panel-color: #e5e7eb;
|
||||
}
|
||||
|
||||
.page {
|
||||
width: 100%;
|
||||
max-width: var(--view-max-width);
|
||||
margin: 0 auto;
|
||||
padding: 2rem 1.5rem;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: var(--view-panel-background);
|
||||
border: 1px solid var(--view-panel-border);
|
||||
color: var(--view-panel-color);
|
||||
}
|
||||
|
||||
.panel .ant-card-body {
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.panel .ant-typography,
|
||||
.panel .ant-typography *,
|
||||
.panel .ant-list-item-meta-title,
|
||||
.panel .ant-list-item-meta-description,
|
||||
.panel .ant-statistic-title,
|
||||
.panel .ant-statistic-content {
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
.panel .ant-typography-secondary,
|
||||
.panel .ant-typography-secondary * {
|
||||
color: rgba(255, 255, 255, 0.7) !important;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -77,9 +77,22 @@ export const API = {
|
|||
organizations: () => '/api/organization/',
|
||||
organization: (id: string) => `/api/organization/${id}/`,
|
||||
organizationRoles: (orgUuid: string) => `/api/organization/${orgUuid}/role/`,
|
||||
organizationRoleMembers: (orgUuid: string, roleId: number) =>
|
||||
`/api/organization/${orgUuid}/role/${roleId}/members/`,
|
||||
organizationMembers: (orgUuid: string) => `/api/organization/${orgUuid}/members/`,
|
||||
organizationRole: (orgUuid: string, roleUuid: string) =>
|
||||
`/api/organization/${orgUuid}/role/${roleUuid}/`,
|
||||
organizationRoleMembers: (orgUuid: string, roleUuid: string) =>
|
||||
`/api/organization/${orgUuid}/role/${roleUuid}/members/`,
|
||||
organizationMembers: (orgUuid: string) => `/api/organization/${orgUuid}/member/`,
|
||||
organizationMemberRemove: (orgUuid: string, userId: number) =>
|
||||
`/api/organization/${orgUuid}/member/${userId}/remove/`,
|
||||
organizationInvites: (orgUuid: string) => `/api/organization/${orgUuid}/invite/`,
|
||||
organizationInviteDetail: (orgUuid: string, token: string) =>
|
||||
`/api/organization/${orgUuid}/invite/${token}/`,
|
||||
organizationRevokeInvite: (orgUuid: string, token: string) =>
|
||||
`/api/organization/${orgUuid}/invite/${token}/revoke/`,
|
||||
organizationCreateInvite: (orgUuid: string, max_uses: number) =>
|
||||
`/api/organization/${orgUuid}/create-invite/?max_uses=${max_uses}`,
|
||||
organizationJoin: (token: string) => `/api/organization/join/${token}/`,
|
||||
organizationLeave: (orgUuid: string) => `/api/organization/${orgUuid}/leave/`,
|
||||
}
|
||||
|
||||
export const apiClient = new ApiClient()
|
||||
|
|
|
|||
|
|
@ -44,13 +44,24 @@ const router = createRouter({
|
|||
component: () => import('../views/OrganizationView.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/organization/:id/manage',
|
||||
name: 'organization-manage',
|
||||
component: () => import('../views/OrganizationManage.vue'),
|
||||
meta: { requiresAuth: true, requiresManager: true },
|
||||
},
|
||||
{
|
||||
path: '/invite/:token',
|
||||
name: 'invite-accept',
|
||||
component: () => import('../views/InviteAccept.vue'),
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
router.beforeEach((to, from, next) => {
|
||||
const userStore = useUserStore()
|
||||
const isAuthenticated = userStore.isAuthenticated
|
||||
// const is_manager = userStore.user?.is_manager || false
|
||||
const isManager = userStore.isGeneralManager
|
||||
|
||||
if (to.meta?.guestOnly && isAuthenticated) {
|
||||
return next({ path: '/' })
|
||||
|
|
@ -58,6 +69,9 @@ router.beforeEach((to, from, next) => {
|
|||
if (to.meta?.requiresAuth && !isAuthenticated) {
|
||||
return next({ path: '/login', query: { redirect: to.fullPath } })
|
||||
}
|
||||
if (to.meta?.requiresManager && !isManager) {
|
||||
return next({ path: '/' })
|
||||
}
|
||||
|
||||
return next()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,91 +1,76 @@
|
|||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { apiClient, isAxiosError, API } from '../router/api'
|
||||
import type { User, SessionResponse } from '../types/user'
|
||||
import type { Organization, Role } from 'src/types/organization'
|
||||
|
||||
export interface User {
|
||||
id: number
|
||||
uuid: string
|
||||
email_address: string
|
||||
first_name: string
|
||||
last_name: string
|
||||
date_of_birth?: string
|
||||
timezone?: string
|
||||
avatar_url?: string
|
||||
is_manager: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface SessionResponse {
|
||||
isAuthenticated: boolean
|
||||
isStaff: boolean
|
||||
}
|
||||
const orgUuidKey = 'userSelectedOrganizationUuid'
|
||||
|
||||
export const useUserStore = defineStore('user', () => {
|
||||
const user = ref<User | null>(null)
|
||||
|
||||
const organizations = ref<Array<{ id: number; uuid: string; name: string }>>([])
|
||||
const selectedOrganizationUuid = ref<string | null>(null)
|
||||
|
||||
const initialized = ref(false)
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const isAuthenticated = computed(() => Boolean(user.value))
|
||||
const displayName = computed(() => {
|
||||
if (!user.value) return ''
|
||||
if (user.value.first_name || user.value.last_name) {
|
||||
return `${user.value.first_name || ''} ${user.value.last_name || ''}`.trim()
|
||||
}
|
||||
return user.value.email_address
|
||||
const isAuthenticated = ref(false)
|
||||
const isAdmin = ref(false)
|
||||
const isGeneralManager = computed(() => {
|
||||
if (!isAuthenticated.value || !user.value) return false
|
||||
return user.value.is_manager
|
||||
})
|
||||
|
||||
const setUser = (value: User | null) => {
|
||||
user.value = value
|
||||
initialized.value = true
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
const user = ref<User | null>(null)
|
||||
const userJoinedOrganizations = ref<Organization[]>([])
|
||||
const userSelectedOrganization = ref<Organization | null>(null)
|
||||
const userJoinedRoles = ref<Role[]>([])
|
||||
|
||||
const displayName = computed(() => {
|
||||
if (!user.value) return ''
|
||||
return `${user.value.first_name} ${user.value.last_name}`
|
||||
})
|
||||
|
||||
const setUser = (userData: User | null) => {
|
||||
user.value = userData
|
||||
isAuthenticated.value = !!userData
|
||||
}
|
||||
|
||||
const setOrganizations = (list: Array<{ id: number; uuid: string; name: string }>) => {
|
||||
organizations.value = list || []
|
||||
const stored = localStorage.getItem('selectedOrganizationUuid')
|
||||
if (!organizations.value.length) {
|
||||
selectedOrganizationUuid.value = null
|
||||
localStorage.removeItem('selectedOrganizationUuid')
|
||||
return
|
||||
}
|
||||
if (organizations.value.length === 1) {
|
||||
selectedOrganizationUuid.value = organizations.value[0].uuid
|
||||
localStorage.setItem('selectedOrganizationUuid', selectedOrganizationUuid.value)
|
||||
return
|
||||
}
|
||||
if (stored) {
|
||||
const found = organizations.value.find((o) => o.uuid === stored)
|
||||
if (found) {
|
||||
selectedOrganizationUuid.value = stored
|
||||
const setJoinedOrganizations = (organizations: Organization[]) => {
|
||||
userJoinedOrganizations.value = organizations
|
||||
if (organizations.length > 0) {
|
||||
const stored = localStorage.getItem(orgUuidKey)
|
||||
const matched = organizations.find((org) => org.uuid === stored)
|
||||
if (matched) {
|
||||
userSelectedOrganization.value = matched
|
||||
return
|
||||
}
|
||||
userSelectedOrganization.value = organizations[0]
|
||||
localStorage.setItem(orgUuidKey, organizations[0].uuid)
|
||||
} else {
|
||||
userSelectedOrganization.value = null
|
||||
localStorage.removeItem(orgUuidKey)
|
||||
}
|
||||
selectedOrganizationUuid.value = organizations.value[0].uuid
|
||||
localStorage.setItem('selectedOrganizationUuid', selectedOrganizationUuid.value)
|
||||
}
|
||||
|
||||
const setSelectedOrganization = (uuid: string | null) => {
|
||||
selectedOrganizationUuid.value = uuid
|
||||
if (uuid) localStorage.setItem('selectedOrganizationUuid', uuid)
|
||||
else localStorage.removeItem('selectedOrganizationUuid')
|
||||
const setSelectedOrganization = (organization: Organization | null) => {
|
||||
userSelectedOrganization.value = organization
|
||||
if (organization) {
|
||||
localStorage.setItem(orgUuidKey, organization.uuid)
|
||||
} else {
|
||||
localStorage.removeItem(orgUuidKey)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchSession = async (force = false) => {
|
||||
if (initialized.value && !force) return user.value
|
||||
if (isAuthenticated.value && !force) return user.value
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const sessionRes = await apiClient.get<SessionResponse>(API.session())
|
||||
if (sessionRes.data?.isAuthenticated) {
|
||||
const meRes = await apiClient.get<User>(API.me())
|
||||
setUser(meRes.data)
|
||||
await fetchOrganizations()
|
||||
const response = await apiClient.get<SessionResponse>(API.session())
|
||||
if (response.data?.isAuthenticated) {
|
||||
const userData = await apiClient.get<User>(API.me())
|
||||
setUser(userData.data)
|
||||
await fetchJoinedOrganizations()
|
||||
} else {
|
||||
setUser(null)
|
||||
isAuthenticated.value = false
|
||||
}
|
||||
return user.value
|
||||
} catch (err: unknown) {
|
||||
|
|
@ -103,6 +88,38 @@ export const useUserStore = defineStore('user', () => {
|
|||
}
|
||||
}
|
||||
|
||||
const fetchJoinedOrganizations = async () => {
|
||||
if (!user.value) return
|
||||
try {
|
||||
const response = await apiClient.get<Organization[]>(API.organizations())
|
||||
setJoinedOrganizations(response.data)
|
||||
return response.data
|
||||
} catch (err: unknown) {
|
||||
console.error('Failed to fetch organizations', err)
|
||||
setJoinedOrganizations([])
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
const fetchJoinedRoles = async () => {
|
||||
if (!user.value || !userSelectedOrganization.value) return
|
||||
try {
|
||||
const response = await apiClient.get<Role[]>(
|
||||
API.organizationRoles(userSelectedOrganization.value.uuid),
|
||||
)
|
||||
setJoinedRoles(response.data)
|
||||
return response.data
|
||||
} catch (err: unknown) {
|
||||
console.error('Failed to fetch role memberships', err)
|
||||
setJoinedRoles([])
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
const setJoinedRoles = (roles: Role[]) => {
|
||||
userJoinedRoles.value = roles
|
||||
}
|
||||
|
||||
const login = async (emailAddress: string, password: string) => {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
|
@ -134,15 +151,12 @@ export const useUserStore = defineStore('user', () => {
|
|||
first_name: string
|
||||
last_name: string
|
||||
date_of_birth?: string
|
||||
role?: string
|
||||
manager: boolean
|
||||
}) => {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
await apiClient.post(API.signup(), {
|
||||
...payload,
|
||||
confirm_password: payload.confirm_password || payload.password,
|
||||
})
|
||||
await apiClient.post(API.signup(), payload)
|
||||
await login(payload.email_address, payload.password)
|
||||
} catch (err: unknown) {
|
||||
if (isAxiosError(err)) {
|
||||
|
|
@ -178,34 +192,27 @@ export const useUserStore = defineStore('user', () => {
|
|||
}
|
||||
}
|
||||
|
||||
const fetchOrganizations = async () => {
|
||||
try {
|
||||
const res = await apiClient.get<Array<{ id: number; uuid: string; name: string }>>(
|
||||
API.organizations(),
|
||||
)
|
||||
setOrganizations(res.data || [])
|
||||
return organizations.value
|
||||
} catch (err: unknown) {
|
||||
console.error('Failed to fetch organizations', err)
|
||||
setOrganizations([])
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
user,
|
||||
organizations,
|
||||
selectedOrganizationUuid,
|
||||
loading,
|
||||
initialized,
|
||||
error,
|
||||
isAuthenticated,
|
||||
displayName,
|
||||
isAuthenticated,
|
||||
isAdmin,
|
||||
isGeneralManager,
|
||||
loading,
|
||||
error,
|
||||
userJoinedOrganizations,
|
||||
userSelectedOrganization,
|
||||
userJoinedRoles,
|
||||
|
||||
setUser,
|
||||
fetchSession,
|
||||
setJoinedOrganizations,
|
||||
setSelectedOrganization,
|
||||
setJoinedRoles,
|
||||
fetchJoinedOrganizations,
|
||||
fetchJoinedRoles,
|
||||
login,
|
||||
register,
|
||||
logout,
|
||||
fetchOrganizations,
|
||||
setSelectedOrganization,
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,31 +1,36 @@
|
|||
import { User } from './user'
|
||||
|
||||
export interface Organization {
|
||||
id: number
|
||||
uuid: string
|
||||
name: string
|
||||
description?: string
|
||||
owner?: {
|
||||
id: number
|
||||
first_name: string
|
||||
last_name: string
|
||||
email_address: string
|
||||
}
|
||||
description: string
|
||||
owner: User
|
||||
created_at: string
|
||||
updated_at: string
|
||||
member_count?: number
|
||||
role_count?: number
|
||||
created_at?: string
|
||||
}
|
||||
|
||||
export interface Role {
|
||||
id: number
|
||||
uuid: string
|
||||
name: string
|
||||
description?: string
|
||||
organization: Organization
|
||||
member_count?: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface RoleMembership {
|
||||
export interface InviteToken {
|
||||
id: number
|
||||
role: {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
token: string
|
||||
invite_url: string
|
||||
created_by: User
|
||||
organization: Organization
|
||||
is_active: boolean
|
||||
expires_at: string
|
||||
is_valid: boolean
|
||||
max_uses?: number
|
||||
uses?: number
|
||||
}
|
||||
|
|
|
|||
18
src/types/user.ts
Normal file
18
src/types/user.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
export interface User {
|
||||
id: number
|
||||
uuid: string
|
||||
email_address: string
|
||||
first_name: string
|
||||
last_name: string
|
||||
date_of_birth?: string
|
||||
timezone?: string
|
||||
avatar_url?: string
|
||||
is_manager: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface SessionResponse {
|
||||
isAuthenticated: boolean
|
||||
isStaff: boolean
|
||||
}
|
||||
|
|
@ -71,7 +71,7 @@ const features = [
|
|||
|
||||
<style scoped>
|
||||
.page {
|
||||
padding: 2rem 1.5rem;
|
||||
max-width: 1100px;
|
||||
}
|
||||
.panel {
|
||||
max-width: 1100px;
|
||||
|
|
|
|||
|
|
@ -188,9 +188,7 @@ const logos = [
|
|||
|
||||
<style scoped>
|
||||
.page {
|
||||
padding: 2rem 1.5rem 3rem;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding-bottom: 3rem;
|
||||
}
|
||||
.hero {
|
||||
margin-bottom: 2.5rem;
|
||||
|
|
|
|||
92
src/views/InviteAccept.vue
Normal file
92
src/views/InviteAccept.vue
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { Card, Button, Spin, message, Result } from 'ant-design-vue'
|
||||
import { apiClient, isAxiosError, API } from '../router/api'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const token = route.params.token as string
|
||||
const loading = ref(false)
|
||||
const accepting = ref(false)
|
||||
const accepted = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
const acceptInvite = async () => {
|
||||
accepting.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const response = await apiClient.post<{ message: string; success: boolean; uuid: string }>(
|
||||
API.organizationJoin(token),
|
||||
)
|
||||
message.success(response.data?.message || 'Successfully joined organization')
|
||||
accepted.value = true
|
||||
setTimeout(() => {
|
||||
if (response.data?.uuid) router.push(`/organization/${response.data.uuid}`)
|
||||
else router.push('/')
|
||||
}, 1500)
|
||||
} catch (err) {
|
||||
console.error('Failed to accept invite:', err)
|
||||
if (isAxiosError(err)) {
|
||||
const respErr = err.response?.data?.error || err.response?.data?.detail
|
||||
error.value = respErr ? String(respErr) : 'Failed to accept invite'
|
||||
} else {
|
||||
error.value = 'Failed to accept invite'
|
||||
}
|
||||
} finally {
|
||||
accepting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
acceptInvite()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<Spin :spinning="loading" tip="Loading invite...">
|
||||
<Card class="panel" :bordered="false">
|
||||
<div v-if="error">
|
||||
<Result status="error" :title="error">
|
||||
<template #extra>
|
||||
<Button type="primary" @click="router.push('/')">Go Home</Button>
|
||||
</template>
|
||||
</Result>
|
||||
</div>
|
||||
|
||||
<div v-else-if="accepted">
|
||||
<Result
|
||||
status="success"
|
||||
title="Successfully Joined Organization"
|
||||
sub-title="Redirecting to organization page..."
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</Spin>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
max-width: 800px;
|
||||
padding: 2rem 1rem;
|
||||
}
|
||||
|
||||
.invite-content {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.org-info {
|
||||
background: #1f2937;
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
.actions {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -113,8 +113,5 @@ onMounted(async () => {
|
|||
.panel {
|
||||
max-width: 400px;
|
||||
width: 100%;
|
||||
background: #0f172a;
|
||||
border: 1px solid #1f2937;
|
||||
color: #e5e7eb;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
364
src/views/OrganizationManage.vue
Normal file
364
src/views/OrganizationManage.vue
Normal file
|
|
@ -0,0 +1,364 @@
|
|||
<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">
|
||||
<Typography.Title :level="2">Manage {{ organization.name }}</Typography.Title>
|
||||
|
||||
<div
|
||||
style="
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.5rem;
|
||||
"
|
||||
>
|
||||
<Button type="default" @click="router.push(`/organization/${orgId}`)">
|
||||
Back to Organization
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Tabs>
|
||||
<Tabs.TabPane key="details" tab="Details">
|
||||
<div class="section">
|
||||
<Typography.Title :level="4">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">
|
||||
<Typography.Title :level="4">
|
||||
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">
|
||||
<Typography.Title :level="4">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.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">
|
||||
<Typography.Title :level="4">
|
||||
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;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.invite-item ::v-deep .ant-tag,
|
||||
.invite-item ::v-deep .ant-tag * {
|
||||
color: #000000 !important;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -3,7 +3,7 @@ import { ref, onMounted, computed } from 'vue'
|
|||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { Card, Typography, Button, List, Space, Spin, message, Tag, Divider } from 'ant-design-vue'
|
||||
import { apiClient, isAxiosError, API } from '../router/api'
|
||||
import { useUserStore as useAuthStore } from '../stores/userStore'
|
||||
import { useUserStore } from '../stores/userStore'
|
||||
import type { Role, Organization } from '../types/organization'
|
||||
|
||||
const router = useRouter()
|
||||
|
|
@ -13,12 +13,13 @@ const orgId = route.params.id as string
|
|||
const organization = ref<Organization | null>(null)
|
||||
const roles = ref<Role[]>([])
|
||||
const members = ref<Array<{ user: { id: number }; role: string }>>([])
|
||||
const userRoles = ref<number[]>([])
|
||||
const loading = ref(false)
|
||||
const auth = useAuthStore()
|
||||
const auth = useUserStore()
|
||||
|
||||
const isManager = computed(() => {
|
||||
if (!auth.user || !organization.value) return false
|
||||
if ((organization.value as Organization & { is_manager?: boolean }).is_manager === true)
|
||||
return true
|
||||
if (organization.value.owner?.id === auth.user.id) return true
|
||||
return members.value.some((m) => m.user?.id === auth.user?.id && m.role === 'employer')
|
||||
})
|
||||
|
|
@ -47,26 +48,38 @@ const fetchRoles = async () => {
|
|||
}
|
||||
|
||||
const fetchUserRoleMemberships = async () => {
|
||||
userRoles.value = []
|
||||
const userRoleUuids: string[] = []
|
||||
const userId = auth.user?.id
|
||||
if (!userId) return
|
||||
if (!userId || !organization.value?.uuid) return
|
||||
try {
|
||||
const checks = roles.value.map(async (r) => {
|
||||
try {
|
||||
const resp = await apiClient.get<Array<{ user: { id: number } }>>(
|
||||
API.organizationRoleMembers(organization.value!.uuid, r.id),
|
||||
API.organizationRoleMembers(organization.value!.uuid, r.uuid),
|
||||
)
|
||||
const found =
|
||||
Array.isArray(resp.data) && resp.data.some((m) => m.user?.id === userId)
|
||||
if (found) userRoles.value.push(r.id)
|
||||
} catch {}
|
||||
if (found && r.uuid) userRoleUuids.push(r.uuid)
|
||||
} catch {
|
||||
// ignore individual role errors
|
||||
}
|
||||
})
|
||||
await Promise.all(checks)
|
||||
} catch {
|
||||
console.error('Failed to fetch user role memberships')
|
||||
// update the global store with actual Role objects the user has joined
|
||||
const joinedRoles = roles.value.filter((r) => userRoleUuids.includes(r.uuid))
|
||||
if (joinedRoles.length) {
|
||||
auth.setJoinedRoles(joinedRoles)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch user role memberships', err)
|
||||
}
|
||||
}
|
||||
|
||||
const isRoleJoined = (roleUuid: string | undefined) => {
|
||||
if (!roleUuid) return false
|
||||
return auth.userJoinedRoles.some((role) => role.uuid === roleUuid)
|
||||
}
|
||||
|
||||
const fetchMembers = async () => {
|
||||
if (!organization.value?.uuid) return
|
||||
try {
|
||||
|
|
@ -79,7 +92,7 @@ const fetchMembers = async () => {
|
|||
}
|
||||
}
|
||||
|
||||
const selectRole = async (roleId: number) => {
|
||||
const selectRole = async (roleUuid: string) => {
|
||||
if (!organization.value?.uuid) {
|
||||
message.error('Organization not loaded')
|
||||
return
|
||||
|
|
@ -94,17 +107,22 @@ const selectRole = async (roleId: number) => {
|
|||
return
|
||||
}
|
||||
}
|
||||
if (userRoles.value.includes(roleId)) {
|
||||
if (auth.userJoinedRoles.some((role) => role.uuid === roleUuid)) {
|
||||
message.info('You are already a member of this role')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await apiClient.post(API.organizationRoleMembers(organization.value.uuid, roleId), {
|
||||
await apiClient.post(API.organizationRoleMembers(organization.value.uuid, roleUuid), {
|
||||
user_id: userId,
|
||||
})
|
||||
message.success('Successfully joined role')
|
||||
if (!userRoles.value.includes(roleId)) userRoles.value.push(roleId)
|
||||
if (!auth.userJoinedRoles.some((role) => role.uuid === roleUuid)) {
|
||||
auth.setJoinedRoles([
|
||||
...auth.userJoinedRoles,
|
||||
roles.value.find((role) => role.uuid === roleUuid)!,
|
||||
])
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to join role:', error)
|
||||
if (isAxiosError(error)) {
|
||||
|
|
@ -169,10 +187,7 @@ onMounted(() => {
|
|||
<List :data-source="roles" :bordered="false">
|
||||
<template #renderItem="{ item }">
|
||||
<List.Item class="role-item">
|
||||
<List.Item.Meta
|
||||
:title="item.name"
|
||||
:description="item.description || 'No description available'"
|
||||
/>
|
||||
<List.Item.Meta :title="item.name" />
|
||||
<Space>
|
||||
<Tag>{{ item.member_count ?? 0 }} members</Tag>
|
||||
<Button
|
||||
|
|
@ -183,10 +198,10 @@ onMounted(() => {
|
|||
Start Onboarding
|
||||
</Button>
|
||||
<Button
|
||||
v-if="!userRoles.includes(item.id)"
|
||||
v-if="item.uuid && !isRoleJoined(item.uuid)"
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="selectRole(item.id)"
|
||||
@click="selectRole(item.uuid)"
|
||||
>
|
||||
Join Role
|
||||
</Button>
|
||||
|
|
@ -206,17 +221,9 @@ onMounted(() => {
|
|||
|
||||
<style scoped>
|
||||
.page {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: #0f172a;
|
||||
border: 1px solid #1f2937;
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
|
@ -231,35 +238,8 @@ onMounted(() => {
|
|||
|
||||
.role-item :deep(.ant-list-item-meta-title),
|
||||
.role-item :deep(.ant-list-item-meta-description) {
|
||||
color: #e5e7eb;
|
||||
background: #0f172a;
|
||||
border: 1px solid #1f2937;
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
margin-top: 1.5rem !important;
|
||||
margin-bottom: 1rem !important;
|
||||
}
|
||||
|
||||
.role-item :deep(.ant-list-item-meta-title),
|
||||
.role-item :deep(.ant-list-item-meta-description) {
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
.panel ::v-deep .ant-typography,
|
||||
.panel ::v-deep .ant-typography * {
|
||||
color: #ffffff !important;
|
||||
}
|
||||
.panel ::v-deep .ant-list-item-meta-title,
|
||||
.panel ::v-deep .ant-list-item-meta-description {
|
||||
color: #ffffff !important;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -78,27 +78,12 @@ const openOrg = (org: Organization) => {
|
|||
|
||||
<style scoped>
|
||||
.page {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
.panel {
|
||||
background: #0f172a;
|
||||
border: 1px solid #1f2937;
|
||||
color: #e5e7eb;
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.panel ::v-deep .ant-typography,
|
||||
.panel ::v-deep .ant-typography * {
|
||||
color: #ffffff !important;
|
||||
}
|
||||
.panel ::v-deep .ant-list-item-meta-title,
|
||||
.panel ::v-deep .ant-list-item-meta-description {
|
||||
color: #ffffff !important;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<script setup lang="ts">
|
||||
import { reactive, computed, onMounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { Card, Typography, Form, Input, Select, Button, message } from 'ant-design-vue'
|
||||
import { Card, Typography, Form, Input, Button, message } from 'ant-design-vue'
|
||||
import { useUserStore } from '../stores/userStore'
|
||||
|
||||
const router = useRouter()
|
||||
|
|
@ -15,7 +15,7 @@ const formState = reactive({
|
|||
lastName: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
role: 'employee' as 'admin' | 'manager' | 'employee',
|
||||
managerCode: '',
|
||||
})
|
||||
|
||||
const confirmRules = [
|
||||
|
|
@ -30,13 +30,14 @@ const confirmRules = [
|
|||
|
||||
const submit = async () => {
|
||||
try {
|
||||
const isManager = formState.managerCode === 'MANAGER2026'
|
||||
await userStore.register({
|
||||
email_address: formState.email,
|
||||
password: formState.password,
|
||||
confirm_password: formState.confirmPassword,
|
||||
first_name: formState.firstName,
|
||||
last_name: formState.lastName,
|
||||
role: formState.role,
|
||||
manager: isManager,
|
||||
})
|
||||
message.success('Account created')
|
||||
const redirect = (route.query.redirect as string) || '/onboarding'
|
||||
|
|
@ -142,13 +143,15 @@ onMounted(async () => {
|
|||
:disabled="loading"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="Role" name="role">
|
||||
<Select v-model:value="formState.role" :disabled="loading">
|
||||
<Select.Option value="employee">Employee</Select.Option>
|
||||
<Select.Option value="manager">Manager</Select.Option>
|
||||
<Select.Option value="admin">Admin</Select.Option>
|
||||
</Select>
|
||||
|
||||
<Form.Item label="Manager code (optional)" name="managerCode">
|
||||
<Input
|
||||
v-model:value="formState.managerCode"
|
||||
placeholder="Enter manager code (leave blank if none)"
|
||||
:disabled="loading"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Button type="primary" html-type="submit" block :loading="loading">Register</Button>
|
||||
</Form>
|
||||
</Card>
|
||||
|
|
@ -161,9 +164,4 @@ onMounted(async () => {
|
|||
margin: 0 auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
.panel {
|
||||
background: #0f172a;
|
||||
border: 1px solid #1f2937;
|
||||
color: #e5e7eb;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
Loading…
Reference in a new issue