diff --git a/app/layout-content.tsx b/app/layout-content.tsx
index 7e9c372..12a52b9 100644
--- a/app/layout-content.tsx
+++ b/app/layout-content.tsx
@@ -18,7 +18,7 @@ export default function LayoutContent({ children }: { children: React.ReactNode
useEffect(() => {
if (auth.user) {
getUserOrganizations()
- .then(({ organizations }) => setOrgs(organizations))
+ .then((organizations) => setOrgs(organizations))
.catch(err => console.error('Failed to fetch orgs for header', err))
}
}, [auth.user])
diff --git a/app/org-settings/page.tsx b/app/org-settings/page.tsx
new file mode 100644
index 0000000..25739a1
--- /dev/null
+++ b/app/org-settings/page.tsx
@@ -0,0 +1,16 @@
+import OrganizationSettings from '@/components/settings/OrganizationSettings'
+
+export const metadata = {
+ title: 'Organization Settings - Pulse',
+ description: 'Manage your organization settings',
+}
+
+export default function OrgSettingsPage() {
+ return (
+
+ )
+}
diff --git a/components/settings/OrganizationSettings.tsx b/components/settings/OrganizationSettings.tsx
new file mode 100644
index 0000000..0d91bc3
--- /dev/null
+++ b/components/settings/OrganizationSettings.tsx
@@ -0,0 +1,532 @@
+'use client'
+
+import { useState, useEffect, useCallback } from 'react'
+import { useRouter } from 'next/navigation'
+import { useAuth } from '@/lib/auth/context'
+import {
+ deleteOrganization,
+ switchContext,
+ getOrganizationMembers,
+ getInvitations,
+ sendInvitation,
+ revokeInvitation,
+ updateOrganization,
+ getOrganization,
+ OrganizationMember,
+ OrganizationInvitation,
+ Organization
+} from '@/lib/api/organization'
+import { toast } from 'sonner'
+import { motion, AnimatePresence } from 'framer-motion'
+import {
+ ExclamationTriangleIcon,
+ PlusIcon,
+ CubeIcon,
+ PersonIcon,
+ CheckIcon,
+ Cross2Icon,
+ CopyIcon
+} from '@radix-ui/react-icons'
+// @ts-ignore
+import { Button, Input } from '@ciphera-net/ui'
+
+export default function OrganizationSettings() {
+ const { user } = useAuth()
+ const router = useRouter()
+ const [activeTab, setActiveTab] = useState<'general' | 'members'>('general')
+
+ const [showDeletePrompt, setShowDeletePrompt] = useState(false)
+ const [deleteConfirm, setDeleteConfirm] = useState('')
+ const [isDeleting, setIsDeleting] = useState(false)
+
+ // Members State
+ const [members, setMembers] = useState([])
+ const [invitations, setInvitations] = useState([])
+ const [isLoadingMembers, setIsLoadingMembers] = useState(true)
+
+ // Invite State
+ const [inviteEmail, setInviteEmail] = useState('')
+ const [inviteRole, setInviteRole] = useState('member')
+ const [isInviting, setIsInviting] = useState(false)
+
+ // Org Update State
+ const [orgDetails, setOrgDetails] = useState(null)
+ const [isEditing, setIsEditing] = useState(false)
+ const [orgName, setOrgName] = useState('')
+ const [orgSlug, setOrgSlug] = useState('')
+ const [isSaving, setIsSaving] = useState(false)
+
+ const getOrgIdFromToken = () => {
+ if (typeof window === 'undefined') return null
+ const token = localStorage.getItem('token')
+ if (!token) return null
+ try {
+ const payload = JSON.parse(atob(token.split('.')[1]))
+ return payload.org_id || null
+ } catch (e) {
+ return null
+ }
+ }
+
+ const currentOrgId = getOrgIdFromToken()
+
+ const loadMembers = useCallback(async () => {
+ if (!currentOrgId) return
+ try {
+ const [membersData, invitesData, orgData] = await Promise.all([
+ getOrganizationMembers(currentOrgId),
+ getInvitations(currentOrgId),
+ getOrganization(currentOrgId)
+ ])
+ setMembers(membersData)
+ setInvitations(invitesData)
+ setOrgDetails(orgData)
+ setOrgName(orgData.name)
+ setOrgSlug(orgData.slug)
+ } catch (error) {
+ console.error('Failed to load data:', error)
+ // toast.error('Failed to load members')
+ } finally {
+ setIsLoadingMembers(false)
+ }
+ }, [currentOrgId])
+
+ useEffect(() => {
+ if (currentOrgId) {
+ loadMembers()
+ } else {
+ setIsLoadingMembers(false)
+ }
+ }, [currentOrgId, loadMembers])
+
+ // If no org ID, we are in personal workspace, so don't show org settings
+ if (!currentOrgId) {
+ return (
+
+
You are in your Personal Workspace. Switch to an Organization to manage its settings.
+
+ )
+ }
+
+ const handleDelete = async () => {
+ if (deleteConfirm !== 'DELETE') return
+
+ setIsDeleting(true)
+ try {
+ await deleteOrganization(currentOrgId)
+ toast.success('Organization deleted successfully')
+
+ // * Clear sticky session
+ localStorage.removeItem('active_org_id')
+
+ // * Switch to personal context explicitly
+ try {
+ const { access_token } = await switchContext(null)
+ localStorage.setItem('token', access_token)
+ window.location.href = '/'
+ } catch (switchErr) {
+ console.error('Failed to switch to personal context after delete:', switchErr)
+ // Fallback: reload and let backend handle invalid token if any
+ window.location.href = '/'
+ }
+
+ } catch (err: any) {
+ console.error(err)
+ toast.error(err.message || 'Failed to delete organization')
+ setIsDeleting(false)
+ }
+ }
+
+ const handleSendInvite = async (e: React.FormEvent) => {
+ e.preventDefault()
+ if (!inviteEmail) return
+
+ setIsInviting(true)
+ try {
+ await sendInvitation(currentOrgId, inviteEmail, inviteRole)
+ toast.success(`Invitation sent to ${inviteEmail}`)
+ setInviteEmail('')
+ loadMembers() // Refresh list
+ } catch (error: any) {
+ toast.error(error.message || 'Failed to send invitation')
+ } finally {
+ setIsInviting(false)
+ }
+ }
+
+ const handleRevokeInvite = async (inviteId: string) => {
+ try {
+ await revokeInvitation(currentOrgId, inviteId)
+ toast.success('Invitation revoked')
+ loadMembers() // Refresh list
+ } catch (error: any) {
+ toast.error(error.message || 'Failed to revoke invitation')
+ }
+ }
+
+ const handleUpdateOrg = async (e: React.FormEvent) => {
+ e.preventDefault()
+ if (!currentOrgId) return
+
+ setIsSaving(true)
+ try {
+ await updateOrganization(currentOrgId, orgName, orgSlug)
+ toast.success('Organization updated successfully')
+ setIsEditing(false)
+ loadMembers()
+ } catch (error: any) {
+ toast.error(error.message || 'Failed to update organization')
+ } finally {
+ setIsSaving(false)
+ }
+ }
+
+ // Helper to find current org name (from members list if available, or just fallback)
+ // Ideally we'd have a full org object, but we have ID.
+ // We can find the current user's membership entry which has org name.
+ const currentOrgName = members.find(m => m.user_id === user?.id)?.organization_name || 'Organization'
+
+ return (
+
+
+
Organization Settings
+
+ Manage your organization workspace and members.
+
+
+
+
+ {/* Sidebar Navigation */}
+
+
+ {/* Content Area */}
+
+
+ {activeTab === 'general' && (
+
+
+
General Information
+
Basic details about your organization.
+
+
+
+
+
+
+
Danger Zone
+
Irreversible actions for this organization.
+
+
+
+
+
Delete Organization
+
Permanently delete this organization and all its data.
+
+
+
+
+
+ )}
+
+ {activeTab === 'members' && (
+
+ {/* Invite Section */}
+
+
Organization Members
+
Manage who has access to this organization.
+
+
+
+
+ {/* Members List */}
+
+
Active Members
+
+ {isLoadingMembers ? (
+
+ ) : members.length === 0 ? (
+
No members found.
+ ) : (
+ members.map((member) => (
+
+
+
+ {member.user_email?.[0].toUpperCase() || '?'}
+
+
+
+ {member.user_email || 'Unknown User'}
+
+
+ Joined {new Date(member.joined_at).toLocaleDateString()}
+
+
+
+
+
+ {member.role}
+
+
+
+ ))
+ )}
+
+
+
+ {/* Pending Invitations */}
+ {invitations.length > 0 && (
+
+
Pending Invitations
+
+ {invitations.map((invite) => (
+
+
+
+
+
+ {invite.email}
+
+
+ Invited as {invite.role} • Expires {new Date(invite.expires_at).toLocaleDateString()}
+
+
+
+
+
+ ))}
+
+
+ )}
+
+ )}
+
+
+
+
+ {/* Delete Confirmation Modal */}
+
+ {showDeletePrompt && (
+
+
+
+
+
+
+
+
Delete Organization?
+
+
+
+
+
+ This action cannot be undone. This will permanently delete the organization, all stored files, and remove all members.
+
+
+
+
+
+ setDeleteConfirm(e.target.value)}
+ className="w-full px-3 py-2 bg-neutral-100 dark:bg-neutral-800 border-none rounded-lg focus:ring-2 focus:ring-red-500 outline-none text-neutral-900 dark:text-white font-mono"
+ placeholder="DELETE"
+ />
+
+
+
+
+
+
+
+
+
+ )}
+
+
+ )
+}
diff --git a/lib/api/organization.ts b/lib/api/organization.ts
index 126cee9..e2f447f 100644
--- a/lib/api/organization.ts
+++ b/lib/api/organization.ts
@@ -1,46 +1,102 @@
-import apiRequest from './client'
+import { authFetch } from './client'
export interface Organization {
id: string
name: string
slug: string
- role: 'owner' | 'admin' | 'member'
- joined_at: string
+ plan_tier: string
+ created_at: string
}
export interface OrganizationMember {
organization_id: string
user_id: string
- role: string
+ role: 'owner' | 'admin' | 'member'
joined_at: string
- organization_name: string
- organization_slug: string
+ organization_name?: string
+ organization_slug?: string
+ user_email?: string
}
-// * Fetch user's organizations
-export async function getUserOrganizations(): Promise<{ organizations: OrganizationMember[] }> {
- // * Route to Auth Service
- // * Note: The client.ts prepends /api/v1, but the auth service routes are /api/v1/auth/organizations
- // * We need to be careful with the prefix.
- // * client.ts: if endpoint starts with /auth, it uses AUTH_API_URL + /api/v1 + endpoint
- // * So if we pass /auth/organizations, it becomes AUTH_API_URL/api/v1/auth/organizations
- // * This matches the router group in main.go: v1.Group("/auth").Group("/organizations")
- return apiRequest<{ organizations: OrganizationMember[] }>('/auth/organizations')
+export interface OrganizationInvitation {
+ id: string
+ organization_id: string
+ email: string
+ role: 'owner' | 'admin' | 'member'
+ invited_by: string
+ expires_at: string
+ created_at: string
}
-// * Create a new organization
+// Create a new organization
export async function createOrganization(name: string, slug: string): Promise {
- return apiRequest('/auth/organizations', {
+ // Use authFetch (Authenticated via Ciphera Auth)
+ // * Note: authFetch returns the parsed JSON body, not the Response object
+ return await authFetch('/auth/organizations', {
method: 'POST',
body: JSON.stringify({ name, slug }),
})
}
-// * Switch context to organization (returns new token)
-export async function switchContext(organizationId: string): Promise<{ access_token: string }> {
- // * Route in main.go is /api/v1/auth/switch-context
- return apiRequest<{ access_token: string }>('/auth/switch-context', {
+// List organizations user belongs to
+export async function getUserOrganizations(): Promise {
+ const data = await authFetch<{ organizations: OrganizationMember[] }>('/auth/organizations')
+ return data.organizations || []
+}
+
+// Switch Context (Get token for specific org)
+export async function switchContext(organizationId: string | null): Promise<{ access_token: string; expires_in: number }> {
+ const payload = { organization_id: organizationId || '' }
+ console.log('Sending switch context request:', payload)
+ return await authFetch<{ access_token: string; expires_in: number }>('/auth/switch-context', {
method: 'POST',
- body: JSON.stringify({ organization_id: organizationId }),
+ body: JSON.stringify(payload),
+ })
+}
+
+// Get organization details
+export async function getOrganization(organizationId: string): Promise {
+ return await authFetch(`/auth/organizations/${organizationId}`)
+}
+
+// Delete an organization
+export async function deleteOrganization(organizationId: string): Promise {
+ await authFetch(`/auth/organizations/${organizationId}`, {
+ method: 'DELETE',
+ })
+}
+
+// Update organization details
+export async function updateOrganization(organizationId: string, name: string, slug: string): Promise {
+ return await authFetch(`/auth/organizations/${organizationId}`, {
+ method: 'PUT',
+ body: JSON.stringify({ name, slug }),
+ })
+}
+
+// Get organization members
+export async function getOrganizationMembers(organizationId: string): Promise {
+ const data = await authFetch<{ members: OrganizationMember[] }>(`/auth/organizations/${organizationId}/members`)
+ return data.members || []
+}
+
+// Send an invitation
+export async function sendInvitation(organizationId: string, email: string, role: string = 'member'): Promise {
+ return await authFetch(`/auth/organizations/${organizationId}/invites`, {
+ method: 'POST',
+ body: JSON.stringify({ email, role }),
+ })
+}
+
+// List invitations
+export async function getInvitations(organizationId: string): Promise {
+ const data = await authFetch<{ invitations: OrganizationInvitation[] }>(`/auth/organizations/${organizationId}/invites`)
+ return data.invitations || []
+}
+
+// Revoke invitation
+export async function revokeInvitation(organizationId: string, inviteId: string): Promise {
+ await authFetch(`/auth/organizations/${organizationId}/invites/${inviteId}`, {
+ method: 'DELETE',
})
}