Skip to content

Commit e921add

Browse files
committed
feat(orgs): enhance organization management with reload functionality and improved UI
1 parent fe3bc36 commit e921add

4 files changed

Lines changed: 150 additions & 80 deletions

File tree

apps/studio/src/components/top-bar.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import { ProjectSwitcher } from '@/components/project-switcher';
3030
import { OrganizationSwitcher } from '@/components/organization-switcher';
3131
import { UserMenu } from '@/components/user-menu';
3232
import { SidebarTrigger } from '@/components/ui/sidebar';
33+
import { useActiveOrganizationId } from '@/hooks/useSession';
3334

3435
const META_TYPE_LABELS: Record<string, string> = {
3536
action: 'Actions',
@@ -74,6 +75,7 @@ function SlashDivider() {
7475

7576
export function TopBar() {
7677
const location = useLocation();
78+
const activeOrgId = useActiveOrganizationId();
7779
const params = useParams({ strict: false }) as {
7880
package?: string;
7981
projectId?: string;
@@ -174,8 +176,12 @@ export function TopBar() {
174176
<SlashDivider />
175177
<div className="hidden sm:flex items-center gap-1.5">
176178
<OrganizationSwitcher />
177-
<SlashDivider />
178-
<ProjectSwitcher />
179+
{activeOrgId && (
180+
<>
181+
<SlashDivider />
182+
<ProjectSwitcher />
183+
</>
184+
)}
179185
</div>
180186
{/* Mobile: Show only current page breadcrumb */}
181187
<div className="sm:hidden min-w-0 flex-1">

apps/studio/src/hooks/useSession.ts

Lines changed: 62 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,9 @@ export interface SessionState {
5454
refresh: () => Promise<void>;
5555
logout: () => Promise<void>;
5656
setActiveOrganization: (organizationId: string) => Promise<void>;
57+
organizations: Organization[];
58+
organizationsLoading: boolean;
59+
reloadOrganizations: () => Promise<void>;
5760
}
5861

5962
export interface Organization {
@@ -87,6 +90,21 @@ export function SessionProvider({ children }: { children: ReactNode }) {
8790
const [session, setSession] = useState<SessionData | null>(null);
8891
const [loading, setLoading] = useState(true);
8992
const [error, setError] = useState<Error | null>(null);
93+
const [organizations, setOrganizations] = useState<Organization[]>([]);
94+
const [organizationsLoading, setOrganizationsLoading] = useState(false);
95+
96+
const reloadOrganizations = useCallback(async () => {
97+
if (!client?.organizations) return;
98+
setOrganizationsLoading(true);
99+
try {
100+
const result = await client.organizations.list();
101+
setOrganizations(result?.organizations ?? []);
102+
} catch {
103+
setOrganizations([]);
104+
} finally {
105+
setOrganizationsLoading(false);
106+
}
107+
}, [client]);
90108

91109
const refresh = useCallback(async () => {
92110
if (!client?.auth) return;
@@ -110,13 +128,22 @@ export function SessionProvider({ children }: { children: ReactNode }) {
110128
refresh();
111129
}, [refresh]);
112130

131+
useEffect(() => {
132+
if (user) {
133+
reloadOrganizations();
134+
} else {
135+
setOrganizations([]);
136+
}
137+
}, [user, reloadOrganizations]);
138+
113139
const logout = useCallback(async () => {
114140
if (!client?.auth) return;
115141
try {
116142
await client.auth.logout();
117143
} finally {
118144
setUser(null);
119145
setSession(null);
146+
setOrganizations([]);
120147
}
121148
}, [client]);
122149

@@ -130,8 +157,30 @@ export function SessionProvider({ children }: { children: ReactNode }) {
130157
);
131158

132159
const value = useMemo<SessionState>(
133-
() => ({ user, session, loading, error, refresh, logout, setActiveOrganization }),
134-
[user, session, loading, error, refresh, logout, setActiveOrganization],
160+
() => ({
161+
user,
162+
session,
163+
loading,
164+
error,
165+
refresh,
166+
logout,
167+
setActiveOrganization,
168+
organizations,
169+
organizationsLoading,
170+
reloadOrganizations,
171+
}),
172+
[
173+
user,
174+
session,
175+
loading,
176+
error,
177+
refresh,
178+
logout,
179+
setActiveOrganization,
180+
organizations,
181+
organizationsLoading,
182+
reloadOrganizations,
183+
],
135184
);
136185

137186
return createElement(SessionContext.Provider, { value }, children);
@@ -156,33 +205,19 @@ export function useActiveOrganizationId(): string | undefined {
156205

157206
/**
158207
* Hook: list every organization the current user belongs to.
208+
*
209+
* Backed by the shared state in {@link SessionProvider}, so every caller
210+
* (top-bar switcher, org list page, new-org redirect) sees the same list
211+
* and a single reload refreshes them all.
159212
*/
160213
export function useOrganizations() {
161-
const client = useClient() as any;
162-
const [organizations, setOrganizations] = useState<Organization[]>([]);
163-
const [loading, setLoading] = useState(false);
164-
const [error, setError] = useState<Error | null>(null);
165-
166-
const load = useCallback(async () => {
167-
if (!client?.organizations) return;
168-
setLoading(true);
169-
setError(null);
170-
try {
171-
const result = await client.organizations.list();
172-
setOrganizations(result?.organizations ?? []);
173-
} catch (err) {
174-
setError(err as Error);
175-
setOrganizations([]);
176-
} finally {
177-
setLoading(false);
178-
}
179-
}, [client]);
180-
181-
useEffect(() => {
182-
load();
183-
}, [load]);
184-
185-
return { organizations, loading, error, reload: load };
214+
const { organizations, organizationsLoading, reloadOrganizations } = useSession();
215+
return {
216+
organizations,
217+
loading: organizationsLoading,
218+
error: null as Error | null,
219+
reload: reloadOrganizations,
220+
};
186221
}
187222

188223
/**

apps/studio/src/routes/orgs.index.tsx

Lines changed: 77 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

3-
import { createFileRoute, Link, useNavigate } from '@tanstack/react-router';
4-
import { Building2, Check, Plus } from 'lucide-react';
3+
import { createFileRoute, useNavigate } from '@tanstack/react-router';
4+
import { Building2, Check, Plus, Settings } from 'lucide-react';
55
import { Button } from '@/components/ui/button';
6-
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
6+
import { Card } from '@/components/ui/card';
7+
import { Badge } from '@/components/ui/badge';
78
import { toast } from '@/hooks/use-toast';
89
import { useOrganizations, useSession } from '@/hooks/useSession';
910

@@ -12,19 +13,20 @@ export const Route = createFileRoute('/orgs/')({
1213
});
1314

1415
function OrgsListPage() {
15-
const { organizations, loading, reload } = useOrganizations();
16+
const { organizations, loading } = useOrganizations();
1617
const { session, setActiveOrganization } = useSession();
1718
const navigate = useNavigate();
1819
const activeId = session?.activeOrganizationId ?? undefined;
1920

20-
const handleSetActive = async (id: string) => {
21+
const handleSelect = async (id: string) => {
2122
try {
22-
await setActiveOrganization(id);
23-
await reload();
24-
toast({ title: 'Organization switched' });
23+
if (id !== activeId) {
24+
await setActiveOrganization(id);
25+
}
26+
navigate({ to: '/projects' });
2527
} catch (err) {
2628
toast({
27-
title: 'Failed to switch',
29+
title: 'Failed to switch organization',
2830
description: (err as Error).message,
2931
variant: 'destructive',
3032
});
@@ -39,58 +41,84 @@ function OrgsListPage() {
3941
<div>
4042
<h1 className="text-2xl font-semibold">Organizations</h1>
4143
<p className="text-sm text-muted-foreground">
42-
Manage the organizations you belong to.
44+
Select an organization to work with, or create a new one.
4345
</p>
4446
</div>
4547
<Button onClick={() => navigate({ to: '/orgs/new' })}>
4648
<Plus className="mr-2 h-4 w-4" /> New organization
4749
</Button>
4850
</div>
51+
4952
{loading && <p className="text-sm text-muted-foreground">Loading…</p>}
53+
5054
{!loading && organizations.length === 0 && (
51-
<Card>
52-
<CardContent className="flex flex-col items-center gap-3 py-12 text-center">
53-
<Building2 className="h-10 w-10 text-muted-foreground" />
54-
<p className="text-sm text-muted-foreground">
55-
You don't belong to any organization yet.
56-
</p>
57-
<Button onClick={() => navigate({ to: '/orgs/new' })}>
58-
Create your first organization
59-
</Button>
60-
</CardContent>
55+
<Card className="p-10 text-center">
56+
<Building2 className="mx-auto mb-3 h-10 w-10 text-muted-foreground" />
57+
<h3 className="text-base font-medium">No organizations yet</h3>
58+
<p className="mb-4 text-sm text-muted-foreground">
59+
Create your first organization to start building.
60+
</p>
61+
<Button onClick={() => navigate({ to: '/orgs/new' })}>
62+
<Plus className="mr-2 h-4 w-4" />
63+
Create organization
64+
</Button>
6165
</Card>
6266
)}
67+
6368
<div className="grid gap-3">
64-
{organizations.map((org) => (
65-
<Card key={org.id}>
66-
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
67-
<div>
68-
<CardTitle className="text-base">{org.name}</CardTitle>
69-
{org.slug && (
70-
<CardDescription className="font-mono text-xs">{org.slug}</CardDescription>
71-
)}
72-
</div>
73-
{org.id === activeId ? (
74-
<span className="flex items-center gap-1 text-xs text-primary">
75-
<Check className="h-3.5 w-3.5" /> Active
76-
</span>
77-
) : (
78-
<Button size="sm" variant="outline" onClick={() => handleSetActive(org.id)}>
79-
Set active
69+
{organizations.map((org) => {
70+
const isActive = org.id === activeId;
71+
return (
72+
<Card
73+
key={org.id}
74+
role="button"
75+
tabIndex={0}
76+
onClick={() => handleSelect(org.id)}
77+
onKeyDown={(e) => {
78+
if (e.key === 'Enter' || e.key === ' ') {
79+
e.preventDefault();
80+
handleSelect(org.id);
81+
}
82+
}}
83+
className={`cursor-pointer p-4 transition-colors hover:bg-accent focus:outline-none focus:ring-2 focus:ring-ring ${
84+
isActive ? 'border-primary ring-1 ring-primary/40' : ''
85+
}`}
86+
>
87+
<div className="flex items-start justify-between gap-4">
88+
<div className="min-w-0 flex-1">
89+
<div className="flex items-center gap-2">
90+
<h3 className="truncate text-base font-medium">
91+
{org.name}
92+
</h3>
93+
{isActive && (
94+
<Badge variant="outline" className="gap-1 text-[10px]">
95+
<Check className="h-3 w-3" />
96+
Active
97+
</Badge>
98+
)}
99+
</div>
100+
{org.slug && (
101+
<code className="mt-1 block font-mono text-xs text-muted-foreground">
102+
{org.slug}
103+
</code>
104+
)}
105+
</div>
106+
<Button
107+
variant="ghost"
108+
size="sm"
109+
className="h-8 w-8 p-0"
110+
onClick={(e) => {
111+
e.stopPropagation();
112+
navigate({ to: '/orgs/$orgId', params: { orgId: org.id } });
113+
}}
114+
aria-label="Organization settings"
115+
>
116+
<Settings className="h-4 w-4" />
80117
</Button>
81-
)}
82-
</CardHeader>
83-
<CardContent className="pt-0">
84-
<Link
85-
to="/orgs/$orgId"
86-
params={{ orgId: org.id }}
87-
className="text-xs text-primary hover:underline"
88-
>
89-
View details →
90-
</Link>
91-
</CardContent>
92-
</Card>
93-
))}
118+
</div>
119+
</Card>
120+
);
121+
})}
94122
</div>
95123
</div>
96124
</div>

apps/studio/src/routes/orgs.new.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ function slugify(input: string): string {
2525
function NewOrgPage() {
2626
const navigate = useNavigate();
2727
const { create, creating } = useCreateOrganization();
28-
const { setActiveOrganization } = useSession();
28+
const { setActiveOrganization, reloadOrganizations } = useSession();
2929
const [name, setName] = useState('');
3030
const [slug, setSlug] = useState('');
3131
const [slugDirty, setSlugDirty] = useState(false);
@@ -45,8 +45,9 @@ function NewOrgPage() {
4545
if (newId) {
4646
await setActiveOrganization(newId).catch(() => {});
4747
}
48+
await reloadOrganizations().catch(() => {});
4849
toast({ title: 'Organization created' });
49-
navigate({ to: '/orgs' });
50+
navigate({ to: '/projects' });
5051
} catch (err) {
5152
toast({
5253
title: 'Failed to create organization',

0 commit comments

Comments
 (0)