Skip to content

Commit 170ecfa

Browse files
committed
feat: add OAuth application management features
- Introduced new hooks for managing OAuth applications, including listing, registering, and deleting applications. - Created routes for displaying a list of OAuth applications, application details, and a registration form. - Implemented consent screen for OAuth authorization requests. - Added new system objects for OAuth access tokens, applications, and consent records. - Updated tests to include new system object names for OAuth-related constants.
1 parent aab77d5 commit 170ecfa

21 files changed

Lines changed: 1701 additions & 8 deletions

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

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
/**
44
* AccountSidebar — global left navigation for the Account portal.
55
*
6-
* Two semantic groups:
6+
* Three semantic groups:
77
*
88
* Account
99
* ├─ Profile
@@ -16,6 +16,9 @@
1616
* ├─ General (/organizations/:id/general — only when an org is active)
1717
* └─ Members (/organizations/:id/members — only when an org is active)
1818
*
19+
* Developer
20+
* └─ OAuth Apps (/account/oauth-applications)
21+
*
1922
* The active org's name is intentionally NOT used as a group label —
2023
* the top-bar OrganizationSwitcher already shows it prominently. When
2124
* collapsed to icon-only the labels hide automatically.
@@ -24,6 +27,7 @@
2427
import { Link, useLocation } from '@tanstack/react-router';
2528
import {
2629
Building2,
30+
KeyRound,
2731
Monitor,
2832
PanelLeft,
2933
Settings,
@@ -53,6 +57,7 @@ interface NavItem {
5357
| '/account/security'
5458
| '/account/sessions'
5559
| '/account/two-factor'
60+
| '/account/oauth-applications'
5661
| '/organizations';
5762
label: string;
5863
icon: React.ComponentType<{ className?: string }>;
@@ -105,8 +110,6 @@ export function AccountSidebar() {
105110

106111
<SidebarSeparator />
107112

108-
<SidebarSeparator />
109-
110113
<SidebarGroup>
111114
<SidebarGroupLabel>Organization</SidebarGroupLabel>
112115
<SidebarGroupContent>
@@ -154,6 +157,28 @@ export function AccountSidebar() {
154157
</SidebarMenu>
155158
</SidebarGroupContent>
156159
</SidebarGroup>
160+
161+
<SidebarSeparator />
162+
163+
<SidebarGroup>
164+
<SidebarGroupLabel>Developer</SidebarGroupLabel>
165+
<SidebarGroupContent>
166+
<SidebarMenu>
167+
<SidebarMenuItem>
168+
<SidebarMenuButton
169+
asChild
170+
isActive={pathname.startsWith('/account/oauth-applications')}
171+
tooltip="OAuth Apps"
172+
>
173+
<Link to="/account/oauth-applications">
174+
<KeyRound className="size-4" />
175+
<span>OAuth Apps</span>
176+
</Link>
177+
</SidebarMenuButton>
178+
</SidebarMenuItem>
179+
</SidebarMenu>
180+
</SidebarGroupContent>
181+
</SidebarGroup>
157182
</SidebarContent>
158183
<SidebarFooter>
159184
<SidebarMenu>
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* React hooks for managing OAuth/OIDC client applications.
5+
*
6+
* Wraps `client.oauth.applications.*` (better-auth `oidc-provider` plugin).
7+
* Used by the OAuth Apps pages under /account/oauth-applications.
8+
*/
9+
10+
import { useCallback, useEffect, useState } from 'react';
11+
import { useClient } from '@objectstack/client-react';
12+
13+
export interface OAuthApplication {
14+
id: string;
15+
name: string;
16+
client_id: string;
17+
client_secret?: string | null;
18+
redirect_urls: string;
19+
type: 'web' | 'native' | 'user-agent-based' | 'public';
20+
disabled?: boolean;
21+
icon?: string | null;
22+
metadata?: string | null;
23+
user_id?: string | null;
24+
created_at?: string;
25+
updated_at?: string;
26+
}
27+
28+
/**
29+
* Hook: list the current user's OAuth client applications.
30+
*/
31+
export function useOAuthApplications() {
32+
const client = useClient() as any;
33+
const [applications, setApplications] = useState<OAuthApplication[]>([]);
34+
const [loading, setLoading] = useState(false);
35+
const [error, setError] = useState<Error | null>(null);
36+
37+
const reload = useCallback(async () => {
38+
if (!client?.oauth?.applications) return;
39+
setLoading(true);
40+
setError(null);
41+
try {
42+
const res = await client.oauth.applications.list();
43+
setApplications(res?.applications ?? []);
44+
} catch (err) {
45+
setError(err as Error);
46+
setApplications([]);
47+
} finally {
48+
setLoading(false);
49+
}
50+
}, [client]);
51+
52+
useEffect(() => {
53+
reload();
54+
}, [reload]);
55+
56+
return { applications, loading, error, reload };
57+
}
58+
59+
/**
60+
* Hook: register a new OAuth client application.
61+
*
62+
* Returns `{ register, registering, error, lastResult }` where `lastResult`
63+
* holds the response of the most recent successful registration — including
64+
* the freshly issued `client_secret`, which is only returned once.
65+
*/
66+
export function useRegisterOAuthApplication() {
67+
const client = useClient() as any;
68+
const [registering, setRegistering] = useState(false);
69+
const [error, setError] = useState<Error | null>(null);
70+
const [lastResult, setLastResult] = useState<any>(null);
71+
72+
const register = useCallback(
73+
async (req: {
74+
client_name: string;
75+
redirect_uris: string[];
76+
token_endpoint_auth_method?: 'none' | 'client_secret_basic' | 'client_secret_post';
77+
grant_types?: string[];
78+
response_types?: string[];
79+
client_uri?: string;
80+
logo_uri?: string;
81+
scope?: string;
82+
contacts?: string[];
83+
tos_uri?: string;
84+
policy_uri?: string;
85+
metadata?: Record<string, unknown>;
86+
}) => {
87+
if (!client?.oauth?.applications?.register) throw new Error('Client not ready');
88+
setRegistering(true);
89+
setError(null);
90+
try {
91+
const result = await client.oauth.applications.register(req);
92+
setLastResult(result);
93+
return result;
94+
} catch (err) {
95+
setError(err as Error);
96+
throw err;
97+
} finally {
98+
setRegistering(false);
99+
}
100+
},
101+
[client],
102+
);
103+
104+
return { register, registering, error, lastResult };
105+
}
106+
107+
/**
108+
* Hook: delete (revoke) an OAuth client application by row id.
109+
*/
110+
export function useDeleteOAuthApplication() {
111+
const client = useClient() as any;
112+
const [deleting, setDeleting] = useState(false);
113+
const [error, setError] = useState<Error | null>(null);
114+
115+
const remove = useCallback(
116+
async (id: string) => {
117+
if (!client?.oauth?.applications?.delete) throw new Error('Client not ready');
118+
setDeleting(true);
119+
setError(null);
120+
try {
121+
return await client.oauth.applications.delete(id);
122+
} catch (err) {
123+
setError(err as Error);
124+
throw err;
125+
} finally {
126+
setDeleting(false);
127+
}
128+
},
129+
[client],
130+
);
131+
132+
return { remove, deleting, error };
133+
}
134+
135+
/**
136+
* Hook: submit a consent decision to a pending OAuth authorization request.
137+
*/
138+
export function useOAuthConsent() {
139+
const client = useClient() as any;
140+
const [submitting, setSubmitting] = useState(false);
141+
const [error, setError] = useState<Error | null>(null);
142+
143+
const submit = useCallback(
144+
async (req: { accept: boolean; consent_code?: string }) => {
145+
if (!client?.oauth?.consent) throw new Error('Client not ready');
146+
setSubmitting(true);
147+
setError(null);
148+
try {
149+
return await client.oauth.consent(req);
150+
} catch (err) {
151+
setError(err as Error);
152+
throw err;
153+
} finally {
154+
setSubmitting(false);
155+
}
156+
},
157+
[client],
158+
);
159+
160+
return { submit, submitting, error };
161+
}

0 commit comments

Comments
 (0)