diff --git a/src/AuthProvider.tsx b/src/AuthProvider.tsx index eb2ba01..28deb85 100644 --- a/src/AuthProvider.tsx +++ b/src/AuthProvider.tsx @@ -11,7 +11,7 @@ import { StepUpVerificationResult, } from '@/client/createSeamlessAuthClient'; import { PasskeyPrfInput } from '@/client/webauthnPrf'; -import { Credential, User } from '@/types'; +import { Credential, Organization, User } from '@/types'; import React, { createContext, ReactNode, @@ -37,6 +37,9 @@ export interface AuthContextType { hasSignedInBefore: boolean; mode: AuthMode; credentials: Credential[]; + organizations: Organization[]; + activeOrganization: Organization | null; + switchOrganization: (organizationId: string) => Promise; stepUpStatus: StepUpStatus | null; updateCredential: (credential: Credential) => Promise; deleteCredential: (credentialId: string) => Promise; @@ -79,6 +82,8 @@ export const AuthProvider: React.FC = ({ }) => { const [user, setUser] = useState(null); const [credentials, setCredentials] = useState([]); + const [organizations, setOrganizations] = useState([]); + const [activeOrganization, setActiveOrganization] = useState(null); const [stepUpStatus, setStepUpStatus] = useState(null); const [isAuthenticated, setIsAuthenticated] = useState(false); const [loading, setLoading] = useState(true); @@ -129,6 +134,8 @@ export const AuthProvider: React.FC = ({ setIsAuthenticated(false); setUser(null); setCredentials([]); + setOrganizations([]); + setActiveOrganization(null); setStepUpStatus(null); } }, [authClient]); @@ -141,6 +148,8 @@ export const AuthProvider: React.FC = ({ setUser(null); setIsAuthenticated(false); setCredentials([]); + setOrganizations([]); + setActiveOrganization(null); setStepUpStatus(null); return; } else { @@ -160,9 +169,12 @@ export const AuthProvider: React.FC = ({ const response = await authClient.getCurrentUser(); if (response.ok) { - const { user, credentials } = await response.json(); + const { user, credentials, organizations, activeOrganization } = + await response.json(); setUser(user); setCredentials(credentials ?? []); + setOrganizations(organizations ?? []); + setActiveOrganization(activeOrganization ?? null); setIsAuthenticated(true); } else { @@ -213,6 +225,16 @@ export const AuthProvider: React.FC = ({ throw new Error('Failed to delete credential'); }; + const switchOrganization = async (organizationId: string) => { + const response = await authClient.switchOrganization(organizationId); + + if (!response.ok) { + throw new Error('Failed to switch organization'); + } + + await validateToken(); + }; + const refreshStepUpStatus = useCallback(async () => { const response = await authClient.getStepUpStatus(); @@ -286,6 +308,9 @@ export const AuthProvider: React.FC = ({ hasSignedInBefore: autoDetectPreviousSignin ? hasSignedInBefore : false, mode: authMode, credentials, + organizations, + activeOrganization, + switchOrganization, stepUpStatus, updateCredential, deleteCredential, diff --git a/src/client/createSeamlessAuthClient.ts b/src/client/createSeamlessAuthClient.ts index 8f8c55f..61ac43b 100644 --- a/src/client/createSeamlessAuthClient.ts +++ b/src/client/createSeamlessAuthClient.ts @@ -13,7 +13,7 @@ import { } from '@simplewebauthn/browser'; import { AuthMode, createFetchWithAuth } from '../fetchWithAuth'; -import { Credential, User } from '../types'; +import { Credential, Organization, OrganizationMembership, User } from '../types'; import { createPrfRequestBody, extractPasskeyPrfResult, @@ -59,6 +59,46 @@ export interface PasskeyMetadata { export interface CurrentUserResult { user: User; credentials: Credential[]; + organizations?: Organization[]; + activeOrganization?: Organization | null; +} + +export interface CreateOrganizationInput { + name: string; + slug?: string; + metadata?: Record | null; +} + +export interface UpdateOrganizationInput { + name?: string; + slug?: string; + metadata?: Record | null; +} + +export interface OrganizationMemberInput { + userId?: string; + email?: string; + roles?: string[]; + scopes?: string[]; +} + +export interface OrganizationMemberUpdateInput { + roles?: string[]; + scopes?: string[]; +} + +export interface OrganizationsResult { + organizations: Organization[]; + activeOrganizationId?: string | null; +} + +export interface OrganizationResult { + organization: Organization; +} + +export interface OrganizationMembersResult { + members: OrganizationMembership[]; + total: number; } export interface PasskeyLoginResult { @@ -140,6 +180,28 @@ export interface SeamlessAuthClient { friendlyName: string | null; }) => Promise; deleteCredential: (id: string) => Promise; + listOrganizations: () => Promise; + createOrganization: (input: CreateOrganizationInput) => Promise; + getOrganization: (organizationId: string) => Promise; + updateOrganization: ( + organizationId: string, + input: UpdateOrganizationInput + ) => Promise; + switchOrganization: (organizationId: string) => Promise; + listOrganizationMembers: (organizationId: string) => Promise; + addOrganizationMember: ( + organizationId: string, + input: OrganizationMemberInput + ) => Promise; + updateOrganizationMember: ( + organizationId: string, + userId: string, + input: OrganizationMemberUpdateInput + ) => Promise; + removeOrganizationMember: ( + organizationId: string, + userId: string + ) => Promise; } const staleStepUpResult = (message: string): StepUpVerificationResult => ({ @@ -575,5 +637,60 @@ export const createSeamlessAuthClient = ( headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id }), }), + + listOrganizations: () => + fetchWithAuth(`/organizations`, { + method: 'GET', + }), + + createOrganization: input => + fetchWithAuth(`/organizations`, { + method: 'POST', + body: JSON.stringify(input), + }), + + getOrganization: organizationId => + fetchWithAuth(`/organizations/${encodeURIComponent(organizationId)}`, { + method: 'GET', + }), + + updateOrganization: (organizationId, input) => + fetchWithAuth(`/organizations/${encodeURIComponent(organizationId)}`, { + method: 'PATCH', + body: JSON.stringify(input), + }), + + switchOrganization: organizationId => + fetchWithAuth(`/organizations/${encodeURIComponent(organizationId)}/switch`, { + method: 'POST', + }), + + listOrganizationMembers: organizationId => + fetchWithAuth(`/organizations/${encodeURIComponent(organizationId)}/members`, { + method: 'GET', + }), + + addOrganizationMember: (organizationId, input) => + fetchWithAuth(`/organizations/${encodeURIComponent(organizationId)}/members`, { + method: 'POST', + body: JSON.stringify(input), + }), + + updateOrganizationMember: (organizationId, userId, input) => + fetchWithAuth( + `/organizations/${encodeURIComponent(organizationId)}/members/${encodeURIComponent(userId)}`, + { + method: 'PATCH', + body: JSON.stringify(input), + } + ), + + removeOrganizationMember: (organizationId, userId) => + fetchWithAuth( + `/organizations/${encodeURIComponent(organizationId)}/members/${encodeURIComponent(userId)}`, + { + method: 'DELETE', + } + ), }; }; diff --git a/src/index.ts b/src/index.ts index da01401..14ac92a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,10 +8,16 @@ import { AuthContextType, AuthProvider, useAuth } from '@/AuthProvider'; import { AuthRoutes } from '@/AuthRoutes'; import { createSeamlessAuthClient, + CreateOrganizationInput, CurrentUserResult, LoginInput, LoginMethod, LoginStartResult, + OrganizationMemberInput, + OrganizationMemberUpdateInput, + OrganizationMembersResult, + OrganizationResult, + OrganizationsResult, PasskeyLoginResult, PasskeyLoginWithPrfResult, PasskeyMetadata, @@ -24,6 +30,7 @@ import { StepUpStatus, StepUpWithPasskeyPrfResult, StepUpVerificationResult, + UpdateOrganizationInput, } from '@/client/createSeamlessAuthClient'; import { encodePrfSalt, @@ -35,7 +42,7 @@ import { import { AuthMode } from '@/fetchWithAuth'; import { useAuthClient } from '@/hooks/useAuthClient'; import { usePasskeySupport } from '@/hooks/usePasskeySupport'; -import { Credential, User } from '@/types'; +import { Credential, Organization, OrganizationMembership, User } from '@/types'; export { AuthProvider, @@ -52,10 +59,18 @@ export type { AuthContextType, AuthMode, Credential, + CreateOrganizationInput, CurrentUserResult, LoginInput, LoginMethod, LoginStartResult, + Organization, + OrganizationMemberInput, + OrganizationMembership, + OrganizationMemberUpdateInput, + OrganizationMembersResult, + OrganizationResult, + OrganizationsResult, PasskeyLoginWithPrfResult, PasskeyLoginResult, PasskeyMetadata, @@ -70,5 +85,6 @@ export type { StepUpStatus, StepUpWithPasskeyPrfResult, StepUpVerificationResult, + UpdateOrganizationInput, User, }; diff --git a/src/types.ts b/src/types.ts index 3ea511e..e1eeda9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -14,6 +14,30 @@ export interface User { email: string; phone: string; roles?: string[]; + activeOrganizationId?: string | null; +} + +export interface OrganizationMembership { + id: string; + organizationId: string; + userId: string; + roles: string[]; + scopes: string[]; + createdAt: string | Date; + updatedAt: string | Date; + user?: User; +} + +export interface Organization { + id: string; + name: string; + slug: string; + createdByUserId: string | null; + metadata: Record | null; + createdAt: string | Date; + updatedAt: string | Date; + membership?: OrganizationMembership; + memberCount?: number; } export interface Credential { diff --git a/tests/createSeamlessAuthClient.test.ts b/tests/createSeamlessAuthClient.test.ts index 82aee96..96ffcac 100644 --- a/tests/createSeamlessAuthClient.test.ts +++ b/tests/createSeamlessAuthClient.test.ts @@ -120,6 +120,44 @@ describe('createSeamlessAuthClient', () => { ); }); + it('uses organization endpoints', async () => { + const response = { ok: true }; + mockFetchWithAuth.mockResolvedValue(response); + + const client = createSeamlessAuthClient({ + apiHost: 'https://api.example.com', + mode: 'server', + }); + + await expect(client.listOrganizations()).resolves.toBe(response); + await expect(client.createOrganization({ name: 'Acme' })).resolves.toBe(response); + await expect(client.switchOrganization('org 1')).resolves.toBe(response); + await expect( + client.addOrganizationMember('org 1', { email: 'member@example.com' }) + ).resolves.toBe(response); + + expect(mockFetchWithAuth).toHaveBeenNthCalledWith(1, '/organizations', { + method: 'GET', + }); + expect(mockFetchWithAuth).toHaveBeenNthCalledWith(2, '/organizations', { + method: 'POST', + body: JSON.stringify({ name: 'Acme' }), + }); + expect(mockFetchWithAuth).toHaveBeenNthCalledWith( + 3, + '/organizations/org%201/switch', + { method: 'POST' } + ); + expect(mockFetchWithAuth).toHaveBeenNthCalledWith( + 4, + '/organizations/org%201/members', + { + method: 'POST', + body: JSON.stringify({ email: 'member@example.com' }), + } + ); + }); + it('returns a successful passkey login result when both WebAuthn steps succeed', async () => { mockFetchWithAuth .mockResolvedValueOnce({