Skip to content

Commit 52ef4b9

Browse files
committed
feat: add organization management pages including Invitations, Members, and Settings
- Implemented InvitationsPage to list and manage organization invitations with filtering and actions. - Created InviteMemberDialog for inviting new members to an organization with a shareable link. - Developed MembersPage to display organization members, allowing role changes and member removal. - Added OrganizationLayout for managing organization-specific routes and navigation. - Built SettingsPage for updating organization details and handling leave/delete actions. - Introduced orgContext for accessing organization data in nested routes.
1 parent f4922ec commit 52ef4b9

17 files changed

Lines changed: 1734 additions & 7 deletions

apps/console/src/App.tsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,11 @@ import {
2525
DefaultHomePage,
2626
DefaultOrganizationsLayout,
2727
DefaultOrganizationsPage,
28+
DefaultOrganizationLayout,
29+
DefaultMembersPage,
30+
DefaultInvitationsPage,
31+
DefaultSettingsPage,
32+
DefaultAcceptInvitationPage,
2833
} from '@object-ui/app-shell';
2934
import { PreviewBanner } from '@object-ui/auth';
3035

@@ -57,6 +62,17 @@ export function App() {
5762
<DefaultOrganizationsLayout><DefaultOrganizationsPage /></DefaultOrganizationsLayout>
5863
</AuthenticatedRoute>
5964
} />
65+
<Route path="/organizations/:slug" element={
66+
<AuthenticatedRoute requireOrganization={false}>
67+
<DefaultOrganizationLayout />
68+
</AuthenticatedRoute>
69+
}>
70+
<Route index element={<Navigate to="members" replace />} />
71+
<Route path="members" element={<DefaultMembersPage />} />
72+
<Route path="invitations" element={<DefaultInvitationsPage />} />
73+
<Route path="settings" element={<DefaultSettingsPage />} />
74+
</Route>
75+
<Route path="/accept-invitation/:invitationId" element={<DefaultAcceptInvitationPage />} />
6076
<Route path="/system/*" element={<SystemRedirect />} />
6177
<Route path="/create-app" element={
6278
<AuthenticatedRoute requireOrganization={false}>

packages/app-shell/src/console/organizations/OrganizationsPage.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { Plus, Search, Loader2 } from 'lucide-react';
2222
import { useAuth } from '@object-ui/auth';
2323
import type { AuthOrganization } from '@object-ui/auth';
2424
import { useObjectTranslation } from '@object-ui/i18n';
25+
import { useNavigate } from 'react-router-dom';
2526
import { CreateWorkspaceDialog } from './CreateWorkspaceDialog';
2627

2728
function getOrgInitials(name: string): string {
@@ -35,6 +36,7 @@ function getOrgInitials(name: string): string {
3536

3637
export function OrganizationsPage() {
3738
const { t } = useObjectTranslation();
39+
const navigate = useNavigate();
3840
const {
3941
organizations,
4042
activeOrganization,
@@ -163,6 +165,12 @@ export function OrganizationsPage() {
163165
: org.slug}
164166
</div>
165167
</div>
168+
<span
169+
className="text-xs text-primary underline-offset-4 hover:underline shrink-0"
170+
onClick={(e) => { e.stopPropagation(); navigate(`/organizations/${org.slug}/members`); }}
171+
>
172+
{t('organizations.manage', { defaultValue: 'Manage' })}
173+
</span>
166174
{isSwitching && <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />}
167175
</button>
168176
);
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
11
export { OrganizationsLayout } from './OrganizationsLayout';
22
export { OrganizationsPage } from './OrganizationsPage';
33
export { CreateWorkspaceDialog } from './CreateWorkspaceDialog';
4+
export { OrganizationLayout } from './manage/OrganizationLayout';
5+
export { MembersPage } from './manage/MembersPage';
6+
export { InvitationsPage } from './manage/InvitationsPage';
7+
export { SettingsPage } from './manage/SettingsPage';
8+
export { AcceptInvitationPage } from './manage/AcceptInvitationPage';
Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
/**
2+
* AcceptInvitationPage
3+
*
4+
* Standalone page for accepting or rejecting an organization invitation.
5+
* Route: /accept-invitation/:invitationId
6+
*/
7+
8+
import { useEffect, useState } from 'react';
9+
import { useNavigate, useParams } from 'react-router-dom';
10+
import { Button } from '@object-ui/components';
11+
import { useAuth } from '@object-ui/auth';
12+
import type { AuthInvitation } from '@object-ui/auth';
13+
import { useObjectTranslation } from '@object-ui/i18n';
14+
import { Loader2, Building2, CheckCircle, XCircle } from 'lucide-react';
15+
import { toast } from 'sonner';
16+
17+
type InvitationWithOrg = AuthInvitation & {
18+
organizationName?: string;
19+
organizationSlug?: string;
20+
};
21+
22+
export function AcceptInvitationPage() {
23+
const { t } = useObjectTranslation();
24+
const navigate = useNavigate();
25+
const { invitationId } = useParams<{ invitationId: string }>();
26+
const { isAuthenticated, isLoading: isAuthLoading, getInvitation, acceptInvitation, rejectInvitation, switchOrganization } =
27+
useAuth();
28+
29+
const [invitation, setInvitation] = useState<InvitationWithOrg | null>(null);
30+
const [isLoading, setIsLoading] = useState(true);
31+
const [error, setError] = useState<string | null>(null);
32+
const [isAccepting, setIsAccepting] = useState(false);
33+
const [isDeclining, setIsDeclining] = useState(false);
34+
35+
// Redirect to login if not authenticated
36+
useEffect(() => {
37+
if (!isAuthLoading && !isAuthenticated) {
38+
navigate('/login?redirect=' + encodeURIComponent(window.location.pathname));
39+
}
40+
}, [isAuthenticated, isAuthLoading, navigate]);
41+
42+
// Fetch invitation details
43+
useEffect(() => {
44+
if (!invitationId || !isAuthenticated) return;
45+
let cancelled = false;
46+
setIsLoading(true);
47+
setError(null);
48+
getInvitation(invitationId)
49+
.then((inv) => {
50+
if (!cancelled) setInvitation(inv);
51+
})
52+
.catch((err) => {
53+
if (!cancelled)
54+
setError(err instanceof Error ? err.message : 'Invitation not found or expired');
55+
})
56+
.finally(() => {
57+
if (!cancelled) setIsLoading(false);
58+
});
59+
return () => {
60+
cancelled = true;
61+
};
62+
}, [invitationId, isAuthenticated, getInvitation]);
63+
64+
const handleAccept = async () => {
65+
if (!invitation || !invitationId) return;
66+
setIsAccepting(true);
67+
try {
68+
await acceptInvitation(invitationId);
69+
await switchOrganization(invitation.organizationId).catch(() => null);
70+
toast.success(t('organization.accept.accepted', { defaultValue: 'Invitation accepted' }));
71+
navigate('/home');
72+
} catch (err) {
73+
toast.error(
74+
err instanceof Error
75+
? err.message
76+
: t('organization.accept.acceptFailed', { defaultValue: 'Failed to accept invitation' }),
77+
);
78+
setIsAccepting(false);
79+
}
80+
};
81+
82+
const handleDecline = async () => {
83+
if (!invitationId) return;
84+
setIsDeclining(true);
85+
try {
86+
await rejectInvitation(invitationId);
87+
toast.success(t('organization.accept.declined', { defaultValue: 'Invitation declined' }));
88+
navigate('/organizations');
89+
} catch (err) {
90+
toast.error(
91+
err instanceof Error
92+
? err.message
93+
: t('organization.accept.declineFailed', { defaultValue: 'Failed to decline invitation' }),
94+
);
95+
setIsDeclining(false);
96+
}
97+
};
98+
99+
// Show spinner while auth is loading
100+
if (isAuthLoading) {
101+
return (
102+
<div className="flex min-h-svh items-center justify-center">
103+
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
104+
</div>
105+
);
106+
}
107+
108+
// Will redirect if not authenticated — show nothing in the meantime
109+
if (!isAuthenticated) return null;
110+
111+
return (
112+
<div className="flex min-h-svh items-center justify-center px-4 py-12 bg-background">
113+
<div className="w-full max-w-md rounded-xl border bg-card p-8 shadow-sm" data-testid="accept-invitation-page">
114+
{isLoading ? (
115+
<div className="flex flex-col items-center gap-4 py-8">
116+
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
117+
<p className="text-sm text-muted-foreground">
118+
{t('organization.accept.loading', { defaultValue: 'Loading invitation…' })}
119+
</p>
120+
</div>
121+
) : error ? (
122+
<div className="flex flex-col items-center gap-4 py-8 text-center">
123+
<XCircle className="h-10 w-10 text-destructive" />
124+
<h1 className="text-xl font-semibold">
125+
{t('organization.accept.errorTitle', { defaultValue: 'Invitation unavailable' })}
126+
</h1>
127+
<p className="text-sm text-muted-foreground">{error}</p>
128+
<Button variant="outline" onClick={() => navigate('/organizations')}>
129+
{t('organization.accept.goToOrgs', { defaultValue: 'Go to organizations' })}
130+
</Button>
131+
</div>
132+
) : invitation ? (
133+
<div className="flex flex-col items-center gap-6 text-center">
134+
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-primary/10">
135+
<Building2 className="h-7 w-7 text-primary" />
136+
</div>
137+
138+
<div>
139+
<h1 className="text-xl font-bold">
140+
{t('organization.accept.title', { defaultValue: 'You have been invited' })}
141+
</h1>
142+
<p className="mt-2 text-sm text-muted-foreground">
143+
{t('organization.accept.description', {
144+
defaultValue:
145+
'You have been invited to join {{orgName}} as {{role}}.',
146+
orgName: invitation.organizationName ?? invitation.organizationId,
147+
role: invitation.role,
148+
})}
149+
</p>
150+
</div>
151+
152+
<div className="w-full rounded-lg border bg-muted/50 p-4 text-left space-y-2">
153+
<div className="flex justify-between text-sm">
154+
<span className="text-muted-foreground">
155+
{t('organization.accept.organization', { defaultValue: 'Organization' })}
156+
</span>
157+
<span className="font-medium">
158+
{invitation.organizationName ?? invitation.organizationId}
159+
</span>
160+
</div>
161+
<div className="flex justify-between text-sm">
162+
<span className="text-muted-foreground">
163+
{t('organization.accept.role', { defaultValue: 'Role' })}
164+
</span>
165+
<span className="font-medium capitalize">{invitation.role}</span>
166+
</div>
167+
{invitation.expiresAt && (
168+
<div className="flex justify-between text-sm">
169+
<span className="text-muted-foreground">
170+
{t('organization.accept.expiresAt', { defaultValue: 'Expires' })}
171+
</span>
172+
<span className="font-medium">
173+
{new Date(invitation.expiresAt).toLocaleDateString()}
174+
</span>
175+
</div>
176+
)}
177+
</div>
178+
179+
<div className="flex w-full gap-3">
180+
<Button
181+
variant="outline"
182+
className="flex-1"
183+
onClick={handleDecline}
184+
disabled={isDeclining || isAccepting}
185+
>
186+
{isDeclining && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
187+
{t('organization.accept.decline', { defaultValue: 'Decline' })}
188+
</Button>
189+
<Button
190+
className="flex-1"
191+
onClick={handleAccept}
192+
disabled={isAccepting || isDeclining}
193+
>
194+
{isAccepting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
195+
<CheckCircle className="mr-2 h-4 w-4" />
196+
{t('organization.accept.accept', { defaultValue: 'Accept invitation' })}
197+
</Button>
198+
</div>
199+
</div>
200+
) : null}
201+
</div>
202+
</div>
203+
);
204+
}

0 commit comments

Comments
 (0)