Skip to content

Commit 5f3d0ea

Browse files
committed
feat: add organization management routes and components
- Implemented organization layout with nested routing for general and members sections. - Created organizations index page to list and select organizations. - Added new organization creation page with form validation and slug generation. - Developed organization detail page to manage members, invitations, and apps. - Integrated dialogs for inviting members and confirming organization deletion. - Enhanced navigation and user feedback with toast notifications.
1 parent 0fb1ac1 commit 5f3d0ea

30 files changed

Lines changed: 625 additions & 442 deletions

apps/account/src/components/account-sidebar.tsx

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,9 @@
1212
* └─ Two-Factor
1313
*
1414
* Organization
15-
* ├─ Overview (/orgs)
16-
* ├─ General (/orgs/:id/general — only when an org is active)
17-
* └─ Members (/orgs/:id/members — only when an org is active)
15+
* ├─ Overview (/organizations)
16+
* ├─ General (/organizations/:id/general — only when an org is active)
17+
* └─ Members (/organizations/:id/members — only when an org is active)
1818
*
1919
* The active org's name is intentionally NOT used as a group label —
2020
* the top-bar OrganizationSwitcher already shows it prominently. When
@@ -53,7 +53,7 @@ interface NavItem {
5353
| '/account/security'
5454
| '/account/sessions'
5555
| '/account/two-factor'
56-
| '/orgs';
56+
| '/organizations';
5757
label: string;
5858
icon: React.ComponentType<{ className?: string }>;
5959
}
@@ -73,8 +73,8 @@ export function AccountSidebar() {
7373
// path as the profile page for active-state purposes.
7474
const normalised = pathname === '/account' ? '/account/profile' : pathname;
7575

76-
// Detect /orgs/<orgId>/* — used to surface the org-scoped sub-items.
77-
const orgMatch = pathname.match(/^\/orgs\/([^/]+)(?:\/.*)?$/);
76+
// Detect /organizations/<orgId>/* — used to surface the org-scoped sub-items.
77+
const orgMatch = pathname.match(/^\/organizations\/([^/]+)(?:\/.*)?$/);
7878
const activeOrgId =
7979
orgMatch && organizations.some((o) => o.id === orgMatch[1]) ? orgMatch[1] : null;
8080

@@ -114,10 +114,10 @@ export function AccountSidebar() {
114114
<SidebarMenuItem>
115115
<SidebarMenuButton
116116
asChild
117-
isActive={pathname === '/orgs'}
117+
isActive={pathname === '/organizations'}
118118
tooltip="Overview"
119119
>
120-
<Link to="/orgs">
120+
<Link to="/organizations">
121121
<Building2 className="size-4" />
122122
<span>Overview</span>
123123
</Link>
@@ -128,10 +128,10 @@ export function AccountSidebar() {
128128
<SidebarMenuItem>
129129
<SidebarMenuButton
130130
asChild
131-
isActive={pathname === `/orgs/${activeOrgId}/general`}
131+
isActive={pathname === `/organizations/${activeOrgId}/general`}
132132
tooltip="General"
133133
>
134-
<Link to="/orgs/$orgId/general" params={{ orgId: activeOrgId }}>
134+
<Link to="/organizations/$orgId/general" params={{ orgId: activeOrgId }}>
135135
<Settings className="size-4" />
136136
<span>General</span>
137137
</Link>
@@ -140,10 +140,10 @@ export function AccountSidebar() {
140140
<SidebarMenuItem>
141141
<SidebarMenuButton
142142
asChild
143-
isActive={pathname === `/orgs/${activeOrgId}/members`}
143+
isActive={pathname === `/organizations/${activeOrgId}/members`}
144144
tooltip="Members"
145145
>
146-
<Link to="/orgs/$orgId/members" params={{ orgId: activeOrgId }}>
146+
<Link to="/organizations/$orgId/members" params={{ orgId: activeOrgId }}>
147147
<Users className="size-4" />
148148
<span>Members</span>
149149
</Link>

apps/account/src/components/organization-switcher.tsx

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* OrganizationSwitcher — Account portal top-bar dropdown for switching
55
* between the user's organizations. Mirrors Studio's switcher.
66
*
7-
* When the user is currently viewing `/orgs/<id>/<section>`, switching to
7+
* When the user is currently viewing `/organizations/<id>/<section>`, switching to
88
* another org navigates to the equivalent section on the new org so the
99
* URL stays in sync with the active org.
1010
*/
@@ -42,7 +42,7 @@ export function OrganizationSwitcher() {
4242
setOpen(false);
4343
if (id === activeId) {
4444
// No-op switch — but still ensure we land on the org page.
45-
navigate({ to: '/orgs/$orgId/general', params: { orgId: id } });
45+
navigate({ to: '/organizations/$orgId/general', params: { orgId: id } });
4646
return;
4747
}
4848
setSwitching(true);
@@ -52,10 +52,10 @@ export function OrganizationSwitcher() {
5252
toast({ title: 'Organization switched' });
5353

5454
// Mirror the current section onto the new org when applicable.
55-
const m = location.pathname.match(/^\/orgs\/[^/]+\/(general|members)\/?$/);
55+
const m = location.pathname.match(/^\/organizations\/[^/]+\/(general|members)\/?$/);
5656
const section = m ? (m[1] as 'general' | 'members') : 'general';
5757
navigate({
58-
to: section === 'members' ? '/orgs/$orgId/members' : '/orgs/$orgId/general',
58+
to: section === 'members' ? '/organizations/$orgId/members' : '/organizations/$orgId/general',
5959
params: { orgId: id },
6060
});
6161
} catch (err) {
@@ -122,7 +122,7 @@ export function OrganizationSwitcher() {
122122
onSelect={(e) => {
123123
e.preventDefault();
124124
setOpen(false);
125-
navigate({ to: '/orgs/new' });
125+
navigate({ to: '/organizations/new' });
126126
}}
127127
className="gap-2 text-sm"
128128
>
@@ -133,7 +133,7 @@ export function OrganizationSwitcher() {
133133
onSelect={(e) => {
134134
e.preventDefault();
135135
setOpen(false);
136-
navigate({ to: '/orgs' });
136+
navigate({ to: '/organizations' });
137137
}}
138138
className="gap-2 text-sm text-muted-foreground"
139139
>

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ export function TopBar() {
5454
else if (p === '/account/two-factor') items.push({ label: 'Two-Factor' });
5555
return items;
5656
}
57-
if (p === '/orgs/new') return [{ label: 'Organizations' }, { label: 'New' }];
57+
if (p === '/organizations/new') return [{ label: 'Organizations' }, { label: 'New' }];
5858
if (params.orgId) {
5959
const tail = p.endsWith('/general')
6060
? 'General'
@@ -63,7 +63,7 @@ export function TopBar() {
6363
: 'Settings';
6464
return [{ label: 'Organizations' }, { label: tail }];
6565
}
66-
if (p === '/orgs' || p.startsWith('/orgs/')) return [{ label: 'Organizations' }];
66+
if (p === '/organizations' || p.startsWith('/organizations/')) return [{ label: 'Organizations' }];
6767
if (p.startsWith('/accept-invitation/')) return [{ label: 'Accept invitation' }];
6868
if (p.startsWith('/auth/device')) return [{ label: 'Device authorization' }];
6969
return [{ label: 'Account' }];

apps/account/src/components/user-menu.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ export function UserMenu() {
8181
<UserIcon className="mr-2 h-3.5 w-3.5" />
8282
Account
8383
</DropdownMenuItem>
84-
<DropdownMenuItem onSelect={() => navigate({ to: '/orgs' })}>
84+
<DropdownMenuItem onSelect={() => navigate({ to: '/organizations' })}>
8585
<Building2 className="mr-2 h-3.5 w-3.5" />
8686
Organizations
8787
</DropdownMenuItem>

apps/account/src/hooks/useSession.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,45 @@ export function useCreateOrganization() {
248248
return { create, creating, error };
249249
}
250250

251+
/**
252+
* Hook: update an organization (owner/admin only — server-side enforced).
253+
*
254+
* Wraps `client.organizations.update(id, data)` (`POST /api/v1/auth/organization/update`).
255+
* On success the local organization list and session are refreshed so the
256+
* top-bar switcher and the active org snapshot reflect the new name/slug.
257+
*/
258+
export function useUpdateOrganization() {
259+
const client = useClient() as any;
260+
const { reloadOrganizations, refresh } = useSession();
261+
const [updating, setUpdating] = useState(false);
262+
const [error, setError] = useState<Error | null>(null);
263+
264+
const update = useCallback(
265+
async (
266+
organizationId: string,
267+
data: { name?: string; slug?: string; logo?: string; metadata?: Record<string, unknown> },
268+
) => {
269+
if (!client?.organizations?.update) throw new Error('Client not ready');
270+
setUpdating(true);
271+
setError(null);
272+
try {
273+
const result = await client.organizations.update(organizationId, data);
274+
await reloadOrganizations();
275+
await refresh();
276+
return result;
277+
} catch (err) {
278+
setError(err as Error);
279+
throw err;
280+
} finally {
281+
setUpdating(false);
282+
}
283+
},
284+
[client, reloadOrganizations, refresh],
285+
);
286+
287+
return { update, updating, error };
288+
}
289+
251290
/**
252291
* Hook: delete an organization via better-auth.
253292
*

0 commit comments

Comments
 (0)