From 48615c10a4628490a9b390bbbc6cf55209e54ead Mon Sep 17 00:00:00 2001 From: Viswamedha Nalabotu Date: Tue, 20 Jan 2026 03:22:07 +0000 Subject: [PATCH] Added organization management, consolidated views and styling --- src/css/styles.css | 39 ++++ src/router/api.ts | 19 +- src/router/index.ts | 16 +- src/stores/userStore.ts | 191 ++++++++-------- src/types/organization.ts | 33 +-- src/types/user.ts | 18 ++ src/views/AboutView.vue | 2 +- src/views/HomeView.vue | 4 +- src/views/InviteAccept.vue | 92 ++++++++ src/views/LoginView.vue | 3 - src/views/OrganizationManage.vue | 364 +++++++++++++++++++++++++++++++ src/views/OrganizationView.vue | 90 +++----- src/views/OrganizationsView.vue | 15 -- src/views/RegisterView.vue | 26 +-- 14 files changed, 711 insertions(+), 201 deletions(-) create mode 100644 src/types/user.ts create mode 100644 src/views/InviteAccept.vue create mode 100644 src/views/OrganizationManage.vue diff --git a/src/css/styles.css b/src/css/styles.css index f0af2dc..a94590c 100644 --- a/src/css/styles.css +++ b/src/css/styles.css @@ -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; +} diff --git a/src/router/api.ts b/src/router/api.ts index 7a0de40..71d1d01 100644 --- a/src/router/api.ts +++ b/src/router/api.ts @@ -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() diff --git a/src/router/index.ts b/src/router/index.ts index 70839c2..72dfcbc 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -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() }) diff --git a/src/stores/userStore.ts b/src/stores/userStore.ts index fcd517b..f61e679 100644 --- a/src/stores/userStore.ts +++ b/src/stores/userStore.ts @@ -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(null) - - const organizations = ref>([]) - const selectedOrganizationUuid = ref(null) - - const initialized = ref(false) - const loading = ref(false) - const error = ref(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(null) + + const user = ref(null) + const userJoinedOrganizations = ref([]) + const userSelectedOrganization = ref(null) + const userJoinedRoles = ref([]) + + 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(API.session()) - if (sessionRes.data?.isAuthenticated) { - const meRes = await apiClient.get(API.me()) - setUser(meRes.data) - await fetchOrganizations() + const response = await apiClient.get(API.session()) + if (response.data?.isAuthenticated) { + const userData = await apiClient.get(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(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( + 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>( - 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, } }) diff --git a/src/types/organization.ts b/src/types/organization.ts index d2658e2..0d52cb5 100644 --- a/src/types/organization.ts +++ b/src/types/organization.ts @@ -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 } diff --git a/src/types/user.ts b/src/types/user.ts new file mode 100644 index 0000000..41a79c0 --- /dev/null +++ b/src/types/user.ts @@ -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 +} diff --git a/src/views/AboutView.vue b/src/views/AboutView.vue index 8d48b50..422ae68 100644 --- a/src/views/AboutView.vue +++ b/src/views/AboutView.vue @@ -71,7 +71,7 @@ const features = [ diff --git a/src/views/LoginView.vue b/src/views/LoginView.vue index 04e7113..4274ea1 100644 --- a/src/views/LoginView.vue +++ b/src/views/LoginView.vue @@ -113,8 +113,5 @@ onMounted(async () => { .panel { max-width: 400px; width: 100%; - background: #0f172a; - border: 1px solid #1f2937; - color: #e5e7eb; } diff --git a/src/views/OrganizationManage.vue b/src/views/OrganizationManage.vue new file mode 100644 index 0000000..a221a44 --- /dev/null +++ b/src/views/OrganizationManage.vue @@ -0,0 +1,364 @@ + + + + + diff --git a/src/views/OrganizationView.vue b/src/views/OrganizationView.vue index cd1bb60..d9ed05e 100644 --- a/src/views/OrganizationView.vue +++ b/src/views/OrganizationView.vue @@ -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(null) const roles = ref([]) const members = ref>([]) -const userRoles = ref([]) 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>( - 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(() => {