From 9767d928ce525cc31b99a22f3c9bc7a368158a3a Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Thu, 22 Jan 2026 01:52:51 +0100 Subject: [PATCH] feat(settings): implement organization settings page --- app/layout-content.tsx | 2 +- app/org-settings/page.tsx | 16 + components/settings/OrganizationSettings.tsx | 532 +++++++++++++++++++ lib/api/organization.ts | 100 +++- 4 files changed, 627 insertions(+), 23 deletions(-) create mode 100644 app/org-settings/page.tsx create mode 100644 components/settings/OrganizationSettings.tsx 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.

+
+ +
+
+ + setOrgName(e.target.value)} + required + minLength={2} + maxLength={50} + disabled={!isEditing} + className={`bg-white dark:bg-neutral-900 ${!isEditing ? 'text-neutral-500' : ''}`} + /> +
+ +
+ +
+ + drop.ciphera.net/ + + setOrgSlug(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ''))} + required + minLength={3} + maxLength={30} + disabled={!isEditing} + className={`rounded-l-none bg-white dark:bg-neutral-900 ${!isEditing ? 'text-neutral-500' : ''}`} + /> +
+

+ Changing the slug will change your organization's URL. +

+
+ +
+ {!isEditing ? ( + + ) : ( + <> + + + + )} +
+
+ +
+
+

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.

+ +
+

Invite New Member

+
+
+ setInviteEmail(e.target.value)} + required + className="bg-white dark:bg-neutral-900" + /> +
+
+ +
+ +
+
+
+ + {/* Members List */} +
+

Active Members

+
+ {isLoadingMembers ? ( +
+
+ Loading members... +
+ ) : 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', }) }