Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 27 additions & 2 deletions src/AuthProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -37,6 +37,9 @@ export interface AuthContextType {
hasSignedInBefore: boolean;
mode: AuthMode;
credentials: Credential[];
organizations: Organization[];
activeOrganization: Organization | null;
switchOrganization: (organizationId: string) => Promise<void>;
stepUpStatus: StepUpStatus | null;
updateCredential: (credential: Credential) => Promise<Credential>;
deleteCredential: (credentialId: string) => Promise<void>;
Expand Down Expand Up @@ -79,6 +82,8 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({
}) => {
const [user, setUser] = useState<User | null>(null);
const [credentials, setCredentials] = useState<Credential[]>([]);
const [organizations, setOrganizations] = useState<Organization[]>([]);
const [activeOrganization, setActiveOrganization] = useState<Organization | null>(null);
const [stepUpStatus, setStepUpStatus] = useState<StepUpStatus | null>(null);
const [isAuthenticated, setIsAuthenticated] = useState<boolean>(false);
const [loading, setLoading] = useState<boolean>(true);
Expand Down Expand Up @@ -129,6 +134,8 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({
setIsAuthenticated(false);
setUser(null);
setCredentials([]);
setOrganizations([]);
setActiveOrganization(null);
setStepUpStatus(null);
}
}, [authClient]);
Expand All @@ -141,6 +148,8 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({
setUser(null);
setIsAuthenticated(false);
setCredentials([]);
setOrganizations([]);
setActiveOrganization(null);
setStepUpStatus(null);
return;
} else {
Expand All @@ -160,9 +169,12 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({
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 {
Expand Down Expand Up @@ -213,6 +225,16 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({
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();

Expand Down Expand Up @@ -286,6 +308,9 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({
hasSignedInBefore: autoDetectPreviousSignin ? hasSignedInBefore : false,
mode: authMode,
credentials,
organizations,
activeOrganization,
switchOrganization,
stepUpStatus,
updateCredential,
deleteCredential,
Expand Down
119 changes: 118 additions & 1 deletion src/client/createSeamlessAuthClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<string, unknown> | null;
}

export interface UpdateOrganizationInput {
name?: string;
slug?: string;
metadata?: Record<string, unknown> | 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 {
Expand Down Expand Up @@ -140,6 +180,28 @@ export interface SeamlessAuthClient {
friendlyName: string | null;
}) => Promise<Response>;
deleteCredential: (id: string) => Promise<Response>;
listOrganizations: () => Promise<Response>;
createOrganization: (input: CreateOrganizationInput) => Promise<Response>;
getOrganization: (organizationId: string) => Promise<Response>;
updateOrganization: (
organizationId: string,
input: UpdateOrganizationInput
) => Promise<Response>;
switchOrganization: (organizationId: string) => Promise<Response>;
listOrganizationMembers: (organizationId: string) => Promise<Response>;
addOrganizationMember: (
organizationId: string,
input: OrganizationMemberInput
) => Promise<Response>;
updateOrganizationMember: (
organizationId: string,
userId: string,
input: OrganizationMemberUpdateInput
) => Promise<Response>;
removeOrganizationMember: (
organizationId: string,
userId: string
) => Promise<Response>;
}

const staleStepUpResult = (message: string): StepUpVerificationResult => ({
Expand Down Expand Up @@ -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',
}
),
};
};
18 changes: 17 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -24,6 +30,7 @@ import {
StepUpStatus,
StepUpWithPasskeyPrfResult,
StepUpVerificationResult,
UpdateOrganizationInput,
} from '@/client/createSeamlessAuthClient';
import {
encodePrfSalt,
Expand All @@ -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,
Expand All @@ -52,10 +59,18 @@ export type {
AuthContextType,
AuthMode,
Credential,
CreateOrganizationInput,
CurrentUserResult,
LoginInput,
LoginMethod,
LoginStartResult,
Organization,
OrganizationMemberInput,
OrganizationMembership,
OrganizationMemberUpdateInput,
OrganizationMembersResult,
OrganizationResult,
OrganizationsResult,
PasskeyLoginWithPrfResult,
PasskeyLoginResult,
PasskeyMetadata,
Expand All @@ -70,5 +85,6 @@ export type {
StepUpStatus,
StepUpWithPasskeyPrfResult,
StepUpVerificationResult,
UpdateOrganizationInput,
User,
};
24 changes: 24 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> | null;
createdAt: string | Date;
updatedAt: string | Date;
membership?: OrganizationMembership;
memberCount?: number;
}

export interface Credential {
Expand Down
38 changes: 38 additions & 0 deletions tests/createSeamlessAuthClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Loading