Skip to content

Commit cc14158

Browse files
committed
feat: implement organization management features including organization switcher, creation, and detail views
- Add `OrganizationSwitcher` component for selecting active organizations. - Create organization management routes: `/orgs`, `/orgs/new`, and `/orgs/$orgId`. - Implement session management with hooks for user authentication and organization handling. - Add login and registration pages for user access. - Enhance `HttpDispatcher` to resolve session placeholders for organization context. - Update `ObjectStackClient` to include organization-related API methods.
1 parent 196edd7 commit cc14158

15 files changed

Lines changed: 1309 additions & 15 deletions

File tree

apps/studio/src/components/new-environment-dialog.tsx

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import {
3535
} from '@/components/ui/select';
3636
import { useDrivers, useProvisionEnvironment } from '@/hooks/useEnvironments';
3737
import { toast } from '@/hooks/use-toast';
38+
import { useActiveOrganizationId, useSession } from '@/hooks/useSession';
3839

3940
const ENV_TYPES: { value: EnvironmentType; label: string; hint: string }[] = [
4041
{ value: 'development', label: 'Development', hint: 'For makers building and iterating' },
@@ -59,6 +60,8 @@ export function NewEnvironmentDialog({
5960
}: NewEnvironmentDialogProps) {
6061
const { provision, provisioning } = useProvisionEnvironment();
6162
const { drivers, loading: driversLoading } = useDrivers();
63+
const activeOrgId = useActiveOrganizationId();
64+
const { user } = useSession();
6265
const [slug, setSlug] = useState('');
6366
const [displayName, setDisplayName] = useState('');
6467
const [envType, setEnvType] = useState<EnvironmentType>('development');
@@ -87,11 +90,18 @@ export function NewEnvironmentDialog({
8790
const handleSubmit = async (e: React.FormEvent) => {
8891
e.preventDefault();
8992
if (!slug.trim()) return;
93+
if (!activeOrgId) {
94+
toast({
95+
title: 'No active organization',
96+
description: 'Select or create an organization first.',
97+
variant: 'destructive',
98+
});
99+
return;
100+
}
90101
try {
91102
const res = await provision({
92-
// organizationId + createdBy come from session on the server.
93-
organizationId: '__session__',
94-
createdBy: '__session__',
103+
organizationId: activeOrgId,
104+
createdBy: user?.id ?? '__session__',
95105
slug: slug.trim(),
96106
displayName: displayName.trim() || undefined,
97107
envType,
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* OrganizationSwitcher
5+
*
6+
* Sits immediately left of the EnvironmentSwitcher in the site header.
7+
* Reads org list + active org from `useOrganizations()` / `useSession()`.
8+
* Selecting an org calls `organizations.setActive()` and refreshes the
9+
* session (so the rest of the app picks up the new `activeOrganizationId`).
10+
*/
11+
12+
import { useMemo, useState } from 'react';
13+
import { useNavigate } from '@tanstack/react-router';
14+
import { Building2, Check, ChevronsUpDown, Plus } from 'lucide-react';
15+
import {
16+
DropdownMenu,
17+
DropdownMenuContent,
18+
DropdownMenuItem,
19+
DropdownMenuLabel,
20+
DropdownMenuSeparator,
21+
DropdownMenuTrigger,
22+
} from '@/components/ui/dropdown-menu';
23+
import { Button } from '@/components/ui/button';
24+
import { useOrganizations, useSession } from '@/hooks/useSession';
25+
import { toast } from '@/hooks/use-toast';
26+
27+
export function OrganizationSwitcher() {
28+
const navigate = useNavigate();
29+
const { organizations, loading, reload } = useOrganizations();
30+
const { session, setActiveOrganization } = useSession();
31+
const [open, setOpen] = useState(false);
32+
const [switching, setSwitching] = useState(false);
33+
34+
const activeId = session?.activeOrganizationId ?? undefined;
35+
const active = useMemo(
36+
() => organizations.find((o) => o.id === activeId) ?? null,
37+
[organizations, activeId],
38+
);
39+
40+
const handleSelect = async (id: string) => {
41+
if (id === activeId) {
42+
setOpen(false);
43+
return;
44+
}
45+
setSwitching(true);
46+
try {
47+
await setActiveOrganization(id);
48+
await reload();
49+
toast({ title: 'Organization switched' });
50+
} catch (err) {
51+
toast({
52+
title: 'Failed to switch organization',
53+
description: (err as Error).message,
54+
variant: 'destructive',
55+
});
56+
} finally {
57+
setSwitching(false);
58+
setOpen(false);
59+
}
60+
};
61+
62+
return (
63+
<DropdownMenu open={open} onOpenChange={setOpen}>
64+
<DropdownMenuTrigger asChild>
65+
<Button
66+
variant="ghost"
67+
size="sm"
68+
className="h-8 gap-2 px-2 text-sm font-medium"
69+
disabled={switching}
70+
>
71+
<Building2 className="h-3.5 w-3.5 text-muted-foreground" />
72+
{active ? (
73+
<span className="max-w-[140px] truncate">{active.name}</span>
74+
) : (
75+
<span className="text-muted-foreground">
76+
{loading ? 'Loading…' : 'Select organization'}
77+
</span>
78+
)}
79+
<ChevronsUpDown className="h-3.5 w-3.5 opacity-60" />
80+
</Button>
81+
</DropdownMenuTrigger>
82+
<DropdownMenuContent align="start" className="w-[280px]" sideOffset={4}>
83+
<DropdownMenuLabel className="text-[10px] uppercase tracking-wider text-muted-foreground">
84+
Organizations
85+
</DropdownMenuLabel>
86+
{organizations.length === 0 && !loading && (
87+
<div className="px-3 py-4 text-center text-xs text-muted-foreground">
88+
No organizations yet.
89+
</div>
90+
)}
91+
{organizations.map((org) => (
92+
<DropdownMenuItem
93+
key={org.id}
94+
onSelect={(e) => {
95+
e.preventDefault();
96+
handleSelect(org.id);
97+
}}
98+
className="flex items-center gap-2"
99+
>
100+
<div className="flex-1 min-w-0">
101+
<div className="truncate font-medium">{org.name}</div>
102+
{org.slug && (
103+
<code className="text-[11px] text-muted-foreground font-mono">
104+
{org.slug}
105+
</code>
106+
)}
107+
</div>
108+
{org.id === activeId && <Check className="h-3.5 w-3.5 text-primary" />}
109+
</DropdownMenuItem>
110+
))}
111+
<DropdownMenuSeparator />
112+
<DropdownMenuItem
113+
onSelect={(e) => {
114+
e.preventDefault();
115+
setOpen(false);
116+
navigate({ to: '/orgs/new' });
117+
}}
118+
className="gap-2 text-sm"
119+
>
120+
<Plus className="h-3.5 w-3.5" />
121+
New organization…
122+
</DropdownMenuItem>
123+
<DropdownMenuItem
124+
onSelect={(e) => {
125+
e.preventDefault();
126+
setOpen(false);
127+
navigate({ to: '/orgs' });
128+
}}
129+
className="gap-2 text-sm text-muted-foreground"
130+
>
131+
Manage organizations
132+
</DropdownMenuItem>
133+
</DropdownMenuContent>
134+
</DropdownMenu>
135+
);
136+
}

apps/studio/src/components/site-header.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ import { Badge } from "@/components/ui/badge"
1515
import { Cpu } from 'lucide-react'
1616
import { config } from '@/lib/config'
1717
import { EnvironmentSwitcher } from '@/components/environment-switcher'
18+
import { OrganizationSwitcher } from '@/components/organization-switcher'
19+
import { UserMenu } from '@/components/user-menu'
1820

1921
interface SiteHeaderProps {
2022
selectedObject?: string | null;
@@ -51,6 +53,8 @@ export function SiteHeader({ selectedObject = null, selectedMeta, selectedView,
5153
<div className="flex items-center gap-2">
5254
<SidebarTrigger className="-ml-1" />
5355
<Separator orientation="vertical" className="mr-2 h-4" />
56+
<OrganizationSwitcher />
57+
<Separator orientation="vertical" className="mx-1 h-4" />
5458
<EnvironmentSwitcher />
5559
<Separator orientation="vertical" className="mx-1 h-4" />
5660
<Breadcrumb>
@@ -107,6 +111,7 @@ export function SiteHeader({ selectedObject = null, selectedMeta, selectedView,
107111
{config.mode.toUpperCase()}
108112
</Badge>
109113
<ThemeToggle />
114+
<UserMenu />
110115
</div>
111116
</header>
112117
)
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* UserMenu — avatar dropdown on the right of the site header. Shows the
5+
* current user and exposes logout + quick links to org/env management.
6+
*/
7+
8+
import { useNavigate } from '@tanstack/react-router';
9+
import { LogOut, Settings, User as UserIcon, Building2 } from 'lucide-react';
10+
import {
11+
DropdownMenu,
12+
DropdownMenuContent,
13+
DropdownMenuItem,
14+
DropdownMenuLabel,
15+
DropdownMenuSeparator,
16+
DropdownMenuTrigger,
17+
} from '@/components/ui/dropdown-menu';
18+
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
19+
import { Button } from '@/components/ui/button';
20+
import { useSession } from '@/hooks/useSession';
21+
22+
function initials(name?: string, email?: string): string {
23+
const src = (name || email || '?').trim();
24+
const parts = src.split(/\s+/);
25+
if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase();
26+
return src.slice(0, 2).toUpperCase();
27+
}
28+
29+
export function UserMenu() {
30+
const navigate = useNavigate();
31+
const { user, loading, logout } = useSession();
32+
33+
if (loading && !user) {
34+
return (
35+
<div className="h-8 w-8 animate-pulse rounded-full bg-muted" aria-hidden />
36+
);
37+
}
38+
39+
if (!user) {
40+
return (
41+
<Button
42+
variant="outline"
43+
size="sm"
44+
className="h-8"
45+
onClick={() => navigate({ to: '/login' })}
46+
>
47+
Sign in
48+
</Button>
49+
);
50+
}
51+
52+
const handleLogout = async () => {
53+
await logout();
54+
navigate({ to: '/login' });
55+
};
56+
57+
return (
58+
<DropdownMenu>
59+
<DropdownMenuTrigger asChild>
60+
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-full">
61+
<Avatar className="h-7 w-7">
62+
{user.image ? <AvatarImage src={user.image} alt={user.name ?? user.email ?? 'User'} /> : null}
63+
<AvatarFallback className="text-[11px]">
64+
{initials(user.name, user.email)}
65+
</AvatarFallback>
66+
</Avatar>
67+
</Button>
68+
</DropdownMenuTrigger>
69+
<DropdownMenuContent align="end" className="w-64">
70+
<DropdownMenuLabel>
71+
<div className="flex flex-col gap-0.5">
72+
<span className="text-sm font-medium truncate">
73+
{user.name || user.email}
74+
</span>
75+
{user.email && (
76+
<span className="text-[11px] text-muted-foreground truncate">
77+
{user.email}
78+
</span>
79+
)}
80+
</div>
81+
</DropdownMenuLabel>
82+
<DropdownMenuSeparator />
83+
<DropdownMenuItem onSelect={() => navigate({ to: '/orgs' })}>
84+
<Building2 className="mr-2 h-3.5 w-3.5" />
85+
Organizations
86+
</DropdownMenuItem>
87+
<DropdownMenuItem onSelect={() => navigate({ to: '/environments' })}>
88+
<Settings className="mr-2 h-3.5 w-3.5" />
89+
Environments
90+
</DropdownMenuItem>
91+
<DropdownMenuItem disabled>
92+
<UserIcon className="mr-2 h-3.5 w-3.5" />
93+
Account settings
94+
</DropdownMenuItem>
95+
<DropdownMenuSeparator />
96+
<DropdownMenuItem onSelect={handleLogout}>
97+
<LogOut className="mr-2 h-3.5 w-3.5" />
98+
Sign out
99+
</DropdownMenuItem>
100+
</DropdownMenuContent>
101+
</DropdownMenu>
102+
);
103+
}

0 commit comments

Comments
 (0)