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
72 changes: 66 additions & 6 deletions docs/content/docs/ui/components.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -63,11 +63,69 @@ Ensure Tailwind CSS is configured in your project. The components use Tailwind u
| `LoadingSpinner` | Animated loading indicator |
| `ErrorDisplay` | Styled error message display |

### Session components

| Component | Description |
|-----------|-------------|
| `LastUsedAccount` | Who signed in last on this browser, as a one-click shortcut |
| `AccountSummaryCard` | Who you are, when you last signed in, and from where |

`LastUsedAccount` reads a record this browser keeps in `localStorage`, written on every successful sign-in and kept for ninety days. Signing out does not clear it: being remembered is the point. It renders nothing when there is no record, so you can drop it at the top of a sign-in form unconditionally, and `forgetLastAccount()` clears it.

It is a convenience and never an authorization. Nothing in that record is trusted, and the person it names still has to authenticate.

`AccountSummaryCard` takes a `summary` prop so you can render it from your own data, and falls back to `useLastSignIn()` when you leave it out.

### Primitives

All Radix-based primitives are exported for building custom components:

`Button`, `Card`, `Input`, `Label`, `Alert`, `Avatar`, `Badge`, `Checkbox`, `Dialog`, `DropdownMenu`, `InputOTP`, `Select`, `Separator`, `Sheet`, `Skeleton`, `Switch`, `Tabs`, `Tooltip`
`Button`, `Card`, `Input`, `Label`, `Alert`, `Avatar`, `Badge`, `Checkbox`, `Dialog`, `DropdownMenu`, `InputOTP`, `InputGroup`, `PhoneInput`, `Select`, `Separator`, `Sheet`, `Skeleton`, `Switch`, `Tabs`, `Tooltip`, `VerificationCodeInput`

#### VerificationCodeInput

One field for every one-time code, replacing the hand-assembled `InputOTP` arrangements the auth forms used to carry.

```tsx
<VerificationCodeInput
length={6}
mode="numeric" // or "alphanumeric", which upper-cases as you type
value={code}
onChange={setCode}
onComplete={submit} // fires on the last character
groupSize={4} // 0 for one flat run; 4 for WDJB-MJHT
invalid={Boolean(error)}
/>
```

Built on `input-otp`, so paste, mobile SMS autofill and `autocomplete="one-time-code"` all work. Boxes shrink to fit their column, so an eight-character code still sits inside a 400px card. When the field is disabled while a code is checked and then re-enabled with the code still incomplete, it takes focus back, so someone correcting one wrong digit does not have to click into the boxes again.

`InputOTP`, `InputOTPGroup`, `InputOTPSlot` and `InputOTPSeparator` are still exported for anyone who built their own arrangement.

#### PhoneInput

A country picker as an input addon, plus a national number that formats itself as you type.

```tsx
<PhoneInput
defaultCountry="PT"
value={e164}
onChange={(value, isValid) => { setE164(value); setValid(isValid); }}
/>
```

The value handed back is always E.164, which is what the API wants and what a database should hold. What the person sees is their own country's grouping, which is the only format they can check at a glance against the handset in front of them. Gate your submit button on `isValid` rather than on a non-empty string.

#### InputGroup and InputAddon

A field that looks like one input but holds several controls: a country picker, a currency symbol, a unit, a suffix button. The group carries the border, height, radius and focus ring, and everything inside is drawn borderless.

```tsx
<InputGroup>
<InputAddon side="start">$</InputAddon>
<InputGroupInput inputMode="decimal" />
</InputGroup>
```

## Usage examples

Expand Down Expand Up @@ -185,17 +243,19 @@ Components use CSS variables for colors, radii, and spacing. Override them in yo

```css
:root {
--authsome-primary: 220 90% 56%;
--authsome-primary: 221 83% 53%;
--authsome-primary-foreground: 0 0% 100%;
--authsome-background: 0 0% 100%;
--authsome-foreground: 222 47% 11%;
--authsome-muted: 210 40% 96%;
--authsome-muted-foreground: 215 16% 47%;
--authsome-border: 214 32% 91%;
--authsome-radius: 0.5rem;
--authsome-muted: 220 14% 96%;
--authsome-muted-foreground: 220 9% 46%;
--authsome-border: 220 13% 91%;
--authsome-radius: 0.625rem;
}
```

Those are the defaults, not a suggestion. The blue is a token like any other, so one line puts it back to whatever you had.

### Dark mode

Add dark mode variables under a `.dark` class or `data-theme="dark"` attribute:
Expand Down
21 changes: 20 additions & 1 deletion docs/content/docs/ui/playground.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ Storybook launches at `http://localhost:6006` by default.

## Story categories

The playground organizes stories into 6 categories with 31 story files covering every component:
The playground organizes stories into 9 categories with 49 story files covering every component:

### Auth Forms (9 stories)

Expand Down Expand Up @@ -57,6 +57,25 @@ The playground organizes stories into 6 categories with 31 story files covering
| `LoadingSpinner` | `<LoadingSpinner />` | Animated spinner in different sizes |
| `ErrorDisplay` | `<ErrorDisplay />` | Error message rendering with dismiss |

### Session (2 stories)

| Story | Component | What it demonstrates |
|-------|-----------|---------------------|
| `LastUsedAccount` | `<LastUsedAccount />` | The returning-visitor chip, with and without a display name, and the nothing-remembered case |
| `AccountSummaryCard` | `<AccountSummaryCard />` | Last sign-in, trusted devices, and two-step on, off or unknown |

### Plugins (4 stories)

| Story | Component | What it demonstrates |
|-------|-----------|---------------------|
| `PhoneSignInForm` | `<PhoneSignInForm />` | Number then code, with a stub that accepts 482917 and rejects everything else |
| `ConsentList` | `<ConsentList />` | Granted and withdrawn purposes, with the empty state |
| `OAuthConsentScreen` | `<OAuthConsentScreen />` | Scopes in readable language, a custom scope, and an invalid request |
| `InvitationCard` | `<InvitationCard />` | Signed in, signed out with a sign-in slot, expired, and not valid |
| `ScimConnection` | `<ScimConnectionCard />` | Connection settings, the token list, and nothing connected yet |

Plugin stories stub the plugin API through `MockAuthProvider`'s `plugins` prop, so every state is reachable without a backend.

### Guards (1 story)

| Story | Component | What it demonstrates |
Expand Down
89 changes: 89 additions & 0 deletions docs/content/docs/ui/plugins.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,95 @@ Asking for a plugin you didn't add throws an error that names it, so a missing r

A plugin's setup runs the first time you ask for it, not when the provider mounts. If its setup throws (a bad option, say), you see the error there too.

## The packages that ship today

Six of them. Each exposes `.` for the plugin definition, which is framework-free, and `./react` for its hooks and components. Install the ones you use and nothing else.

### @authsome/plugin-phone

Phone sign-in, two steps.

```tsx
import { phone } from "@authsome/plugin-phone";
import { PhoneSignInForm, usePhoneAuth } from "@authsome/plugin-phone/react";

<AuthProvider plugins={[phone()]}>
<PhoneSignInForm onSuccess={({ newUser }) => ...} />
</AuthProvider>
```

`usePhoneAuth()` gives you `start`, `verify`, `isSending`, `isVerifying` and `error` if you'd rather build the screens yourself. `verify` adopts the session, so by the time it resolves `useAuth()` already reports the person as signed in.

### @authsome/plugin-consent

GDPR purpose consent. Not the OAuth screen: that's the next one.

```tsx
import { consent } from "@authsome/plugin-consent";
import { ConsentGate, ConsentList, useConsents } from "@authsome/plugin-consent/react";

<ConsentGate purpose="marketing_email" version="2026-01" title="Can we email you?">
<NewsletterSignup />
</ConsentGate>
```

`<ConsentGate>` holds its children back until a purpose has been agreed to. Bump `version` when the wording changes in a way that matters and everyone is asked again. `<ConsentList>` is the settings view, and a withdrawn consent stays in the list rather than vanishing, because the point of a consent record is that it's a history.

### @authsome/plugin-oauth2

The "App X wants access" screen, from `oauth2provider`.

```tsx
import { oauth2 } from "@authsome/plugin-oauth2";
import { ConnectedAppsList, OAuthConsentScreen } from "@authsome/plugin-oauth2/react";

<OAuthConsentScreen cid={searchParams.get("cid")} />
```

Scope strings come back raw, so the component ships readable labels for the standard ones and takes a `scopeLabels` map for yours. Anything unmapped falls back to the raw string, because asking someone to approve a blank line is worse than showing them `custom:widgets`.

### @authsome/plugin-subscription

Self-service billing. It wraps the tenant routes, so a client never carries a subscription ID and can't aim a write at someone else's.

```tsx
import { subscription } from "@authsome/plugin-subscription";
import { Entitled, useSubscription, usePlans } from "@authsome/plugin-subscription/react";
import { CurrentPlanCard, PricingTable, UsageMeter } from "@authsome/plugin-subscription/components";

const { subscription: current, canManage } = useSubscription();
```

`canManage` comes from the server, and the components hide their write controls when it's false rather than letting you press a button that will 403. `<Entitled feature="...">` is the gate for a feature the current plan may or may not include. Styled components live under `./components`, separately from the hooks, so you can take one without the other.

### @authsome/plugin-organization

Invitations, both ends.

```tsx
import { organization } from "@authsome/plugin-organization";
import { InvitationCard, InvitationList } from "@authsome/plugin-organization/react";

<InvitationCard token={token} isSignedIn={isSignedIn} signInSlot={<YourSignInButtons />} />
```

`useInvitation(token)` needs no session, which is the point: whoever is deciding whether to accept usually hasn't signed in, and often has no account at all. Accepting does need one, so when nobody is signed in the card renders your `signInSlot` instead of the accept button rather than trying to build a sign-in flow of its own.

### @authsome/plugin-scim

The dashboard side of SCIM: connect an identity provider, mint the token it authenticates with, rotate it, revoke it.

```tsx
import { scim } from "@authsome/plugin-scim";
import { ScimConnectionCard, ScimTokenList } from "@authsome/plugin-scim/react";

<ScimConnectionCard orgId={orgId}>
<ScimTokenList configId={configId} />
</ScimConnectionCard>
```

Every route needs an organization owner or admin. A token's plaintext exists once, in the response that minted it, and the store keeps only a hash, so `<ScimTokenList>` shows a new one in a banner you have to dismiss deliberately. Rotating takes a grace window, which keeps the old token working while you update the identity provider.

## Writing a plugin

A plugin has a name and a setup function. The name has to be the server plugin's `Name()`, because that's how the plugin can tell from the client config whether the server side is installed.
Expand Down
136 changes: 136 additions & 0 deletions flutter/packages/authsome_core/lib/src/generated/api_client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2013,6 +2013,18 @@ class AuthClient {
return EnrollResponse.fromJson(Map<String, dynamic>.from(res as Map));
}

/// Read MFA enrollment
/// GET /v1/mfa/enrollment
Future<EnrollmentResponse> getMFAEnrollment({required String token}) async {
final path = '/v1/mfa/enrollment';
final res = await _request(
'GET',
path,
token: token,
);
return EnrollmentResponse.fromJson(Map<String, dynamic>.from(res as Map));
}

/// Disable MFA
/// DELETE /v1/mfa/enrollment
Future<DisableResponse> disableMFA({required DisableRequest body, required String token}) async {
Expand Down Expand Up @@ -2416,6 +2428,18 @@ class AuthClient {
return OrganizationStatusResponse.fromJson(Map<String, dynamic>.from(res as Map));
}

/// Get invitation
/// GET /v1/orgs/invitations/{inviteToken}
Future<InvitationPreview> getInvitation({required String inviteToken, required String token}) async {
final path = '/v1/orgs/invitations/$inviteToken';
final res = await _request(
'GET',
path,
token: token,
);
return InvitationPreview.fromJson(Map<String, dynamic>.from(res as Map));
}

/// Get organization
/// GET /v1/orgs/{orgId}
Future<Organization> getOrganization({required String orgId, required String token}) async {
Expand Down Expand Up @@ -2546,6 +2570,31 @@ class AuthClient {
return OrganizationStatusResponse.fromJson(Map<String, dynamic>.from(res as Map));
}

/// List SCIM configurations
/// GET /v1/orgs/{orgId}/scim/configs
Future<ConfigListResponse> listSCIMConfigs({required String orgId, required String token}) async {
final path = '/v1/orgs/$orgId/scim/configs';
final res = await _request(
'GET',
path,
token: token,
);
return ConfigListResponse.fromJson(Map<String, dynamic>.from(res as Map));
}

/// Create SCIM configuration
/// POST /v1/orgs/{orgId}/scim/configs
Future<SCIMConfig> createSCIMConfig({required String orgId, required CreateSCIMConfigRequest body, required String token}) async {
final path = '/v1/orgs/$orgId/scim/configs';
final res = await _request(
'POST',
path,
body: body.toJson(),
token: token,
);
return SCIMConfig.fromJson(Map<String, dynamic>.from(res as Map));
}

/// List teams
/// GET /v1/orgs/{orgId}/teams
Future<TeamListResponse> listTeams({required String orgId, required String token, int? limit, String? cursor}) async {
Expand Down Expand Up @@ -2896,6 +2945,93 @@ class AuthClient {
return ApiStatusResponse.fromJson(Map<String, dynamic>.from(res as Map));
}

/// Get SCIM configuration
/// GET /v1/scim/configs/{configId}
Future<SCIMConfig> getSCIMConfig({required String configId, required String token}) async {
final path = '/v1/scim/configs/$configId';
final res = await _request(
'GET',
path,
token: token,
);
return SCIMConfig.fromJson(Map<String, dynamic>.from(res as Map));
}

/// Update SCIM configuration
/// PATCH /v1/scim/configs/{configId}
Future<SCIMConfig> updateSCIMConfig({required String configId, required UpdateSCIMConfigRequest body, required String token}) async {
final path = '/v1/scim/configs/$configId';
final res = await _request(
'PATCH',
path,
body: body.toJson(),
token: token,
);
return SCIMConfig.fromJson(Map<String, dynamic>.from(res as Map));
}

/// Delete SCIM configuration
/// DELETE /v1/scim/configs/{configId}
Future<ScimStatusResponse> deleteSCIMConfig({required String configId, required String token}) async {
final path = '/v1/scim/configs/$configId';
final res = await _request(
'DELETE',
path,
token: token,
);
return ScimStatusResponse.fromJson(Map<String, dynamic>.from(res as Map));
}

/// List SCIM tokens
/// GET /v1/scim/configs/{configId}/tokens
Future<TokenListResponse> listSCIMTokens({required String configId, required String token}) async {
final path = '/v1/scim/configs/$configId/tokens';
final res = await _request(
'GET',
path,
token: token,
);
return TokenListResponse.fromJson(Map<String, dynamic>.from(res as Map));
}

/// Create SCIM token
/// POST /v1/scim/configs/{configId}/tokens
Future<TokenCreatedResponse> createSCIMToken({required String configId, required CreateSCIMTokenRequest body, required String token}) async {
final path = '/v1/scim/configs/$configId/tokens';
final res = await _request(
'POST',
path,
body: body.toJson(),
token: token,
);
return TokenCreatedResponse.fromJson(Map<String, dynamic>.from(res as Map));
}

/// Revoke SCIM token
/// DELETE /v1/scim/tokens/{tokenId}
Future<ScimStatusResponse> revokeSCIMToken({required String tokenId, required String token}) async {
final path = '/v1/scim/tokens/$tokenId';
final res = await _request(
'DELETE',
path,
token: token,
);
return ScimStatusResponse.fromJson(Map<String, dynamic>.from(res as Map));
}

/// Rotate SCIM token
/// POST /v1/scim/tokens/{tokenId}/rotate
Future<TokenCreatedResponse> rotateSCIMToken({required String tokenId, required RotateSCIMTokenRequest body, required String token}) async {
final path = '/v1/scim/tokens/$tokenId/rotate';
final res = await _request(
'POST',
path,
body: body.toJson(),
token: token,
);
return TokenCreatedResponse.fromJson(Map<String, dynamic>.from(res as Map));
}

/// List sessions
/// GET /v1/sessions
Future<SessionListResponse> listSessions({required String token, int? limit, String? cursor}) async {
Expand Down
Loading
Loading