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',
|
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New',
|
||||||
monospace;
|
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/',
|
organizations: () => '/api/organization/',
|
||||||
organization: (id: string) => `/api/organization/${id}/`,
|
organization: (id: string) => `/api/organization/${id}/`,
|
||||||
organizationRoles: (orgUuid: string) => `/api/organization/${orgUuid}/role/`,
|
organizationRoles: (orgUuid: string) => `/api/organization/${orgUuid}/role/`,
|
||||||
organizationRoleMembers: (orgUuid: string, roleId: number) =>
|
organizationRole: (orgUuid: string, roleUuid: string) =>
|
||||||
`/api/organization/${orgUuid}/role/${roleId}/members/`,
|
`/api/organization/${orgUuid}/role/${roleUuid}/`,
|
||||||
organizationMembers: (orgUuid: string) => `/api/organization/${orgUuid}/members/`,
|
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()
|
export const apiClient = new ApiClient()
|
||||||
|
|
|
||||||
|
|
@ -44,13 +44,24 @@ const router = createRouter({
|
||||||
component: () => import('../views/OrganizationView.vue'),
|
component: () => import('../views/OrganizationView.vue'),
|
||||||
meta: { requiresAuth: true },
|
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) => {
|
router.beforeEach((to, from, next) => {
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
const isAuthenticated = userStore.isAuthenticated
|
const isAuthenticated = userStore.isAuthenticated
|
||||||
// const is_manager = userStore.user?.is_manager || false
|
const isManager = userStore.isGeneralManager
|
||||||
|
|
||||||
if (to.meta?.guestOnly && isAuthenticated) {
|
if (to.meta?.guestOnly && isAuthenticated) {
|
||||||
return next({ path: '/' })
|
return next({ path: '/' })
|
||||||
|
|
@ -58,6 +69,9 @@ router.beforeEach((to, from, next) => {
|
||||||
if (to.meta?.requiresAuth && !isAuthenticated) {
|
if (to.meta?.requiresAuth && !isAuthenticated) {
|
||||||
return next({ path: '/login', query: { redirect: to.fullPath } })
|
return next({ path: '/login', query: { redirect: to.fullPath } })
|
||||||
}
|
}
|
||||||
|
if (to.meta?.requiresManager && !isManager) {
|
||||||
|
return next({ path: '/' })
|
||||||
|
}
|
||||||
|
|
||||||
return next()
|
return next()
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,91 +1,76 @@
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { apiClient, isAxiosError, API } from '../router/api'
|
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 {
|
const orgUuidKey = 'userSelectedOrganizationUuid'
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useUserStore = defineStore('user', () => {
|
export const useUserStore = defineStore('user', () => {
|
||||||
const user = ref<User | null>(null)
|
const isAuthenticated = ref(false)
|
||||||
|
const isAdmin = ref(false)
|
||||||
const organizations = ref<Array<{ id: number; uuid: string; name: string }>>([])
|
const isGeneralManager = computed(() => {
|
||||||
const selectedOrganizationUuid = ref<string | null>(null)
|
if (!isAuthenticated.value || !user.value) return false
|
||||||
|
return user.value.is_manager
|
||||||
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 setUser = (value: User | null) => {
|
const loading = ref(false)
|
||||||
user.value = value
|
const error = ref<string | null>(null)
|
||||||
initialized.value = true
|
|
||||||
|
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 }>) => {
|
const setJoinedOrganizations = (organizations: Organization[]) => {
|
||||||
organizations.value = list || []
|
userJoinedOrganizations.value = organizations
|
||||||
const stored = localStorage.getItem('selectedOrganizationUuid')
|
if (organizations.length > 0) {
|
||||||
if (!organizations.value.length) {
|
const stored = localStorage.getItem(orgUuidKey)
|
||||||
selectedOrganizationUuid.value = null
|
const matched = organizations.find((org) => org.uuid === stored)
|
||||||
localStorage.removeItem('selectedOrganizationUuid')
|
if (matched) {
|
||||||
|
userSelectedOrganization.value = matched
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (organizations.value.length === 1) {
|
userSelectedOrganization.value = organizations[0]
|
||||||
selectedOrganizationUuid.value = organizations.value[0].uuid
|
localStorage.setItem(orgUuidKey, organizations[0].uuid)
|
||||||
localStorage.setItem('selectedOrganizationUuid', selectedOrganizationUuid.value)
|
} else {
|
||||||
return
|
userSelectedOrganization.value = null
|
||||||
|
localStorage.removeItem(orgUuidKey)
|
||||||
}
|
}
|
||||||
if (stored) {
|
|
||||||
const found = organizations.value.find((o) => o.uuid === stored)
|
|
||||||
if (found) {
|
|
||||||
selectedOrganizationUuid.value = stored
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
selectedOrganizationUuid.value = organizations.value[0].uuid
|
|
||||||
localStorage.setItem('selectedOrganizationUuid', selectedOrganizationUuid.value)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const setSelectedOrganization = (uuid: string | null) => {
|
const setSelectedOrganization = (organization: Organization | null) => {
|
||||||
selectedOrganizationUuid.value = uuid
|
userSelectedOrganization.value = organization
|
||||||
if (uuid) localStorage.setItem('selectedOrganizationUuid', uuid)
|
if (organization) {
|
||||||
else localStorage.removeItem('selectedOrganizationUuid')
|
localStorage.setItem(orgUuidKey, organization.uuid)
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem(orgUuidKey)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const fetchSession = async (force = false) => {
|
const fetchSession = async (force = false) => {
|
||||||
if (initialized.value && !force) return user.value
|
if (isAuthenticated.value && !force) return user.value
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
try {
|
try {
|
||||||
const sessionRes = await apiClient.get<SessionResponse>(API.session())
|
const response = await apiClient.get<SessionResponse>(API.session())
|
||||||
if (sessionRes.data?.isAuthenticated) {
|
if (response.data?.isAuthenticated) {
|
||||||
const meRes = await apiClient.get<User>(API.me())
|
const userData = await apiClient.get<User>(API.me())
|
||||||
setUser(meRes.data)
|
setUser(userData.data)
|
||||||
await fetchOrganizations()
|
await fetchJoinedOrganizations()
|
||||||
} else {
|
} else {
|
||||||
setUser(null)
|
setUser(null)
|
||||||
|
isAuthenticated.value = false
|
||||||
}
|
}
|
||||||
return user.value
|
return user.value
|
||||||
} catch (err: unknown) {
|
} 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) => {
|
const login = async (emailAddress: string, password: string) => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
|
|
@ -134,15 +151,12 @@ export const useUserStore = defineStore('user', () => {
|
||||||
first_name: string
|
first_name: string
|
||||||
last_name: string
|
last_name: string
|
||||||
date_of_birth?: string
|
date_of_birth?: string
|
||||||
role?: string
|
manager: boolean
|
||||||
}) => {
|
}) => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
try {
|
try {
|
||||||
await apiClient.post(API.signup(), {
|
await apiClient.post(API.signup(), payload)
|
||||||
...payload,
|
|
||||||
confirm_password: payload.confirm_password || payload.password,
|
|
||||||
})
|
|
||||||
await login(payload.email_address, payload.password)
|
await login(payload.email_address, payload.password)
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
if (isAxiosError(err)) {
|
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 {
|
return {
|
||||||
user,
|
user,
|
||||||
organizations,
|
|
||||||
selectedOrganizationUuid,
|
|
||||||
loading,
|
|
||||||
initialized,
|
|
||||||
error,
|
|
||||||
isAuthenticated,
|
|
||||||
displayName,
|
displayName,
|
||||||
|
isAuthenticated,
|
||||||
|
isAdmin,
|
||||||
|
isGeneralManager,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
userJoinedOrganizations,
|
||||||
|
userSelectedOrganization,
|
||||||
|
userJoinedRoles,
|
||||||
|
|
||||||
|
setUser,
|
||||||
fetchSession,
|
fetchSession,
|
||||||
|
setJoinedOrganizations,
|
||||||
|
setSelectedOrganization,
|
||||||
|
setJoinedRoles,
|
||||||
|
fetchJoinedOrganizations,
|
||||||
|
fetchJoinedRoles,
|
||||||
login,
|
login,
|
||||||
register,
|
register,
|
||||||
logout,
|
logout,
|
||||||
fetchOrganizations,
|
|
||||||
setSelectedOrganization,
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,31 +1,36 @@
|
||||||
|
import { User } from './user'
|
||||||
|
|
||||||
export interface Organization {
|
export interface Organization {
|
||||||
id: number
|
id: number
|
||||||
uuid: string
|
uuid: string
|
||||||
name: string
|
name: string
|
||||||
description?: string
|
description: string
|
||||||
owner?: {
|
owner: User
|
||||||
id: number
|
created_at: string
|
||||||
first_name: string
|
updated_at: string
|
||||||
last_name: string
|
|
||||||
email_address: string
|
|
||||||
}
|
|
||||||
member_count?: number
|
member_count?: number
|
||||||
role_count?: number
|
role_count?: number
|
||||||
created_at?: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Role {
|
export interface Role {
|
||||||
id: number
|
id: number
|
||||||
uuid: string
|
uuid: string
|
||||||
name: string
|
name: string
|
||||||
description?: string
|
organization: Organization
|
||||||
member_count?: number
|
member_count?: number
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RoleMembership {
|
export interface InviteToken {
|
||||||
id: number
|
id: number
|
||||||
role: {
|
token: string
|
||||||
id: number
|
invite_url: string
|
||||||
name: 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>
|
<style scoped>
|
||||||
.page {
|
.page {
|
||||||
padding: 2rem 1.5rem;
|
max-width: 1100px;
|
||||||
}
|
}
|
||||||
.panel {
|
.panel {
|
||||||
max-width: 1100px;
|
max-width: 1100px;
|
||||||
|
|
|
||||||
|
|
@ -188,9 +188,7 @@ const logos = [
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.page {
|
.page {
|
||||||
padding: 2rem 1.5rem 3rem;
|
padding-bottom: 3rem;
|
||||||
max-width: 1200px;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
}
|
||||||
.hero {
|
.hero {
|
||||||
margin-bottom: 2.5rem;
|
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 {
|
.panel {
|
||||||
max-width: 400px;
|
max-width: 400px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background: #0f172a;
|
|
||||||
border: 1px solid #1f2937;
|
|
||||||
color: #e5e7eb;
|
|
||||||
}
|
}
|
||||||
</style>
|
</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 { useRouter, useRoute } from 'vue-router'
|
||||||
import { Card, Typography, Button, List, Space, Spin, message, Tag, Divider } from 'ant-design-vue'
|
import { Card, Typography, Button, List, Space, Spin, message, Tag, Divider } from 'ant-design-vue'
|
||||||
import { apiClient, isAxiosError, API } from '../router/api'
|
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'
|
import type { Role, Organization } from '../types/organization'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
@ -13,12 +13,13 @@ const orgId = route.params.id as string
|
||||||
const organization = ref<Organization | null>(null)
|
const organization = ref<Organization | null>(null)
|
||||||
const roles = ref<Role[]>([])
|
const roles = ref<Role[]>([])
|
||||||
const members = ref<Array<{ user: { id: number }; role: string }>>([])
|
const members = ref<Array<{ user: { id: number }; role: string }>>([])
|
||||||
const userRoles = ref<number[]>([])
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const auth = useAuthStore()
|
const auth = useUserStore()
|
||||||
|
|
||||||
const isManager = computed(() => {
|
const isManager = computed(() => {
|
||||||
if (!auth.user || !organization.value) return false
|
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
|
if (organization.value.owner?.id === auth.user.id) return true
|
||||||
return members.value.some((m) => m.user?.id === auth.user?.id && m.role === 'employer')
|
return members.value.some((m) => m.user?.id === auth.user?.id && m.role === 'employer')
|
||||||
})
|
})
|
||||||
|
|
@ -47,24 +48,36 @@ const fetchRoles = async () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const fetchUserRoleMemberships = async () => {
|
const fetchUserRoleMemberships = async () => {
|
||||||
userRoles.value = []
|
const userRoleUuids: string[] = []
|
||||||
const userId = auth.user?.id
|
const userId = auth.user?.id
|
||||||
if (!userId) return
|
if (!userId || !organization.value?.uuid) return
|
||||||
try {
|
try {
|
||||||
const checks = roles.value.map(async (r) => {
|
const checks = roles.value.map(async (r) => {
|
||||||
try {
|
try {
|
||||||
const resp = await apiClient.get<Array<{ user: { id: number } }>>(
|
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 =
|
const found =
|
||||||
Array.isArray(resp.data) && resp.data.some((m) => m.user?.id === userId)
|
Array.isArray(resp.data) && resp.data.some((m) => m.user?.id === userId)
|
||||||
if (found) userRoles.value.push(r.id)
|
if (found && r.uuid) userRoleUuids.push(r.uuid)
|
||||||
} catch {}
|
} catch {
|
||||||
|
// ignore individual role errors
|
||||||
|
}
|
||||||
})
|
})
|
||||||
await Promise.all(checks)
|
await Promise.all(checks)
|
||||||
} catch {
|
// update the global store with actual Role objects the user has joined
|
||||||
console.error('Failed to fetch user role memberships')
|
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 () => {
|
const fetchMembers = async () => {
|
||||||
|
|
@ -79,7 +92,7 @@ const fetchMembers = async () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectRole = async (roleId: number) => {
|
const selectRole = async (roleUuid: string) => {
|
||||||
if (!organization.value?.uuid) {
|
if (!organization.value?.uuid) {
|
||||||
message.error('Organization not loaded')
|
message.error('Organization not loaded')
|
||||||
return
|
return
|
||||||
|
|
@ -94,17 +107,22 @@ const selectRole = async (roleId: number) => {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (userRoles.value.includes(roleId)) {
|
if (auth.userJoinedRoles.some((role) => role.uuid === roleUuid)) {
|
||||||
message.info('You are already a member of this role')
|
message.info('You are already a member of this role')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await apiClient.post(API.organizationRoleMembers(organization.value.uuid, roleId), {
|
await apiClient.post(API.organizationRoleMembers(organization.value.uuid, roleUuid), {
|
||||||
user_id: userId,
|
user_id: userId,
|
||||||
})
|
})
|
||||||
message.success('Successfully joined role')
|
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) {
|
} catch (error) {
|
||||||
console.error('Failed to join role:', error)
|
console.error('Failed to join role:', error)
|
||||||
if (isAxiosError(error)) {
|
if (isAxiosError(error)) {
|
||||||
|
|
@ -169,10 +187,7 @@ onMounted(() => {
|
||||||
<List :data-source="roles" :bordered="false">
|
<List :data-source="roles" :bordered="false">
|
||||||
<template #renderItem="{ item }">
|
<template #renderItem="{ item }">
|
||||||
<List.Item class="role-item">
|
<List.Item class="role-item">
|
||||||
<List.Item.Meta
|
<List.Item.Meta :title="item.name" />
|
||||||
:title="item.name"
|
|
||||||
:description="item.description || 'No description available'"
|
|
||||||
/>
|
|
||||||
<Space>
|
<Space>
|
||||||
<Tag>{{ item.member_count ?? 0 }} members</Tag>
|
<Tag>{{ item.member_count ?? 0 }} members</Tag>
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -183,10 +198,10 @@ onMounted(() => {
|
||||||
Start Onboarding
|
Start Onboarding
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
v-if="!userRoles.includes(item.id)"
|
v-if="item.uuid && !isRoleJoined(item.uuid)"
|
||||||
type="primary"
|
type="primary"
|
||||||
size="small"
|
size="small"
|
||||||
@click="selectRole(item.id)"
|
@click="selectRole(item.uuid)"
|
||||||
>
|
>
|
||||||
Join Role
|
Join Role
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -206,17 +221,9 @@ onMounted(() => {
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.page {
|
.page {
|
||||||
max-width: 1200px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel {
|
|
||||||
background: #0f172a;
|
|
||||||
border: 1px solid #1f2937;
|
|
||||||
color: #e5e7eb;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
|
@ -231,35 +238,8 @@ onMounted(() => {
|
||||||
|
|
||||||
.role-item :deep(.ant-list-item-meta-title),
|
.role-item :deep(.ant-list-item-meta-title),
|
||||||
.role-item :deep(.ant-list-item-meta-description) {
|
.role-item :deep(.ant-list-item-meta-description) {
|
||||||
color: #e5e7eb;
|
|
||||||
background: #0f172a;
|
background: #0f172a;
|
||||||
border: 1px solid #1f2937;
|
border: 1px solid #1f2937;
|
||||||
color: #e5e7eb;
|
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>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -78,27 +78,12 @@ const openOrg = (org: Organization) => {
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.page {
|
.page {
|
||||||
max-width: 1200px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
.panel {
|
|
||||||
background: #0f172a;
|
|
||||||
border: 1px solid #1f2937;
|
|
||||||
color: #e5e7eb;
|
|
||||||
}
|
|
||||||
.header {
|
.header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-bottom: 1rem;
|
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>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { reactive, computed, onMounted } from 'vue'
|
import { reactive, computed, onMounted } from 'vue'
|
||||||
import { useRouter, useRoute } from 'vue-router'
|
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'
|
import { useUserStore } from '../stores/userStore'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
@ -15,7 +15,7 @@ const formState = reactive({
|
||||||
lastName: '',
|
lastName: '',
|
||||||
password: '',
|
password: '',
|
||||||
confirmPassword: '',
|
confirmPassword: '',
|
||||||
role: 'employee' as 'admin' | 'manager' | 'employee',
|
managerCode: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
const confirmRules = [
|
const confirmRules = [
|
||||||
|
|
@ -30,13 +30,14 @@ const confirmRules = [
|
||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
try {
|
try {
|
||||||
|
const isManager = formState.managerCode === 'MANAGER2026'
|
||||||
await userStore.register({
|
await userStore.register({
|
||||||
email_address: formState.email,
|
email_address: formState.email,
|
||||||
password: formState.password,
|
password: formState.password,
|
||||||
confirm_password: formState.confirmPassword,
|
confirm_password: formState.confirmPassword,
|
||||||
first_name: formState.firstName,
|
first_name: formState.firstName,
|
||||||
last_name: formState.lastName,
|
last_name: formState.lastName,
|
||||||
role: formState.role,
|
manager: isManager,
|
||||||
})
|
})
|
||||||
message.success('Account created')
|
message.success('Account created')
|
||||||
const redirect = (route.query.redirect as string) || '/onboarding'
|
const redirect = (route.query.redirect as string) || '/onboarding'
|
||||||
|
|
@ -142,13 +143,15 @@ onMounted(async () => {
|
||||||
:disabled="loading"
|
:disabled="loading"
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item label="Role" name="role">
|
|
||||||
<Select v-model:value="formState.role" :disabled="loading">
|
<Form.Item label="Manager code (optional)" name="managerCode">
|
||||||
<Select.Option value="employee">Employee</Select.Option>
|
<Input
|
||||||
<Select.Option value="manager">Manager</Select.Option>
|
v-model:value="formState.managerCode"
|
||||||
<Select.Option value="admin">Admin</Select.Option>
|
placeholder="Enter manager code (leave blank if none)"
|
||||||
</Select>
|
:disabled="loading"
|
||||||
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
<Button type="primary" html-type="submit" block :loading="loading">Register</Button>
|
<Button type="primary" html-type="submit" block :loading="loading">Register</Button>
|
||||||
</Form>
|
</Form>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
@ -161,9 +164,4 @@ onMounted(async () => {
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
.panel {
|
|
||||||
background: #0f172a;
|
|
||||||
border: 1px solid #1f2937;
|
|
||||||
color: #e5e7eb;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue