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
24 changes: 24 additions & 0 deletions .changeset/provider-result-convention.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
'@seamless-auth/react': minor
---

Unify `useAuth()` on the same result convention as the headless client. Every provider helper now returns `SeamlessAuthResult` and none of them throw, so the whole SDK reports failure one way.

```tsx
const { error } = await updateCredential({ ...credential, friendlyName: 'Work laptop' });
if (error) {
setMessage(error.message);
}
```

BREAKING: `useAuth()` previously mixed four styles. Seven helpers threw, four returned a result, `handlePasskeyLogin` returned a boolean, and `refreshStepUpStatus` returned `StepUpStatus | null`.

Migration:

- `deleteUser`, `updateCredential`, `deleteCredential`, `switchOrganization`, `listOAuthProviders`, `startOAuthLogin`, and `finishOAuthLogin` no longer throw. Replace `try`/`catch` with an `error` check.
- `handlePasskeyLogin` returns a result instead of a boolean. Replace `if (await handlePasskeyLogin())` with `if (!(await handlePasskeyLogin()).error)`.
- `refreshStepUpStatus` returns a result instead of `StepUpStatus | null`. Read `data` instead of the return value directly.
- `updateCredential` returns the credential under `data`.
- `logout`, `logoutAllSessions`, and `refreshSession` now return a result. Existing callers that ignore the return value keep working.

Helpers that mutate provider state still do so only when the call succeeds.
63 changes: 34 additions & 29 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,21 +114,21 @@ You are still responsible for your app’s route protection and redirects.
markSignedIn(): void;
hasRole(role: string): boolean | undefined;
hasScopedRole(role: string | string[]): boolean | undefined;
listOAuthProviders(): Promise<OAuthProvidersResult>;
startOAuthLogin(input: StartOAuthLoginInput): Promise<StartOAuthLoginResult>;
finishOAuthLogin(input: FinishOAuthLoginInput): Promise<void>;
refreshSession(): Promise<void>;
refreshStepUpStatus(): Promise<StepUpStatus | null>;
listOAuthProviders(): Promise<SeamlessAuthResult<OAuthProvidersResult>>;
startOAuthLogin(input: StartOAuthLoginInput): Promise<SeamlessAuthResult<StartOAuthLoginResult>>;
finishOAuthLogin(input: FinishOAuthLoginInput): Promise<SeamlessAuthResult<MessageResult>>;
refreshSession(): Promise<SeamlessAuthResult<CurrentUserResult>>;
refreshStepUpStatus(): Promise<SeamlessAuthResult<StepUpStatus>>;
verifyStepUpWithPasskey(): Promise<SeamlessAuthResult<StepUpStatus>>;
verifyStepUpWithPasskeyPrf(input: PasskeyPrfInput): Promise<SeamlessAuthResult<StepUpPrfData>>;
verifyStepUpWithTotp(code: string): Promise<SeamlessAuthResult<StepUpStatus>>;
logout(): Promise<void>;
logoutAllSessions(): Promise<void>;
deleteUser(): Promise<void>;
logout(): Promise<SeamlessAuthResult<MessageResult>>;
logoutAllSessions(): Promise<SeamlessAuthResult<MessageResult>>;
deleteUser(): Promise<SeamlessAuthResult<MessageResult>>;
login(identifier: string, passkeyAvailable: boolean): Promise<SeamlessAuthResult<LoginStartResult>>;
handlePasskeyLogin(): Promise<boolean>;
updateCredential(credential: Credential): Promise<Credential>;
deleteCredential(credentialId: string): Promise<void>;
handlePasskeyLogin(): Promise<SeamlessAuthResult<PasskeyLoginData>>;
updateCredential(credential: Credential): Promise<SeamlessAuthResult<Credential>>;
deleteCredential(credentialId: string): Promise<SeamlessAuthResult<MessageResult>>;
}
```

Expand Down Expand Up @@ -201,7 +201,7 @@ function DeleteAccountButton() {
const { refreshStepUpStatus, verifyStepUpWithPasskey } = useAuth();

async function handleDeleteAccount() {
const status = await refreshStepUpStatus();
const { data: status } = await refreshStepUpStatus();
const fresh = status?.fresh ? true : !(await verifyStepUpWithPasskey()).error;

if (!fresh) {
Expand Down Expand Up @@ -511,16 +511,21 @@ function CustomLogin() {
}
```

### Two error styles, on purpose
### One error style everywhere

The headless client and the provider helpers report failure differently:
`useAuth()` helpers and the headless client report failure the same way: both return
`{ data, error }` and neither throws. Whatever surface you reach for, the handling is identical.

- `useAuthClient()` and `createSeamlessAuthClient()` return `{ data, error }` and never throw.
- `useAuth()` helpers such as `updateCredential`, `deleteCredential`, `switchOrganization`,
`finishOAuthLogin`, `listOAuthProviders`, and `startOAuthLogin` **throw** a `SeamlessAuthError`.
```tsx
const { error } = await updateCredential({ ...credential, friendlyName: 'Work laptop' });

if (error) {
setMessage(error.message);
}
```

The provider helpers throw because they also mutate provider state, so there is no partial success to
hand back. Wrap those in `try`/`catch`, and check `error` on client calls.
Helpers that also mutate provider state, such as `switchOrganization` and `deleteCredential`, apply
that state change only when the call succeeds, then hand the result back for you to inspect.

## Custom UI Recipes

Expand Down Expand Up @@ -679,10 +684,10 @@ path.
### Credential management

`useAuth()` exposes the signed-in user's passkeys plus helpers to rename and remove them. These
helpers update provider state and throw on failure.
helpers update provider state on success and report failure through `error`.

```tsx
import { SeamlessAuthError, useAuth } from '@seamless-auth/react';
import { useAuth } from '@seamless-auth/react';
import type { Credential } from '@seamless-auth/react';
import { useState } from 'react';

Expand All @@ -691,18 +696,18 @@ function PasskeyList() {
const [message, setMessage] = useState('');

async function rename(credential: Credential, friendlyName: string) {
try {
await updateCredential({ ...credential, friendlyName });
} catch (error) {
setMessage(error instanceof SeamlessAuthError ? error.message : 'Rename failed.');
const { error } = await updateCredential({ ...credential, friendlyName });

if (error) {
setMessage(error.message);
}
}

async function remove(credentialId: string) {
try {
await deleteCredential(credentialId);
} catch (error) {
setMessage(error instanceof SeamlessAuthError ? error.message : 'Removal failed.');
const { error } = await deleteCredential(credentialId);

if (error) {
setMessage(error.message);
}
}

Expand Down
137 changes: 64 additions & 73 deletions src/AuthProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,13 @@

import {
createSeamlessAuthClient,
CurrentUserResult,
FinishOAuthLoginInput,
LoginStartResult,
MessageResult,
OAuthProvidersResult,
OrganizationSwitchResult,
PasskeyLoginData,
StartOAuthLoginInput,
StartOAuthLoginResult,
StepUpPrfData,
Expand All @@ -32,10 +36,10 @@ import { hasScopedRole as rolesGrantScopedAccess } from './scopedRoles';

export interface AuthContextType {
user: User | null;
logout: () => Promise<void>;
logoutAllSessions: () => Promise<void>;
deleteUser: () => Promise<void>;
refreshSession: () => Promise<void>;
logout: () => Promise<SeamlessAuthResult<MessageResult>>;
logoutAllSessions: () => Promise<SeamlessAuthResult<MessageResult>>;
deleteUser: () => Promise<SeamlessAuthResult<MessageResult>>;
refreshSession: () => Promise<SeamlessAuthResult<CurrentUserResult>>;
isAuthenticated: boolean;
hasRole: (role: string) => boolean | undefined;
hasScopedRole: (role: string | string[]) => boolean | undefined;
Expand All @@ -45,19 +49,25 @@ export interface AuthContextType {
credentials: Credential[];
organizations: Organization[];
activeOrganization: Organization | null;
switchOrganization: (organizationId: string) => Promise<void>;
listOAuthProviders: () => Promise<OAuthProvidersResult>;
startOAuthLogin: (input: StartOAuthLoginInput) => Promise<StartOAuthLoginResult>;
finishOAuthLogin: (input: FinishOAuthLoginInput) => Promise<void>;
switchOrganization: (
organizationId: string
) => Promise<SeamlessAuthResult<OrganizationSwitchResult>>;
listOAuthProviders: () => Promise<SeamlessAuthResult<OAuthProvidersResult>>;
startOAuthLogin: (
input: StartOAuthLoginInput
) => Promise<SeamlessAuthResult<StartOAuthLoginResult>>;
finishOAuthLogin: (
input: FinishOAuthLoginInput
) => Promise<SeamlessAuthResult<MessageResult>>;
stepUpStatus: StepUpStatus | null;
updateCredential: (credential: Credential) => Promise<Credential>;
deleteCredential: (credentialId: string) => Promise<void>;
updateCredential: (credential: Credential) => Promise<SeamlessAuthResult<Credential>>;
deleteCredential: (credentialId: string) => Promise<SeamlessAuthResult<MessageResult>>;
login: (
identifier: string,
passkeyAvailable: boolean
) => Promise<SeamlessAuthResult<LoginStartResult>>;
handlePasskeyLogin: () => Promise<boolean>;
refreshStepUpStatus: () => Promise<StepUpStatus | null>;
handlePasskeyLogin: () => Promise<SeamlessAuthResult<PasskeyLoginData>>;
refreshStepUpStatus: () => Promise<SeamlessAuthResult<StepUpStatus>>;
verifyStepUpWithPasskey: () => Promise<SeamlessAuthResult<StepUpStatus>>;
verifyStepUpWithPasskeyPrf: (
input: PasskeyPrfInput
Expand Down Expand Up @@ -112,15 +122,13 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({
authClient.login({ identifier, passkeyAvailable });

const handlePasskeyLogin = async () => {
const { error } = await authClient.loginWithPasskey();
const result = await authClient.loginWithPasskey();

if (error) {
console.error('Passkey login failed.');
return false;
if (!result.error) {
await validateToken();
}

await validateToken();
return true;
return result;
};

const resetAuthState = useCallback(() => {
Expand All @@ -138,7 +146,7 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({
// when the server call fails, otherwise the UI keeps presenting a signed-in
// user whose session is already gone.
try {
await authClient.logout();
return await authClient.logout();
} finally {
resetAuthState();
}
Expand All @@ -150,21 +158,20 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({
// when the server call fails, otherwise the UI keeps presenting a signed-in
// user whose session is already gone.
try {
await authClient.logoutAllSessions();
return await authClient.logoutAllSessions();
} finally {
resetAuthState();
}
}, [authClient, resetAuthState]);

const deleteUser = async () => {
const { error } = await authClient.deleteUser();
const result = await authClient.deleteUser();

if (error) {
console.error('Something went wrong deleting user.');
throw error;
if (!result.error) {
resetAuthState();
}

resetAuthState();
return result;
};

const hasRole = (role: string) => user?.roles?.includes(role);
Expand All @@ -174,20 +181,23 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({
const validateToken = useCallback(async () => {
setLoading(true);

const { data, error } = await authClient.getCurrentUser();
const result = await authClient.getCurrentUser();

if (error) {
if (result.error) {
// An unusable session is cleared rather than left half-applied.
await logout();
setLoading(false);
return;
return result;
}

setUser(data.user);
setCredentials(data.credentials ?? []);
setOrganizations(data.organizations ?? []);
setActiveOrganization(data.activeOrganization ?? null);
setUser(result.data.user);
setCredentials(result.data.credentials ?? []);
setOrganizations(result.data.organizations ?? []);
setActiveOrganization(result.data.activeOrganization ?? null);
setIsAuthenticated(true);
setLoading(false);

return result;
}, [authClient, logout]);

const updateCredential = async (credential: Credential) => {
Expand All @@ -197,7 +207,7 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({
});

if (error) {
throw error;
return { data: null, error };
}

const updatedCredential = data.credential;
Expand All @@ -210,71 +220,52 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({
)
);

return updatedCredential;
return { data: updatedCredential, error: null };
};

const deleteCredential = async (credentialId: string) => {
const { error } = await authClient.deleteCredential(credentialId);
const result = await authClient.deleteCredential(credentialId);

if (error) {
throw error;
if (!result.error) {
setCredentials(currentCredentials =>
currentCredentials.filter(credential => credential.id !== credentialId)
);
}

setCredentials(currentCredentials =>
currentCredentials.filter(credential => credential.id !== credentialId)
);
return result;
};

const switchOrganization = async (organizationId: string) => {
const { error } = await authClient.switchOrganization(organizationId);
const result = await authClient.switchOrganization(organizationId);

if (error) {
throw error;
}

await validateToken();
};

const listOAuthProviders = async () => {
const { data, error } = await authClient.listOAuthProviders();

if (error) {
throw error;
if (!result.error) {
await validateToken();
}

return data;
return result;
};

const startOAuthLogin = async (input: StartOAuthLoginInput) => {
const { data, error } = await authClient.startOAuthLogin(input);
const listOAuthProviders = () => authClient.listOAuthProviders();

if (error) {
throw error;
}

return data;
};
const startOAuthLogin = (input: StartOAuthLoginInput) =>
authClient.startOAuthLogin(input);

const finishOAuthLogin = async (input: FinishOAuthLoginInput) => {
const { error } = await authClient.finishOAuthLogin(input);
const result = await authClient.finishOAuthLogin(input);

if (error) {
throw error;
if (!result.error) {
await validateToken();
}

await validateToken();
return result;
};

const refreshStepUpStatus = useCallback(async () => {
const { data, error } = await authClient.getStepUpStatus();
const result = await authClient.getStepUpStatus();

if (error) {
setStepUpStatus(null);
return null;
}
setStepUpStatus(result.error ? null : result.data);

setStepUpStatus(data);
return data;
return result;
}, [authClient]);

const verifyStepUpWithPasskey = useCallback(async () => {
Expand Down
Loading
Loading