Added organization management, consolidated views and styling

This commit is contained in:
Viswamedha Nalabotu 2026-01-20 03:22:07 +00:00
parent eaba88decd
commit 48615c10a4
14 changed files with 711 additions and 201 deletions

View file

@ -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;
}

View file

@ -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()

View file

@ -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()
}) })

View file

@ -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) {
return userSelectedOrganization.value = matched
}
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
return 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) => { 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,
} }
}) })

View file

@ -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
View 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
}

View file

@ -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;

View file

@ -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;

View 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>

View file

@ -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>

View 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>

View file

@ -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,26 +48,38 @@ 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 () => {
if (!organization.value?.uuid) return if (!organization.value?.uuid) return
try { try {
@ -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>

View file

@ -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>

View file

@ -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>