diff --git a/docs/content/docs/ui/components.mdx b/docs/content/docs/ui/components.mdx index c373a10b..8ae21310 100644 --- a/docs/content/docs/ui/components.mdx +++ b/docs/content/docs/ui/components.mdx @@ -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 + +``` + +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 + { 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 + + $ + + +``` ## Usage examples @@ -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: diff --git a/docs/content/docs/ui/playground.mdx b/docs/content/docs/ui/playground.mdx index 2661852a..038c0b40 100644 --- a/docs/content/docs/ui/playground.mdx +++ b/docs/content/docs/ui/playground.mdx @@ -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) @@ -57,6 +57,25 @@ The playground organizes stories into 6 categories with 31 story files covering | `LoadingSpinner` | `` | Animated spinner in different sizes | | `ErrorDisplay` | `` | Error message rendering with dismiss | +### Session (2 stories) + +| Story | Component | What it demonstrates | +|-------|-----------|---------------------| +| `LastUsedAccount` | `` | The returning-visitor chip, with and without a display name, and the nothing-remembered case | +| `AccountSummaryCard` | `` | Last sign-in, trusted devices, and two-step on, off or unknown | + +### Plugins (4 stories) + +| Story | Component | What it demonstrates | +|-------|-----------|---------------------| +| `PhoneSignInForm` | `` | Number then code, with a stub that accepts 482917 and rejects everything else | +| `ConsentList` | `` | Granted and withdrawn purposes, with the empty state | +| `OAuthConsentScreen` | `` | Scopes in readable language, a custom scope, and an invalid request | +| `InvitationCard` | `` | Signed in, signed out with a sign-in slot, expired, and not valid | +| `ScimConnection` | `` | 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 | diff --git a/docs/content/docs/ui/plugins.mdx b/docs/content/docs/ui/plugins.mdx index d092bef7..503c1e07 100644 --- a/docs/content/docs/ui/plugins.mdx +++ b/docs/content/docs/ui/plugins.mdx @@ -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"; + + + ...} /> + +``` + +`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"; + + + + +``` + +`` 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. `` 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"; + + +``` + +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. `` 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"; + +} /> +``` + +`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"; + + + + +``` + +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 `` 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. diff --git a/flutter/packages/authsome_core/lib/src/generated/api_client.dart b/flutter/packages/authsome_core/lib/src/generated/api_client.dart index 83528f36..3d6bbb2f 100644 --- a/flutter/packages/authsome_core/lib/src/generated/api_client.dart +++ b/flutter/packages/authsome_core/lib/src/generated/api_client.dart @@ -2013,6 +2013,18 @@ class AuthClient { return EnrollResponse.fromJson(Map.from(res as Map)); } + /// Read MFA enrollment + /// GET /v1/mfa/enrollment + Future getMFAEnrollment({required String token}) async { + final path = '/v1/mfa/enrollment'; + final res = await _request( +'GET', + path, + token: token, + ); + return EnrollmentResponse.fromJson(Map.from(res as Map)); + } + /// Disable MFA /// DELETE /v1/mfa/enrollment Future disableMFA({required DisableRequest body, required String token}) async { @@ -2416,6 +2428,18 @@ class AuthClient { return OrganizationStatusResponse.fromJson(Map.from(res as Map)); } + /// Get invitation + /// GET /v1/orgs/invitations/{inviteToken} + Future 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.from(res as Map)); + } + /// Get organization /// GET /v1/orgs/{orgId} Future getOrganization({required String orgId, required String token}) async { @@ -2546,6 +2570,31 @@ class AuthClient { return OrganizationStatusResponse.fromJson(Map.from(res as Map)); } + /// List SCIM configurations + /// GET /v1/orgs/{orgId}/scim/configs + Future 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.from(res as Map)); + } + + /// Create SCIM configuration + /// POST /v1/orgs/{orgId}/scim/configs + Future 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.from(res as Map)); + } + /// List teams /// GET /v1/orgs/{orgId}/teams Future listTeams({required String orgId, required String token, int? limit, String? cursor}) async { @@ -2896,6 +2945,93 @@ class AuthClient { return ApiStatusResponse.fromJson(Map.from(res as Map)); } + /// Get SCIM configuration + /// GET /v1/scim/configs/{configId} + Future 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.from(res as Map)); + } + + /// Update SCIM configuration + /// PATCH /v1/scim/configs/{configId} + Future 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.from(res as Map)); + } + + /// Delete SCIM configuration + /// DELETE /v1/scim/configs/{configId} + Future 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.from(res as Map)); + } + + /// List SCIM tokens + /// GET /v1/scim/configs/{configId}/tokens + Future 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.from(res as Map)); + } + + /// Create SCIM token + /// POST /v1/scim/configs/{configId}/tokens + Future 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.from(res as Map)); + } + + /// Revoke SCIM token + /// DELETE /v1/scim/tokens/{tokenId} + Future 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.from(res as Map)); + } + + /// Rotate SCIM token + /// POST /v1/scim/tokens/{tokenId}/rotate + Future 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.from(res as Map)); + } + /// List sessions /// GET /v1/sessions Future listSessions({required String token, int? limit, String? cursor}) async { diff --git a/flutter/packages/authsome_core/lib/src/generated/api_types.dart b/flutter/packages/authsome_core/lib/src/generated/api_types.dart index 58b64ed6..ce9d5654 100644 --- a/flutter/packages/authsome_core/lib/src/generated/api_types.dart +++ b/flutter/packages/authsome_core/lib/src/generated/api_types.dart @@ -2286,6 +2286,29 @@ class CloneEnvironmentResponse { String toString() => 'CloneEnvironmentResponse(${toJson()})'; } +class ConfigListResponse { + final List configs; + + const ConfigListResponse({ + required this.configs, + }); + + factory ConfigListResponse.fromJson(Map json) { + return ConfigListResponse( + configs: (json['configs'] as List).map((e) => SCIMConfig.fromJson(Map.from(e as Map))).toList(), + ); + } + + Map toJson() { + return { + 'configs': configs.map((e) => e.toJson()).toList(), + }; + } + + @override + String toString() => 'ConfigListResponse(${toJson()})'; +} + class Connection { final String? acsUrl; final bool active; @@ -3160,6 +3183,80 @@ class CreateRoleRequest { String toString() => 'CreateRoleRequest(${toJson()})'; } +class CreateSCIMConfigRequest { + final bool? autoCreate; + final bool? autoSuspend; + final String? defaultRole; + final bool? enabled; + final bool? groupSync; + final Map? metadata; + final String name; + + const CreateSCIMConfigRequest({ + this.autoCreate, + this.autoSuspend, + this.defaultRole, + this.enabled, + this.groupSync, + this.metadata, + required this.name, + }); + + factory CreateSCIMConfigRequest.fromJson(Map json) { + return CreateSCIMConfigRequest( + autoCreate: json['auto_create'] as bool?, + autoSuspend: json['auto_suspend'] as bool?, + defaultRole: json['default_role'] as String?, + enabled: json['enabled'] as bool?, + groupSync: json['group_sync'] as bool?, + metadata: json['metadata'] == null ? null : Map.from(json['metadata'] as Map), + name: json['name'] as String, + ); + } + + Map toJson() { + return { + if (autoCreate != null) 'auto_create': autoCreate, + if (autoSuspend != null) 'auto_suspend': autoSuspend, + if (defaultRole != null) 'default_role': defaultRole, + if (enabled != null) 'enabled': enabled, + if (groupSync != null) 'group_sync': groupSync, + if (metadata != null) 'metadata': metadata, + 'name': name, + }; + } + + @override + String toString() => 'CreateSCIMConfigRequest(${toJson()})'; +} + +class CreateSCIMTokenRequest { + final String? expiresAt; + final String name; + + const CreateSCIMTokenRequest({ + this.expiresAt, + required this.name, + }); + + factory CreateSCIMTokenRequest.fromJson(Map json) { + return CreateSCIMTokenRequest( + expiresAt: json['expires_at'] as String?, + name: json['name'] as String, + ); + } + + Map toJson() { + return { + if (expiresAt != null) 'expires_at': expiresAt, + 'name': name, + }; + } + + @override + String toString() => 'CreateSCIMTokenRequest(${toJson()})'; +} + class CreateSubscriptionRequest { final String appId; final String planId; @@ -4019,6 +4116,68 @@ class EnrollResponse { String toString() => 'EnrollResponse(${toJson()})'; } +class EnrolledFactor { + final String enrolledAt; + final String method; + final bool verified; + + const EnrolledFactor({ + required this.enrolledAt, + required this.method, + required this.verified, + }); + + factory EnrolledFactor.fromJson(Map json) { + return EnrolledFactor( + enrolledAt: json['enrolled_at'] as String, + method: json['method'] as String, + verified: json['verified'] as bool, + ); + } + + Map toJson() { + return { + 'enrolled_at': enrolledAt, + 'method': method, + 'verified': verified, + }; + } + + @override + String toString() => 'EnrolledFactor(${toJson()})'; +} + +class EnrollmentResponse { + final bool enabled; + final List factors; + final int recoveryCodesRemaining; + + const EnrollmentResponse({ + required this.enabled, + required this.factors, + required this.recoveryCodesRemaining, + }); + + factory EnrollmentResponse.fromJson(Map json) { + return EnrollmentResponse( + enabled: json['enabled'] as bool, + factors: (json['factors'] as List).map((e) => EnrolledFactor.fromJson(Map.from(e as Map))).toList(), + recoveryCodesRemaining: (json['recovery_codes_remaining'] as num).toInt(), + ); + } + + Map toJson() { + return { + 'enabled': enabled, + 'factors': factors.map((e) => e.toJson()).toList(), + 'recovery_codes_remaining': recoveryCodesRemaining, + }; + } + + @override + String toString() => 'EnrollmentResponse(${toJson()})'; +} + class EntitlementResponse { final bool allowed; final String feature; @@ -4735,6 +4894,53 @@ class InvitationListResponse { String toString() => 'InvitationListResponse(${toJson()})'; } +class InvitationPreview { + final String emailMasked; + final String expiresAt; + final String inviterName; + final String orgId; + final String orgName; + final String? orgSlug; + final String role; + + const InvitationPreview({ + required this.emailMasked, + required this.expiresAt, + required this.inviterName, + required this.orgId, + required this.orgName, + this.orgSlug, + required this.role, + }); + + factory InvitationPreview.fromJson(Map json) { + return InvitationPreview( + emailMasked: json['email_masked'] as String, + expiresAt: json['expires_at'] as String, + inviterName: json['inviter_name'] as String, + orgId: json['org_id'] as String, + orgName: json['org_name'] as String, + orgSlug: json['org_slug'] as String?, + role: json['role'] as String, + ); + } + + Map toJson() { + return { + 'email_masked': emailMasked, + 'expires_at': expiresAt, + 'inviter_name': inviterName, + 'org_id': orgId, + 'org_name': orgName, + if (orgSlug != null) 'org_slug': orgSlug, + 'role': role, + }; + } + + @override + String toString() => 'InvitationPreview(${toJson()})'; +} + class InvoiceResponse { final String currency; final String id; @@ -7003,6 +7209,33 @@ class RotateClientSecretResponse { String toString() => 'RotateClientSecretResponse(${toJson()})'; } +class RotateSCIMTokenRequest { + final int? graceSeconds; + final String? name; + + const RotateSCIMTokenRequest({ + this.graceSeconds, + this.name, + }); + + factory RotateSCIMTokenRequest.fromJson(Map json) { + return RotateSCIMTokenRequest( + graceSeconds: json['grace_seconds'] == null ? null : (json['grace_seconds'] as num).toInt(), + name: json['name'] as String?, + ); + } + + Map toJson() { + return { + if (graceSeconds != null) 'grace_seconds': graceSeconds, + if (name != null) 'name': name, + }; + } + + @override + String toString() => 'RotateSCIMTokenRequest(${toJson()})'; +} + class RotateWebhookSecretResponse { final String secret; @@ -7026,6 +7259,73 @@ class RotateWebhookSecretResponse { String toString() => 'RotateWebhookSecretResponse(${toJson()})'; } +class SCIMConfig { + final String appId; + final bool autoCreate; + final bool autoSuspend; + final String createdAt; + final String defaultRole; + final bool enabled; + final bool groupSync; + final String id; + final Map? metadata; + final String name; + final String? orgId; + final String updatedAt; + + const SCIMConfig({ + required this.appId, + required this.autoCreate, + required this.autoSuspend, + required this.createdAt, + required this.defaultRole, + required this.enabled, + required this.groupSync, + required this.id, + this.metadata, + required this.name, + this.orgId, + required this.updatedAt, + }); + + factory SCIMConfig.fromJson(Map json) { + return SCIMConfig( + appId: json['app_id'] as String, + autoCreate: json['auto_create'] as bool, + autoSuspend: json['auto_suspend'] as bool, + createdAt: json['created_at'] as String, + defaultRole: json['default_role'] as String, + enabled: json['enabled'] as bool, + groupSync: json['group_sync'] as bool, + id: json['id'] as String, + metadata: json['metadata'] == null ? null : Map.from(json['metadata'] as Map), + name: json['name'] as String, + orgId: json['org_id'] as String?, + updatedAt: json['updated_at'] as String, + ); + } + + Map toJson() { + return { + 'app_id': appId, + 'auto_create': autoCreate, + 'auto_suspend': autoSuspend, + 'created_at': createdAt, + 'default_role': defaultRole, + 'enabled': enabled, + 'group_sync': groupSync, + 'id': id, + if (metadata != null) 'metadata': metadata, + 'name': name, + if (orgId != null) 'org_id': orgId, + 'updated_at': updatedAt, + }; + } + + @override + String toString() => 'SCIMConfig(${toJson()})'; +} + class SMSSendRequest { final String? phone; @@ -7187,6 +7487,29 @@ class ScimListResponse { String toString() => 'ScimListResponse(${toJson()})'; } +class ScimStatusResponse { + final String status; + + const ScimStatusResponse({ + required this.status, + }); + + factory ScimStatusResponse.fromJson(Map json) { + return ScimStatusResponse( + status: json['status'] as String, + ); + } + + Map toJson() { + return { + 'status': status, + }; + } + + @override + String toString() => 'ScimStatusResponse(${toJson()})'; +} + class ScopeValue { final bool enforced; final String scope; @@ -8282,6 +8605,80 @@ class TenantRequest { String toString() => 'TenantRequest(${toJson()})'; } +class Token { + final String configId; + final String createdAt; + final String? expiresAt; + final String id; + final String? lastUsedAt; + final String name; + + const Token({ + required this.configId, + required this.createdAt, + this.expiresAt, + required this.id, + this.lastUsedAt, + required this.name, + }); + + factory Token.fromJson(Map json) { + return Token( + configId: json['config_id'] as String, + createdAt: json['created_at'] as String, + expiresAt: json['expires_at'] as String?, + id: json['id'] as String, + lastUsedAt: json['last_used_at'] as String?, + name: json['name'] as String, + ); + } + + Map toJson() { + return { + 'config_id': configId, + 'created_at': createdAt, + if (expiresAt != null) 'expires_at': expiresAt, + 'id': id, + if (lastUsedAt != null) 'last_used_at': lastUsedAt, + 'name': name, + }; + } + + @override + String toString() => 'Token(${toJson()})'; +} + +class TokenCreatedResponse { + final String baseUrl; + final String plaintext; + final Token? token; + + const TokenCreatedResponse({ + required this.baseUrl, + required this.plaintext, + this.token, + }); + + factory TokenCreatedResponse.fromJson(Map json) { + return TokenCreatedResponse( + baseUrl: json['base_url'] as String, + plaintext: json['plaintext'] as String, + token: json['token'] == null ? null : Token.fromJson(Map.from(json['token'] as Map)), + ); + } + + Map toJson() { + return { + 'base_url': baseUrl, + 'plaintext': plaintext, + if (token != null) 'token': token?.toJson(), + }; + } + + @override + String toString() => 'TokenCreatedResponse(${toJson()})'; +} + class TokenExchangeRequest { final List? scopes; final String subject; @@ -8356,6 +8753,29 @@ class TokenExchangeResponse { String toString() => 'TokenExchangeResponse(${toJson()})'; } +class TokenListResponse { + final List tokens; + + const TokenListResponse({ + required this.tokens, + }); + + factory TokenListResponse.fromJson(Map json) { + return TokenListResponse( + tokens: (json['tokens'] as List).map((e) => Token.fromJson(Map.from(e as Map))).toList(), + ); + } + + Map toJson() { + return { + 'tokens': tokens.map((e) => e.toJson()).toList(), + }; + } + + @override + String toString() => 'TokenListResponse(${toJson()})'; +} + class UIMetadata { final VisibilityCondition? condition; final String? helpText; @@ -8884,6 +9304,53 @@ class UpdateRoleRequest { String toString() => 'UpdateRoleRequest(${toJson()})'; } +class UpdateSCIMConfigRequest { + final bool? autoCreate; + final bool? autoSuspend; + final String? defaultRole; + final bool? enabled; + final bool? groupSync; + final Map? metadata; + final String? name; + + const UpdateSCIMConfigRequest({ + this.autoCreate, + this.autoSuspend, + this.defaultRole, + this.enabled, + this.groupSync, + this.metadata, + this.name, + }); + + factory UpdateSCIMConfigRequest.fromJson(Map json) { + return UpdateSCIMConfigRequest( + autoCreate: json['auto_create'] as bool?, + autoSuspend: json['auto_suspend'] as bool?, + defaultRole: json['default_role'] as String?, + enabled: json['enabled'] as bool?, + groupSync: json['group_sync'] as bool?, + metadata: json['metadata'] == null ? null : Map.from(json['metadata'] as Map), + name: json['name'] as String?, + ); + } + + Map toJson() { + return { + if (autoCreate != null) 'auto_create': autoCreate, + if (autoSuspend != null) 'auto_suspend': autoSuspend, + if (defaultRole != null) 'default_role': defaultRole, + if (enabled != null) 'enabled': enabled, + if (groupSync != null) 'group_sync': groupSync, + if (metadata != null) 'metadata': metadata, + if (name != null) 'name': name, + }; + } + + @override + String toString() => 'UpdateSCIMConfigRequest(${toJson()})'; +} + class UpdateSubscriptionRequest { final String? cancelUrl; final String? plan; diff --git a/plugins/mfa/enrollment_read_test.go b/plugins/mfa/enrollment_read_test.go new file mode 100644 index 00000000..3a1d0928 --- /dev/null +++ b/plugins/mfa/enrollment_read_test.go @@ -0,0 +1,145 @@ +package mfa_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/xraph/forge" + + "github.com/xraph/authsome/id" + "github.com/xraph/authsome/plugins/mfa" +) + +// GET /v1/mfa/enrollment answers "is two-step on for me". The account summary +// card reads it, and before it existed that row had to guess. + +func newEnrollmentHandler(t *testing.T) (*mfa.MemoryStore, http.Handler) { + t.Helper() + p, s := newTestPlugin(t) + router := forge.NewRouter() + require.NoError(t, p.RegisterRoutes(router)) + return s, router.Handler() +} + +func enroll(t *testing.T, s *mfa.MemoryStore, userID id.UserID, method string, verified bool) { + t.Helper() + require.NoError(t, s.CreateEnrollment(context.Background(), &mfa.Enrollment{ + ID: id.NewMFAID(), + UserID: userID, + Method: method, + Secret: "JBSWY3DPEHPK3PXP", + Verified: verified, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + })) +} + +func readEnrollment(t *testing.T, h http.Handler, userID id.UserID) *httptest.ResponseRecorder { + t.Helper() + rec := httptest.NewRecorder() + h.ServeHTTP(rec, authedRequest(t, "GET", "/v1/mfa/enrollment", nil, userID)) + return rec +} + +func decodeEnrollment(t *testing.T, rec *httptest.ResponseRecorder) mfa.EnrollmentResponse { + t.Helper() + var got mfa.EnrollmentResponse + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got)) + return got +} + +func TestGetEnrollment_NothingEnrolled(t *testing.T) { + _, h := newEnrollmentHandler(t) + + rec := readEnrollment(t, h, id.NewUserID()) + require.Equal(t, http.StatusOK, rec.Code, "body=%s", rec.Body.String()) + + got := decodeEnrollment(t, rec) + assert.False(t, got.Enabled) + assert.Empty(t, got.Factors) + assert.Zero(t, got.RecoveryCodesRemaining) +} + +func TestGetEnrollment_VerifiedTOTPTurnsItOn(t *testing.T) { + s, h := newEnrollmentHandler(t) + userID := id.NewUserID() + enroll(t, s, userID, "totp", true) + + got := decodeEnrollment(t, readEnrollment(t, h, userID)) + assert.True(t, got.Enabled) + require.Len(t, got.Factors, 1) + assert.Equal(t, "totp", got.Factors[0].Method) + assert.True(t, got.Factors[0].Verified) +} + +// An enrollment that was started and never verified is reported, because the +// settings page has to show it, but it does not make two-step on. +func TestGetEnrollment_UnverifiedDoesNotCount(t *testing.T) { + s, h := newEnrollmentHandler(t) + userID := id.NewUserID() + enroll(t, s, userID, "totp", false) + + got := decodeEnrollment(t, readEnrollment(t, h, userID)) + assert.False(t, got.Enabled) + require.Len(t, got.Factors, 1) + assert.False(t, got.Factors[0].Verified) +} + +func TestGetEnrollment_ReportsEveryFactor(t *testing.T) { + s, h := newEnrollmentHandler(t) + userID := id.NewUserID() + enroll(t, s, userID, "totp", true) + enroll(t, s, userID, "sms", true) + + got := decodeEnrollment(t, readEnrollment(t, h, userID)) + assert.True(t, got.Enabled) + methods := []string{got.Factors[0].Method, got.Factors[1].Method} + assert.ElementsMatch(t, []string{"totp", "sms"}, methods) +} + +// The count is what an account page needs to warn someone who is running low. +// The codes themselves are never in the response. +func TestGetEnrollment_CountsUnusedRecoveryCodes(t *testing.T) { + s, h := newEnrollmentHandler(t) + userID := id.NewUserID() + enroll(t, s, userID, "totp", true) + + now := time.Now() + require.NoError(t, s.CreateRecoveryCodes(context.Background(), []*mfa.RecoveryCode{ + {ID: id.NewRecoveryCodeID(), UserID: userID, CodeHash: "h1", CreatedAt: now}, + {ID: id.NewRecoveryCodeID(), UserID: userID, CodeHash: "h2", CreatedAt: now}, + {ID: id.NewRecoveryCodeID(), UserID: userID, CodeHash: "h3", Used: true, UsedAt: &now, CreatedAt: now}, + })) + + rec := readEnrollment(t, h, userID) + got := decodeEnrollment(t, rec) + assert.Equal(t, 2, got.RecoveryCodesRemaining, "a used code is not one you have left") + assert.NotContains(t, rec.Body.String(), "h1", "a code hash has no business in this response") +} + +// One user's enrollment says nothing about anyone else's. +func TestGetEnrollment_IsScopedToTheCaller(t *testing.T) { + s, h := newEnrollmentHandler(t) + mine, theirs := id.NewUserID(), id.NewUserID() + enroll(t, s, theirs, "totp", true) + + got := decodeEnrollment(t, readEnrollment(t, h, mine)) + assert.False(t, got.Enabled) + assert.Empty(t, got.Factors) +} + +func TestGetEnrollment_NeverReturnsTheSecret(t *testing.T) { + s, h := newEnrollmentHandler(t) + userID := id.NewUserID() + enroll(t, s, userID, "totp", true) + + rec := readEnrollment(t, h, userID) + assert.NotContains(t, rec.Body.String(), "JBSWY3DPEHPK3PXP") +} diff --git a/plugins/mfa/plugin.go b/plugins/mfa/plugin.go index 2f5053bd..e304a8cb 100644 --- a/plugins/mfa/plugin.go +++ b/plugins/mfa/plugin.go @@ -217,6 +217,16 @@ func (p *Plugin) RegisterRoutes(router forge.Router) error { return err } + if err := g.GET("/enrollment", p.handleGetEnrollment, + forge.WithSummary("Read MFA enrollment"), + forge.WithDescription("Reports whether the signed-in user has a second factor set up, which kinds, and how many recovery codes are left. Never returns a secret or a recovery code."), + forge.WithOperationID("getMFAEnrollment"), + forge.WithResponseSchema(http.StatusOK, "Enrollment state", EnrollmentResponse{}), + forge.WithErrorResponses(), + ); err != nil { + return err + } + if err := g.DELETE("/enrollment", p.handleDisable, ownSession, forge.WithSummary("Disable MFA"), @@ -342,6 +352,29 @@ type DisableResponse struct { Status string `json:"status"` } +// EnrollmentRequest binds GET /mfa/enrollment. It takes nothing: the +// enrollment it reports is always the caller's own. +type EnrollmentRequest struct{} + +// EnrolledFactor is one second factor the caller has set up. +type EnrolledFactor struct { + Method string `json:"method" description:"Factor kind: totp or sms"` + Verified bool `json:"verified" description:"Whether the factor completed its first verification"` + EnrolledAt time.Time `json:"enrolled_at" description:"When the factor was set up"` +} + +// EnrollmentResponse answers "is two-step on for me, and with what". +// +// It carries no secret, no recovery code and nothing about anyone else. The +// remaining recovery code count is a number rather than the codes, because +// an account settings page needs to warn someone who is running low without +// showing codes that only exist to be shown once. +type EnrollmentResponse struct { + Enabled bool `json:"enabled" description:"Whether any factor is verified"` + Factors []EnrolledFactor `json:"factors" description:"Every factor set up, verified or not"` + RecoveryCodesRemaining int `json:"recovery_codes_remaining" description:"How many unused recovery codes are left"` +} + // RecoveryVerifyRequest is the request body for verifying an MFA recovery code. type RecoveryVerifyRequest struct { Code string `json:"code"` @@ -828,6 +861,47 @@ func safePrefix(s string) string { } // handleDisable removes MFA enrollment for the authenticated user. +// handleGetEnrollment answers whether two-step sign-in is on for the caller. +// +// A delegated session may read this. It is the one MFA route that changes +// nothing, and an agent acting for a user has a legitimate reason to know +// whether that user is protected. +func (p *Plugin) handleGetEnrollment(ctx forge.Context, _ *EnrollmentRequest) (*EnrollmentResponse, error) { + userID, ok := middleware.UserIDFrom(ctx.Context()) + if !ok || userID.Prefix() == "" { + return nil, forge.Unauthorized("authentication required") + } + + resp := &EnrollmentResponse{Factors: []EnrolledFactor{}} + for _, method := range []string{"totp", "sms"} { + e, err := p.store.GetEnrollment(ctx.Context(), userID, method) + if err != nil || e == nil { + continue + } + resp.Factors = append(resp.Factors, EnrolledFactor{ + Method: e.Method, + Verified: e.Verified, + EnrolledAt: e.CreatedAt, + }) + if e.Verified { + resp.Enabled = true + } + } + + // A failure to read recovery codes leaves the count at zero rather than + // failing the whole answer. The enrollment state is the part that matters + // and it is already known by this point. + if codes, err := p.store.GetRecoveryCodes(ctx.Context(), userID); err == nil { + for _, c := range codes { + if !c.Used { + resp.RecoveryCodesRemaining++ + } + } + } + + return nil, ctx.JSON(http.StatusOK, resp) +} + func (p *Plugin) handleDisable(ctx forge.Context, req *DisableRequest) (*DisableResponse, error) { // The route refuses a delegated session too. Checked here as well so a // mount without that guard still fails closed. diff --git a/plugins/organization/export_test.go b/plugins/organization/export_test.go index b8ceeb3a..9c784bb4 100644 --- a/plugins/organization/export_test.go +++ b/plugins/organization/export_test.go @@ -9,3 +9,11 @@ import ( func (p *Plugin) SetPermCheckerForTest(pc plugin.PermissionChecker) { p.permChecker = pc } + +// MaskEmail and InviterName expose the invitation preview's two formatting +// helpers so they can be tested directly. Both decide what a page shows to +// someone who may not own the address, so their edges are worth pinning. +var ( + MaskEmail = maskEmail + InviterName = inviterName +) diff --git a/plugins/organization/handlers.go b/plugins/organization/handlers.go index 3895003c..a112531e 100644 --- a/plugins/organization/handlers.go +++ b/plugins/organization/handlers.go @@ -4,6 +4,8 @@ import ( "errors" "fmt" "net/http" + "strings" + "time" "github.com/xraph/forge" log "github.com/xraph/go-utils/log" @@ -16,6 +18,7 @@ import ( "github.com/xraph/authsome/page" "github.com/xraph/authsome/plugin" "github.com/xraph/authsome/store" + "github.com/xraph/authsome/user" ) // RegisterRoutes registers organization management routes on a forge.Router. @@ -141,6 +144,18 @@ func (p *Plugin) registerOrgRoutes(router forge.Router) error { return err } + // Unauthenticated on purpose: whoever is deciding whether to accept has + // usually not signed in, and often has no account at all. + if err := g.GET("/orgs/invitations/:inviteToken", p.handleGetInvitation, + forge.WithSummary("Get invitation"), + forge.WithDescription("Reads a pending invitation by its token so the person holding it can see who invited them, to which organization, and as what, before accepting. No session required."), + forge.WithOperationID("getInvitation"), + forge.WithResponseSchema(http.StatusOK, "Invitation preview", InvitationPreview{}), + forge.WithErrorResponses(), + ); err != nil { + return err + } + if err := g.POST("/orgs/invitations/decline", p.handleDeclineInvitation, forge.WithSummary("Decline invitation"), forge.WithDescription("Declines a pending organization invitation using the invitation token."), @@ -623,6 +638,91 @@ func (p *Plugin) handleAcceptInvitation(ctx forge.Context, req *AcceptInvitation return nil, ctx.JSON(http.StatusCreated, member) } +// maskEmail turns "rex.raphael@acme.com" into "re***@acme.com". +// +// Enough for someone to recognise their own address, not enough for whoever +// forwarded or intercepted the link to learn a new one. The domain stays +// whole because it is the part that tells an invitee which of their accounts +// to sign in with. +func maskEmail(email string) string { + at := strings.LastIndex(email, "@") + if at <= 0 { + return "***" + } + local, domain := email[:at], email[at:] + keep := 2 + if len(local) < keep { + keep = len(local) + } + return local[:keep] + "***" + domain +} + +// inviterName is the best human-readable name available for a user, falling +// back through the fields until something is non-empty. An inviter whose +// account was deleted leaves "Someone" rather than an empty line. +func inviterName(u *user.User) string { + if u == nil { + return "Someone" + } + if name := strings.TrimSpace(u.FirstName + " " + u.LastName); name != "" { + return name + } + if u.DisplayUsername != "" { + return u.DisplayUsername + } + if u.Username != "" { + return u.Username + } + if u.Email != "" { + return u.Email + } + return "Someone" +} + +func (p *Plugin) handleGetInvitation(ctx forge.Context, req *GetInvitationRequest) (*InvitationPreview, error) { + token := req.Token + if token == "" { + token = ctx.Param("inviteToken") + } + if token == "" { + return nil, forge.BadRequest("token is required") + } + + inv, err := p.store.GetInvitationByToken(ctx.Context(), token) + // One answer for every reason an invitation cannot be shown: never + // existed, already used, declined, expired. Telling them apart would let + // anyone holding a list of guesses learn which tokens are real. + if err != nil || inv == nil || inv.Status != organization.InvitationPending { + return nil, forge.NotFound("invitation not found") + } + if !inv.ExpiresAt.IsZero() && time.Now().After(inv.ExpiresAt) { + return nil, forge.NotFound("invitation not found") + } + + org, err := p.store.GetOrganization(ctx.Context(), inv.OrgID) + if err != nil { + return nil, forge.NotFound("invitation not found") + } + + // A missing inviter is not a reason to refuse the invitation. The account + // may have been deleted since it was sent, and the seat is still valid. + var invitedBy *user.User + if u, uErr := p.store.GetUser(ctx.Context(), inv.InviterID); uErr == nil { + invitedBy = u + } + + resp := &InvitationPreview{ + OrgID: inv.OrgID.String(), + OrgName: org.Name, + OrgSlug: org.Slug, + InviterName: inviterName(invitedBy), + Role: string(inv.Role), + EmailMasked: maskEmail(inv.Email), + ExpiresAt: inv.ExpiresAt, + } + return nil, ctx.JSON(http.StatusOK, resp) +} + func (p *Plugin) handleDeclineInvitation(ctx forge.Context, req *DeclineInvitationRequest) (*StatusResponse, error) { if req.Token == "" { return nil, forge.BadRequest("token is required") diff --git a/plugins/organization/invitation_preview_test.go b/plugins/organization/invitation_preview_test.go new file mode 100644 index 00000000..4488797e --- /dev/null +++ b/plugins/organization/invitation_preview_test.go @@ -0,0 +1,175 @@ +package organization_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/xraph/authsome/id" + "github.com/xraph/authsome/organization" + orgplugin "github.com/xraph/authsome/plugins/organization" + "github.com/xraph/authsome/user" +) + +// GET /v1/orgs/invitations/:inviteToken lets whoever holds an invitation see +// what they are being asked to join before they sign in. It is +// unauthenticated on purpose, so what it does NOT say matters as much as what +// it does. + +// seedInvitation stores an invitation with a known plaintext token. +func seedInvitation( + t *testing.T, + p *orgplugin.Plugin, + orgID id.OrgID, + inviter id.UserID, + token string, + mutate func(*organization.Invitation), +) *organization.Invitation { + t.Helper() + inv := &organization.Invitation{ + ID: id.NewInvitationID(), + OrgID: orgID, + Email: "sofia@acme.com", + Role: organization.RoleMember, + InviterID: inviter, + Status: organization.InvitationPending, + Token: token, + ExpiresAt: time.Now().Add(6 * 24 * time.Hour), + } + if mutate != nil { + mutate(inv) + } + require.NoError(t, p.CreateInvitation(context.Background(), inv)) + return inv +} + +func getPreview(t *testing.T, h http.Handler, token string) *httptest.ResponseRecorder { + t.Helper() + rec := httptest.NewRecorder() + // The zero UserID means no session, which is the whole point of the route. + h.ServeHTTP(rec, orgReq("GET", "/v1/orgs/invitations/"+token, nil, id.UserID{})) + return rec +} + +func TestGetInvitation_ShowsWhatYouAreJoining(t *testing.T) { + p, h := newOrgHTTP(t) + owner := id.NewUserID() + o := seedOrg(t, p, owner) + inv := seedInvitation(t, p, o.ID, owner, "tok-visible", nil) + + rec := getPreview(t, h, "tok-visible") + require.Equal(t, http.StatusOK, rec.Code, "body=%s", rec.Body.String()) + + var got orgplugin.InvitationPreview + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got)) + + assert.Equal(t, o.ID.String(), got.OrgID) + assert.Equal(t, "Acme", got.OrgName) + assert.Equal(t, "acme", got.OrgSlug) + assert.Equal(t, string(organization.RoleMember), got.Role) + assert.WithinDuration(t, inv.ExpiresAt, got.ExpiresAt, time.Second) +} + +func TestGetInvitation_MasksTheInvitedAddress(t *testing.T) { + p, h := newOrgHTTP(t) + o := seedOrg(t, p, id.NewUserID()) + seedInvitation(t, p, o.ID, id.NewUserID(), "tok-mask", nil) + + rec := getPreview(t, h, "tok-mask") + require.Equal(t, http.StatusOK, rec.Code) + + // Enough to recognise your own address, not enough to learn a new one, + // and the raw address never appears anywhere in the body. + assert.Equal(t, "so***@acme.com", gjsonField(t, rec.Body.Bytes(), "email_masked")) + assert.NotContains(t, rec.Body.String(), "sofia@acme.com") +} + +func TestGetInvitation_NeverLeaksTheToken(t *testing.T) { + p, h := newOrgHTTP(t) + o := seedOrg(t, p, id.NewUserID()) + seedInvitation(t, p, o.ID, id.NewUserID(), "tok-secret", nil) + + rec := getPreview(t, h, "tok-secret") + require.Equal(t, http.StatusOK, rec.Code) + assert.NotContains(t, rec.Body.String(), "tok-secret") +} + +// Expired, consumed and never-existed all answer the same way, so a list of +// guessed tokens cannot be used to find out which ones are real. +func TestGetInvitation_IndistinguishableFailures(t *testing.T) { + p, h := newOrgHTTP(t) + o := seedOrg(t, p, id.NewUserID()) + + seedInvitation(t, p, o.ID, id.NewUserID(), "tok-expired", func(i *organization.Invitation) { + i.ExpiresAt = time.Now().Add(-time.Hour) + }) + seedInvitation(t, p, o.ID, id.NewUserID(), "tok-accepted", func(i *organization.Invitation) { + i.Status = organization.InvitationAccepted + }) + seedInvitation(t, p, o.ID, id.NewUserID(), "tok-declined", func(i *organization.Invitation) { + i.Status = organization.InvitationDeclined + }) + + for _, token := range []string{"tok-expired", "tok-accepted", "tok-declined", "tok-never-existed"} { + t.Run(token, func(t *testing.T) { + rec := getPreview(t, h, token) + assert.Equal(t, http.StatusNotFound, rec.Code, "body=%s", rec.Body.String()) + }) + } +} + +func TestMaskEmail(t *testing.T) { + cases := []struct{ in, want string }{ + {"sofia@acme.com", "so***@acme.com"}, + {"rex.raphael@acme.com", "re***@acme.com"}, + // A one-character local part keeps what little there is rather than + // panicking on the slice. + {"a@acme.com", "a***@acme.com"}, + // No @ at all is not an address, so there is nothing safe to show. + {"not-an-address", "***"}, + {"", "***"}, + // A leading @ leaves no local part to keep. + {"@acme.com", "***"}, + } + for _, c := range cases { + assert.Equal(t, c.want, orgplugin.MaskEmail(c.in), "input %q", c.in) + } +} + +func TestInviterName_FallsThroughToSomething(t *testing.T) { + assert.Equal(t, "Rex Raphael", orgplugin.InviterName(&user.User{FirstName: "Rex", LastName: "Raphael"})) + assert.Equal(t, "Rex", orgplugin.InviterName(&user.User{FirstName: "Rex"})) + assert.Equal(t, "rexr", orgplugin.InviterName(&user.User{DisplayUsername: "rexr"})) + assert.Equal(t, "rex", orgplugin.InviterName(&user.User{Username: "rex"})) + assert.Equal(t, "rex@acme.com", orgplugin.InviterName(&user.User{Email: "rex@acme.com"})) + // An inviter whose account was deleted since they sent it. + assert.Equal(t, "Someone", orgplugin.InviterName(nil)) + assert.Equal(t, "Someone", orgplugin.InviterName(&user.User{})) +} + +// An invitation whose inviter is no longer in the store still shows, because +// the seat is valid whether or not the account that offered it still exists. +func TestGetInvitation_SurvivesAMissingInviter(t *testing.T) { + p, h := newOrgHTTP(t) + o := seedOrg(t, p, id.NewUserID()) + seedInvitation(t, p, o.ID, id.NewUserID(), "tok-ghost", nil) + + rec := getPreview(t, h, "tok-ghost") + require.Equal(t, http.StatusOK, rec.Code, "body=%s", rec.Body.String()) + assert.Equal(t, "Someone", gjsonField(t, rec.Body.Bytes(), "inviter_name")) +} + +// gjsonField reads one top-level string field out of a JSON body. +func gjsonField(t *testing.T, body []byte, field string) string { + t.Helper() + var m map[string]any + require.NoError(t, json.Unmarshal(body, &m)) + s, _ := m[field].(string) + return s +} diff --git a/plugins/organization/types.go b/plugins/organization/types.go index a4fb28df..12a57a6c 100644 --- a/plugins/organization/types.go +++ b/plugins/organization/types.go @@ -1,5 +1,7 @@ package organization +import "time" + // --------------------------------------------------------------------------- // Organization requests // --------------------------------------------------------------------------- @@ -94,6 +96,29 @@ type DeclineInvitationRequest struct { Token string `json:"token" description:"Invitation token"` } +// GetInvitationRequest binds GET /orgs/invitations/:token. +type GetInvitationRequest struct { + Token string `path:"inviteToken" description:"Invitation token"` +} + +// InvitationPreview is what the person holding an invitation token is shown +// before they accept it. +// +// Deliberately not organization.Invitation. That row has no organization name +// and no inviter name, which are the two things the page actually needs, and +// it carries the token and the raw invited address, which the page does not. +// The email is masked so the screen can say who the invitation is for without +// handing the address to whoever opened the link. +type InvitationPreview struct { + OrgID string `json:"org_id" description:"Organization the invitation is for"` + OrgName string `json:"org_name" description:"Organization's display name"` + OrgSlug string `json:"org_slug,omitempty" description:"Organization's slug"` + InviterName string `json:"inviter_name" description:"Who sent the invitation"` + Role string `json:"role" description:"Role the invitee will hold"` + EmailMasked string `json:"email_masked" description:"Partially masked invited address"` + ExpiresAt time.Time `json:"expires_at" description:"When the invitation stops working"` +} + // --------------------------------------------------------------------------- // Team requests // --------------------------------------------------------------------------- diff --git a/plugins/scim/admin_handlers.go b/plugins/scim/admin_handlers.go new file mode 100644 index 00000000..15524659 --- /dev/null +++ b/plugins/scim/admin_handlers.go @@ -0,0 +1,316 @@ +package scim + +import ( + "errors" + "fmt" + "net/http" + "strings" + "time" + + "github.com/xraph/forge" + + "github.com/xraph/authsome/id" + "github.com/xraph/authsome/middleware" + "github.com/xraph/authsome/organization" + "github.com/xraph/authsome/store" +) + +// adminRoles are the organization roles that may manage SCIM for an org. +// +// Connecting an identity provider decides who gets an account and what they +// can reach, so it sits with the roles that already decide membership rather +// than with anyone who happens to be in the org. +var adminRoles = []organization.MemberRole{ + organization.RoleOwner, + organization.RoleAdmin, +} + +// requireOrgAdmin checks the caller is an owner or admin of orgID. +// +// A caller who is not a member at all gets 404 rather than 403, so an +// outsider probing organization IDs cannot tell which ones exist. +func (p *Plugin) requireOrgAdmin(ctx forge.Context, orgID id.OrgID) error { + userID, ok := middleware.UserIDFrom(ctx.Context()) + if !ok || userID.Prefix() == "" { + return forge.Unauthorized("authentication required") + } + if p.authStore == nil { + return forge.Forbidden("organization membership cannot be checked") + } + member, err := p.authStore.GetMemberByUserAndOrg(ctx.Context(), userID, orgID) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + return forge.NotFound("organization not found") + } + return forge.NewHTTPError(http.StatusInternalServerError, "look up membership") + } + for _, r := range adminRoles { + if member.Role == r { + return nil + } + } + return forge.Forbidden("insufficient organization role") +} + +// configForCaller loads a config by ID and checks the caller may manage it. +// +// An app-level config (one with no OrgID) has no organization to check a role +// against, so it is not reachable through these routes at all. Managing one +// is an operator job, done where the app itself is configured. Returning 404 +// rather than 403 keeps the two cases indistinguishable from outside. +func (p *Plugin) configForCaller(ctx forge.Context, raw string) (*SCIMConfig, error) { + if p.service == nil { + return nil, forge.NewHTTPError(http.StatusInternalServerError, "scim service unavailable") + } + configID, err := id.ParseSCIMConfigID(raw) + if err != nil { + return nil, forge.BadRequest(fmt.Sprintf("invalid config id: %v", err)) + } + cfg, err := p.service.GetConfig(ctx.Context(), configID) + if err != nil || cfg == nil { + return nil, forge.NotFound("scim configuration not found") + } + if cfg.OrgID.IsNil() { + return nil, forge.NotFound("scim configuration not found") + } + if err := p.requireOrgAdmin(ctx, cfg.OrgID); err != nil { + return nil, err + } + return cfg, nil +} + +// scimBaseURL is what gets pasted into an identity provider. +func (p *Plugin) scimBaseURL() string { + return strings.TrimRight(p.config.BasePath, "/") +} + +func (p *Plugin) handleListConfigs(ctx forge.Context, req *ListSCIMConfigsRequest) (*ConfigListResponse, error) { + // Distinct names on purpose. Reusing err makes gocritic call the second + // one a sloppy reassignment, and shadowing it makes govet call it a + // shadow, so neither linter is happy with the other's fix. + orgID, parseErr := id.ParseOrgID(req.OrgID) + if parseErr != nil { + return nil, forge.BadRequest(fmt.Sprintf("invalid org id: %v", parseErr)) + } + if err := p.requireOrgAdmin(ctx, orgID); err != nil { + return nil, err + } + if p.service == nil { + return nil, forge.NewHTTPError(http.StatusInternalServerError, "scim service unavailable") + } + configs, err := p.service.ListConfigsByOrg(ctx.Context(), orgID) + if err != nil { + return nil, forge.NewHTTPError(http.StatusInternalServerError, "list scim configurations") + } + if configs == nil { + configs = []*SCIMConfig{} + } + return nil, ctx.JSON(http.StatusOK, &ConfigListResponse{Configs: configs}) +} + +func (p *Plugin) handleCreateConfig(ctx forge.Context, req *CreateSCIMConfigRequest) (*SCIMConfig, error) { + // Distinct names on purpose. Reusing err makes gocritic call the second + // one a sloppy reassignment, and shadowing it makes govet call it a + // shadow, so neither linter is happy with the other's fix. + orgID, parseErr := id.ParseOrgID(req.OrgID) + if parseErr != nil { + return nil, forge.BadRequest(fmt.Sprintf("invalid org id: %v", parseErr)) + } + if err := p.requireOrgAdmin(ctx, orgID); err != nil { + return nil, err + } + if strings.TrimSpace(req.Name) == "" { + return nil, forge.BadRequest("name is required") + } + if p.service == nil { + return nil, forge.NewHTTPError(http.StatusInternalServerError, "scim service unavailable") + } + + // A connection that quietly does nothing is worse than no connection, so + // a new one is enabled and creates users unless the caller says otherwise. + cfg := &SCIMConfig{ + ID: id.NewSCIMConfigID(), + AppID: p.configAppID(), + OrgID: orgID, + Name: strings.TrimSpace(req.Name), + Enabled: boolOr(req.Enabled, true), + AutoCreate: boolOr(req.AutoCreate, true), + AutoSuspend: boolOr(req.AutoSuspend, true), + GroupSync: boolOr(req.GroupSync, false), + DefaultRole: req.DefaultRole, + Metadata: req.Metadata, + } + if err := p.service.CreateConfig(ctx.Context(), cfg); err != nil { + return nil, forge.NewHTTPError(http.StatusInternalServerError, "create scim configuration") + } + return nil, ctx.JSON(http.StatusCreated, cfg) +} + +func (p *Plugin) handleGetConfig(ctx forge.Context, req *GetSCIMConfigRequest) (*SCIMConfig, error) { + cfg, err := p.configForCaller(ctx, req.ConfigID) + if err != nil { + return nil, err + } + return nil, ctx.JSON(http.StatusOK, cfg) +} + +func (p *Plugin) handleUpdateConfig(ctx forge.Context, req *UpdateSCIMConfigRequest) (*SCIMConfig, error) { + cfg, err := p.configForCaller(ctx, req.ConfigID) + if err != nil { + return nil, err + } + if req.Name != nil { + if strings.TrimSpace(*req.Name) == "" { + return nil, forge.BadRequest("name cannot be empty") + } + cfg.Name = strings.TrimSpace(*req.Name) + } + if req.Enabled != nil { + cfg.Enabled = *req.Enabled + } + if req.AutoCreate != nil { + cfg.AutoCreate = *req.AutoCreate + } + if req.AutoSuspend != nil { + cfg.AutoSuspend = *req.AutoSuspend + } + if req.GroupSync != nil { + cfg.GroupSync = *req.GroupSync + } + if req.DefaultRole != nil { + cfg.DefaultRole = *req.DefaultRole + } + if req.Metadata != nil { + cfg.Metadata = req.Metadata + } + if err := p.service.UpdateConfig(ctx.Context(), cfg); err != nil { + return nil, forge.NewHTTPError(http.StatusInternalServerError, "update scim configuration") + } + return nil, ctx.JSON(http.StatusOK, cfg) +} + +func (p *Plugin) handleDeleteConfig(ctx forge.Context, req *GetSCIMConfigRequest) (*StatusResponse, error) { + cfg, err := p.configForCaller(ctx, req.ConfigID) + if err != nil { + return nil, err + } + if err := p.service.DeleteConfig(ctx.Context(), cfg.ID); err != nil { + return nil, forge.NewHTTPError(http.StatusInternalServerError, "delete scim configuration") + } + return nil, ctx.JSON(http.StatusOK, &StatusResponse{Status: "deleted"}) +} + +func (p *Plugin) handleListTokens(ctx forge.Context, req *ListSCIMTokensRequest) (*TokenListResponse, error) { + cfg, err := p.configForCaller(ctx, req.ConfigID) + if err != nil { + return nil, err + } + tokens, err := p.service.ListTokens(ctx.Context(), cfg.ID) + if err != nil { + return nil, forge.NewHTTPError(http.StatusInternalServerError, "list scim tokens") + } + if tokens == nil { + tokens = []*Token{} + } + return nil, ctx.JSON(http.StatusOK, &TokenListResponse{Tokens: tokens}) +} + +func (p *Plugin) handleCreateToken(ctx forge.Context, req *CreateSCIMTokenRequest) (*TokenCreatedResponse, error) { + cfg, err := p.configForCaller(ctx, req.ConfigID) + if err != nil { + return nil, err + } + if strings.TrimSpace(req.Name) == "" { + return nil, forge.BadRequest("name is required") + } + if req.ExpiresAt != nil && req.ExpiresAt.Before(time.Now()) { + return nil, forge.BadRequest("expires_at is in the past") + } + + plaintext, token, err := p.service.GenerateToken( + ctx.Context(), cfg.ID, strings.TrimSpace(req.Name), req.ExpiresAt, + ) + if err != nil { + return nil, forge.NewHTTPError(http.StatusInternalServerError, "generate scim token") + } + return nil, ctx.JSON(http.StatusCreated, &TokenCreatedResponse{ + Token: token, + Plaintext: plaintext, + BaseURL: p.scimBaseURL(), + }) +} + +func (p *Plugin) handleRotateToken(ctx forge.Context, req *RotateSCIMTokenRequest) (*TokenCreatedResponse, error) { + tokenID, err := p.tokenForCaller(ctx, req.TokenID) + if err != nil { + return nil, err + } + if req.GraceSeconds < 0 { + return nil, forge.BadRequest("grace_seconds cannot be negative") + } + + plaintext, token, err := p.service.RotateToken( + ctx.Context(), tokenID, strings.TrimSpace(req.Name), + time.Duration(req.GraceSeconds)*time.Second, + ) + if err != nil { + return nil, forge.NewHTTPError(http.StatusInternalServerError, "rotate scim token") + } + return nil, ctx.JSON(http.StatusCreated, &TokenCreatedResponse{ + Token: token, + Plaintext: plaintext, + BaseURL: p.scimBaseURL(), + }) +} + +func (p *Plugin) handleRevokeToken(ctx forge.Context, req *RevokeSCIMTokenRequest) (*StatusResponse, error) { + tokenID, err := p.tokenForCaller(ctx, req.TokenID) + if err != nil { + return nil, err + } + if err := p.service.RevokeToken(ctx.Context(), tokenID); err != nil { + return nil, forge.NewHTTPError(http.StatusInternalServerError, "revoke scim token") + } + return nil, ctx.JSON(http.StatusOK, &StatusResponse{Status: "revoked"}) +} + +// tokenForCaller resolves a token ID and checks the caller may manage the +// configuration it belongs to. Without this a token ID alone would be enough +// to rotate or revoke someone else's connection. +func (p *Plugin) tokenForCaller(ctx forge.Context, raw string) (id.SCIMTokenID, error) { + if p.service == nil { + return id.SCIMTokenID{}, forge.NewHTTPError(http.StatusInternalServerError, "scim service unavailable") + } + tokenID, err := id.ParseSCIMTokenID(raw) + if err != nil { + return id.SCIMTokenID{}, forge.BadRequest(fmt.Sprintf("invalid token id: %v", err)) + } + tok, err := p.scimStore.GetToken(ctx.Context(), tokenID) + if err != nil || tok == nil { + return id.SCIMTokenID{}, forge.NotFound("scim token not found") + } + // The configuration itself is not needed here, only the check that the + // caller may manage it. + if _, err = p.configForCaller(ctx, tok.ConfigID.String()); err != nil { + return id.SCIMTokenID{}, err + } + return tokenID, nil +} + +// configAppID is the app a new configuration belongs to. SCIM configs are +// per-organization, and an organization belongs to exactly one app, so the +// engine's default is the right answer for every deployment that has one. +func (p *Plugin) configAppID() id.AppID { + appID, err := id.ParseAppID(p.defaultAppID) + if err != nil { + return id.AppID{} + } + return appID +} + +func boolOr(v *bool, fallback bool) bool { + if v == nil { + return fallback + } + return *v +} diff --git a/plugins/scim/admin_test.go b/plugins/scim/admin_test.go new file mode 100644 index 00000000..8dbb2d1d --- /dev/null +++ b/plugins/scim/admin_test.go @@ -0,0 +1,455 @@ +package scim + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/xraph/forge" + + authsome "github.com/xraph/authsome" + "github.com/xraph/authsome/id" + "github.com/xraph/authsome/internal/secutil" + "github.com/xraph/authsome/middleware" + "github.com/xraph/authsome/organization" +) + +// The SCIM admin routes are the dashboard surface: connect an identity +// provider, mint the token it authenticates with, rotate it, revoke it. +// +// They are deliberately NOT the SCIM 2.0 group. That group authenticates +// with a provisioning bearer token, and an IdP holding one has no business +// minting more, so these authenticate as an organization owner or admin +// instead. Most of what follows pins that boundary. + +type adminFixture struct { + p *Plugin + eng *authsome.Engine + h http.Handler + appID id.AppID +} + +func newAdminFixture(t *testing.T) *adminFixture { + t.Helper() + p := New() + eng := secutil.NewTestEngine(t, authsome.WithPlugin(p)) + router := forge.NewRouter() + require.NoError(t, p.RegisterRoutes(router)) + appID, err := id.ParseAppID(scimLifecycleTestAppID) + require.NoError(t, err) + return &adminFixture{p: p, eng: eng, h: router.Handler(), appID: appID} +} + +// org creates an organization with one member at the given role. +func (f *adminFixture) org(t *testing.T, role organization.MemberRole) (id.OrgID, id.UserID) { + t.Helper() + now := time.Now() + o := &organization.Organization{ + ID: id.NewOrgID(), AppID: f.appID, Name: "Acme", Slug: "acme-" + id.NewOrgID().String(), + CreatedAt: now, UpdatedAt: now, + } + require.NoError(t, f.eng.Store().CreateOrganization(context.Background(), o)) + + userID := id.NewUserID() + require.NoError(t, f.eng.Store().CreateMember(context.Background(), &organization.Member{ + ID: id.NewMemberID(), OrgID: o.ID, UserID: userID, Role: role, + CreatedAt: now, UpdatedAt: now, + })) + return o.ID, userID +} + +// config stores a SCIM configuration for an organization. +func (f *adminFixture) config(t *testing.T, orgID id.OrgID) *SCIMConfig { + t.Helper() + cfg := &SCIMConfig{ + ID: id.NewSCIMConfigID(), Name: "Okta", AppID: f.appID, OrgID: orgID, + Enabled: true, AutoCreate: true, AutoSuspend: true, + } + require.NoError(t, f.p.service.CreateConfig(context.Background(), cfg)) + return cfg +} + +func (f *adminFixture) do(t *testing.T, method, path string, body any, as id.UserID) *httptest.ResponseRecorder { + t.Helper() + var req *http.Request + if body != nil { + b, err := json.Marshal(body) + require.NoError(t, err) + req = httptest.NewRequestWithContext(context.Background(), method, path, bytes.NewReader(b)) + req.Header.Set("Content-Type", "application/json") + } else { + req = httptest.NewRequestWithContext(context.Background(), method, path, nil) + } + if !as.IsNil() { + req = req.WithContext(middleware.WithUserID(req.Context(), as)) + } + rec := httptest.NewRecorder() + f.h.ServeHTTP(rec, req) + return rec +} + +// ── Authorization ──────────────────────────────────────── + +func TestSCIMAdmin_OwnerAndAdminMayList(t *testing.T) { + for _, role := range []organization.MemberRole{organization.RoleOwner, organization.RoleAdmin} { + t.Run(string(role), func(t *testing.T) { + f := newAdminFixture(t) + orgID, userID := f.org(t, role) + f.config(t, orgID) + + rec := f.do(t, "GET", "/v1/orgs/"+orgID.String()+"/scim/configs", nil, userID) + require.Equal(t, http.StatusOK, rec.Code, "body=%s", rec.Body.String()) + + var got ConfigListResponse + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got)) + assert.Len(t, got.Configs, 1) + }) + } +} + +// Connecting an identity provider decides who gets an account, so it sits +// with the roles that already decide membership. +func TestSCIMAdmin_PlainMemberIsRefused(t *testing.T) { + f := newAdminFixture(t) + orgID, userID := f.org(t, organization.RoleMember) + + rec := f.do(t, "GET", "/v1/orgs/"+orgID.String()+"/scim/configs", nil, userID) + assert.Equal(t, http.StatusForbidden, rec.Code, "body=%s", rec.Body.String()) +} + +func TestSCIMAdmin_UnauthenticatedIsRefused(t *testing.T) { + f := newAdminFixture(t) + orgID, _ := f.org(t, organization.RoleOwner) + + rec := f.do(t, "GET", "/v1/orgs/"+orgID.String()+"/scim/configs", nil, id.UserID{}) + assert.Equal(t, http.StatusUnauthorized, rec.Code, "body=%s", rec.Body.String()) +} + +// A non-member gets 404, not 403, so probing organization IDs tells an +// outsider nothing about which ones exist. +func TestSCIMAdmin_NonMemberCannotTellTheOrgExists(t *testing.T) { + f := newAdminFixture(t) + orgID, _ := f.org(t, organization.RoleOwner) + stranger := id.NewUserID() + + existing := f.do(t, "GET", "/v1/orgs/"+orgID.String()+"/scim/configs", nil, stranger) + invented := f.do(t, "GET", "/v1/orgs/"+id.NewOrgID().String()+"/scim/configs", nil, stranger) + + assert.Equal(t, http.StatusNotFound, existing.Code) + assert.Equal(t, invented.Code, existing.Code, "an org that exists and one that does not must answer the same") +} + +// An admin of one organization is nobody in another. +func TestSCIMAdmin_CannotReachAnotherOrgsConfig(t *testing.T) { + f := newAdminFixture(t) + mineID, mineOwner := f.org(t, organization.RoleOwner) + theirsID, _ := f.org(t, organization.RoleOwner) + _ = mineID + theirCfg := f.config(t, theirsID) + + rec := f.do(t, "GET", "/v1/scim/configs/"+theirCfg.ID.String(), nil, mineOwner) + assert.Equal(t, http.StatusNotFound, rec.Code, "body=%s", rec.Body.String()) +} + +// An app-level config has no organization to check a role against, so it is +// not reachable here at all. Managing one stays an operator job. +func TestSCIMAdmin_AppLevelConfigIsNotReachable(t *testing.T) { + f := newAdminFixture(t) + _, owner := f.org(t, organization.RoleOwner) + + appCfg := &SCIMConfig{ + ID: id.NewSCIMConfigID(), Name: "app-wide", AppID: f.appID, Enabled: true, + } + require.NoError(t, f.p.service.CreateConfig(context.Background(), appCfg)) + + rec := f.do(t, "GET", "/v1/scim/configs/"+appCfg.ID.String(), nil, owner) + assert.Equal(t, http.StatusNotFound, rec.Code, "body=%s", rec.Body.String()) +} + +// ── Configurations ─────────────────────────────────────── + +func TestSCIMAdmin_CreateDefaultsToConnected(t *testing.T) { + f := newAdminFixture(t) + orgID, owner := f.org(t, organization.RoleOwner) + + rec := f.do(t, "POST", "/v1/orgs/"+orgID.String()+"/scim/configs", + map[string]any{"name": "Okta"}, owner) + require.Equal(t, http.StatusCreated, rec.Code, "body=%s", rec.Body.String()) + + var got SCIMConfig + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got)) + // A connection that quietly does nothing is worse than no connection. + assert.True(t, got.Enabled) + assert.True(t, got.AutoCreate) + assert.True(t, got.AutoSuspend) + assert.False(t, got.GroupSync) + assert.Equal(t, orgID.String(), got.OrgID.String()) +} + +func TestSCIMAdmin_CreateHonoursExplicitFalse(t *testing.T) { + f := newAdminFixture(t) + orgID, owner := f.org(t, organization.RoleOwner) + + rec := f.do(t, "POST", "/v1/orgs/"+orgID.String()+"/scim/configs", + map[string]any{"name": "Okta", "auto_create": false, "group_sync": true}, owner) + require.Equal(t, http.StatusCreated, rec.Code, "body=%s", rec.Body.String()) + + var got SCIMConfig + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got)) + assert.False(t, got.AutoCreate, "an explicit false is not the same as leaving it out") + assert.True(t, got.GroupSync) +} + +func TestSCIMAdmin_CreateRejectsAnEmptyName(t *testing.T) { + f := newAdminFixture(t) + orgID, owner := f.org(t, organization.RoleOwner) + + rec := f.do(t, "POST", "/v1/orgs/"+orgID.String()+"/scim/configs", + map[string]any{"name": " "}, owner) + assert.Equal(t, http.StatusBadRequest, rec.Code, "body=%s", rec.Body.String()) +} + +// PATCH changes what you send and leaves the rest alone. +func TestSCIMAdmin_UpdateIsPartial(t *testing.T) { + f := newAdminFixture(t) + orgID, owner := f.org(t, organization.RoleOwner) + cfg := f.config(t, orgID) + + rec := f.do(t, "PATCH", "/v1/scim/configs/"+cfg.ID.String(), + map[string]any{"enabled": false}, owner) + require.Equal(t, http.StatusOK, rec.Code, "body=%s", rec.Body.String()) + + var got SCIMConfig + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got)) + assert.False(t, got.Enabled) + assert.Equal(t, "Okta", got.Name, "a field left out of a PATCH keeps its value") + assert.True(t, got.AutoCreate) +} + +// Every optional field is its own branch, and a PATCH that sets all of them +// is the only way to know none of them was wired to the wrong struct member. +func TestSCIMAdmin_UpdateCanChangeEverything(t *testing.T) { + f := newAdminFixture(t) + orgID, owner := f.org(t, organization.RoleOwner) + cfg := f.config(t, orgID) + + rec := f.do(t, "PATCH", "/v1/scim/configs/"+cfg.ID.String(), map[string]any{ + "name": "Entra", + "enabled": false, + "auto_create": false, + "auto_suspend": false, + "group_sync": true, + "default_role": "admin", + "metadata": map[string]string{"env": "prod"}, + }, owner) + require.Equal(t, http.StatusOK, rec.Code, "body=%s", rec.Body.String()) + + var got SCIMConfig + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got)) + assert.Equal(t, "Entra", got.Name) + assert.False(t, got.Enabled) + assert.False(t, got.AutoCreate) + assert.False(t, got.AutoSuspend) + assert.True(t, got.GroupSync) + assert.Equal(t, "admin", got.DefaultRole) + assert.Equal(t, map[string]string{"env": "prod"}, got.Metadata) +} + +func TestSCIMAdmin_UpdateRejectsAnEmptyName(t *testing.T) { + f := newAdminFixture(t) + orgID, owner := f.org(t, organization.RoleOwner) + cfg := f.config(t, orgID) + + rec := f.do(t, "PATCH", "/v1/scim/configs/"+cfg.ID.String(), + map[string]any{"name": " "}, owner) + assert.Equal(t, http.StatusBadRequest, rec.Code, "body=%s", rec.Body.String()) +} + +// A config with no tokens answers with an empty list, not null, so a client +// can iterate it without a nil check. +func TestSCIMAdmin_ListTokensIsEmptyNotNull(t *testing.T) { + f := newAdminFixture(t) + orgID, owner := f.org(t, organization.RoleOwner) + cfg := f.config(t, orgID) + + rec := f.do(t, "GET", "/v1/scim/configs/"+cfg.ID.String()+"/tokens", nil, owner) + require.Equal(t, http.StatusOK, rec.Code, "body=%s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), "[]") + + var got TokenListResponse + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got)) + assert.NotNil(t, got.Tokens) + assert.Empty(t, got.Tokens) +} + +// Same for configurations: an org that has connected nothing yet. +func TestSCIMAdmin_ListConfigsIsEmptyNotNull(t *testing.T) { + f := newAdminFixture(t) + orgID, owner := f.org(t, organization.RoleOwner) + + rec := f.do(t, "GET", "/v1/orgs/"+orgID.String()+"/scim/configs", nil, owner) + require.Equal(t, http.StatusOK, rec.Code, "body=%s", rec.Body.String()) + + var got ConfigListResponse + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got)) + assert.NotNil(t, got.Configs) + assert.Empty(t, got.Configs) +} + +func TestSCIMAdmin_CreateTokenRejectsAnEmptyName(t *testing.T) { + f := newAdminFixture(t) + orgID, owner := f.org(t, organization.RoleOwner) + cfg := f.config(t, orgID) + + rec := f.do(t, "POST", "/v1/scim/configs/"+cfg.ID.String()+"/tokens", + map[string]any{"name": ""}, owner) + assert.Equal(t, http.StatusBadRequest, rec.Code, "body=%s", rec.Body.String()) +} + +// Rotating or revoking a token that does not exist is a 404, not a 500. +func TestSCIMAdmin_UnknownTokenIsNotFound(t *testing.T) { + f := newAdminFixture(t) + _, owner := f.org(t, organization.RoleOwner) + ghost := id.NewSCIMTokenID().String() + + assert.Equal(t, http.StatusNotFound, + f.do(t, "DELETE", "/v1/scim/tokens/"+ghost, nil, owner).Code) + assert.Equal(t, http.StatusNotFound, + f.do(t, "POST", "/v1/scim/tokens/"+ghost+"/rotate", map[string]any{}, owner).Code) +} + +func TestSCIMAdmin_DeleteRemovesIt(t *testing.T) { + f := newAdminFixture(t) + orgID, owner := f.org(t, organization.RoleOwner) + cfg := f.config(t, orgID) + + rec := f.do(t, "DELETE", "/v1/scim/configs/"+cfg.ID.String(), nil, owner) + require.Equal(t, http.StatusOK, rec.Code, "body=%s", rec.Body.String()) + + after := f.do(t, "GET", "/v1/scim/configs/"+cfg.ID.String(), nil, owner) + assert.Equal(t, http.StatusNotFound, after.Code) +} + +func TestSCIMAdmin_MalformedIDsAreBadRequests(t *testing.T) { + f := newAdminFixture(t) + _, owner := f.org(t, organization.RoleOwner) + + assert.Equal(t, http.StatusBadRequest, + f.do(t, "GET", "/v1/orgs/not-an-org/scim/configs", nil, owner).Code) + assert.Equal(t, http.StatusBadRequest, + f.do(t, "GET", "/v1/scim/configs/not-a-config", nil, owner).Code) + assert.Equal(t, http.StatusBadRequest, + f.do(t, "DELETE", "/v1/scim/tokens/not-a-token", nil, owner).Code) +} + +// ── Tokens ─────────────────────────────────────────────── + +func TestSCIMAdmin_CreateTokenReturnsThePlaintextOnce(t *testing.T) { + f := newAdminFixture(t) + orgID, owner := f.org(t, organization.RoleOwner) + cfg := f.config(t, orgID) + + rec := f.do(t, "POST", "/v1/scim/configs/"+cfg.ID.String()+"/tokens", + map[string]any{"name": "Okta production"}, owner) + require.Equal(t, http.StatusCreated, rec.Code, "body=%s", rec.Body.String()) + + var created TokenCreatedResponse + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &created)) + require.NotEmpty(t, created.Plaintext) + assert.NotEmpty(t, created.BaseURL, "the IdP needs the URL as well as the token") + + // Listing afterwards shows the record and never the value again. + list := f.do(t, "GET", "/v1/scim/configs/"+cfg.ID.String()+"/tokens", nil, owner) + require.Equal(t, http.StatusOK, list.Code) + assert.NotContains(t, list.Body.String(), created.Plaintext) + + var tokens TokenListResponse + require.NoError(t, json.Unmarshal(list.Body.Bytes(), &tokens)) + require.Len(t, tokens.Tokens, 1) + assert.Equal(t, "Okta production", tokens.Tokens[0].Name) +} + +func TestSCIMAdmin_CreateTokenRejectsAPastExpiry(t *testing.T) { + f := newAdminFixture(t) + orgID, owner := f.org(t, organization.RoleOwner) + cfg := f.config(t, orgID) + + rec := f.do(t, "POST", "/v1/scim/configs/"+cfg.ID.String()+"/tokens", + map[string]any{"name": "stale", "expires_at": time.Now().Add(-time.Hour)}, owner) + assert.Equal(t, http.StatusBadRequest, rec.Code, "body=%s", rec.Body.String()) +} + +func TestSCIMAdmin_RotateMintsADifferentToken(t *testing.T) { + f := newAdminFixture(t) + orgID, owner := f.org(t, organization.RoleOwner) + cfg := f.config(t, orgID) + + first, tok, err := f.p.service.GenerateToken(context.Background(), cfg.ID, "first", nil) + require.NoError(t, err) + + rec := f.do(t, "POST", "/v1/scim/tokens/"+tok.ID.String()+"/rotate", + map[string]any{"name": "second", "grace_seconds": 60}, owner) + require.Equal(t, http.StatusCreated, rec.Code, "body=%s", rec.Body.String()) + + var rotated TokenCreatedResponse + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &rotated)) + assert.NotEqual(t, first, rotated.Plaintext) +} + +func TestSCIMAdmin_RotateRejectsANegativeGrace(t *testing.T) { + f := newAdminFixture(t) + orgID, owner := f.org(t, organization.RoleOwner) + cfg := f.config(t, orgID) + _, tok, err := f.p.service.GenerateToken(context.Background(), cfg.ID, "t", nil) + require.NoError(t, err) + + rec := f.do(t, "POST", "/v1/scim/tokens/"+tok.ID.String()+"/rotate", + map[string]any{"grace_seconds": -1}, owner) + assert.Equal(t, http.StatusBadRequest, rec.Code, "body=%s", rec.Body.String()) +} + +func TestSCIMAdmin_RevokeToken(t *testing.T) { + f := newAdminFixture(t) + orgID, owner := f.org(t, organization.RoleOwner) + cfg := f.config(t, orgID) + _, tok, err := f.p.service.GenerateToken(context.Background(), cfg.ID, "t", nil) + require.NoError(t, err) + + rec := f.do(t, "DELETE", "/v1/scim/tokens/"+tok.ID.String(), nil, owner) + require.Equal(t, http.StatusOK, rec.Code, "body=%s", rec.Body.String()) + + var status StatusResponse + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &status)) + assert.Equal(t, "revoked", status.Status) +} + +// A token id alone must not be enough. Without the ownership check on the +// configuration it belongs to, anyone could rotate or revoke anyone's token. +func TestSCIMAdmin_CannotTouchAnotherOrgsToken(t *testing.T) { + f := newAdminFixture(t) + _, mineOwner := f.org(t, organization.RoleOwner) + theirsID, _ := f.org(t, organization.RoleOwner) + theirCfg := f.config(t, theirsID) + _, theirTok, err := f.p.service.GenerateToken(context.Background(), theirCfg.ID, "theirs", nil) + require.NoError(t, err) + + assert.Equal(t, http.StatusNotFound, + f.do(t, "DELETE", "/v1/scim/tokens/"+theirTok.ID.String(), nil, mineOwner).Code) + assert.Equal(t, http.StatusNotFound, + f.do(t, "POST", "/v1/scim/tokens/"+theirTok.ID.String()+"/rotate", map[string]any{}, mineOwner).Code) +} + +func TestBoolOr(t *testing.T) { + yes, no := true, false + assert.True(t, boolOr(nil, true), "nil means take the default") + assert.False(t, boolOr(nil, false)) + assert.True(t, boolOr(&yes, false), "a pointer wins over the default") + assert.False(t, boolOr(&no, true)) +} diff --git a/plugins/scim/admin_types.go b/plugins/scim/admin_types.go new file mode 100644 index 00000000..0f866a51 --- /dev/null +++ b/plugins/scim/admin_types.go @@ -0,0 +1,104 @@ +package scim + +import "time" + +// --------------------------------------------------------------------------- +// Admin API requests and responses +// +// These are the routes a dashboard calls to connect an IdP: create a config, +// mint a token to paste into the IdP, rotate it, revoke it. They are a +// separate surface from the SCIM 2.0 protocol in routes.go, which an IdP +// calls with one of these tokens, and they are mounted under /v1 rather than +// the SCIM base path so an IdP's bearer token can never reach them. +// --------------------------------------------------------------------------- + +// ListSCIMConfigsRequest binds GET /orgs/:orgId/scim/configs. +type ListSCIMConfigsRequest struct { + OrgID string `path:"orgId" description:"Organization ID"` +} + +// ConfigListResponse is a page of SCIM configurations. +type ConfigListResponse struct { + Configs []*SCIMConfig `json:"configs" description:"SCIM configurations for the organization"` +} + +// CreateSCIMConfigRequest binds POST /orgs/:orgId/scim/configs. +type CreateSCIMConfigRequest struct { + OrgID string `path:"orgId" description:"Organization ID"` + Name string `json:"name" description:"A name for this connection, e.g. the IdP's name"` + Enabled *bool `json:"enabled,omitempty" description:"Whether provisioning is accepted. Defaults to true"` + AutoCreate *bool `json:"auto_create,omitempty" description:"Create users on first push"` + AutoSuspend *bool `json:"auto_suspend,omitempty" description:"Suspend users the IdP deactivates"` + GroupSync *bool `json:"group_sync,omitempty" description:"Sync SCIM groups to teams"` + DefaultRole string `json:"default_role,omitempty" description:"Org role given to provisioned users"` + Metadata map[string]string `json:"metadata,omitempty" description:"Free-form metadata"` +} + +// GetSCIMConfigRequest binds GET and DELETE /scim/configs/:configId. +type GetSCIMConfigRequest struct { + ConfigID string `path:"configId" description:"SCIM configuration ID"` +} + +// UpdateSCIMConfigRequest binds PATCH /scim/configs/:configId. Every field is +// a pointer so that leaving one out means "do not change it", which is what +// separates a PATCH from a PUT. +type UpdateSCIMConfigRequest struct { + ConfigID string `path:"configId" description:"SCIM configuration ID"` + Name *string `json:"name,omitempty" description:"New name"` + Enabled *bool `json:"enabled,omitempty" description:"Turn provisioning on or off"` + AutoCreate *bool `json:"auto_create,omitempty" description:"Create users on first push"` + AutoSuspend *bool `json:"auto_suspend,omitempty" description:"Suspend users the IdP deactivates"` + GroupSync *bool `json:"group_sync,omitempty" description:"Sync SCIM groups to teams"` + DefaultRole *string `json:"default_role,omitempty" description:"Org role given to provisioned users"` + Metadata map[string]string `json:"metadata,omitempty" description:"Replaces the stored metadata"` +} + +// ListSCIMTokensRequest binds GET /scim/configs/:configId/tokens. +type ListSCIMTokensRequest struct { + ConfigID string `path:"configId" description:"SCIM configuration ID"` +} + +// TokenListResponse is a config's tokens. The plaintext is never here: +// it exists once, in the response that created it. +type TokenListResponse struct { + Tokens []*Token `json:"tokens" description:"Tokens for this configuration"` +} + +// CreateSCIMTokenRequest binds POST /scim/configs/:configId/tokens. +type CreateSCIMTokenRequest struct { + ConfigID string `path:"configId" description:"SCIM configuration ID"` + Name string `json:"name" description:"A name for this token, e.g. which IdP holds it"` + ExpiresAt *time.Time `json:"expires_at,omitempty" description:"When it stops working. Omit for no expiry"` +} + +// RotateSCIMTokenRequest binds POST /scim/tokens/:tokenId/rotate. +type RotateSCIMTokenRequest struct { + TokenID string `path:"tokenId" description:"Token being replaced"` + Name string `json:"name,omitempty" description:"Name for the replacement"` + // GraceSeconds keeps the old token working for a while after the new one + // is minted, so an IdP can be updated without a window where provisioning + // fails. Zero revokes the old one at once. + GraceSeconds int `json:"grace_seconds,omitempty" description:"How long the replaced token keeps working, in seconds"` +} + +// RevokeSCIMTokenRequest binds DELETE /scim/tokens/:tokenId. +type RevokeSCIMTokenRequest struct { + TokenID string `path:"tokenId" description:"Token to revoke"` +} + +// TokenCreatedResponse carries a freshly minted token. +// +// Plaintext appears here and nowhere else, ever: the store keeps a hash. A +// caller that loses it has to rotate. The base URL is included because it is +// the other half of what gets pasted into an IdP, and having to assemble it +// from documentation is how people paste the wrong one. +type TokenCreatedResponse struct { + Token *Token `json:"token" description:"The token record"` + Plaintext string `json:"plaintext" description:"The bearer token. Shown once and never again"` + BaseURL string `json:"base_url" description:"SCIM 2.0 base URL to configure in the identity provider"` +} + +// StatusResponse is the answer to a delete. +type StatusResponse struct { + Status string `json:"status" description:"Outcome"` +} diff --git a/plugins/scim/routes.go b/plugins/scim/routes.go index ec75452e..ad98f8aa 100644 --- a/plugins/scim/routes.go +++ b/plugins/scim/routes.go @@ -1,6 +1,8 @@ package scim import ( + "net/http" + "github.com/xraph/forge" authsome "github.com/xraph/authsome" @@ -119,8 +121,114 @@ func (p *Plugin) RegisterRoutes(router forge.Router) error { return err } - return scim.DELETE("/Groups/:groupId", p.handleDeleteGroup, + if err := scim.DELETE("/Groups/:groupId", p.handleDeleteGroup, forge.WithSummary("Delete SCIM Group"), forge.WithOperationID("scimDeleteGroup"), + ); err != nil { + return err + } + + return p.registerAdminRoutes(router) +} + +// registerAdminRoutes mounts the dashboard surface: creating a SCIM +// configuration, minting a token for an identity provider, rotating it, +// revoking it. +// +// These live under /v1 rather than the SCIM base path on purpose. The SCIM +// group authenticates with a provisioning bearer token, and an identity +// provider holding one has no business creating more. These authenticate as +// a signed-in organization owner or admin instead. +func (p *Plugin) registerAdminRoutes(router forge.Router) error { + g := router.Group("/v1", forge.WithGroupTags("SCIM Admin")) + + if err := g.GET("/orgs/:orgId/scim/configs", p.handleListConfigs, + forge.WithSummary("List SCIM configurations"), + forge.WithDescription("Lists an organization's SCIM configurations. Requires an organization owner or admin."), + forge.WithOperationID("listSCIMConfigs"), + forge.WithResponseSchema(http.StatusOK, "SCIM configurations", ConfigListResponse{}), + forge.WithErrorResponses(), + ); err != nil { + return err + } + + if err := g.POST("/orgs/:orgId/scim/configs", p.handleCreateConfig, + forge.WithSummary("Create SCIM configuration"), + forge.WithDescription("Creates a SCIM configuration for an organization, which an identity provider then provisions into."), + forge.WithOperationID("createSCIMConfig"), + forge.WithRequestSchema(CreateSCIMConfigRequest{}), + forge.WithCreatedResponse(SCIMConfig{}), + forge.WithErrorResponses(), + ); err != nil { + return err + } + + if err := g.GET("/scim/configs/:configId", p.handleGetConfig, + forge.WithSummary("Get SCIM configuration"), + forge.WithOperationID("getSCIMConfig"), + forge.WithResponseSchema(http.StatusOK, "SCIM configuration", SCIMConfig{}), + forge.WithErrorResponses(), + ); err != nil { + return err + } + + if err := g.PATCH("/scim/configs/:configId", p.handleUpdateConfig, + forge.WithSummary("Update SCIM configuration"), + forge.WithDescription("Changes only the fields present in the body."), + forge.WithOperationID("updateSCIMConfig"), + forge.WithRequestSchema(UpdateSCIMConfigRequest{}), + forge.WithResponseSchema(http.StatusOK, "Updated configuration", SCIMConfig{}), + forge.WithErrorResponses(), + ); err != nil { + return err + } + + if err := g.DELETE("/scim/configs/:configId", p.handleDeleteConfig, + forge.WithSummary("Delete SCIM configuration"), + forge.WithDescription("Removes the configuration and stops accepting provisioning for it."), + forge.WithOperationID("deleteSCIMConfig"), + forge.WithResponseSchema(http.StatusOK, "Deleted", StatusResponse{}), + forge.WithErrorResponses(), + ); err != nil { + return err + } + + if err := g.GET("/scim/configs/:configId/tokens", p.handleListTokens, + forge.WithSummary("List SCIM tokens"), + forge.WithDescription("Lists a configuration's tokens. The token values themselves are never returned."), + forge.WithOperationID("listSCIMTokens"), + forge.WithResponseSchema(http.StatusOK, "Tokens", TokenListResponse{}), + forge.WithErrorResponses(), + ); err != nil { + return err + } + + if err := g.POST("/scim/configs/:configId/tokens", p.handleCreateToken, + forge.WithSummary("Create SCIM token"), + forge.WithDescription("Mints a bearer token for an identity provider. The plaintext is returned once and never again."), + forge.WithOperationID("createSCIMToken"), + forge.WithRequestSchema(CreateSCIMTokenRequest{}), + forge.WithCreatedResponse(TokenCreatedResponse{}), + forge.WithErrorResponses(), + ); err != nil { + return err + } + + if err := g.POST("/scim/tokens/:tokenId/rotate", p.handleRotateToken, + forge.WithSummary("Rotate SCIM token"), + forge.WithDescription("Mints a replacement and optionally keeps the old token working for a grace period, so an identity provider can be updated without a gap in provisioning."), + forge.WithOperationID("rotateSCIMToken"), + forge.WithRequestSchema(RotateSCIMTokenRequest{}), + forge.WithCreatedResponse(TokenCreatedResponse{}), + forge.WithErrorResponses(), + ); err != nil { + return err + } + + return g.DELETE("/scim/tokens/:tokenId", p.handleRevokeToken, + forge.WithSummary("Revoke SCIM token"), + forge.WithOperationID("revokeSCIMToken"), + forge.WithResponseSchema(http.StatusOK, "Revoked", StatusResponse{}), + forge.WithErrorResponses(), ) } diff --git a/sdk/dart/lib/src/client.dart b/sdk/dart/lib/src/client.dart index 890d55e1..81f646ef 100644 --- a/sdk/dart/lib/src/client.dart +++ b/sdk/dart/lib/src/client.dart @@ -1985,6 +1985,18 @@ class AuthClient { return EnrollResponse.fromJson(Map.from(res as Map)); } + /// Read MFA enrollment + /// GET /v1/mfa/enrollment + Future getMFAEnrollment({required String token}) async { + final path = '/v1/mfa/enrollment'; + final res = await _request( +'GET', + path, + token: token, + ); + return EnrollmentResponse.fromJson(Map.from(res as Map)); + } + /// Disable MFA /// DELETE /v1/mfa/enrollment Future disableMFA({required DisableRequest body, required String token}) async { @@ -2388,6 +2400,18 @@ class AuthClient { return OrganizationStatusResponse.fromJson(Map.from(res as Map)); } + /// Get invitation + /// GET /v1/orgs/invitations/{inviteToken} + Future 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.from(res as Map)); + } + /// Get organization /// GET /v1/orgs/{orgId} Future getOrganization({required String orgId, required String token}) async { @@ -2518,6 +2542,31 @@ class AuthClient { return OrganizationStatusResponse.fromJson(Map.from(res as Map)); } + /// List SCIM configurations + /// GET /v1/orgs/{orgId}/scim/configs + Future 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.from(res as Map)); + } + + /// Create SCIM configuration + /// POST /v1/orgs/{orgId}/scim/configs + Future 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.from(res as Map)); + } + /// List teams /// GET /v1/orgs/{orgId}/teams Future listTeams({required String orgId, required String token, int? limit, String? cursor}) async { @@ -2868,6 +2917,93 @@ class AuthClient { return ApiStatusResponse.fromJson(Map.from(res as Map)); } + /// Get SCIM configuration + /// GET /v1/scim/configs/{configId} + Future 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.from(res as Map)); + } + + /// Update SCIM configuration + /// PATCH /v1/scim/configs/{configId} + Future 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.from(res as Map)); + } + + /// Delete SCIM configuration + /// DELETE /v1/scim/configs/{configId} + Future 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.from(res as Map)); + } + + /// List SCIM tokens + /// GET /v1/scim/configs/{configId}/tokens + Future 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.from(res as Map)); + } + + /// Create SCIM token + /// POST /v1/scim/configs/{configId}/tokens + Future 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.from(res as Map)); + } + + /// Revoke SCIM token + /// DELETE /v1/scim/tokens/{tokenId} + Future 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.from(res as Map)); + } + + /// Rotate SCIM token + /// POST /v1/scim/tokens/{tokenId}/rotate + Future 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.from(res as Map)); + } + /// List sessions /// GET /v1/sessions Future listSessions({required String token, int? limit, String? cursor}) async { diff --git a/sdk/dart/lib/src/types.dart b/sdk/dart/lib/src/types.dart index 58b64ed6..ce9d5654 100644 --- a/sdk/dart/lib/src/types.dart +++ b/sdk/dart/lib/src/types.dart @@ -2286,6 +2286,29 @@ class CloneEnvironmentResponse { String toString() => 'CloneEnvironmentResponse(${toJson()})'; } +class ConfigListResponse { + final List configs; + + const ConfigListResponse({ + required this.configs, + }); + + factory ConfigListResponse.fromJson(Map json) { + return ConfigListResponse( + configs: (json['configs'] as List).map((e) => SCIMConfig.fromJson(Map.from(e as Map))).toList(), + ); + } + + Map toJson() { + return { + 'configs': configs.map((e) => e.toJson()).toList(), + }; + } + + @override + String toString() => 'ConfigListResponse(${toJson()})'; +} + class Connection { final String? acsUrl; final bool active; @@ -3160,6 +3183,80 @@ class CreateRoleRequest { String toString() => 'CreateRoleRequest(${toJson()})'; } +class CreateSCIMConfigRequest { + final bool? autoCreate; + final bool? autoSuspend; + final String? defaultRole; + final bool? enabled; + final bool? groupSync; + final Map? metadata; + final String name; + + const CreateSCIMConfigRequest({ + this.autoCreate, + this.autoSuspend, + this.defaultRole, + this.enabled, + this.groupSync, + this.metadata, + required this.name, + }); + + factory CreateSCIMConfigRequest.fromJson(Map json) { + return CreateSCIMConfigRequest( + autoCreate: json['auto_create'] as bool?, + autoSuspend: json['auto_suspend'] as bool?, + defaultRole: json['default_role'] as String?, + enabled: json['enabled'] as bool?, + groupSync: json['group_sync'] as bool?, + metadata: json['metadata'] == null ? null : Map.from(json['metadata'] as Map), + name: json['name'] as String, + ); + } + + Map toJson() { + return { + if (autoCreate != null) 'auto_create': autoCreate, + if (autoSuspend != null) 'auto_suspend': autoSuspend, + if (defaultRole != null) 'default_role': defaultRole, + if (enabled != null) 'enabled': enabled, + if (groupSync != null) 'group_sync': groupSync, + if (metadata != null) 'metadata': metadata, + 'name': name, + }; + } + + @override + String toString() => 'CreateSCIMConfigRequest(${toJson()})'; +} + +class CreateSCIMTokenRequest { + final String? expiresAt; + final String name; + + const CreateSCIMTokenRequest({ + this.expiresAt, + required this.name, + }); + + factory CreateSCIMTokenRequest.fromJson(Map json) { + return CreateSCIMTokenRequest( + expiresAt: json['expires_at'] as String?, + name: json['name'] as String, + ); + } + + Map toJson() { + return { + if (expiresAt != null) 'expires_at': expiresAt, + 'name': name, + }; + } + + @override + String toString() => 'CreateSCIMTokenRequest(${toJson()})'; +} + class CreateSubscriptionRequest { final String appId; final String planId; @@ -4019,6 +4116,68 @@ class EnrollResponse { String toString() => 'EnrollResponse(${toJson()})'; } +class EnrolledFactor { + final String enrolledAt; + final String method; + final bool verified; + + const EnrolledFactor({ + required this.enrolledAt, + required this.method, + required this.verified, + }); + + factory EnrolledFactor.fromJson(Map json) { + return EnrolledFactor( + enrolledAt: json['enrolled_at'] as String, + method: json['method'] as String, + verified: json['verified'] as bool, + ); + } + + Map toJson() { + return { + 'enrolled_at': enrolledAt, + 'method': method, + 'verified': verified, + }; + } + + @override + String toString() => 'EnrolledFactor(${toJson()})'; +} + +class EnrollmentResponse { + final bool enabled; + final List factors; + final int recoveryCodesRemaining; + + const EnrollmentResponse({ + required this.enabled, + required this.factors, + required this.recoveryCodesRemaining, + }); + + factory EnrollmentResponse.fromJson(Map json) { + return EnrollmentResponse( + enabled: json['enabled'] as bool, + factors: (json['factors'] as List).map((e) => EnrolledFactor.fromJson(Map.from(e as Map))).toList(), + recoveryCodesRemaining: (json['recovery_codes_remaining'] as num).toInt(), + ); + } + + Map toJson() { + return { + 'enabled': enabled, + 'factors': factors.map((e) => e.toJson()).toList(), + 'recovery_codes_remaining': recoveryCodesRemaining, + }; + } + + @override + String toString() => 'EnrollmentResponse(${toJson()})'; +} + class EntitlementResponse { final bool allowed; final String feature; @@ -4735,6 +4894,53 @@ class InvitationListResponse { String toString() => 'InvitationListResponse(${toJson()})'; } +class InvitationPreview { + final String emailMasked; + final String expiresAt; + final String inviterName; + final String orgId; + final String orgName; + final String? orgSlug; + final String role; + + const InvitationPreview({ + required this.emailMasked, + required this.expiresAt, + required this.inviterName, + required this.orgId, + required this.orgName, + this.orgSlug, + required this.role, + }); + + factory InvitationPreview.fromJson(Map json) { + return InvitationPreview( + emailMasked: json['email_masked'] as String, + expiresAt: json['expires_at'] as String, + inviterName: json['inviter_name'] as String, + orgId: json['org_id'] as String, + orgName: json['org_name'] as String, + orgSlug: json['org_slug'] as String?, + role: json['role'] as String, + ); + } + + Map toJson() { + return { + 'email_masked': emailMasked, + 'expires_at': expiresAt, + 'inviter_name': inviterName, + 'org_id': orgId, + 'org_name': orgName, + if (orgSlug != null) 'org_slug': orgSlug, + 'role': role, + }; + } + + @override + String toString() => 'InvitationPreview(${toJson()})'; +} + class InvoiceResponse { final String currency; final String id; @@ -7003,6 +7209,33 @@ class RotateClientSecretResponse { String toString() => 'RotateClientSecretResponse(${toJson()})'; } +class RotateSCIMTokenRequest { + final int? graceSeconds; + final String? name; + + const RotateSCIMTokenRequest({ + this.graceSeconds, + this.name, + }); + + factory RotateSCIMTokenRequest.fromJson(Map json) { + return RotateSCIMTokenRequest( + graceSeconds: json['grace_seconds'] == null ? null : (json['grace_seconds'] as num).toInt(), + name: json['name'] as String?, + ); + } + + Map toJson() { + return { + if (graceSeconds != null) 'grace_seconds': graceSeconds, + if (name != null) 'name': name, + }; + } + + @override + String toString() => 'RotateSCIMTokenRequest(${toJson()})'; +} + class RotateWebhookSecretResponse { final String secret; @@ -7026,6 +7259,73 @@ class RotateWebhookSecretResponse { String toString() => 'RotateWebhookSecretResponse(${toJson()})'; } +class SCIMConfig { + final String appId; + final bool autoCreate; + final bool autoSuspend; + final String createdAt; + final String defaultRole; + final bool enabled; + final bool groupSync; + final String id; + final Map? metadata; + final String name; + final String? orgId; + final String updatedAt; + + const SCIMConfig({ + required this.appId, + required this.autoCreate, + required this.autoSuspend, + required this.createdAt, + required this.defaultRole, + required this.enabled, + required this.groupSync, + required this.id, + this.metadata, + required this.name, + this.orgId, + required this.updatedAt, + }); + + factory SCIMConfig.fromJson(Map json) { + return SCIMConfig( + appId: json['app_id'] as String, + autoCreate: json['auto_create'] as bool, + autoSuspend: json['auto_suspend'] as bool, + createdAt: json['created_at'] as String, + defaultRole: json['default_role'] as String, + enabled: json['enabled'] as bool, + groupSync: json['group_sync'] as bool, + id: json['id'] as String, + metadata: json['metadata'] == null ? null : Map.from(json['metadata'] as Map), + name: json['name'] as String, + orgId: json['org_id'] as String?, + updatedAt: json['updated_at'] as String, + ); + } + + Map toJson() { + return { + 'app_id': appId, + 'auto_create': autoCreate, + 'auto_suspend': autoSuspend, + 'created_at': createdAt, + 'default_role': defaultRole, + 'enabled': enabled, + 'group_sync': groupSync, + 'id': id, + if (metadata != null) 'metadata': metadata, + 'name': name, + if (orgId != null) 'org_id': orgId, + 'updated_at': updatedAt, + }; + } + + @override + String toString() => 'SCIMConfig(${toJson()})'; +} + class SMSSendRequest { final String? phone; @@ -7187,6 +7487,29 @@ class ScimListResponse { String toString() => 'ScimListResponse(${toJson()})'; } +class ScimStatusResponse { + final String status; + + const ScimStatusResponse({ + required this.status, + }); + + factory ScimStatusResponse.fromJson(Map json) { + return ScimStatusResponse( + status: json['status'] as String, + ); + } + + Map toJson() { + return { + 'status': status, + }; + } + + @override + String toString() => 'ScimStatusResponse(${toJson()})'; +} + class ScopeValue { final bool enforced; final String scope; @@ -8282,6 +8605,80 @@ class TenantRequest { String toString() => 'TenantRequest(${toJson()})'; } +class Token { + final String configId; + final String createdAt; + final String? expiresAt; + final String id; + final String? lastUsedAt; + final String name; + + const Token({ + required this.configId, + required this.createdAt, + this.expiresAt, + required this.id, + this.lastUsedAt, + required this.name, + }); + + factory Token.fromJson(Map json) { + return Token( + configId: json['config_id'] as String, + createdAt: json['created_at'] as String, + expiresAt: json['expires_at'] as String?, + id: json['id'] as String, + lastUsedAt: json['last_used_at'] as String?, + name: json['name'] as String, + ); + } + + Map toJson() { + return { + 'config_id': configId, + 'created_at': createdAt, + if (expiresAt != null) 'expires_at': expiresAt, + 'id': id, + if (lastUsedAt != null) 'last_used_at': lastUsedAt, + 'name': name, + }; + } + + @override + String toString() => 'Token(${toJson()})'; +} + +class TokenCreatedResponse { + final String baseUrl; + final String plaintext; + final Token? token; + + const TokenCreatedResponse({ + required this.baseUrl, + required this.plaintext, + this.token, + }); + + factory TokenCreatedResponse.fromJson(Map json) { + return TokenCreatedResponse( + baseUrl: json['base_url'] as String, + plaintext: json['plaintext'] as String, + token: json['token'] == null ? null : Token.fromJson(Map.from(json['token'] as Map)), + ); + } + + Map toJson() { + return { + 'base_url': baseUrl, + 'plaintext': plaintext, + if (token != null) 'token': token?.toJson(), + }; + } + + @override + String toString() => 'TokenCreatedResponse(${toJson()})'; +} + class TokenExchangeRequest { final List? scopes; final String subject; @@ -8356,6 +8753,29 @@ class TokenExchangeResponse { String toString() => 'TokenExchangeResponse(${toJson()})'; } +class TokenListResponse { + final List tokens; + + const TokenListResponse({ + required this.tokens, + }); + + factory TokenListResponse.fromJson(Map json) { + return TokenListResponse( + tokens: (json['tokens'] as List).map((e) => Token.fromJson(Map.from(e as Map))).toList(), + ); + } + + Map toJson() { + return { + 'tokens': tokens.map((e) => e.toJson()).toList(), + }; + } + + @override + String toString() => 'TokenListResponse(${toJson()})'; +} + class UIMetadata { final VisibilityCondition? condition; final String? helpText; @@ -8884,6 +9304,53 @@ class UpdateRoleRequest { String toString() => 'UpdateRoleRequest(${toJson()})'; } +class UpdateSCIMConfigRequest { + final bool? autoCreate; + final bool? autoSuspend; + final String? defaultRole; + final bool? enabled; + final bool? groupSync; + final Map? metadata; + final String? name; + + const UpdateSCIMConfigRequest({ + this.autoCreate, + this.autoSuspend, + this.defaultRole, + this.enabled, + this.groupSync, + this.metadata, + this.name, + }); + + factory UpdateSCIMConfigRequest.fromJson(Map json) { + return UpdateSCIMConfigRequest( + autoCreate: json['auto_create'] as bool?, + autoSuspend: json['auto_suspend'] as bool?, + defaultRole: json['default_role'] as String?, + enabled: json['enabled'] as bool?, + groupSync: json['group_sync'] as bool?, + metadata: json['metadata'] == null ? null : Map.from(json['metadata'] as Map), + name: json['name'] as String?, + ); + } + + Map toJson() { + return { + if (autoCreate != null) 'auto_create': autoCreate, + if (autoSuspend != null) 'auto_suspend': autoSuspend, + if (defaultRole != null) 'default_role': defaultRole, + if (enabled != null) 'enabled': enabled, + if (groupSync != null) 'group_sync': groupSync, + if (metadata != null) 'metadata': metadata, + if (name != null) 'name': name, + }; + } + + @override + String toString() => 'UpdateSCIMConfigRequest(${toJson()})'; +} + class UpdateSubscriptionRequest { final String? cancelUrl; final String? plan; diff --git a/sdk/go/client.go b/sdk/go/client.go index 213e4946..0d5a1318 100644 --- a/sdk/go/client.go +++ b/sdk/go/client.go @@ -2172,6 +2172,16 @@ func (c *Client) EnrollMFA(ctx context.Context, req *EnrollMFARequest) (*EnrollR return &result, nil } +// GetMFAEnrollment — Read MFA enrollment +func (c *Client) GetMFAEnrollment(ctx context.Context) (*EnrollmentResponse, error) { + path := "/v1/mfa/enrollment" + var result EnrollmentResponse + if err := c.do(ctx, "GET", path, nil, &result); err != nil { + return nil, err + } + return &result, nil +} + // DisableMFA — Disable MFA func (c *Client) DisableMFA(ctx context.Context, req *DisableMFARequest) (*DisableResponse, error) { body, err := json.Marshal(req) @@ -2667,6 +2677,17 @@ func (c *Client) DeclineInvitation(ctx context.Context, req *DeclineInvitationRe return &result, nil } +// GetInvitation — Get invitation +func (c *Client) GetInvitation(ctx context.Context, inviteToken string) (*InvitationPreview, error) { + path := "/v1/orgs/invitations/{inviteToken}" + path = strings.Replace(path, "{inviteToken}", inviteToken, 1) + var result InvitationPreview + if err := c.do(ctx, "GET", path, nil, &result); err != nil { + return nil, err + } + return &result, nil +} + // GetOrganization — Get organization func (c *Client) GetOrganization(ctx context.Context, orgId string) (*Organization, error) { path := "/v1/orgs/{orgId}" @@ -2810,6 +2831,32 @@ func (c *Client) RemoveMember(ctx context.Context, orgId string, memberId string return &result, nil } +// ListSCIMConfigs — List SCIM configurations +func (c *Client) ListSCIMConfigs(ctx context.Context, orgId string) (*ConfigListResponse, error) { + path := "/v1/orgs/{orgId}/scim/configs" + path = strings.Replace(path, "{orgId}", orgId, 1) + var result ConfigListResponse + if err := c.do(ctx, "GET", path, nil, &result); err != nil { + return nil, err + } + return &result, nil +} + +// CreateSCIMConfig — Create SCIM configuration +func (c *Client) CreateSCIMConfig(ctx context.Context, orgId string, req *CreateSCIMConfigRequest) (*SCIMConfig, error) { + body, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("marshal request: %w", err) + } + path := "/v1/orgs/{orgId}/scim/configs" + path = strings.Replace(path, "{orgId}", orgId, 1) + var result SCIMConfig + if err := c.do(ctx, "POST", path, body, &result); err != nil { + return nil, err + } + return &result, nil +} + // ListTeams — List teams func (c *Client) ListTeams(ctx context.Context, orgId string, params *ListTeamsParams) (*TeamListResponse, error) { path := "/v1/orgs/{orgId}/teams" @@ -3181,6 +3228,95 @@ func (c *Client) AuthsomeUnassignRole(ctx context.Context, roleId string, req *A return &result, nil } +// GetSCIMConfig — Get SCIM configuration +func (c *Client) GetSCIMConfig(ctx context.Context, configId string) (*SCIMConfig, error) { + path := "/v1/scim/configs/{configId}" + path = strings.Replace(path, "{configId}", configId, 1) + var result SCIMConfig + if err := c.do(ctx, "GET", path, nil, &result); err != nil { + return nil, err + } + return &result, nil +} + +// UpdateSCIMConfig — Update SCIM configuration +func (c *Client) UpdateSCIMConfig(ctx context.Context, configId string, req *UpdateSCIMConfigRequest) (*SCIMConfig, error) { + body, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("marshal request: %w", err) + } + path := "/v1/scim/configs/{configId}" + path = strings.Replace(path, "{configId}", configId, 1) + var result SCIMConfig + if err := c.do(ctx, "PATCH", path, body, &result); err != nil { + return nil, err + } + return &result, nil +} + +// DeleteSCIMConfig — Delete SCIM configuration +func (c *Client) DeleteSCIMConfig(ctx context.Context, configId string) (*ScimStatusResponse, error) { + path := "/v1/scim/configs/{configId}" + path = strings.Replace(path, "{configId}", configId, 1) + var result ScimStatusResponse + if err := c.do(ctx, "DELETE", path, nil, &result); err != nil { + return nil, err + } + return &result, nil +} + +// ListSCIMTokens — List SCIM tokens +func (c *Client) ListSCIMTokens(ctx context.Context, configId string) (*TokenListResponse, error) { + path := "/v1/scim/configs/{configId}/tokens" + path = strings.Replace(path, "{configId}", configId, 1) + var result TokenListResponse + if err := c.do(ctx, "GET", path, nil, &result); err != nil { + return nil, err + } + return &result, nil +} + +// CreateSCIMToken — Create SCIM token +func (c *Client) CreateSCIMToken(ctx context.Context, configId string, req *CreateSCIMTokenRequest) (*TokenCreatedResponse, error) { + body, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("marshal request: %w", err) + } + path := "/v1/scim/configs/{configId}/tokens" + path = strings.Replace(path, "{configId}", configId, 1) + var result TokenCreatedResponse + if err := c.do(ctx, "POST", path, body, &result); err != nil { + return nil, err + } + return &result, nil +} + +// RevokeSCIMToken — Revoke SCIM token +func (c *Client) RevokeSCIMToken(ctx context.Context, tokenId string) (*ScimStatusResponse, error) { + path := "/v1/scim/tokens/{tokenId}" + path = strings.Replace(path, "{tokenId}", tokenId, 1) + var result ScimStatusResponse + if err := c.do(ctx, "DELETE", path, nil, &result); err != nil { + return nil, err + } + return &result, nil +} + +// RotateSCIMToken — Rotate SCIM token +func (c *Client) RotateSCIMToken(ctx context.Context, tokenId string, req *RotateSCIMTokenRequest) (*TokenCreatedResponse, error) { + body, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("marshal request: %w", err) + } + path := "/v1/scim/tokens/{tokenId}/rotate" + path = strings.Replace(path, "{tokenId}", tokenId, 1) + var result TokenCreatedResponse + if err := c.do(ctx, "POST", path, body, &result); err != nil { + return nil, err + } + return &result, nil +} + // ListSessions — List sessions func (c *Client) ListSessions(ctx context.Context, params *ListSessionsParams) (*SessionListResponse, error) { path := "/v1/sessions" diff --git a/sdk/go/types.go b/sdk/go/types.go index 1def91f7..76fba26d 100644 --- a/sdk/go/types.go +++ b/sdk/go/types.go @@ -525,6 +525,11 @@ type CloneEnvironmentResponse struct { WebhooksCloned int64 `json:"webhooks_cloned"` } +// ConfigListResponse represents the ConfigListResponse schema. +type ConfigListResponse struct { + Configs []*SCIMConfig `json:"configs"` +} + // Connection represents the Connection schema. type Connection struct { AcsURL string `json:"acs_url,omitempty"` @@ -730,6 +735,23 @@ type CreateRoleRequest struct { Slug string `json:"slug"` } +// CreateSCIMConfigRequest represents the CreateSCIMConfigRequest schema. +type CreateSCIMConfigRequest struct { + AutoCreate bool `json:"auto_create,omitempty"` + AutoSuspend bool `json:"auto_suspend,omitempty"` + DefaultRole string `json:"default_role,omitempty"` + Enabled bool `json:"enabled,omitempty"` + GroupSync bool `json:"group_sync,omitempty"` + Metadata map[string]any `json:"metadata,omitempty"` + Name string `json:"name"` +} + +// CreateSCIMTokenRequest represents the CreateSCIMTokenRequest schema. +type CreateSCIMTokenRequest struct { + ExpiresAt string `json:"expires_at,omitempty"` + Name string `json:"name"` +} + // CreateSubscriptionRequest represents the CreateSubscriptionRequest schema. type CreateSubscriptionRequest struct { AppID string `json:"app_id"` @@ -927,6 +949,20 @@ type EnrollResponse struct { Secret string `json:"secret"` } +// EnrolledFactor represents the EnrolledFactor schema. +type EnrolledFactor struct { + EnrolledAt string `json:"enrolled_at"` + Method string `json:"method"` + Verified bool `json:"verified"` +} + +// EnrollmentResponse represents the EnrollmentResponse schema. +type EnrollmentResponse struct { + Enabled bool `json:"enabled"` + Factors []*EnrolledFactor `json:"factors"` + RecoveryCodesRemaining int64 `json:"recovery_codes_remaining"` +} + // EntitlementResponse represents the EntitlementResponse schema. type EntitlementResponse struct { Allowed bool `json:"allowed"` @@ -1091,6 +1127,17 @@ type InvitationListResponse struct { NextCursor string `json:"next_cursor,omitempty"` } +// InvitationPreview represents the InvitationPreview schema. +type InvitationPreview struct { + EmailMasked string `json:"email_masked"` + ExpiresAt string `json:"expires_at"` + InviterName string `json:"inviter_name"` + OrgID string `json:"org_id"` + OrgName string `json:"org_name"` + OrgSlug string `json:"org_slug,omitempty"` + Role string `json:"role"` +} + // InvoiceResponse represents the InvoiceResponse schema. type InvoiceResponse struct { Currency string `json:"currency"` @@ -1611,11 +1658,33 @@ type RotateClientSecretResponse struct { ID string `json:"id"` } +// RotateSCIMTokenRequest represents the RotateSCIMTokenRequest schema. +type RotateSCIMTokenRequest struct { + GraceSeconds int64 `json:"grace_seconds,omitempty"` + Name string `json:"name,omitempty"` +} + // RotateWebhookSecretResponse represents the RotateWebhookSecretResponse schema. type RotateWebhookSecretResponse struct { Secret string `json:"secret"` } +// SCIMConfig represents the SCIMConfig schema. +type SCIMConfig struct { + AppID string `json:"app_id"` + AutoCreate bool `json:"auto_create"` + AutoSuspend bool `json:"auto_suspend"` + CreatedAt string `json:"created_at"` + DefaultRole string `json:"default_role"` + Enabled bool `json:"enabled"` + GroupSync bool `json:"group_sync"` + ID string `json:"id"` + Metadata map[string]any `json:"metadata,omitempty"` + Name string `json:"name"` + OrgID string `json:"org_id,omitempty"` + UpdatedAt string `json:"updated_at"` +} + // SMSSendRequest represents the SMSSendRequest schema. type SMSSendRequest struct { Phone string `json:"phone,omitempty"` @@ -1652,6 +1721,11 @@ type ScimListResponse struct { TotalResults int64 `json:"total_results"` } +// ScimStatusResponse represents the ScimStatusResponse schema. +type ScimStatusResponse struct { + Status string `json:"status"` +} + // ScopeValue represents the ScopeValue schema. type ScopeValue struct { Enforced bool `json:"enforced"` @@ -1905,6 +1979,23 @@ type TenantRequest struct { TenantID string `json:"tenant_id,omitempty"` } +// Token represents the Token schema. +type Token struct { + ConfigID string `json:"config_id"` + CreatedAt string `json:"created_at"` + ExpiresAt string `json:"expires_at,omitempty"` + ID string `json:"id"` + LastUsedAt string `json:"last_used_at,omitempty"` + Name string `json:"name"` +} + +// TokenCreatedResponse represents the TokenCreatedResponse schema. +type TokenCreatedResponse struct { + BaseURL string `json:"base_url"` + Plaintext string `json:"plaintext"` + Token *Token `json:"token,omitempty"` +} + // TokenExchangeRequest represents the TokenExchangeRequest schema. type TokenExchangeRequest struct { Scopes []string `json:"scopes,omitempty"` @@ -1922,6 +2013,11 @@ type TokenExchangeResponse struct { TokenType string `json:"token_type"` } +// TokenListResponse represents the TokenListResponse schema. +type TokenListResponse struct { + Tokens []*Token `json:"tokens"` +} + // UIMetadata represents the UIMetadata schema. type UIMetadata struct { Condition *VisibilityCondition `json:"condition,omitempty"` @@ -2045,6 +2141,17 @@ type UpdateRoleRequest struct { ParentID string `json:"parent_id,omitempty"` } +// UpdateSCIMConfigRequest represents the UpdateSCIMConfigRequest schema. +type UpdateSCIMConfigRequest struct { + AutoCreate bool `json:"auto_create,omitempty"` + AutoSuspend bool `json:"auto_suspend,omitempty"` + DefaultRole string `json:"default_role,omitempty"` + Enabled bool `json:"enabled,omitempty"` + GroupSync bool `json:"group_sync,omitempty"` + Metadata map[string]any `json:"metadata,omitempty"` + Name string `json:"name,omitempty"` +} + // UpdateSubscriptionRequest represents the UpdateSubscriptionRequest schema. type UpdateSubscriptionRequest struct { CancelURL string `json:"cancel_url,omitempty"` diff --git a/sdk/typescript/src/client.ts b/sdk/typescript/src/client.ts index f91fcdf0..94604e79 100644 --- a/sdk/typescript/src/client.ts +++ b/sdk/typescript/src/client.ts @@ -65,6 +65,7 @@ import type { ClientConfigToggle, CloneEnvironmentRequest, CloneEnvironmentResponse, + ConfigListResponse, Connection, Consent, ConsentDecisionBody, @@ -83,6 +84,8 @@ import type { CreateOrgRequest, CreatePlanRequest, CreateRoleRequest, + CreateSCIMConfigRequest, + CreateSCIMTokenRequest, CreateSubscriptionRequest, CreateTeamRequest, CreateWebhookRequest, @@ -107,6 +110,8 @@ import type { EnforceSettingRequest, EnrollRequest, EnrollResponse, + EnrolledFactor, + EnrollmentResponse, EntitlementResponse, EntitlementsResponse, Environment, @@ -127,6 +132,7 @@ import type { IntrospectUser, Invitation, InvitationListResponse, + InvitationPreview, InvoiceResponse, JWK, JWKSResponse, @@ -190,13 +196,16 @@ import type { Role, RoleListResponse, RotateClientSecretResponse, + RotateSCIMTokenRequest, RotateWebhookSecretResponse, + SCIMConfig, SMSSendRequest, SMSSendResponse, SMSVerifyRequest, SMSVerifyResponse, SchemaList, ScimListResponse, + ScimStatusResponse, ScopeValue, SeatsView, SelectOption, @@ -225,8 +234,11 @@ import type { Team, TeamListResponse, TenantRequest, + Token, + TokenCreatedResponse, TokenExchangeRequest, TokenExchangeResponse, + TokenListResponse, UIMetadata, UnassignRoleRequest, UnlinkAuthMethodResponse, @@ -239,6 +251,7 @@ import type { UpdateOrgRequest, UpdateRegistrationRequest, UpdateRoleRequest, + UpdateSCIMConfigRequest, UpdateSubscriptionRequest, UpdateTeamRequest, UpdateWebhookRequest, @@ -2304,6 +2317,19 @@ export class AuthClient { ); } + /** + * Read MFA enrollment + * GET /v1/mfa/enrollment + */ + async getMFAEnrollment(): Promise { + const path = "/v1/mfa/enrollment"; + return this.request( + 'GET', + path, + undefined, + ); + } + /** * Disable MFA * DELETE /v1/mfa/enrollment @@ -2706,6 +2732,19 @@ export class AuthClient { ); } + /** + * Get invitation + * GET /v1/orgs/invitations/{inviteToken} + */ + async getInvitation(inviteToken: string): Promise { + const path = `/v1/orgs/invitations/${inviteToken}`; + return this.request( + 'GET', + path, + undefined, + ); + } + /** * Get organization * GET /v1/orgs/{orgId} @@ -2831,6 +2870,32 @@ export class AuthClient { ); } + /** + * List SCIM configurations + * GET /v1/orgs/{orgId}/scim/configs + */ + async listSCIMConfigs(orgId: string): Promise { + const path = `/v1/orgs/${orgId}/scim/configs`; + return this.request( + 'GET', + path, + undefined, + ); + } + + /** + * Create SCIM configuration + * POST /v1/orgs/{orgId}/scim/configs + */ + async createSCIMConfig(orgId: string, body: CreateSCIMConfigRequest): Promise { + const path = `/v1/orgs/${orgId}/scim/configs`; + return this.request( + 'POST', + path, + body, + ); + } + /** * List teams * GET /v1/orgs/{orgId}/teams @@ -3189,6 +3254,97 @@ export class AuthClient { ); } + /** + * Get SCIM configuration + * GET /v1/scim/configs/{configId} + */ + async getSCIMConfig(configId: string): Promise { + const path = `/v1/scim/configs/${configId}`; + return this.request( + 'GET', + path, + undefined, + ); + } + + /** + * Update SCIM configuration + * PATCH /v1/scim/configs/{configId} + */ + async updateSCIMConfig(configId: string, body: UpdateSCIMConfigRequest): Promise { + const path = `/v1/scim/configs/${configId}`; + return this.request( + 'PATCH', + path, + body, + ); + } + + /** + * Delete SCIM configuration + * DELETE /v1/scim/configs/{configId} + */ + async deleteSCIMConfig(configId: string): Promise { + const path = `/v1/scim/configs/${configId}`; + return this.request( + 'DELETE', + path, + undefined, + ); + } + + /** + * List SCIM tokens + * GET /v1/scim/configs/{configId}/tokens + */ + async listSCIMTokens(configId: string): Promise { + const path = `/v1/scim/configs/${configId}/tokens`; + return this.request( + 'GET', + path, + undefined, + ); + } + + /** + * Create SCIM token + * POST /v1/scim/configs/{configId}/tokens + */ + async createSCIMToken(configId: string, body: CreateSCIMTokenRequest): Promise { + const path = `/v1/scim/configs/${configId}/tokens`; + return this.request( + 'POST', + path, + body, + ); + } + + /** + * Revoke SCIM token + * DELETE /v1/scim/tokens/{tokenId} + */ + async revokeSCIMToken(tokenId: string): Promise { + const path = `/v1/scim/tokens/${tokenId}`; + return this.request( + 'DELETE', + path, + undefined, + ); + } + + /** + * Rotate SCIM token + * POST /v1/scim/tokens/{tokenId}/rotate + */ + async rotateSCIMToken(tokenId: string, body: RotateSCIMTokenRequest): Promise { + const path = `/v1/scim/tokens/${tokenId}/rotate`; + return this.request( + 'POST', + path, + body, + ); + } + /** * List sessions * GET /v1/sessions diff --git a/sdk/typescript/src/index.ts b/sdk/typescript/src/index.ts index 132980d9..f9b55d35 100644 --- a/sdk/typescript/src/index.ts +++ b/sdk/typescript/src/index.ts @@ -69,6 +69,7 @@ export type { ClientConfigToggle, CloneEnvironmentRequest, CloneEnvironmentResponse, + ConfigListResponse, Connection, Consent, ConsentDecisionBody, @@ -87,6 +88,8 @@ export type { CreateOrgRequest, CreatePlanRequest, CreateRoleRequest, + CreateSCIMConfigRequest, + CreateSCIMTokenRequest, CreateSubscriptionRequest, CreateTeamRequest, CreateWebhookRequest, @@ -111,6 +114,8 @@ export type { EnforceSettingRequest, EnrollRequest, EnrollResponse, + EnrolledFactor, + EnrollmentResponse, EntitlementResponse, EntitlementsResponse, Environment, @@ -131,6 +136,7 @@ export type { IntrospectUser, Invitation, InvitationListResponse, + InvitationPreview, InvoiceResponse, JWK, JWKSResponse, @@ -194,13 +200,16 @@ export type { Role, RoleListResponse, RotateClientSecretResponse, + RotateSCIMTokenRequest, RotateWebhookSecretResponse, + SCIMConfig, SMSSendRequest, SMSSendResponse, SMSVerifyRequest, SMSVerifyResponse, SchemaList, ScimListResponse, + ScimStatusResponse, ScopeValue, SeatsView, SelectOption, @@ -229,8 +238,11 @@ export type { Team, TeamListResponse, TenantRequest, + Token, + TokenCreatedResponse, TokenExchangeRequest, TokenExchangeResponse, + TokenListResponse, UIMetadata, UnassignRoleRequest, UnlinkAuthMethodResponse, @@ -243,6 +255,7 @@ export type { UpdateOrgRequest, UpdateRegistrationRequest, UpdateRoleRequest, + UpdateSCIMConfigRequest, UpdateSubscriptionRequest, UpdateTeamRequest, UpdateWebhookRequest, diff --git a/sdk/typescript/src/types.ts b/sdk/typescript/src/types.ts index 18559a98..76eaa6cc 100644 --- a/sdk/typescript/src/types.ts +++ b/sdk/typescript/src/types.ts @@ -459,6 +459,10 @@ export interface CloneEnvironmentResponse { webhooks_cloned: number; } +export interface ConfigListResponse { + configs: SCIMConfig[]; +} + export interface Connection { acs_url?: string; active: boolean; @@ -646,6 +650,21 @@ export interface CreateRoleRequest { slug: string; } +export interface CreateSCIMConfigRequest { + auto_create?: boolean; + auto_suspend?: boolean; + default_role?: string; + enabled?: boolean; + group_sync?: boolean; + metadata?: Record; + name: string; +} + +export interface CreateSCIMTokenRequest { + expires_at?: string; + name: string; +} + export interface CreateSubscriptionRequest { app_id: string; plan_id: string; @@ -819,6 +838,18 @@ export interface EnrollResponse { secret: string; } +export interface EnrolledFactor { + enrolled_at: string; + method: string; + verified: boolean; +} + +export interface EnrollmentResponse { + enabled: boolean; + factors: EnrolledFactor[]; + recovery_codes_remaining: number; +} + export interface EntitlementResponse { allowed: boolean; feature: string; @@ -963,6 +994,16 @@ export interface InvitationListResponse { next_cursor?: string; } +export interface InvitationPreview { + email_masked: string; + expires_at: string; + inviter_name: string; + org_id: string; + org_name: string; + org_slug?: string; + role: string; +} + export interface InvoiceResponse { currency: string; id: string; @@ -1420,10 +1461,30 @@ export interface RotateClientSecretResponse { id: string; } +export interface RotateSCIMTokenRequest { + grace_seconds?: number; + name?: string; +} + export interface RotateWebhookSecretResponse { secret: string; } +export interface SCIMConfig { + app_id: string; + auto_create: boolean; + auto_suspend: boolean; + created_at: string; + default_role: string; + enabled: boolean; + group_sync: boolean; + id: string; + metadata?: Record; + name: string; + org_id?: string; + updated_at: string; +} + export interface SMSSendRequest { phone?: string; } @@ -1454,6 +1515,10 @@ export interface ScimListResponse { totalResults: number; } +export interface ScimStatusResponse { + status: string; +} + export interface ScopeValue { enforced: boolean; scope: string; @@ -1679,6 +1744,21 @@ export interface TenantRequest { tenant_id?: string; } +export interface Token { + config_id: string; + created_at: string; + expires_at?: string; + id: string; + last_used_at?: string; + name: string; +} + +export interface TokenCreatedResponse { + base_url: string; + plaintext: string; + token?: Token; +} + export interface TokenExchangeRequest { scopes?: string[]; subject: string; @@ -1694,6 +1774,10 @@ export interface TokenExchangeResponse { token_type: string; } +export interface TokenListResponse { + tokens: Token[]; +} + export interface UIMetadata { condition?: VisibilityCondition; help_text?: string; @@ -1805,6 +1889,16 @@ export interface UpdateRoleRequest { parent_id?: string; } +export interface UpdateSCIMConfigRequest { + auto_create?: boolean; + auto_suspend?: boolean; + default_role?: string; + enabled?: boolean; + group_sync?: boolean; + metadata?: Record; + name?: string; +} + export interface UpdateSubscriptionRequest { cancel_url?: string; plan?: string; diff --git a/sdkgen/spec.json b/sdkgen/spec.json index 0e9c19ab..7f86004a 100644 --- a/sdkgen/spec.json +++ b/sdkgen/spec.json @@ -1657,6 +1657,32 @@ ], "type": "object" }, + "ConfigListResponse": { + "properties": { + "configs": { + "description": "SCIM configurations for the organization", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/SCIMConfig" + }, + { + "enum": [ + null + ], + "nullable": true, + "type": "object" + } + ] + }, + "type": "array" + } + }, + "required": [ + "configs" + ], + "type": "object" + }, "Connection": { "properties": { "acs_url": { @@ -2404,6 +2430,59 @@ ], "type": "object" }, + "CreateSCIMConfigRequest": { + "properties": { + "auto_create": { + "description": "Create users on first push", + "type": "boolean" + }, + "auto_suspend": { + "description": "Suspend users the IdP deactivates", + "type": "boolean" + }, + "default_role": { + "description": "Org role given to provisioned users", + "type": "string" + }, + "enabled": { + "description": "Whether provisioning is accepted. Defaults to true", + "type": "boolean" + }, + "group_sync": { + "description": "Sync SCIM groups to teams", + "type": "boolean" + }, + "metadata": { + "additionalProperties": true, + "description": "Free-form metadata", + "type": "object" + }, + "name": { + "description": "A name for this connection, e.g. the IdP's name", + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "CreateSCIMTokenRequest": { + "properties": { + "expires_at": { + "description": "When it stops working. Omit for no expiry", + "type": "string" + }, + "name": { + "description": "A name for this token, e.g. which IdP holds it", + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, "CreateSubscriptionRequest": { "properties": { "app_id": { @@ -3028,6 +3107,54 @@ ], "type": "object" }, + "EnrolledFactor": { + "properties": { + "enrolled_at": { + "description": "When the factor was set up", + "format": "date-time", + "type": "string" + }, + "method": { + "description": "Factor kind: totp or sms", + "type": "string" + }, + "verified": { + "description": "Whether the factor completed its first verification", + "type": "boolean" + } + }, + "required": [ + "method", + "verified", + "enrolled_at" + ], + "type": "object" + }, + "EnrollmentResponse": { + "properties": { + "enabled": { + "description": "Whether any factor is verified", + "type": "boolean" + }, + "factors": { + "description": "Every factor set up, verified or not", + "items": { + "$ref": "#/components/schemas/EnrolledFactor" + }, + "type": "array" + }, + "recovery_codes_remaining": { + "description": "How many unused recovery codes are left", + "type": "integer" + } + }, + "required": [ + "enabled", + "factors", + "recovery_codes_remaining" + ], + "type": "object" + }, "EntitlementResponse": { "properties": { "allowed": { @@ -3524,6 +3651,48 @@ ], "type": "object" }, + "InvitationPreview": { + "properties": { + "email_masked": { + "description": "Partially masked invited address", + "type": "string" + }, + "expires_at": { + "description": "When the invitation stops working", + "format": "date-time", + "type": "string" + }, + "inviter_name": { + "description": "Who sent the invitation", + "type": "string" + }, + "org_id": { + "description": "Organization the invitation is for", + "type": "string" + }, + "org_name": { + "description": "Organization's display name", + "type": "string" + }, + "org_slug": { + "description": "Organization's slug", + "type": "string" + }, + "role": { + "description": "Role the invitee will hold", + "type": "string" + } + }, + "required": [ + "org_id", + "org_name", + "inviter_name", + "role", + "email_masked", + "expires_at" + ], + "type": "object" + }, "InvoiceResponse": { "properties": { "currency": { @@ -5134,6 +5303,19 @@ ], "type": "object" }, + "RotateSCIMTokenRequest": { + "properties": { + "grace_seconds": { + "description": "How long the replaced token keeps working, in seconds", + "type": "integer" + }, + "name": { + "description": "Name for the replacement", + "type": "string" + } + }, + "type": "object" + }, "RotateWebhookSecretResponse": { "properties": { "secret": { @@ -5146,6 +5328,62 @@ ], "type": "object" }, + "SCIMConfig": { + "properties": { + "app_id": { + "type": "string" + }, + "auto_create": { + "type": "boolean" + }, + "auto_suspend": { + "type": "boolean" + }, + "created_at": { + "format": "date-time", + "type": "string" + }, + "default_role": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "group_sync": { + "type": "boolean" + }, + "id": { + "type": "string" + }, + "metadata": { + "additionalProperties": true, + "type": "object" + }, + "name": { + "type": "string" + }, + "org_id": { + "type": "string" + }, + "updated_at": { + "format": "date-time", + "type": "string" + } + }, + "required": [ + "id", + "app_id", + "name", + "enabled", + "auto_create", + "auto_suspend", + "group_sync", + "default_role", + "created_at", + "updated_at" + ], + "type": "object" + }, "SMSSendRequest": { "properties": { "phone": { @@ -5235,6 +5473,19 @@ "type": "object", "x-forge-type": "github.com/xraph/authsome/plugins/scim.ListResponse" }, + "ScimStatusResponse": { + "properties": { + "status": { + "description": "Outcome", + "type": "string" + } + }, + "required": [ + "status" + ], + "type": "object", + "x-forge-type": "github.com/xraph/authsome/plugins/scim.StatusResponse" + }, "ScopeValue": { "properties": { "enforced": { @@ -5970,6 +6221,57 @@ }, "type": "object" }, + "Token": { + "properties": { + "config_id": { + "type": "string" + }, + "created_at": { + "format": "date-time", + "type": "string" + }, + "expires_at": { + "type": "string" + }, + "id": { + "type": "string" + }, + "last_used_at": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "config_id", + "name", + "created_at" + ], + "type": "object" + }, + "TokenCreatedResponse": { + "properties": { + "base_url": { + "description": "SCIM 2.0 base URL to configure in the identity provider", + "type": "string" + }, + "plaintext": { + "description": "The bearer token. Shown once and never again", + "type": "string" + }, + "token": { + "$ref": "#/components/schemas/Token", + "description": "The token record" + } + }, + "required": [ + "plaintext", + "base_url" + ], + "type": "object" + }, "TokenExchangeRequest": { "properties": { "scopes": { @@ -6033,6 +6335,32 @@ ], "type": "object" }, + "TokenListResponse": { + "properties": { + "tokens": { + "description": "Tokens for this configuration", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/Token" + }, + { + "enum": [ + null + ], + "nullable": true, + "type": "object" + } + ] + }, + "type": "array" + } + }, + "required": [ + "tokens" + ], + "type": "object" + }, "UIMetadata": { "properties": { "condition": { @@ -6408,6 +6736,40 @@ }, "type": "object" }, + "UpdateSCIMConfigRequest": { + "properties": { + "auto_create": { + "description": "Create users on first push", + "type": "boolean" + }, + "auto_suspend": { + "description": "Suspend users the IdP deactivates", + "type": "boolean" + }, + "default_role": { + "description": "Org role given to provisioned users", + "type": "string" + }, + "enabled": { + "description": "Turn provisioning on or off", + "type": "boolean" + }, + "group_sync": { + "description": "Sync SCIM groups to teams", + "type": "boolean" + }, + "metadata": { + "additionalProperties": true, + "description": "Replaces the stored metadata", + "type": "object" + }, + "name": { + "description": "New name", + "type": "string" + } + }, + "type": "object" + }, "UpdateSubscriptionRequest": { "properties": { "cancel_url": { @@ -29390,21 +29752,20 @@ "tags": [ "MFA" ] - } - }, - "/v1/mfa/recovery/regenerate": { - "post": { - "operationId": "regenerateMFARecoveryCodes", + }, + "get": { + "description": "Reports whether the signed-in user has a second factor set up, which kinds, and how many recovery codes are left. Never returns a secret or a recovery code.", + "operationId": "getMFAEnrollment", "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RecoveryRegenerateResponse" + "$ref": "#/components/schemas/EnrollmentResponse" } } }, - "description": "New recovery codes generated" + "description": "Enrollment state" }, "400": { "content": { @@ -29552,36 +29913,25 @@ "bearerAuth": [] } ], - "summary": "Regenerate MFA recovery codes", + "summary": "Read MFA enrollment", "tags": [ "MFA" ] } }, - "/v1/mfa/recovery/verify": { + "/v1/mfa/recovery/regenerate": { "post": { - "operationId": "verifyMFARecovery", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RecoveryVerifyRequest" - } - } - }, - "description": "Request body", - "required": true - }, + "operationId": "regenerateMFARecoveryCodes", "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RecoveryVerifyResponse" + "$ref": "#/components/schemas/RecoveryRegenerateResponse" } } }, - "description": "Recovery code accepted" + "description": "New recovery codes generated" }, "400": { "content": { @@ -29724,20 +30074,25 @@ "description": "Internal Server Error" } }, - "summary": "Verify MFA recovery code", + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Regenerate MFA recovery codes", "tags": [ "MFA" ] } }, - "/v1/mfa/sms/send": { + "/v1/mfa/recovery/verify": { "post": { - "operationId": "sendSMSCode", + "operationId": "verifyMFARecovery", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SMSSendRequest" + "$ref": "#/components/schemas/RecoveryVerifyRequest" } } }, @@ -29749,11 +30104,11 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SMSSendResponse" + "$ref": "#/components/schemas/RecoveryVerifyResponse" } } }, - "description": "SMS code sent" + "description": "Recovery code accepted" }, "400": { "content": { @@ -29896,25 +30251,20 @@ "description": "Internal Server Error" } }, - "security": [ - { - "bearerAuth": [] - } - ], - "summary": "Send SMS verification code", + "summary": "Verify MFA recovery code", "tags": [ "MFA" ] } }, - "/v1/mfa/sms/verify": { + "/v1/mfa/sms/send": { "post": { - "operationId": "verifySMSCode", + "operationId": "sendSMSCode", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SMSVerifyRequest" + "$ref": "#/components/schemas/SMSSendRequest" } } }, @@ -29926,11 +30276,11 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SMSVerifyResponse" + "$ref": "#/components/schemas/SMSSendResponse" } } }, - "description": "SMS code verified" + "description": "SMS code sent" }, "400": { "content": { @@ -30078,20 +30428,20 @@ "bearerAuth": [] } ], - "summary": "Verify SMS code", + "summary": "Send SMS verification code", "tags": [ "MFA" ] } }, - "/v1/mfa/verify": { + "/v1/mfa/sms/verify": { "post": { - "operationId": "verifyMFA", + "operationId": "verifySMSCode", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/VerifyMFARequest" + "$ref": "#/components/schemas/SMSVerifyRequest" } } }, @@ -30103,11 +30453,11 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/VerifyMFAResponse" + "$ref": "#/components/schemas/SMSVerifyResponse" } } }, - "description": "Verified" + "description": "SMS code verified" }, "400": { "content": { @@ -30250,184 +30600,127 @@ "description": "Internal Server Error" } }, - "summary": "Verify MFA code", + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Verify SMS code", "tags": [ "MFA" ] } }, - "/v1/oauth/authorize": { - "get": { - "description": "Authorization endpoint for the OAuth2 authorization code flow.", - "operationId": "oauth2Authorize", - "parameters": [ - { - "in": "query", - "name": "response_type", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "client_id", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "redirect_uri", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "scope", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "state", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "code_challenge", - "schema": { - "type": "string" + "/v1/mfa/verify": { + "post": { + "operationId": "verifyMFA", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VerifyMFARequest" + } } }, - { - "in": "query", - "name": "code_challenge_method", - "schema": { - "type": "string" - } + "description": "Request body", + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VerifyMFAResponse" + } + } + }, + "description": "Verified" }, - { - "in": "query", - "name": "resource", - "schema": { - "items": { - "type": "string" - }, - "type": "array" - } + "400": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "description": "HTTP status code", + "type": "integer" + }, + "details": { + "description": "Additional error details", + "type": "string" + }, + "error": { + "description": "Error message", + "type": "string" + } + }, + "required": [ + "error", + "code" + ], + "type": "object" + } + } + }, + "description": "Bad Request" }, - { - "in": "query", - "name": "prompt", - "schema": { - "type": "string" - } + "401": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "description": "HTTP status code", + "type": "integer" + }, + "details": { + "description": "Additional error details", + "type": "string" + }, + "error": { + "description": "Error message", + "type": "string" + } + }, + "required": [ + "error", + "code" + ], + "type": "object" + } + } + }, + "description": "Unauthorized" }, - { - "in": "query", - "name": "nonce", - "schema": { - "type": "string" - } + "403": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "description": "HTTP status code", + "type": "integer" + }, + "details": { + "description": "Additional error details", + "type": "string" + }, + "error": { + "description": "Error message", + "type": "string" + } + }, + "required": [ + "error", + "code" + ], + "type": "object" + } + } + }, + "description": "Forbidden" }, - { - "in": "query", - "name": "max_age", - "schema": { - "type": "string" - } - } - ], - "responses": { - "400": { - "content": { - "application/json": { - "schema": { - "properties": { - "code": { - "description": "HTTP status code", - "type": "integer" - }, - "details": { - "description": "Additional error details", - "type": "string" - }, - "error": { - "description": "Error message", - "type": "string" - } - }, - "required": [ - "error", - "code" - ], - "type": "object" - } - } - }, - "description": "Bad Request" - }, - "401": { - "content": { - "application/json": { - "schema": { - "properties": { - "code": { - "description": "HTTP status code", - "type": "integer" - }, - "details": { - "description": "Additional error details", - "type": "string" - }, - "error": { - "description": "Error message", - "type": "string" - } - }, - "required": [ - "error", - "code" - ], - "type": "object" - } - } - }, - "description": "Unauthorized" - }, - "403": { - "content": { - "application/json": { - "schema": { - "properties": { - "code": { - "description": "HTTP status code", - "type": "integer" - }, - "details": { - "description": "Additional error details", - "type": "string" - }, - "error": { - "description": "Error message", - "type": "string" - } - }, - "required": [ - "error", - "code" - ], - "type": "object" - } - } - }, - "description": "Forbidden" - }, - "404": { + "404": { "content": { "application/json": { "schema": { @@ -30484,39 +30777,99 @@ "description": "Internal Server Error" } }, - "summary": "OAuth2 Authorization", + "summary": "Verify MFA code", "tags": [ - "OAuth2" + "MFA" ] } }, - "/v1/oauth/consent": { + "/v1/oauth/authorize": { "get": { - "description": "Shows the pending authorization request for the signed-in user to approve or deny. Renders HTML, or JSON when Accept asks for it.", - "operationId": "oauth2ConsentPage", + "description": "Authorization endpoint for the OAuth2 authorization code flow.", + "operationId": "oauth2Authorize", "parameters": [ { - "description": "Consent id from the redirect the authorize endpoint sent", "in": "query", - "name": "cid", - "required": true, + "name": "response_type", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "client_id", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "redirect_uri", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "scope", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "state", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "code_challenge", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "code_challenge_method", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "resource", + "schema": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + { + "in": "query", + "name": "prompt", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "nonce", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "max_age", "schema": { - "description": "Consent id from the redirect the authorize endpoint sent", "type": "string" } } ], "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ConsentView" - } - } - }, - "description": "The pending request" - }, "400": { "content": { "application/json": { @@ -30658,40 +31011,38 @@ "description": "Internal Server Error" } }, - "security": [ - { - "session": [] - } - ], - "summary": "OAuth2 consent", + "summary": "OAuth2 Authorization", "tags": [ "OAuth2" ] - }, - "post": { - "description": "Records the signed-in user's approval or denial of a pending authorization request and completes it. A form post is answered with a redirect, a JSON post with the redirect URL.", - "operationId": "oauth2ConsentDecision", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ConsentDecisionBody" - } + } + }, + "/v1/oauth/consent": { + "get": { + "description": "Shows the pending authorization request for the signed-in user to approve or deny. Renders HTML, or JSON when Accept asks for it.", + "operationId": "oauth2ConsentPage", + "parameters": [ + { + "description": "Consent id from the redirect the authorize endpoint sent", + "in": "query", + "name": "cid", + "required": true, + "schema": { + "description": "Consent id from the redirect the authorize endpoint sent", + "type": "string" } - }, - "description": "Request body", - "required": true - }, + } + ], "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ConsentResult" + "$ref": "#/components/schemas/ConsentView" } } }, - "description": "Where to send the browser" + "description": "The pending request" }, "400": { "content": { @@ -30839,38 +31190,19 @@ "session": [] } ], - "summary": "OAuth2 consent decision", + "summary": "OAuth2 consent", "tags": [ "OAuth2" ] - } - }, - "/v1/oauth/device/authorize": { + }, "post": { - "description": "Device authorization endpoint (RFC 8628). Returns a device_code and user_code for device/CLI authentication.", - "operationId": "oauth2DeviceAuthorize", + "description": "Records the signed-in user's approval or denial of a pending authorization request and completes it. A form post is answered with a redirect, a JSON post with the redirect URL.", + "operationId": "oauth2ConsentDecision", "requestBody": { "content": { - "application/x-www-form-urlencoded": { + "application/json": { "schema": { - "properties": { - "client_id": { - "type": "string" - }, - "resource": { - "items": { - "type": "string" - }, - "type": "array" - }, - "scope": { - "type": "string" - } - }, - "required": [ - "client_id" - ], - "type": "object" + "$ref": "#/components/schemas/ConsentDecisionBody" } } }, @@ -30882,11 +31214,11 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeviceAuthResponse" + "$ref": "#/components/schemas/ConsentResult" } } }, - "description": "Device authorization response" + "description": "Where to send the browser" }, "400": { "content": { @@ -31029,31 +31361,41 @@ "description": "Internal Server Error" } }, - "summary": "Device Authorization", + "security": [ + { + "session": [] + } + ], + "summary": "OAuth2 consent decision", "tags": [ "OAuth2" ] } }, - "/v1/oauth/device/complete": { + "/v1/oauth/device/authorize": { "post": { - "description": "Approve or deny a device authorization request. Requires authenticated user. Used by external verification UIs (e.g. authsome-ui).", - "operationId": "oauth2DeviceComplete", + "description": "Device authorization endpoint (RFC 8628). Returns a device_code and user_code for device/CLI authentication.", + "operationId": "oauth2DeviceAuthorize", "requestBody": { "content": { "application/x-www-form-urlencoded": { "schema": { "properties": { - "action": { + "client_id": { "type": "string" }, - "user_code": { + "resource": { + "items": { + "type": "string" + }, + "type": "array" + }, + "scope": { "type": "string" } }, "required": [ - "user_code", - "action" + "client_id" ], "type": "object" } @@ -31067,11 +31409,11 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeviceCompleteResponse" + "$ref": "#/components/schemas/DeviceAuthResponse" } } }, - "description": "Device completion response" + "description": "Device authorization response" }, "400": { "content": { @@ -31214,31 +31556,49 @@ "description": "Internal Server Error" } }, - "security": [ - { - "session": [] - } - ], - "summary": "Complete device authorization", + "summary": "Device Authorization", "tags": [ "OAuth2" ] } }, - "/v1/oauth/jwks": { - "get": { - "description": "Public keys that verify id_tokens and asymmetric access tokens issued by this provider.", - "operationId": "oauth2JWKS", + "/v1/oauth/device/complete": { + "post": { + "description": "Approve or deny a device authorization request. Requires authenticated user. Used by external verification UIs (e.g. authsome-ui).", + "operationId": "oauth2DeviceComplete", + "requestBody": { + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "properties": { + "action": { + "type": "string" + }, + "user_code": { + "type": "string" + } + }, + "required": [ + "user_code", + "action" + ], + "type": "object" + } + } + }, + "description": "Request body", + "required": true + }, "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/JWKSResponse" + "$ref": "#/components/schemas/DeviceCompleteResponse" } } }, - "description": "JWKS" + "description": "Device completion response" }, "400": { "content": { @@ -31383,58 +31743,30 @@ }, "security": [ { - "bearerAuth": [] + "session": [] } ], - "summary": "JSON Web Key Set", + "summary": "Complete device authorization", "tags": [ "OAuth2" ] } }, - "/v1/oauth/logout": { + "/v1/oauth/jwks": { "get": { - "description": "OpenID Connect RP-Initiated Logout. Ends the session when id_token_hint names the signed-in user, otherwise asks first, then returns to a registered post_logout_redirect_uri.", - "operationId": "oauth2Logout", - "parameters": [ - { - "description": "An id_token this server issued; ends the session without asking when it names the signed-in user", - "in": "query", - "name": "id_token_hint", - "schema": { - "description": "An id_token this server issued; ends the session without asking when it names the signed-in user", - "type": "string" - } - }, - { - "description": "The client asking, when there is no id_token_hint", - "in": "query", - "name": "client_id", - "schema": { - "description": "The client asking, when there is no id_token_hint", - "type": "string" - } - }, - { - "description": "Where to send the browser afterwards; must be registered for the client", - "in": "query", - "name": "post_logout_redirect_uri", - "schema": { - "description": "Where to send the browser afterwards; must be registered for the client", - "type": "string" - } - }, - { - "description": "Echoed on the post-logout redirect", - "in": "query", - "name": "state", - "schema": { - "description": "Echoed on the post-logout redirect", - "type": "string" - } - } - ], + "description": "Public keys that verify id_tokens and asymmetric access tokens issued by this provider.", + "operationId": "oauth2JWKS", "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JWKSResponse" + } + } + }, + "description": "JWKS" + }, "400": { "content": { "application/json": { @@ -31581,39 +31913,54 @@ "bearerAuth": [] } ], - "summary": "OpenID Connect logout", + "summary": "JSON Web Key Set", "tags": [ "OAuth2" ] - }, - "post": { + } + }, + "/v1/oauth/logout": { + "get": { "description": "OpenID Connect RP-Initiated Logout. Ends the session when id_token_hint names the signed-in user, otherwise asks first, then returns to a registered post_logout_redirect_uri.", - "operationId": "oauth2LogoutPost", - "requestBody": { - "content": { - "application/x-www-form-urlencoded": { - "schema": { - "properties": { - "client_id": { - "type": "string" - }, - "id_token_hint": { - "type": "string" - }, - "post_logout_redirect_uri": { - "type": "string" - }, - "state": { - "type": "string" - } - }, - "type": "object" - } + "operationId": "oauth2Logout", + "parameters": [ + { + "description": "An id_token this server issued; ends the session without asking when it names the signed-in user", + "in": "query", + "name": "id_token_hint", + "schema": { + "description": "An id_token this server issued; ends the session without asking when it names the signed-in user", + "type": "string" } }, - "description": "Request body", - "required": true - }, + { + "description": "The client asking, when there is no id_token_hint", + "in": "query", + "name": "client_id", + "schema": { + "description": "The client asking, when there is no id_token_hint", + "type": "string" + } + }, + { + "description": "Where to send the browser afterwards; must be registered for the client", + "in": "query", + "name": "post_logout_redirect_uri", + "schema": { + "description": "Where to send the browser afterwards; must be registered for the client", + "type": "string" + } + }, + { + "description": "Echoed on the post-logout redirect", + "in": "query", + "name": "state", + "schema": { + "description": "Echoed on the post-logout redirect", + "type": "string" + } + } + ], "responses": { "400": { "content": { @@ -31761,46 +32108,69 @@ "bearerAuth": [] } ], - "summary": "OpenID Connect logout (form post)", + "summary": "OpenID Connect logout", "tags": [ "OAuth2" ] - } - }, - "/v1/oauth/logout/confirm": { + }, "post": { "description": "OpenID Connect RP-Initiated Logout. Ends the session when id_token_hint names the signed-in user, otherwise asks first, then returns to a registered post_logout_redirect_uri.", - "operationId": "oauth2LogoutConfirm", - "responses": { - "400": { - "content": { - "application/json": { - "schema": { - "properties": { - "code": { - "description": "HTTP status code", - "type": "integer" - }, - "details": { - "description": "Additional error details", - "type": "string" - }, - "error": { - "description": "Error message", - "type": "string" - } + "operationId": "oauth2LogoutPost", + "requestBody": { + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "properties": { + "client_id": { + "type": "string" }, - "required": [ - "error", - "code" - ], - "type": "object" - } + "id_token_hint": { + "type": "string" + }, + "post_logout_redirect_uri": { + "type": "string" + }, + "state": { + "type": "string" + } + }, + "type": "object" } - }, - "description": "Bad Request" + } }, - "401": { + "description": "Request body", + "required": true + }, + "responses": { + "400": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "description": "HTTP status code", + "type": "integer" + }, + "details": { + "description": "Additional error details", + "type": "string" + }, + "error": { + "description": "Error message", + "type": "string" + } + }, + "required": [ + "error", + "code" + ], + "type": "object" + } + } + }, + "description": "Bad Request" + }, + "401": { "content": { "application/json": { "schema": { @@ -31918,38 +32288,17 @@ "bearerAuth": [] } ], - "summary": "Confirm OpenID Connect logout", + "summary": "OpenID Connect logout (form post)", "tags": [ "OAuth2" ] } }, - "/v1/oauth/register": { + "/v1/oauth/logout/confirm": { "post": { - "description": "RFC 7591 dynamic client registration.", - "operationId": "oauth2RegisterClient", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RegisterClientRequest" - } - } - }, - "description": "Request body", - "required": true - }, + "description": "OpenID Connect RP-Initiated Logout. Ends the session when id_token_hint names the signed-in user, otherwise asks first, then returns to a registered post_logout_redirect_uri.", + "operationId": "oauth2LogoutConfirm", "responses": { - "201": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RegisterClientResponse" - } - } - }, - "description": "Client registered" - }, "400": { "content": { "application/json": { @@ -32096,28 +32445,37 @@ "bearerAuth": [] } ], - "summary": "Register OAuth2 client", + "summary": "Confirm OpenID Connect logout", "tags": [ "OAuth2" ] } }, - "/v1/oauth/register/{clientId}": { - "delete": { - "operationId": "oauth2DeleteRegistration", - "parameters": [ - { - "in": "path", - "name": "clientId", - "required": true, - "schema": { - "type": "string" + "/v1/oauth/register": { + "post": { + "description": "RFC 7591 dynamic client registration.", + "operationId": "oauth2RegisterClient", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RegisterClientRequest" + } } - } - ], + }, + "description": "Request body", + "required": true + }, "responses": { - "204": { - "description": "No Content" + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RegisterClientResponse" + } + } + }, + "description": "Client registered" }, "400": { "content": { @@ -32265,13 +32623,15 @@ "bearerAuth": [] } ], - "summary": "Delete OAuth2 client registration", + "summary": "Register OAuth2 client", "tags": [ "OAuth2" ] - }, - "get": { - "operationId": "oauth2ReadRegistration", + } + }, + "/v1/oauth/register/{clientId}": { + "delete": { + "operationId": "oauth2DeleteRegistration", "parameters": [ { "in": "path", @@ -32283,15 +32643,8 @@ } ], "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RegisterClientResponse" - } - } - }, - "description": "Client information" + "204": { + "description": "No Content" }, "400": { "content": { @@ -32439,13 +32792,13 @@ "bearerAuth": [] } ], - "summary": "Read OAuth2 client registration", + "summary": "Delete OAuth2 client registration", "tags": [ "OAuth2" ] }, - "put": { - "operationId": "oauth2UpdateRegistration", + "get": { + "operationId": "oauth2ReadRegistration", "parameters": [ { "in": "path", @@ -32456,17 +32809,6 @@ } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateRegistrationRequest" - } - } - }, - "description": "Request body", - "required": true - }, "responses": { "200": { "content": { @@ -32624,35 +32966,28 @@ "bearerAuth": [] } ], - "summary": "Update OAuth2 client registration", + "summary": "Read OAuth2 client registration", "tags": [ "OAuth2" ] - } - }, - "/v1/oauth/revoke": { - "post": { - "description": "Revokes an access or refresh token.", - "operationId": "oauth2Revoke", + }, + "put": { + "operationId": "oauth2UpdateRegistration", + "parameters": [ + { + "in": "path", + "name": "clientId", + "required": true, + "schema": { + "type": "string" + } + } + ], "requestBody": { "content": { - "application/x-www-form-urlencoded": { + "application/json": { "schema": { - "properties": { - "client_id": { - "type": "string" - }, - "client_secret": { - "type": "string" - }, - "token": { - "type": "string" - }, - "token_type_hint": { - "type": "string" - } - }, - "type": "object" + "$ref": "#/components/schemas/UpdateRegistrationRequest" } } }, @@ -32660,6 +32995,16 @@ "required": true }, "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RegisterClientResponse" + } + } + }, + "description": "Client information" + }, "400": { "content": { "application/json": { @@ -32806,70 +33151,31 @@ "bearerAuth": [] } ], - "summary": "Revoke token", + "summary": "Update OAuth2 client registration", "tags": [ "OAuth2" ] } }, - "/v1/oauth/token": { + "/v1/oauth/revoke": { "post": { - "description": "Token endpoint for exchanging authorization codes or client credentials for access tokens.", - "operationId": "oauth2Token", + "description": "Revokes an access or refresh token.", + "operationId": "oauth2Revoke", "requestBody": { "content": { "application/x-www-form-urlencoded": { "schema": { "properties": { - "actor_token": { - "type": "string" - }, - "actor_token_type": { - "type": "string" - }, - "audience": { - "type": "string" - }, "client_id": { "type": "string" }, "client_secret": { "type": "string" }, - "code": { - "type": "string" - }, - "code_verifier": { - "type": "string" - }, - "device_code": { - "type": "string" - }, - "grant_type": { - "type": "string" - }, - "redirect_uri": { - "type": "string" - }, - "refresh_token": { - "type": "string" - }, - "requested_token_type": { - "type": "string" - }, - "resource": { - "items": { - "type": "string" - }, - "type": "array" - }, - "scope": { - "type": "string" - }, - "subject_token": { + "token": { "type": "string" }, - "subject_token_type": { + "token_type_hint": { "type": "string" } }, @@ -32881,45 +33187,35 @@ "required": true }, "responses": { - "200": { + "400": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Oauth2providerTokenResponse" + "properties": { + "code": { + "description": "HTTP status code", + "type": "integer" + }, + "details": { + "description": "Additional error details", + "type": "string" + }, + "error": { + "description": "Error message", + "type": "string" + } + }, + "required": [ + "error", + "code" + ], + "type": "object" } } }, - "description": "Token response" + "description": "Bad Request" }, - "400": { - "content": { - "application/json": { - "schema": { - "properties": { - "code": { - "description": "HTTP status code", - "type": "integer" - }, - "details": { - "description": "Additional error details", - "type": "string" - }, - "error": { - "description": "Error message", - "type": "string" - } - }, - "required": [ - "error", - "code" - ], - "type": "object" - } - } - }, - "description": "Bad Request" - }, - "401": { + "401": { "content": { "application/json": { "schema": { @@ -33032,26 +33328,95 @@ "description": "Internal Server Error" } }, - "summary": "OAuth2 Token", + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Revoke token", "tags": [ "OAuth2" ] } }, - "/v1/oauth/userinfo": { - "get": { - "description": "Returns claims about the authenticated user.", - "operationId": "oauth2UserInfo", + "/v1/oauth/token": { + "post": { + "description": "Token endpoint for exchanging authorization codes or client credentials for access tokens.", + "operationId": "oauth2Token", + "requestBody": { + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "properties": { + "actor_token": { + "type": "string" + }, + "actor_token_type": { + "type": "string" + }, + "audience": { + "type": "string" + }, + "client_id": { + "type": "string" + }, + "client_secret": { + "type": "string" + }, + "code": { + "type": "string" + }, + "code_verifier": { + "type": "string" + }, + "device_code": { + "type": "string" + }, + "grant_type": { + "type": "string" + }, + "redirect_uri": { + "type": "string" + }, + "refresh_token": { + "type": "string" + }, + "requested_token_type": { + "type": "string" + }, + "resource": { + "items": { + "type": "string" + }, + "type": "array" + }, + "scope": { + "type": "string" + }, + "subject_token": { + "type": "string" + }, + "subject_token_type": { + "type": "string" + } + }, + "type": "object" + } + } + }, + "description": "Request body", + "required": true + }, "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UserInfo" + "$ref": "#/components/schemas/Oauth2providerTokenResponse" } } }, - "description": "UserInfo" + "description": "Token response" }, "400": { "content": { @@ -33194,19 +33559,16 @@ "description": "Internal Server Error" } }, - "security": [ - { - "bearerAuth": [] - } - ], - "summary": "OIDC UserInfo", + "summary": "OAuth2 Token", "tags": [ "OAuth2" ] - }, - "post": { - "description": "Returns claims about the authenticated user. Same as the GET form.", - "operationId": "oauth2UserInfoPost", + } + }, + "/v1/oauth/userinfo": { + "get": { + "description": "Returns claims about the authenticated user.", + "operationId": "oauth2UserInfo", "responses": { "200": { "content": { @@ -33364,46 +33726,24 @@ "bearerAuth": [] } ], - "summary": "OIDC UserInfo (POST)", + "summary": "OIDC UserInfo", "tags": [ "OAuth2" ] - } - }, - "/v1/orgs": { - "get": { - "description": "Returns all organizations the authenticated user belongs to.", - "operationId": "listOrganizations", - "parameters": [ - { - "description": "Page size (default 50, max 200)", - "in": "query", - "name": "limit", - "schema": { - "description": "Page size (default 50, max 200)", - "type": "integer" - } - }, - { - "description": "Cursor from the previous page's next_cursor", - "in": "query", - "name": "cursor", - "schema": { - "description": "Cursor from the previous page's next_cursor", - "type": "string" - } - } - ], + }, + "post": { + "description": "Returns claims about the authenticated user. Same as the GET form.", + "operationId": "oauth2UserInfoPost", "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/OrgListResponse" + "$ref": "#/components/schemas/UserInfo" } } }, - "description": "Organization list" + "description": "UserInfo" }, "400": { "content": { @@ -33551,35 +33891,46 @@ "bearerAuth": [] } ], - "summary": "List organizations", + "summary": "OIDC UserInfo (POST)", "tags": [ - "organizations" + "OAuth2" ] - }, - "post": { - "description": "Creates a new organization. The authenticated user becomes the owner.", - "operationId": "createOrganization", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateOrgRequest" - } + } + }, + "/v1/orgs": { + "get": { + "description": "Returns all organizations the authenticated user belongs to.", + "operationId": "listOrganizations", + "parameters": [ + { + "description": "Page size (default 50, max 200)", + "in": "query", + "name": "limit", + "schema": { + "description": "Page size (default 50, max 200)", + "type": "integer" } }, - "description": "Request body", - "required": true - }, + { + "description": "Cursor from the previous page's next_cursor", + "in": "query", + "name": "cursor", + "schema": { + "description": "Cursor from the previous page's next_cursor", + "type": "string" + } + } + ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Organization" + "$ref": "#/components/schemas/OrgListResponse" } } }, - "description": "Created" + "description": "Organization list" }, "400": { "content": { @@ -33727,48 +34078,35 @@ "bearerAuth": [] } ], - "summary": "Create organization", + "summary": "List organizations", "tags": [ "organizations" ] - } - }, - "/v1/orgs/check-slug": { - "get": { - "description": "Checks whether an organization slug is available.", - "operationId": "checkOrgSlug", - "parameters": [ - { - "description": "Application ID", - "in": "query", - "name": "app_id", - "required": true, - "schema": { - "description": "Application ID", - "type": "string" + }, + "post": { + "description": "Creates a new organization. The authenticated user becomes the owner.", + "operationId": "createOrganization", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateOrgRequest" + } } }, - { - "description": "Slug to check", - "in": "query", - "name": "slug", - "required": true, - "schema": { - "description": "Slug to check", - "type": "string" - } - } - ], + "description": "Request body", + "required": true + }, "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SlugAvailableResponse" + "$ref": "#/components/schemas/Organization" } } }, - "description": "Slug availability" + "description": "Created" }, "400": { "content": { @@ -33916,37 +34254,48 @@ "bearerAuth": [] } ], - "summary": "Check slug availability", + "summary": "Create organization", "tags": [ "organizations" ] } }, - "/v1/orgs/invitations/accept": { - "post": { - "description": "Accepts a pending organization invitation using the invitation token. The signed-in user must own the email the invitation was sent to.", - "operationId": "acceptInvitation", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AcceptInvitationRequest" - } + "/v1/orgs/check-slug": { + "get": { + "description": "Checks whether an organization slug is available.", + "operationId": "checkOrgSlug", + "parameters": [ + { + "description": "Application ID", + "in": "query", + "name": "app_id", + "required": true, + "schema": { + "description": "Application ID", + "type": "string" } }, - "description": "Request body", - "required": true - }, + { + "description": "Slug to check", + "in": "query", + "name": "slug", + "required": true, + "schema": { + "description": "Slug to check", + "type": "string" + } + } + ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Member" + "$ref": "#/components/schemas/SlugAvailableResponse" } } }, - "description": "Created" + "description": "Slug availability" }, "400": { "content": { @@ -34094,21 +34443,21 @@ "bearerAuth": [] } ], - "summary": "Accept invitation", + "summary": "Check slug availability", "tags": [ "organizations" ] } }, - "/v1/orgs/invitations/decline": { + "/v1/orgs/invitations/accept": { "post": { - "description": "Declines a pending organization invitation using the invitation token.", - "operationId": "declineInvitation", + "description": "Accepts a pending organization invitation using the invitation token. The signed-in user must own the email the invitation was sent to.", + "operationId": "acceptInvitation", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeclineInvitationRequest" + "$ref": "#/components/schemas/AcceptInvitationRequest" } } }, @@ -34116,15 +34465,15 @@ "required": true }, "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/OrganizationStatusResponse" + "$ref": "#/components/schemas/Member" } } }, - "description": "Declined" + "description": "Created" }, "400": { "content": { @@ -34272,28 +34621,27 @@ "bearerAuth": [] } ], - "summary": "Decline invitation", + "summary": "Accept invitation", "tags": [ "organizations" ] } }, - "/v1/orgs/{orgId}": { - "delete": { - "description": "Deletes an organization and all its members.", - "operationId": "deleteOrganization", - "parameters": [ - { - "description": "Organization identifier", - "in": "path", - "name": "orgId", - "required": true, - "schema": { - "description": "Organization identifier", - "type": "string" + "/v1/orgs/invitations/decline": { + "post": { + "description": "Declines a pending organization invitation using the invitation token.", + "operationId": "declineInvitation", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeclineInvitationRequest" + } } - } - ], + }, + "description": "Request body", + "required": true + }, "responses": { "200": { "content": { @@ -34303,7 +34651,7 @@ } } }, - "description": "Deleted" + "description": "Declined" }, "400": { "content": { @@ -34451,22 +34799,24 @@ "bearerAuth": [] } ], - "summary": "Delete organization", + "summary": "Decline invitation", "tags": [ "organizations" ] - }, + } + }, + "/v1/orgs/invitations/{inviteToken}": { "get": { - "description": "Returns details of a specific organization.", - "operationId": "getOrganization", + "description": "Reads a pending invitation by its token so the person holding it can see who invited them, to which organization, and as what, before accepting. No session required.", + "operationId": "getInvitation", "parameters": [ { - "description": "Organization identifier", + "description": "Invitation token", "in": "path", - "name": "orgId", + "name": "inviteToken", "required": true, "schema": { - "description": "Organization identifier", + "description": "Invitation token", "type": "string" } } @@ -34476,11 +34826,11 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Organization" + "$ref": "#/components/schemas/InvitationPreview" } } }, - "description": "Organization details" + "description": "Invitation preview" }, "400": { "content": { @@ -34628,14 +34978,16 @@ "bearerAuth": [] } ], - "summary": "Get organization", + "summary": "Get invitation", "tags": [ "organizations" ] - }, - "patch": { - "description": "Updates an organization's name or logo.", - "operationId": "updateOrganization", + } + }, + "/v1/orgs/{orgId}": { + "delete": { + "description": "Deletes an organization and all its members.", + "operationId": "deleteOrganization", "parameters": [ { "description": "Organization identifier", @@ -34648,27 +35000,16 @@ } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateOrgRequest" - } - } - }, - "description": "Request body", - "required": true - }, "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Organization" + "$ref": "#/components/schemas/OrganizationStatusResponse" } } }, - "description": "Updated organization" + "description": "Deleted" }, "400": { "content": { @@ -34816,16 +35157,14 @@ "bearerAuth": [] } ], - "summary": "Update organization", + "summary": "Delete organization", "tags": [ "organizations" ] - } - }, - "/v1/orgs/{orgId}/invitations": { + }, "get": { - "description": "Returns all pending invitations for an organization.", - "operationId": "listInvitations", + "description": "Returns details of a specific organization.", + "operationId": "getOrganization", "parameters": [ { "description": "Organization identifier", @@ -34836,24 +35175,6 @@ "description": "Organization identifier", "type": "string" } - }, - { - "description": "Page size (default 50, max 200)", - "in": "query", - "name": "limit", - "schema": { - "description": "Page size (default 50, max 200)", - "type": "integer" - } - }, - { - "description": "Cursor from the previous page's next_cursor", - "in": "query", - "name": "cursor", - "schema": { - "description": "Cursor from the previous page's next_cursor", - "type": "string" - } } ], "responses": { @@ -34861,11 +35182,11 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/InvitationListResponse" + "$ref": "#/components/schemas/Organization" } } }, - "description": "Invitation list" + "description": "Organization details" }, "400": { "content": { @@ -35013,14 +35334,14 @@ "bearerAuth": [] } ], - "summary": "List invitations", + "summary": "Get organization", "tags": [ "organizations" ] }, - "post": { - "description": "Creates an invitation to join an organization.", - "operationId": "createInvitation", + "patch": { + "description": "Updates an organization's name or logo.", + "operationId": "updateOrganization", "parameters": [ { "description": "Organization identifier", @@ -35037,7 +35358,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateInvitationRequest" + "$ref": "#/components/schemas/UpdateOrgRequest" } } }, @@ -35045,15 +35366,15 @@ "required": true }, "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Invitation" + "$ref": "#/components/schemas/Organization" } } }, - "description": "Created" + "description": "Updated organization" }, "400": { "content": { @@ -35201,16 +35522,16 @@ "bearerAuth": [] } ], - "summary": "Create invitation", + "summary": "Update organization", "tags": [ "organizations" ] } }, - "/v1/orgs/{orgId}/members": { + "/v1/orgs/{orgId}/invitations": { "get": { - "description": "Returns all members of an organization.", - "operationId": "listMembers", + "description": "Returns all pending invitations for an organization.", + "operationId": "listInvitations", "parameters": [ { "description": "Organization identifier", @@ -35246,11 +35567,11 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MemberListResponse" + "$ref": "#/components/schemas/InvitationListResponse" } } }, - "description": "Member list" + "description": "Invitation list" }, "400": { "content": { @@ -35398,14 +35719,14 @@ "bearerAuth": [] } ], - "summary": "List members", + "summary": "List invitations", "tags": [ "organizations" ] }, "post": { - "description": "Adds a user as a member of an organization.", - "operationId": "addMember", + "description": "Creates an invitation to join an organization.", + "operationId": "createInvitation", "parameters": [ { "description": "Organization identifier", @@ -35422,7 +35743,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AddMemberRequest" + "$ref": "#/components/schemas/CreateInvitationRequest" } } }, @@ -35434,7 +35755,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Member" + "$ref": "#/components/schemas/Invitation" } } }, @@ -35586,16 +35907,16 @@ "bearerAuth": [] } ], - "summary": "Add member", + "summary": "Create invitation", "tags": [ "organizations" ] } }, - "/v1/orgs/{orgId}/members/{memberId}": { - "delete": { - "description": "Removes a member from an organization.", - "operationId": "removeMember", + "/v1/orgs/{orgId}/members": { + "get": { + "description": "Returns all members of an organization.", + "operationId": "listMembers", "parameters": [ { "description": "Organization identifier", @@ -35608,12 +35929,20 @@ } }, { - "description": "Member identifier", - "in": "path", - "name": "memberId", - "required": true, + "description": "Page size (default 50, max 200)", + "in": "query", + "name": "limit", "schema": { - "description": "Member identifier", + "description": "Page size (default 50, max 200)", + "type": "integer" + } + }, + { + "description": "Cursor from the previous page's next_cursor", + "in": "query", + "name": "cursor", + "schema": { + "description": "Cursor from the previous page's next_cursor", "type": "string" } } @@ -35623,11 +35952,11 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/OrganizationStatusResponse" + "$ref": "#/components/schemas/MemberListResponse" } } }, - "description": "Removed" + "description": "Member list" }, "400": { "content": { @@ -35775,14 +36104,14 @@ "bearerAuth": [] } ], - "summary": "Remove member", + "summary": "List members", "tags": [ "organizations" ] }, - "patch": { - "description": "Updates a member's role within an organization.", - "operationId": "updateMember", + "post": { + "description": "Adds a user as a member of an organization.", + "operationId": "addMember", "parameters": [ { "description": "Organization identifier", @@ -35793,23 +36122,13 @@ "description": "Organization identifier", "type": "string" } - }, - { - "description": "Member identifier", - "in": "path", - "name": "memberId", - "required": true, - "schema": { - "description": "Member identifier", - "type": "string" - } } ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UpdateMemberRequest" + "$ref": "#/components/schemas/AddMemberRequest" } } }, @@ -35817,7 +36136,7 @@ "required": true }, "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { @@ -35825,7 +36144,7 @@ } } }, - "description": "Updated member" + "description": "Created" }, "400": { "content": { @@ -35973,16 +36292,16 @@ "bearerAuth": [] } ], - "summary": "Update member role", + "summary": "Add member", "tags": [ "organizations" ] } }, - "/v1/orgs/{orgId}/teams": { - "get": { - "description": "Returns all teams in an organization.", - "operationId": "listTeams", + "/v1/orgs/{orgId}/members/{memberId}": { + "delete": { + "description": "Removes a member from an organization.", + "operationId": "removeMember", "parameters": [ { "description": "Organization identifier", @@ -35995,20 +36314,12 @@ } }, { - "description": "Page size (default 50, max 200)", - "in": "query", - "name": "limit", - "schema": { - "description": "Page size (default 50, max 200)", - "type": "integer" - } - }, - { - "description": "Cursor from the previous page's next_cursor", - "in": "query", - "name": "cursor", + "description": "Member identifier", + "in": "path", + "name": "memberId", + "required": true, "schema": { - "description": "Cursor from the previous page's next_cursor", + "description": "Member identifier", "type": "string" } } @@ -36018,11 +36329,11 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TeamListResponse" + "$ref": "#/components/schemas/OrganizationStatusResponse" } } }, - "description": "Team list" + "description": "Removed" }, "400": { "content": { @@ -36170,14 +36481,14 @@ "bearerAuth": [] } ], - "summary": "List teams", + "summary": "Remove member", "tags": [ "organizations" ] }, - "post": { - "description": "Creates a new team within an organization.", - "operationId": "createTeam", + "patch": { + "description": "Updates a member's role within an organization.", + "operationId": "updateMember", "parameters": [ { "description": "Organization identifier", @@ -36188,13 +36499,23 @@ "description": "Organization identifier", "type": "string" } + }, + { + "description": "Member identifier", + "in": "path", + "name": "memberId", + "required": true, + "schema": { + "description": "Member identifier", + "type": "string" + } } ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateTeamRequest" + "$ref": "#/components/schemas/UpdateMemberRequest" } } }, @@ -36202,15 +36523,15 @@ "required": true }, "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Team" + "$ref": "#/components/schemas/Member" } } }, - "description": "Created" + "description": "Updated member" }, "400": { "content": { @@ -36358,221 +36679,24 @@ "bearerAuth": [] } ], - "summary": "Create team", + "summary": "Update member role", "tags": [ "organizations" ] } }, - "/v1/orgs/{orgId}/teams/{teamId}": { - "delete": { - "description": "Deletes a team from an organization.", - "operationId": "deleteTeam", - "parameters": [ - { - "description": "Organization identifier", - "in": "path", - "name": "orgId", - "required": true, - "schema": { - "description": "Organization identifier", - "type": "string" - } - }, - { - "description": "Team identifier", - "in": "path", - "name": "teamId", - "required": true, - "schema": { - "description": "Team identifier", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/OrganizationStatusResponse" - } - } - }, - "description": "Deleted" - }, - "400": { - "content": { - "application/json": { - "schema": { - "properties": { - "code": { - "description": "HTTP status code", - "type": "integer" - }, - "details": { - "description": "Additional error details", - "type": "string" - }, - "error": { - "description": "Error message", - "type": "string" - } - }, - "required": [ - "error", - "code" - ], - "type": "object" - } - } - }, - "description": "Bad Request" - }, - "401": { - "content": { - "application/json": { - "schema": { - "properties": { - "code": { - "description": "HTTP status code", - "type": "integer" - }, - "details": { - "description": "Additional error details", - "type": "string" - }, - "error": { - "description": "Error message", - "type": "string" - } - }, - "required": [ - "error", - "code" - ], - "type": "object" - } - } - }, - "description": "Unauthorized" - }, - "403": { - "content": { - "application/json": { - "schema": { - "properties": { - "code": { - "description": "HTTP status code", - "type": "integer" - }, - "details": { - "description": "Additional error details", - "type": "string" - }, - "error": { - "description": "Error message", - "type": "string" - } - }, - "required": [ - "error", - "code" - ], - "type": "object" - } - } - }, - "description": "Forbidden" - }, - "404": { - "content": { - "application/json": { - "schema": { - "properties": { - "code": { - "description": "HTTP status code", - "type": "integer" - }, - "details": { - "description": "Additional error details", - "type": "string" - }, - "error": { - "description": "Error message", - "type": "string" - } - }, - "required": [ - "error", - "code" - ], - "type": "object" - } - } - }, - "description": "Not Found" - }, - "500": { - "content": { - "application/json": { - "schema": { - "properties": { - "code": { - "description": "HTTP status code", - "type": "integer" - }, - "details": { - "description": "Additional error details", - "type": "string" - }, - "error": { - "description": "Error message", - "type": "string" - } - }, - "required": [ - "error", - "code" - ], - "type": "object" - } - } - }, - "description": "Internal Server Error" - } - }, - "security": [ - { - "bearerAuth": [] - } - ], - "summary": "Delete team", - "tags": [ - "organizations" - ] - }, + "/v1/orgs/{orgId}/scim/configs": { "get": { - "description": "Returns details of a specific team.", - "operationId": "getTeam", + "description": "Lists an organization's SCIM configurations. Requires an organization owner or admin.", + "operationId": "listSCIMConfigs", "parameters": [ { - "description": "Organization identifier", + "description": "Organization ID", "in": "path", "name": "orgId", "required": true, "schema": { - "description": "Organization identifier", - "type": "string" - } - }, - { - "description": "Team identifier", - "in": "path", - "name": "teamId", - "required": true, - "schema": { - "description": "Team identifier", + "description": "Organization ID", "type": "string" } } @@ -36582,11 +36706,11 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Team" + "$ref": "#/components/schemas/ConfigListResponse" } } }, - "description": "Team details" + "description": "SCIM configurations" }, "400": { "content": { @@ -36734,14 +36858,399 @@ "bearerAuth": [] } ], - "summary": "Get team", + "summary": "List SCIM configurations", + "tags": [ + "SCIM Admin" + ] + }, + "post": { + "description": "Creates a SCIM configuration for an organization, which an identity provider then provisions into.", + "operationId": "createSCIMConfig", + "parameters": [ + { + "description": "Organization ID", + "in": "path", + "name": "orgId", + "required": true, + "schema": { + "description": "Organization ID", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateSCIMConfigRequest" + } + } + }, + "description": "Request body", + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SCIMConfig" + } + } + }, + "description": "Created" + }, + "400": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "description": "HTTP status code", + "type": "integer" + }, + "details": { + "description": "Additional error details", + "type": "string" + }, + "error": { + "description": "Error message", + "type": "string" + } + }, + "required": [ + "error", + "code" + ], + "type": "object" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "description": "HTTP status code", + "type": "integer" + }, + "details": { + "description": "Additional error details", + "type": "string" + }, + "error": { + "description": "Error message", + "type": "string" + } + }, + "required": [ + "error", + "code" + ], + "type": "object" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "description": "HTTP status code", + "type": "integer" + }, + "details": { + "description": "Additional error details", + "type": "string" + }, + "error": { + "description": "Error message", + "type": "string" + } + }, + "required": [ + "error", + "code" + ], + "type": "object" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "description": "HTTP status code", + "type": "integer" + }, + "details": { + "description": "Additional error details", + "type": "string" + }, + "error": { + "description": "Error message", + "type": "string" + } + }, + "required": [ + "error", + "code" + ], + "type": "object" + } + } + }, + "description": "Not Found" + }, + "500": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "description": "HTTP status code", + "type": "integer" + }, + "details": { + "description": "Additional error details", + "type": "string" + }, + "error": { + "description": "Error message", + "type": "string" + } + }, + "required": [ + "error", + "code" + ], + "type": "object" + } + } + }, + "description": "Internal Server Error" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Create SCIM configuration", + "tags": [ + "SCIM Admin" + ] + } + }, + "/v1/orgs/{orgId}/teams": { + "get": { + "description": "Returns all teams in an organization.", + "operationId": "listTeams", + "parameters": [ + { + "description": "Organization identifier", + "in": "path", + "name": "orgId", + "required": true, + "schema": { + "description": "Organization identifier", + "type": "string" + } + }, + { + "description": "Page size (default 50, max 200)", + "in": "query", + "name": "limit", + "schema": { + "description": "Page size (default 50, max 200)", + "type": "integer" + } + }, + { + "description": "Cursor from the previous page's next_cursor", + "in": "query", + "name": "cursor", + "schema": { + "description": "Cursor from the previous page's next_cursor", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TeamListResponse" + } + } + }, + "description": "Team list" + }, + "400": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "description": "HTTP status code", + "type": "integer" + }, + "details": { + "description": "Additional error details", + "type": "string" + }, + "error": { + "description": "Error message", + "type": "string" + } + }, + "required": [ + "error", + "code" + ], + "type": "object" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "description": "HTTP status code", + "type": "integer" + }, + "details": { + "description": "Additional error details", + "type": "string" + }, + "error": { + "description": "Error message", + "type": "string" + } + }, + "required": [ + "error", + "code" + ], + "type": "object" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "description": "HTTP status code", + "type": "integer" + }, + "details": { + "description": "Additional error details", + "type": "string" + }, + "error": { + "description": "Error message", + "type": "string" + } + }, + "required": [ + "error", + "code" + ], + "type": "object" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "description": "HTTP status code", + "type": "integer" + }, + "details": { + "description": "Additional error details", + "type": "string" + }, + "error": { + "description": "Error message", + "type": "string" + } + }, + "required": [ + "error", + "code" + ], + "type": "object" + } + } + }, + "description": "Not Found" + }, + "500": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "description": "HTTP status code", + "type": "integer" + }, + "details": { + "description": "Additional error details", + "type": "string" + }, + "error": { + "description": "Error message", + "type": "string" + } + }, + "required": [ + "error", + "code" + ], + "type": "object" + } + } + }, + "description": "Internal Server Error" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "List teams", "tags": [ "organizations" ] }, - "patch": { - "description": "Updates a team's name or slug.", - "operationId": "updateTeam", + "post": { + "description": "Creates a new team within an organization.", + "operationId": "createTeam", "parameters": [ { "description": "Organization identifier", @@ -36752,23 +37261,1480 @@ "description": "Organization identifier", "type": "string" } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateTeamRequest" + } + } + }, + "description": "Request body", + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Team" + } + } + }, + "description": "Created" + }, + "400": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "description": "HTTP status code", + "type": "integer" + }, + "details": { + "description": "Additional error details", + "type": "string" + }, + "error": { + "description": "Error message", + "type": "string" + } + }, + "required": [ + "error", + "code" + ], + "type": "object" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "description": "HTTP status code", + "type": "integer" + }, + "details": { + "description": "Additional error details", + "type": "string" + }, + "error": { + "description": "Error message", + "type": "string" + } + }, + "required": [ + "error", + "code" + ], + "type": "object" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "description": "HTTP status code", + "type": "integer" + }, + "details": { + "description": "Additional error details", + "type": "string" + }, + "error": { + "description": "Error message", + "type": "string" + } + }, + "required": [ + "error", + "code" + ], + "type": "object" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "description": "HTTP status code", + "type": "integer" + }, + "details": { + "description": "Additional error details", + "type": "string" + }, + "error": { + "description": "Error message", + "type": "string" + } + }, + "required": [ + "error", + "code" + ], + "type": "object" + } + } + }, + "description": "Not Found" + }, + "500": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "description": "HTTP status code", + "type": "integer" + }, + "details": { + "description": "Additional error details", + "type": "string" + }, + "error": { + "description": "Error message", + "type": "string" + } + }, + "required": [ + "error", + "code" + ], + "type": "object" + } + } + }, + "description": "Internal Server Error" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Create team", + "tags": [ + "organizations" + ] + } + }, + "/v1/orgs/{orgId}/teams/{teamId}": { + "delete": { + "description": "Deletes a team from an organization.", + "operationId": "deleteTeam", + "parameters": [ + { + "description": "Organization identifier", + "in": "path", + "name": "orgId", + "required": true, + "schema": { + "description": "Organization identifier", + "type": "string" + } + }, + { + "description": "Team identifier", + "in": "path", + "name": "teamId", + "required": true, + "schema": { + "description": "Team identifier", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OrganizationStatusResponse" + } + } + }, + "description": "Deleted" + }, + "400": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "description": "HTTP status code", + "type": "integer" + }, + "details": { + "description": "Additional error details", + "type": "string" + }, + "error": { + "description": "Error message", + "type": "string" + } + }, + "required": [ + "error", + "code" + ], + "type": "object" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "description": "HTTP status code", + "type": "integer" + }, + "details": { + "description": "Additional error details", + "type": "string" + }, + "error": { + "description": "Error message", + "type": "string" + } + }, + "required": [ + "error", + "code" + ], + "type": "object" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "description": "HTTP status code", + "type": "integer" + }, + "details": { + "description": "Additional error details", + "type": "string" + }, + "error": { + "description": "Error message", + "type": "string" + } + }, + "required": [ + "error", + "code" + ], + "type": "object" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "description": "HTTP status code", + "type": "integer" + }, + "details": { + "description": "Additional error details", + "type": "string" + }, + "error": { + "description": "Error message", + "type": "string" + } + }, + "required": [ + "error", + "code" + ], + "type": "object" + } + } + }, + "description": "Not Found" + }, + "500": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "description": "HTTP status code", + "type": "integer" + }, + "details": { + "description": "Additional error details", + "type": "string" + }, + "error": { + "description": "Error message", + "type": "string" + } + }, + "required": [ + "error", + "code" + ], + "type": "object" + } + } + }, + "description": "Internal Server Error" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Delete team", + "tags": [ + "organizations" + ] + }, + "get": { + "description": "Returns details of a specific team.", + "operationId": "getTeam", + "parameters": [ + { + "description": "Organization identifier", + "in": "path", + "name": "orgId", + "required": true, + "schema": { + "description": "Organization identifier", + "type": "string" + } + }, + { + "description": "Team identifier", + "in": "path", + "name": "teamId", + "required": true, + "schema": { + "description": "Team identifier", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Team" + } + } + }, + "description": "Team details" + }, + "400": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "description": "HTTP status code", + "type": "integer" + }, + "details": { + "description": "Additional error details", + "type": "string" + }, + "error": { + "description": "Error message", + "type": "string" + } + }, + "required": [ + "error", + "code" + ], + "type": "object" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "description": "HTTP status code", + "type": "integer" + }, + "details": { + "description": "Additional error details", + "type": "string" + }, + "error": { + "description": "Error message", + "type": "string" + } + }, + "required": [ + "error", + "code" + ], + "type": "object" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "description": "HTTP status code", + "type": "integer" + }, + "details": { + "description": "Additional error details", + "type": "string" + }, + "error": { + "description": "Error message", + "type": "string" + } + }, + "required": [ + "error", + "code" + ], + "type": "object" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "description": "HTTP status code", + "type": "integer" + }, + "details": { + "description": "Additional error details", + "type": "string" + }, + "error": { + "description": "Error message", + "type": "string" + } + }, + "required": [ + "error", + "code" + ], + "type": "object" + } + } + }, + "description": "Not Found" + }, + "500": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "description": "HTTP status code", + "type": "integer" + }, + "details": { + "description": "Additional error details", + "type": "string" + }, + "error": { + "description": "Error message", + "type": "string" + } + }, + "required": [ + "error", + "code" + ], + "type": "object" + } + } + }, + "description": "Internal Server Error" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Get team", + "tags": [ + "organizations" + ] + }, + "patch": { + "description": "Updates a team's name or slug.", + "operationId": "updateTeam", + "parameters": [ + { + "description": "Organization identifier", + "in": "path", + "name": "orgId", + "required": true, + "schema": { + "description": "Organization identifier", + "type": "string" + } + }, + { + "description": "Team identifier", + "in": "path", + "name": "teamId", + "required": true, + "schema": { + "description": "Team identifier", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateTeamRequest" + } + } + }, + "description": "Request body", + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Team" + } + } + }, + "description": "Updated team" + }, + "400": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "description": "HTTP status code", + "type": "integer" + }, + "details": { + "description": "Additional error details", + "type": "string" + }, + "error": { + "description": "Error message", + "type": "string" + } + }, + "required": [ + "error", + "code" + ], + "type": "object" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "description": "HTTP status code", + "type": "integer" + }, + "details": { + "description": "Additional error details", + "type": "string" + }, + "error": { + "description": "Error message", + "type": "string" + } + }, + "required": [ + "error", + "code" + ], + "type": "object" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "description": "HTTP status code", + "type": "integer" + }, + "details": { + "description": "Additional error details", + "type": "string" + }, + "error": { + "description": "Error message", + "type": "string" + } + }, + "required": [ + "error", + "code" + ], + "type": "object" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "description": "HTTP status code", + "type": "integer" + }, + "details": { + "description": "Additional error details", + "type": "string" + }, + "error": { + "description": "Error message", + "type": "string" + } + }, + "required": [ + "error", + "code" + ], + "type": "object" + } + } + }, + "description": "Not Found" + }, + "500": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "description": "HTTP status code", + "type": "integer" + }, + "details": { + "description": "Additional error details", + "type": "string" + }, + "error": { + "description": "Error message", + "type": "string" + } + }, + "required": [ + "error", + "code" + ], + "type": "object" + } + } + }, + "description": "Internal Server Error" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Update team", + "tags": [ + "organizations" + ] + } + }, + "/v1/passkeys": { + "get": { + "operationId": "listPasskeys", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PasskeyListResponse" + } + } + }, + "description": "Credential list" + }, + "400": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "description": "HTTP status code", + "type": "integer" + }, + "details": { + "description": "Additional error details", + "type": "string" + }, + "error": { + "description": "Error message", + "type": "string" + } + }, + "required": [ + "error", + "code" + ], + "type": "object" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "description": "HTTP status code", + "type": "integer" + }, + "details": { + "description": "Additional error details", + "type": "string" + }, + "error": { + "description": "Error message", + "type": "string" + } + }, + "required": [ + "error", + "code" + ], + "type": "object" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "description": "HTTP status code", + "type": "integer" + }, + "details": { + "description": "Additional error details", + "type": "string" + }, + "error": { + "description": "Error message", + "type": "string" + } + }, + "required": [ + "error", + "code" + ], + "type": "object" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "description": "HTTP status code", + "type": "integer" + }, + "details": { + "description": "Additional error details", + "type": "string" + }, + "error": { + "description": "Error message", + "type": "string" + } + }, + "required": [ + "error", + "code" + ], + "type": "object" + } + } + }, + "description": "Not Found" + }, + "500": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "description": "HTTP status code", + "type": "integer" + }, + "details": { + "description": "Additional error details", + "type": "string" + }, + "error": { + "description": "Error message", + "type": "string" + } + }, + "required": [ + "error", + "code" + ], + "type": "object" + } + } + }, + "description": "Internal Server Error" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "List passkeys", + "tags": [ + "Passkeys" + ] + } + }, + "/v1/passkeys/login/begin": { + "post": { + "operationId": "passkeyLoginBegin", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LoginBeginResponse" + } + } + }, + "description": "Assertion options" + }, + "400": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "description": "HTTP status code", + "type": "integer" + }, + "details": { + "description": "Additional error details", + "type": "string" + }, + "error": { + "description": "Error message", + "type": "string" + } + }, + "required": [ + "error", + "code" + ], + "type": "object" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "description": "HTTP status code", + "type": "integer" + }, + "details": { + "description": "Additional error details", + "type": "string" + }, + "error": { + "description": "Error message", + "type": "string" + } + }, + "required": [ + "error", + "code" + ], + "type": "object" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "description": "HTTP status code", + "type": "integer" + }, + "details": { + "description": "Additional error details", + "type": "string" + }, + "error": { + "description": "Error message", + "type": "string" + } + }, + "required": [ + "error", + "code" + ], + "type": "object" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "description": "HTTP status code", + "type": "integer" + }, + "details": { + "description": "Additional error details", + "type": "string" + }, + "error": { + "description": "Error message", + "type": "string" + } + }, + "required": [ + "error", + "code" + ], + "type": "object" + } + } + }, + "description": "Not Found" + }, + "500": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "description": "HTTP status code", + "type": "integer" + }, + "details": { + "description": "Additional error details", + "type": "string" + }, + "error": { + "description": "Error message", + "type": "string" + } + }, + "required": [ + "error", + "code" + ], + "type": "object" + } + } + }, + "description": "Internal Server Error" + } + }, + "summary": "Begin passkey login", + "tags": [ + "Passkeys" + ] + } + }, + "/v1/passkeys/login/finish": { + "post": { + "operationId": "passkeyLoginFinish", + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "id": { + "type": "string" + }, + "rawId": { + "type": "string" + }, + "response": { + "$ref": "#/components/schemas/AssertionResponse" + }, + "type": { + "type": "string" + } + }, + "required": [ + "id", + "rawId", + "type", + "response" + ], + "type": "object" + } + } + }, + "description": "Request body", + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LoginFinishResponse" + } + } + }, + "description": "Authenticated" + }, + "400": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "description": "HTTP status code", + "type": "integer" + }, + "details": { + "description": "Additional error details", + "type": "string" + }, + "error": { + "description": "Error message", + "type": "string" + } + }, + "required": [ + "error", + "code" + ], + "type": "object" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "description": "HTTP status code", + "type": "integer" + }, + "details": { + "description": "Additional error details", + "type": "string" + }, + "error": { + "description": "Error message", + "type": "string" + } + }, + "required": [ + "error", + "code" + ], + "type": "object" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "description": "HTTP status code", + "type": "integer" + }, + "details": { + "description": "Additional error details", + "type": "string" + }, + "error": { + "description": "Error message", + "type": "string" + } + }, + "required": [ + "error", + "code" + ], + "type": "object" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "description": "HTTP status code", + "type": "integer" + }, + "details": { + "description": "Additional error details", + "type": "string" + }, + "error": { + "description": "Error message", + "type": "string" + } + }, + "required": [ + "error", + "code" + ], + "type": "object" + } + } + }, + "description": "Not Found" + }, + "500": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "description": "HTTP status code", + "type": "integer" + }, + "details": { + "description": "Additional error details", + "type": "string" + }, + "error": { + "description": "Error message", + "type": "string" + } + }, + "required": [ + "error", + "code" + ], + "type": "object" + } + } + }, + "description": "Internal Server Error" + } + }, + "summary": "Complete passkey login", + "tags": [ + "Passkeys" + ] + } + }, + "/v1/passkeys/register/begin": { + "post": { + "operationId": "passkeyRegisterBegin", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RegisterBeginRequest" + } + } + }, + "description": "Request body", + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RegisterBeginResponse" + } + } + }, + "description": "Registration options" + }, + "400": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "description": "HTTP status code", + "type": "integer" + }, + "details": { + "description": "Additional error details", + "type": "string" + }, + "error": { + "description": "Error message", + "type": "string" + } + }, + "required": [ + "error", + "code" + ], + "type": "object" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "description": "HTTP status code", + "type": "integer" + }, + "details": { + "description": "Additional error details", + "type": "string" + }, + "error": { + "description": "Error message", + "type": "string" + } + }, + "required": [ + "error", + "code" + ], + "type": "object" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "description": "HTTP status code", + "type": "integer" + }, + "details": { + "description": "Additional error details", + "type": "string" + }, + "error": { + "description": "Error message", + "type": "string" + } + }, + "required": [ + "error", + "code" + ], + "type": "object" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "description": "HTTP status code", + "type": "integer" + }, + "details": { + "description": "Additional error details", + "type": "string" + }, + "error": { + "description": "Error message", + "type": "string" + } + }, + "required": [ + "error", + "code" + ], + "type": "object" + } + } + }, + "description": "Not Found" }, + "500": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "description": "HTTP status code", + "type": "integer" + }, + "details": { + "description": "Additional error details", + "type": "string" + }, + "error": { + "description": "Error message", + "type": "string" + } + }, + "required": [ + "error", + "code" + ], + "type": "object" + } + } + }, + "description": "Internal Server Error" + } + }, + "security": [ { - "description": "Team identifier", - "in": "path", - "name": "teamId", - "required": true, - "schema": { - "description": "Team identifier", - "type": "string" - } + "bearerAuth": [] } ], + "summary": "Begin passkey registration", + "tags": [ + "Passkeys" + ] + } + }, + "/v1/passkeys/register/finish": { + "post": { + "operationId": "passkeyRegisterFinish", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UpdateTeamRequest" + "properties": { + "id": { + "type": "string" + }, + "rawId": { + "type": "string" + }, + "response": { + "$ref": "#/components/schemas/AttestationResponse" + }, + "type": { + "type": "string" + } + }, + "required": [ + "id", + "rawId", + "type", + "response" + ], + "type": "object" } } }, @@ -36780,11 +38746,11 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Team" + "$ref": "#/components/schemas/RegisterFinishResponse" } } }, - "description": "Updated team" + "description": "Registered" }, "400": { "content": { @@ -36932,25 +38898,35 @@ "bearerAuth": [] } ], - "summary": "Update team", + "summary": "Complete passkey registration", "tags": [ - "organizations" + "Passkeys" ] } }, - "/v1/passkeys": { - "get": { - "operationId": "listPasskeys", + "/v1/passkeys/{credentialId}": { + "delete": { + "operationId": "deletePasskey", + "parameters": [ + { + "in": "path", + "name": "credentialId", + "required": true, + "schema": { + "type": "string" + } + } + ], "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PasskeyListResponse" + "$ref": "#/components/schemas/DeleteResponse" } } }, - "description": "Credential list" + "description": "Deleted" }, "400": { "content": { @@ -37098,25 +39074,37 @@ "bearerAuth": [] } ], - "summary": "List passkeys", + "summary": "Delete passkey", "tags": [ "Passkeys" ] } }, - "/v1/passkeys/login/begin": { + "/v1/phone/start": { "post": { - "operationId": "passkeyLoginBegin", + "description": "Sends an OTP code to the given phone number.", + "operationId": "phoneAuthStart", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StartRequest" + } + } + }, + "description": "Request body", + "required": true + }, "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/LoginBeginResponse" + "$ref": "#/components/schemas/PhoneStartResponse" } } }, - "description": "Assertion options" + "description": "OTP sent" }, "400": { "content": { @@ -37259,40 +39247,382 @@ "description": "Internal Server Error" } }, - "summary": "Begin passkey login", + "summary": "Start phone authentication", "tags": [ - "Passkeys" + "Phone Auth" ] } }, - "/v1/passkeys/login/finish": { + "/v1/phone/verify": { "post": { - "operationId": "passkeyLoginFinish", + "description": "Verifies the OTP code and creates or finds the user, returning a session.", + "operationId": "phoneAuthVerify", "requestBody": { "content": { "application/json": { "schema": { - "properties": { - "id": { - "type": "string" + "$ref": "#/components/schemas/PhoneVerifyRequest" + } + } + }, + "description": "Request body", + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PhoneVerifyResponse" + } + } + }, + "description": "Authenticated" + }, + "400": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "description": "HTTP status code", + "type": "integer" + }, + "details": { + "description": "Additional error details", + "type": "string" + }, + "error": { + "description": "Error message", + "type": "string" + } }, - "rawId": { - "type": "string" + "required": [ + "error", + "code" + ], + "type": "object" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "description": "HTTP status code", + "type": "integer" + }, + "details": { + "description": "Additional error details", + "type": "string" + }, + "error": { + "description": "Error message", + "type": "string" + } }, - "response": { - "$ref": "#/components/schemas/AssertionResponse" + "required": [ + "error", + "code" + ], + "type": "object" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "description": "HTTP status code", + "type": "integer" + }, + "details": { + "description": "Additional error details", + "type": "string" + }, + "error": { + "description": "Error message", + "type": "string" + } }, - "type": { - "type": "string" - } - }, - "required": [ - "id", - "rawId", - "type", - "response" - ], - "type": "object" + "required": [ + "error", + "code" + ], + "type": "object" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "description": "HTTP status code", + "type": "integer" + }, + "details": { + "description": "Additional error details", + "type": "string" + }, + "error": { + "description": "Error message", + "type": "string" + } + }, + "required": [ + "error", + "code" + ], + "type": "object" + } + } + }, + "description": "Not Found" + }, + "500": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "description": "HTTP status code", + "type": "integer" + }, + "details": { + "description": "Additional error details", + "type": "string" + }, + "error": { + "description": "Error message", + "type": "string" + } + }, + "required": [ + "error", + "code" + ], + "type": "object" + } + } + }, + "description": "Internal Server Error" + } + }, + "summary": "Verify phone OTP", + "tags": [ + "Phone Auth" + ] + } + }, + "/v1/principals/me/delegations": { + "get": { + "description": "Lists the live delegation grants naming the calling principal as subject, so it can see the agents holding authority over it. There is no revoke route yet; Engine.RevokeDelegation is reachable only from engine-embedding code today.", + "operationId": "listMyDelegations", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DelegationListResponse" + } + } + }, + "description": "Delegations" + }, + "400": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "description": "HTTP status code", + "type": "integer" + }, + "details": { + "description": "Additional error details", + "type": "string" + }, + "error": { + "description": "Error message", + "type": "string" + } + }, + "required": [ + "error", + "code" + ], + "type": "object" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "description": "HTTP status code", + "type": "integer" + }, + "details": { + "description": "Additional error details", + "type": "string" + }, + "error": { + "description": "Error message", + "type": "string" + } + }, + "required": [ + "error", + "code" + ], + "type": "object" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "description": "HTTP status code", + "type": "integer" + }, + "details": { + "description": "Additional error details", + "type": "string" + }, + "error": { + "description": "Error message", + "type": "string" + } + }, + "required": [ + "error", + "code" + ], + "type": "object" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "description": "HTTP status code", + "type": "integer" + }, + "details": { + "description": "Additional error details", + "type": "string" + }, + "error": { + "description": "Error message", + "type": "string" + } + }, + "required": [ + "error", + "code" + ], + "type": "object" + } + } + }, + "description": "Not Found" + }, + "500": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "description": "HTTP status code", + "type": "integer" + }, + "details": { + "description": "Additional error details", + "type": "string" + }, + "error": { + "description": "Error message", + "type": "string" + } + }, + "required": [ + "error", + "code" + ], + "type": "object" + } + } + }, + "description": "Internal Server Error" + } + }, + "security": [ + { + "session": [] + }, + { + "session-cookie": [] + }, + { + "api-key": [] + }, + { + "jwt": [] + } + ], + "summary": "List what may act on your behalf", + "tags": [ + "principals" + ] + } + }, + "/v1/principals/{id}/children": { + "post": { + "description": "Creates a short-lived principal under the calling parent, with its own credential. Scopes must be a subset of the parent's and the TTL is capped by the parent's expiry.", + "operationId": "mintChildPrincipal", + "parameters": [ + { + "description": "The calling parent principal's own ID", + "in": "path", + "name": "id", + "required": true, + "schema": { + "description": "The calling parent principal's own ID", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MintChildRequest" } } }, @@ -37300,15 +39630,15 @@ "required": true }, "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/LoginFinishResponse" + "$ref": "#/components/schemas/MintChildResponse" } } }, - "description": "Authenticated" + "description": "Child principal" }, "400": { "content": { @@ -37451,20 +39781,35 @@ "description": "Internal Server Error" } }, - "summary": "Complete passkey login", + "security": [ + { + "session": [] + }, + { + "session-cookie": [] + }, + { + "api-key": [] + }, + { + "jwt": [] + } + ], + "summary": "Mint an ephemeral child principal", "tags": [ - "Passkeys" + "principals" ] } }, - "/v1/passkeys/register/begin": { + "/v1/refresh": { "post": { - "operationId": "passkeyRegisterBegin", + "description": "Exchanges a refresh token for new session and refresh tokens.", + "operationId": "refreshTokens", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RegisterBeginRequest" + "$ref": "#/components/schemas/RefreshRequest" } } }, @@ -37476,11 +39821,11 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RegisterBeginResponse" + "$ref": "#/components/schemas/ApiTokenResponse" } } }, - "description": "Registration options" + "description": "Refreshed tokens" }, "400": { "content": { @@ -37623,45 +39968,21 @@ "description": "Internal Server Error" } }, - "security": [ - { - "bearerAuth": [] - } - ], - "summary": "Begin passkey registration", + "summary": "Refresh tokens", "tags": [ - "Passkeys" + "authentication" ] } }, - "/v1/passkeys/register/finish": { + "/v1/reset-password": { "post": { - "operationId": "passkeyRegisterFinish", + "description": "Resets a user's password using a reset token. Revokes all existing sessions.", + "operationId": "resetPassword", "requestBody": { "content": { "application/json": { "schema": { - "properties": { - "id": { - "type": "string" - }, - "rawId": { - "type": "string" - }, - "response": { - "$ref": "#/components/schemas/AttestationResponse" - }, - "type": { - "type": "string" - } - }, - "required": [ - "id", - "rawId", - "type", - "response" - ], - "type": "object" + "$ref": "#/components/schemas/ResetPasswordRequest" } } }, @@ -37673,11 +39994,11 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RegisterFinishResponse" + "$ref": "#/components/schemas/ApiStatusResponse" } } }, - "description": "Registered" + "description": "Password reset" }, "400": { "content": { @@ -37820,26 +40141,24 @@ "description": "Internal Server Error" } }, - "security": [ - { - "bearerAuth": [] - } - ], - "summary": "Complete passkey registration", + "summary": "Reset password", "tags": [ - "Passkeys" + "password" ] } }, - "/v1/passkeys/{credentialId}": { - "delete": { - "operationId": "deletePasskey", + "/v1/roles": { + "get": { + "description": "Returns all RBAC roles for an app.", + "operationId": "authsomeListRoles", "parameters": [ { - "in": "path", - "name": "credentialId", + "description": "Application ID", + "in": "query", + "name": "app_id", "required": true, "schema": { + "description": "Application ID", "type": "string" } } @@ -37849,11 +40168,11 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeleteResponse" + "$ref": "#/components/schemas/RoleListResponse" } } }, - "description": "Deleted" + "description": "Role list" }, "400": { "content": { @@ -38001,21 +40320,19 @@ "bearerAuth": [] } ], - "summary": "Delete passkey", + "summary": "List roles", "tags": [ - "Passkeys" + "RBAC" ] - } - }, - "/v1/phone/start": { + }, "post": { - "description": "Sends an OTP code to the given phone number.", - "operationId": "phoneAuthStart", + "description": "Creates a new RBAC role.", + "operationId": "authsomeCreateRole", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/StartRequest" + "$ref": "#/components/schemas/CreateRoleRequest" } } }, @@ -38023,15 +40340,15 @@ "required": true }, "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PhoneStartResponse" + "$ref": "#/components/schemas/Role" } } }, - "description": "OTP sent" + "description": "Created" }, "400": { "content": { @@ -38174,37 +40491,43 @@ "description": "Internal Server Error" } }, - "summary": "Start phone authentication", + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Create role", "tags": [ - "Phone Auth" + "RBAC" ] } }, - "/v1/phone/verify": { - "post": { - "description": "Verifies the OTP code and creates or finds the user, returning a session.", - "operationId": "phoneAuthVerify", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PhoneVerifyRequest" - } + "/v1/roles/{roleId}": { + "delete": { + "description": "Deletes a role and its permissions and assignments.", + "operationId": "authsomeDeleteRole", + "parameters": [ + { + "description": "Role identifier", + "in": "path", + "name": "roleId", + "required": true, + "schema": { + "description": "Role identifier", + "type": "string" } - }, - "description": "Request body", - "required": true - }, + } + ], "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PhoneVerifyResponse" + "$ref": "#/components/schemas/ApiStatusResponse" } } }, - "description": "Authenticated" + "description": "Deleted" }, "400": { "content": { @@ -38347,26 +40670,41 @@ "description": "Internal Server Error" } }, - "summary": "Verify phone OTP", + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Delete role", "tags": [ - "Phone Auth" + "RBAC" ] - } - }, - "/v1/principals/me/delegations": { + }, "get": { - "description": "Lists the live delegation grants naming the calling principal as subject, so it can see the agents holding authority over it. There is no revoke route yet; Engine.RevokeDelegation is reachable only from engine-embedding code today.", - "operationId": "listMyDelegations", + "description": "Returns a role by ID.", + "operationId": "authsomeGetRole", + "parameters": [ + { + "description": "Role identifier", + "in": "path", + "name": "roleId", + "required": true, + "schema": { + "description": "Role identifier", + "type": "string" + } + } + ], "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DelegationListResponse" + "$ref": "#/components/schemas/Role" } } }, - "description": "Delegations" + "description": "Role details" }, "400": { "content": { @@ -38511,36 +40849,25 @@ }, "security": [ { - "session": [] - }, - { - "session-cookie": [] - }, - { - "api-key": [] - }, - { - "jwt": [] + "bearerAuth": [] } ], - "summary": "List what may act on your behalf", + "summary": "Get role", "tags": [ - "principals" + "RBAC" ] - } - }, - "/v1/principals/{id}/children": { - "post": { - "description": "Creates a short-lived principal under the calling parent, with its own credential. Scopes must be a subset of the parent's and the TTL is capped by the parent's expiry.", - "operationId": "mintChildPrincipal", + }, + "patch": { + "description": "Updates a role's name or description.", + "operationId": "authsomeUpdateRole", "parameters": [ { - "description": "The calling parent principal's own ID", + "description": "Role identifier", "in": "path", - "name": "id", + "name": "roleId", "required": true, "schema": { - "description": "The calling parent principal's own ID", + "description": "Role identifier", "type": "string" } } @@ -38549,7 +40876,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MintChildRequest" + "$ref": "#/components/schemas/UpdateRoleRequest" } } }, @@ -38557,15 +40884,15 @@ "required": true }, "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MintChildResponse" + "$ref": "#/components/schemas/Role" } } }, - "description": "Child principal" + "description": "Updated role" }, "400": { "content": { @@ -38710,33 +41037,36 @@ }, "security": [ { - "session": [] - }, - { - "session-cookie": [] - }, - { - "api-key": [] - }, - { - "jwt": [] + "bearerAuth": [] } ], - "summary": "Mint an ephemeral child principal", + "summary": "Update role", "tags": [ - "principals" + "RBAC" ] } }, - "/v1/refresh": { + "/v1/roles/{roleId}/assign": { "post": { - "description": "Exchanges a refresh token for new session and refresh tokens.", - "operationId": "refreshTokens", + "description": "Assigns a role to a user, optionally scoped to an organization.", + "operationId": "authsomeAssignRole", + "parameters": [ + { + "description": "Role identifier", + "in": "path", + "name": "roleId", + "required": true, + "schema": { + "description": "Role identifier", + "type": "string" + } + } + ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RefreshRequest" + "$ref": "#/components/schemas/AssignRoleRequest" } } }, @@ -38748,11 +41078,11 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ApiTokenResponse" + "$ref": "#/components/schemas/ApiStatusResponse" } } }, - "description": "Refreshed tokens" + "description": "Assigned" }, "400": { "content": { @@ -38895,197 +41225,29 @@ "description": "Internal Server Error" } }, - "summary": "Refresh tokens", - "tags": [ - "authentication" - ] - } - }, - "/v1/reset-password": { - "post": { - "description": "Resets a user's password using a reset token. Revokes all existing sessions.", - "operationId": "resetPassword", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ResetPasswordRequest" - } - } - }, - "description": "Request body", - "required": true - }, - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ApiStatusResponse" - } - } - }, - "description": "Password reset" - }, - "400": { - "content": { - "application/json": { - "schema": { - "properties": { - "code": { - "description": "HTTP status code", - "type": "integer" - }, - "details": { - "description": "Additional error details", - "type": "string" - }, - "error": { - "description": "Error message", - "type": "string" - } - }, - "required": [ - "error", - "code" - ], - "type": "object" - } - } - }, - "description": "Bad Request" - }, - "401": { - "content": { - "application/json": { - "schema": { - "properties": { - "code": { - "description": "HTTP status code", - "type": "integer" - }, - "details": { - "description": "Additional error details", - "type": "string" - }, - "error": { - "description": "Error message", - "type": "string" - } - }, - "required": [ - "error", - "code" - ], - "type": "object" - } - } - }, - "description": "Unauthorized" - }, - "403": { - "content": { - "application/json": { - "schema": { - "properties": { - "code": { - "description": "HTTP status code", - "type": "integer" - }, - "details": { - "description": "Additional error details", - "type": "string" - }, - "error": { - "description": "Error message", - "type": "string" - } - }, - "required": [ - "error", - "code" - ], - "type": "object" - } - } - }, - "description": "Forbidden" - }, - "404": { - "content": { - "application/json": { - "schema": { - "properties": { - "code": { - "description": "HTTP status code", - "type": "integer" - }, - "details": { - "description": "Additional error details", - "type": "string" - }, - "error": { - "description": "Error message", - "type": "string" - } - }, - "required": [ - "error", - "code" - ], - "type": "object" - } - } - }, - "description": "Not Found" - }, - "500": { - "content": { - "application/json": { - "schema": { - "properties": { - "code": { - "description": "HTTP status code", - "type": "integer" - }, - "details": { - "description": "Additional error details", - "type": "string" - }, - "error": { - "description": "Error message", - "type": "string" - } - }, - "required": [ - "error", - "code" - ], - "type": "object" - } - } - }, - "description": "Internal Server Error" + "security": [ + { + "bearerAuth": [] } - }, - "summary": "Reset password", + ], + "summary": "Assign role to user", "tags": [ - "password" + "RBAC" ] } }, - "/v1/roles": { + "/v1/roles/{roleId}/permissions": { "get": { - "description": "Returns all RBAC roles for an app.", - "operationId": "authsomeListRoles", + "description": "Returns all permissions for a role.", + "operationId": "authsomeListRolePermissions", "parameters": [ { - "description": "Application ID", - "in": "query", - "name": "app_id", + "description": "Role identifier", + "in": "path", + "name": "roleId", "required": true, "schema": { - "description": "Application ID", + "description": "Role identifier", "type": "string" } } @@ -39095,11 +41257,11 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RoleListResponse" + "$ref": "#/components/schemas/PermissionListResponse" } } }, - "description": "Role list" + "description": "Permission list" }, "400": { "content": { @@ -39247,19 +41409,31 @@ "bearerAuth": [] } ], - "summary": "List roles", + "summary": "List role permissions", "tags": [ "RBAC" ] }, "post": { - "description": "Creates a new RBAC role.", - "operationId": "authsomeCreateRole", + "description": "Adds a permission (action + resource) to a role.", + "operationId": "authsomeAddPermission", + "parameters": [ + { + "description": "Role identifier", + "in": "path", + "name": "roleId", + "required": true, + "schema": { + "description": "Role identifier", + "type": "string" + } + } + ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateRoleRequest" + "$ref": "#/components/schemas/AddPermissionRequest" } } }, @@ -39271,7 +41445,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Role" + "$ref": "#/components/schemas/Permission" } } }, @@ -39423,16 +41597,16 @@ "bearerAuth": [] } ], - "summary": "Create role", + "summary": "Add permission to role", "tags": [ "RBAC" ] } }, - "/v1/roles/{roleId}": { + "/v1/roles/{roleId}/permissions/{permissionId}": { "delete": { - "description": "Deletes a role and its permissions and assignments.", - "operationId": "authsomeDeleteRole", + "description": "Removes a permission from a role.", + "operationId": "authsomeRemovePermission", "parameters": [ { "description": "Role identifier", @@ -39443,6 +41617,16 @@ "description": "Role identifier", "type": "string" } + }, + { + "description": "Permission identifier", + "in": "path", + "name": "permissionId", + "required": true, + "schema": { + "description": "Permission identifier", + "type": "string" + } } ], "responses": { @@ -39454,7 +41638,7 @@ } } }, - "description": "Deleted" + "description": "Removed" }, "400": { "content": { @@ -39602,14 +41786,16 @@ "bearerAuth": [] } ], - "summary": "Delete role", + "summary": "Remove permission from role", "tags": [ "RBAC" ] - }, - "get": { - "description": "Returns a role by ID.", - "operationId": "authsomeGetRole", + } + }, + "/v1/roles/{roleId}/unassign": { + "post": { + "description": "Removes a role assignment from a user.", + "operationId": "authsomeUnassignRole", "parameters": [ { "description": "Role identifier", @@ -39622,16 +41808,27 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnassignRoleRequest" + } + } + }, + "description": "Request body", + "required": true + }, "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Role" + "$ref": "#/components/schemas/ApiStatusResponse" } } }, - "description": "Role details" + "description": "Unassigned" }, "400": { "content": { @@ -39779,47 +41976,214 @@ "bearerAuth": [] } ], - "summary": "Get role", + "summary": "Unassign role from user", "tags": [ "RBAC" ] - }, - "patch": { - "description": "Updates a role's name or description.", - "operationId": "authsomeUpdateRole", + } + }, + "/v1/scim/configs/{configId}": { + "delete": { + "description": "Removes the configuration and stops accepting provisioning for it.", + "operationId": "deleteSCIMConfig", "parameters": [ { - "description": "Role identifier", + "description": "SCIM configuration ID", "in": "path", - "name": "roleId", + "name": "configId", "required": true, "schema": { - "description": "Role identifier", + "description": "SCIM configuration ID", "type": "string" } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateRoleRequest" + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ScimStatusResponse" + } } - } + }, + "description": "Deleted" }, - "description": "Request body", - "required": true + "400": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "description": "HTTP status code", + "type": "integer" + }, + "details": { + "description": "Additional error details", + "type": "string" + }, + "error": { + "description": "Error message", + "type": "string" + } + }, + "required": [ + "error", + "code" + ], + "type": "object" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "description": "HTTP status code", + "type": "integer" + }, + "details": { + "description": "Additional error details", + "type": "string" + }, + "error": { + "description": "Error message", + "type": "string" + } + }, + "required": [ + "error", + "code" + ], + "type": "object" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "description": "HTTP status code", + "type": "integer" + }, + "details": { + "description": "Additional error details", + "type": "string" + }, + "error": { + "description": "Error message", + "type": "string" + } + }, + "required": [ + "error", + "code" + ], + "type": "object" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "description": "HTTP status code", + "type": "integer" + }, + "details": { + "description": "Additional error details", + "type": "string" + }, + "error": { + "description": "Error message", + "type": "string" + } + }, + "required": [ + "error", + "code" + ], + "type": "object" + } + } + }, + "description": "Not Found" + }, + "500": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "description": "HTTP status code", + "type": "integer" + }, + "details": { + "description": "Additional error details", + "type": "string" + }, + "error": { + "description": "Error message", + "type": "string" + } + }, + "required": [ + "error", + "code" + ], + "type": "object" + } + } + }, + "description": "Internal Server Error" + } }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Delete SCIM configuration", + "tags": [ + "SCIM Admin" + ] + }, + "get": { + "operationId": "getSCIMConfig", + "parameters": [ + { + "description": "SCIM configuration ID", + "in": "path", + "name": "configId", + "required": true, + "schema": { + "description": "SCIM configuration ID", + "type": "string" + } + } + ], "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Role" + "$ref": "#/components/schemas/SCIMConfig" } } }, - "description": "Updated role" + "description": "SCIM configuration" }, "400": { "content": { @@ -39967,24 +42331,22 @@ "bearerAuth": [] } ], - "summary": "Update role", + "summary": "Get SCIM configuration", "tags": [ - "RBAC" + "SCIM Admin" ] - } - }, - "/v1/roles/{roleId}/assign": { - "post": { - "description": "Assigns a role to a user, optionally scoped to an organization.", - "operationId": "authsomeAssignRole", + }, + "patch": { + "description": "Changes only the fields present in the body.", + "operationId": "updateSCIMConfig", "parameters": [ { - "description": "Role identifier", + "description": "SCIM configuration ID", "in": "path", - "name": "roleId", + "name": "configId", "required": true, "schema": { - "description": "Role identifier", + "description": "SCIM configuration ID", "type": "string" } } @@ -39993,7 +42355,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AssignRoleRequest" + "$ref": "#/components/schemas/UpdateSCIMConfigRequest" } } }, @@ -40005,11 +42367,11 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ApiStatusResponse" + "$ref": "#/components/schemas/SCIMConfig" } } }, - "description": "Assigned" + "description": "Updated configuration" }, "400": { "content": { @@ -40157,24 +42519,24 @@ "bearerAuth": [] } ], - "summary": "Assign role to user", + "summary": "Update SCIM configuration", "tags": [ - "RBAC" + "SCIM Admin" ] } }, - "/v1/roles/{roleId}/permissions": { + "/v1/scim/configs/{configId}/tokens": { "get": { - "description": "Returns all permissions for a role.", - "operationId": "authsomeListRolePermissions", + "description": "Lists a configuration's tokens. The token values themselves are never returned.", + "operationId": "listSCIMTokens", "parameters": [ { - "description": "Role identifier", + "description": "SCIM configuration ID", "in": "path", - "name": "roleId", + "name": "configId", "required": true, "schema": { - "description": "Role identifier", + "description": "SCIM configuration ID", "type": "string" } } @@ -40184,11 +42546,11 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PermissionListResponse" + "$ref": "#/components/schemas/TokenListResponse" } } }, - "description": "Permission list" + "description": "Tokens" }, "400": { "content": { @@ -40336,22 +42698,22 @@ "bearerAuth": [] } ], - "summary": "List role permissions", + "summary": "List SCIM tokens", "tags": [ - "RBAC" + "SCIM Admin" ] }, "post": { - "description": "Adds a permission (action + resource) to a role.", - "operationId": "authsomeAddPermission", + "description": "Mints a bearer token for an identity provider. The plaintext is returned once and never again.", + "operationId": "createSCIMToken", "parameters": [ { - "description": "Role identifier", + "description": "SCIM configuration ID", "in": "path", - "name": "roleId", + "name": "configId", "required": true, "schema": { - "description": "Role identifier", + "description": "SCIM configuration ID", "type": "string" } } @@ -40360,7 +42722,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AddPermissionRequest" + "$ref": "#/components/schemas/CreateSCIMTokenRequest" } } }, @@ -40372,7 +42734,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Permission" + "$ref": "#/components/schemas/TokenCreatedResponse" } } }, @@ -40524,34 +42886,23 @@ "bearerAuth": [] } ], - "summary": "Add permission to role", + "summary": "Create SCIM token", "tags": [ - "RBAC" + "SCIM Admin" ] } }, - "/v1/roles/{roleId}/permissions/{permissionId}": { + "/v1/scim/tokens/{tokenId}": { "delete": { - "description": "Removes a permission from a role.", - "operationId": "authsomeRemovePermission", + "operationId": "revokeSCIMToken", "parameters": [ { - "description": "Role identifier", - "in": "path", - "name": "roleId", - "required": true, - "schema": { - "description": "Role identifier", - "type": "string" - } - }, - { - "description": "Permission identifier", + "description": "Token to revoke", "in": "path", - "name": "permissionId", + "name": "tokenId", "required": true, "schema": { - "description": "Permission identifier", + "description": "Token to revoke", "type": "string" } } @@ -40561,11 +42912,11 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ApiStatusResponse" + "$ref": "#/components/schemas/ScimStatusResponse" } } }, - "description": "Removed" + "description": "Revoked" }, "400": { "content": { @@ -40713,24 +43064,24 @@ "bearerAuth": [] } ], - "summary": "Remove permission from role", + "summary": "Revoke SCIM token", "tags": [ - "RBAC" + "SCIM Admin" ] } }, - "/v1/roles/{roleId}/unassign": { + "/v1/scim/tokens/{tokenId}/rotate": { "post": { - "description": "Removes a role assignment from a user.", - "operationId": "authsomeUnassignRole", + "description": "Mints a replacement and optionally keeps the old token working for a grace period, so an identity provider can be updated without a gap in provisioning.", + "operationId": "rotateSCIMToken", "parameters": [ { - "description": "Role identifier", + "description": "Token being replaced", "in": "path", - "name": "roleId", + "name": "tokenId", "required": true, "schema": { - "description": "Role identifier", + "description": "Token being replaced", "type": "string" } } @@ -40739,7 +43090,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UnassignRoleRequest" + "$ref": "#/components/schemas/RotateSCIMTokenRequest" } } }, @@ -40747,15 +43098,15 @@ "required": true }, "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ApiStatusResponse" + "$ref": "#/components/schemas/TokenCreatedResponse" } } }, - "description": "Unassigned" + "description": "Created" }, "400": { "content": { @@ -40903,9 +43254,9 @@ "bearerAuth": [] } ], - "summary": "Unassign role from user", + "summary": "Rotate SCIM token", "tags": [ - "RBAC" + "SCIM Admin" ] } }, diff --git a/ui/apps/storybook/package.json b/ui/apps/storybook/package.json index 8b3cbc4b..30a94784 100644 --- a/ui/apps/storybook/package.json +++ b/ui/apps/storybook/package.json @@ -10,7 +10,13 @@ "dependencies": { "@authsome/ui-core": "workspace:*", "@authsome/ui-react": "workspace:*", - "@authsome/ui-components": "workspace:*" + "@authsome/ui-components": "workspace:*", + "@authsome/plugin-phone": "workspace:*", + "@authsome/plugin-consent": "workspace:*", + "@authsome/plugin-oauth2": "workspace:*", + "@authsome/plugin-subscription": "workspace:*", + "@authsome/plugin-organization": "workspace:*", + "@authsome/plugin-scim": "workspace:*" }, "devDependencies": { "@storybook/addon-a11y": "^8.6.19", diff --git a/ui/apps/storybook/src/mocks/auth-provider.tsx b/ui/apps/storybook/src/mocks/auth-provider.tsx index af453d15..59b84641 100644 --- a/ui/apps/storybook/src/mocks/auth-provider.tsx +++ b/ui/apps/storybook/src/mocks/auth-provider.tsx @@ -1,6 +1,7 @@ import React, { useCallback, useMemo, useRef, useState } from "react"; import { AuthContext, type AuthContextValue } from "@authsome/ui-react"; -import { AuthClientError } from "@authsome/ui-core"; +import { AuthClientError, pluginKeyName } from "@authsome/ui-core"; +import type { PluginKey } from "@authsome/ui-core"; import type { AuthState, User, @@ -218,6 +219,17 @@ export interface MockAuthProviderProps { | "email_verification_required"; signUpBehavior?: "authenticated" | "email_verification_required"; waitlistStatus?: WaitlistStatus; + /** + * Stub plugin APIs, keyed by the plugin's name, so a story can render a + * component that calls usePlugin. The real manager builds these from + * registered plugins; here a story just hands over the object it wants + * the component to see. + * + * ```tsx + * ... } }}> + * ``` + */ + plugins?: Record; } /** @@ -235,6 +247,7 @@ export function MockAuthProvider({ signInBehavior = "authenticated", signUpBehavior, waitlistStatus = "pending", + plugins, }: MockAuthProviderProps) { const [state, setState] = useState( stateFromPreset(initialState, user, MOCK_SESSION), @@ -469,7 +482,23 @@ export function MockAuthProvider({ state, manager: { getState: () => stateRef.current, - } as AuthContextValue["manager"], + // usePlugin goes through the manager, so a stub that only answers + // getState makes manager.plugin a TypeError rather than a readable + // failure. Looking the name up in the stub map keeps stories from + // needing a registered plugin, and an unknown name says which one + // the story forgot. + plugin: (key: PluginKey) => { + const name = pluginKeyName(key); + const api = plugins?.[name]; + if (!api) { + throw new Error( + `MockAuthProvider: no stub for the "${name}" plugin. ` + + `Pass plugins={{ ${name}: { ... } }} in the story.`, + ); + } + return api; + }, + } as unknown as AuthContextValue["manager"], client: mockClient, user: currentUser, session: currentSession, @@ -518,6 +547,7 @@ export function MockAuthProvider({ simulateError, errorMessage, updateState, + plugins, ], ); diff --git a/ui/apps/storybook/src/stories/plugins/ConsentList.stories.tsx b/ui/apps/storybook/src/stories/plugins/ConsentList.stories.tsx new file mode 100644 index 00000000..312a08b4 --- /dev/null +++ b/ui/apps/storybook/src/stories/plugins/ConsentList.stories.tsx @@ -0,0 +1,84 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { ConsentList } from "@authsome/plugin-consent/react"; +import { MockAuthProvider } from "../../mocks/auth-provider"; + +const consents = [ + { + id: "c1", + user_id: "u1", + app_id: "a1", + purpose: "marketing_email", + version: "2026-01", + granted: true, + granted_at: "2026-09-02T10:00:00Z", + ip_address: "81.193.4.22", + created_at: "2026-09-02T10:00:00Z", + updated_at: "2026-09-02T10:00:00Z", + }, + { + id: "c2", + user_id: "u1", + app_id: "a1", + purpose: "product_analytics", + version: "2025-11", + granted: true, + granted_at: "2025-11-14T09:30:00Z", + revoked_at: "2026-04-01T08:00:00Z", + ip_address: "81.193.4.22", + created_at: "2025-11-14T09:30:00Z", + updated_at: "2026-04-01T08:00:00Z", + }, +]; + +const stub = { + list: async () => ({ consents }), + grant: async () => consents[0], + revoke: async () => ({ status: "revoked" }), +}; + +const meta: Meta = { + title: "Plugins/Consent List", + component: ConsentList, + parameters: { layout: "centered" }, + decorators: [ + (Story) => ( + +
+ +
+
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + labels: { + marketing_email: { + title: "Product news by email", + description: "Occasional updates about what we have shipped.", + }, + product_analytics: { + title: "Usage analytics", + description: "Helps us see which features are worth keeping.", + }, + }, + }, +}; + +export const Empty: Story = { + decorators: [ + (Story) => ( + ({ consents: [] }) } }} + > +
+ +
+
+ ), + ], +}; diff --git a/ui/apps/storybook/src/stories/plugins/InvitationCard.stories.tsx b/ui/apps/storybook/src/stories/plugins/InvitationCard.stories.tsx new file mode 100644 index 00000000..5d81eaa6 --- /dev/null +++ b/ui/apps/storybook/src/stories/plugins/InvitationCard.stories.tsx @@ -0,0 +1,101 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { InvitationCard } from "@authsome/plugin-organization/react"; +import { Button } from "@authsome/ui-components"; +import { MockAuthProvider } from "../../mocks/auth-provider"; + +const preview = { + org_id: "org_1", + org_name: "Revenue Ops", + org_slug: "revenue-ops", + inviter_name: "Rex Raphael", + role: "builder", + email_masked: "so***@acme.com", + expires_at: new Date(Date.now() + 6 * 86400000).toISOString(), +}; + +const stub = { + getInvitation: async () => preview, + acceptInvitation: async () => ({ + id: "m1", + org_id: "org_1", + user_id: "u1", + role: "builder", + created_at: "", + updated_at: "", + }), + declineInvitation: async () => ({ status: "declined" }), + listInvitations: async () => ({ invitations: [] }), + createInvitation: async () => ({}) as never, +}; + +const wrap = (plugins: Record) => + function Decorator(Story: () => JSX.Element) { + return ( + + + + ); + }; + +const meta: Meta = { + title: "Plugins/Invitation Card", + component: InvitationCard, + parameters: { layout: "centered" }, + decorators: [wrap({ organization: stub })], +}; +export default meta; +type Story = StoryObj; + +export const SignedIn: Story = { + args: { + token: "inv_1", + isSignedIn: true, + roleLabels: { builder: "Builder" }, + }, +}; + +export const SignedOut: Story = { + name: "Signed out (sign-in slot)", + args: { + token: "inv_1", + isSignedIn: false, + roleLabels: { builder: "Builder" }, + signInSlot: ( +
+ + +
+ ), + }, +}; + +export const Expired: Story = { + args: { token: "inv_1", isSignedIn: true }, + decorators: [ + wrap({ + organization: { + ...stub, + getInvitation: async () => ({ + ...preview, + expires_at: new Date(Date.now() - 86400000).toISOString(), + }), + }, + }), + ], +}; + +export const NotValid: Story = { + args: { token: "bad", isSignedIn: true }, + decorators: [ + wrap({ + organization: { + ...stub, + getInvitation: async () => { + throw new Error("not found"); + }, + }, + }), + ], +}; diff --git a/ui/apps/storybook/src/stories/plugins/OAuthConsentScreen.stories.tsx b/ui/apps/storybook/src/stories/plugins/OAuthConsentScreen.stories.tsx new file mode 100644 index 00000000..e3533382 --- /dev/null +++ b/ui/apps/storybook/src/stories/plugins/OAuthConsentScreen.stories.tsx @@ -0,0 +1,87 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { OAuthConsentScreen } from "@authsome/plugin-oauth2/react"; +import { MockAuthProvider } from "../../mocks/auth-provider"; + +const stub = { + getConsentRequest: async () => ({ + cid: "cid_1", + client_id: "cli_1", + client_name: "Kineta", + scopes: ["openid", "profile", "email", "offline_access"], + csrf: "csrf_1", + action: "/v1/oauth/consent", + }), + decide: async () => ({ redirect_url: "#decided" }), + listGrants: async () => ({ grants: [] }), + revokeGrant: async () => undefined, +}; + +const meta: Meta = { + title: "Plugins/OAuth Consent Screen", + component: OAuthConsentScreen, + parameters: { layout: "centered" }, + decorators: [ + (Story) => ( + + + + ), + ], +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { cid: "cid_1", onDecided: () => {} }, +}; + +export const CustomScope: Story = { + args: { + cid: "cid_1", + onDecided: () => {}, + scopeLabels: { "custom:widgets": "Manage your widgets" }, + }, + decorators: [ + (Story) => ( + ({ + cid: "cid_1", + client_id: "cli_1", + client_name: "Widget Co", + scopes: ["openid", "custom:widgets", "unmapped:thing"], + csrf: "csrf_1", + action: "/v1/oauth/consent", + }), + }, + }} + > + + + ), + ], +}; + +export const NotValid: Story = { + args: { cid: "cid_bad", onDecided: () => {} }, + decorators: [ + (Story) => ( + { + throw new Error("expired"); + }, + }, + }} + > + + + ), + ], +}; diff --git a/ui/apps/storybook/src/stories/plugins/PhoneSignInForm.stories.tsx b/ui/apps/storybook/src/stories/plugins/PhoneSignInForm.stories.tsx new file mode 100644 index 00000000..280ef667 --- /dev/null +++ b/ui/apps/storybook/src/stories/plugins/PhoneSignInForm.stories.tsx @@ -0,0 +1,57 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { PhoneSignInForm } from "@authsome/plugin-phone/react"; +import { MockAuthProvider } from "../../mocks/auth-provider"; + +/** + * The stub stands in for the server. `start` always succeeds; `verify` + * accepts 482917 and rejects everything else, so the error state is + * reachable without a backend. + */ +const phoneStub = { + start: async () => { + await new Promise((r) => setTimeout(r, 400)); + return { status: "sent", expires_in: 300 }; + }, + verify: async ({ code }: { code: string }) => { + await new Promise((r) => setTimeout(r, 400)); + if (code !== "482917") throw new Error("That code is wrong or has expired."); + return { + session_token: "sess_mock", + refresh_token: "ref_mock", + expires_at: new Date(Date.now() + 3600_000).toISOString(), + new_user: false, + user: { id: "usr_1", email: "rex@acme.com" }, + }; + }, +}; + +const meta: Meta = { + title: "Plugins/Phone Sign In", + component: PhoneSignInForm, + parameters: { layout: "centered" }, + decorators: [ + (Story) => ( + + + + ), + ], +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const Borderless: Story = { + args: { variant: "borderless" }, +}; + +export const FourDigitCode: Story = { + args: { codeLength: 4 }, +}; + +export const WithFooter: Story = { + args: { + footer: Use email instead, + }, +}; diff --git a/ui/apps/storybook/src/stories/plugins/ScimConnection.stories.tsx b/ui/apps/storybook/src/stories/plugins/ScimConnection.stories.tsx new file mode 100644 index 00000000..c8ba003f --- /dev/null +++ b/ui/apps/storybook/src/stories/plugins/ScimConnection.stories.tsx @@ -0,0 +1,98 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { ScimConnectionCard, ScimTokenList } from "@authsome/plugin-scim/react"; +import { MockAuthProvider } from "../../mocks/auth-provider"; + +const config = { + id: "cfg_1", + app_id: "a1", + org_id: "org_1", + name: "Okta production", + enabled: true, + auto_create: true, + auto_suspend: true, + group_sync: false, + default_role: "member", + created_at: "2026-05-01T00:00:00Z", + updated_at: "2026-10-01T00:00:00Z", +}; + +const tokens = [ + { + id: "tok_1", + config_id: "cfg_1", + name: "Okta production", + last_used_at: "2026-11-09T22:14:00Z", + created_at: "2026-05-01T00:00:00Z", + }, + { + id: "tok_2", + config_id: "cfg_1", + name: "Okta sandbox", + expires_at: "2026-06-01T00:00:00Z", + created_at: "2026-01-01T00:00:00Z", + }, +]; + +const stub = { + listConfigs: async () => ({ configs: [config] }), + createConfig: async () => config, + getConfig: async () => config, + updateConfig: async () => config, + deleteConfig: async () => ({ status: "deleted" }), + listTokens: async () => ({ tokens }), + createToken: async () => ({ + token: tokens[0], + plaintext: "scim_live_8f2c4d9a1b6e3047a5c8d2f1e9b7a403", + base_url: "/scim/v2", + }), + rotateToken: async () => ({ + token: tokens[0], + plaintext: "scim_live_1a2b3c4d5e6f70819a2b3c4d5e6f7081", + base_url: "/scim/v2", + }), + revokeToken: async () => ({ status: "revoked" }), +}; + +const meta: Meta = { + title: "Plugins/SCIM Connection", + component: ScimConnectionCard, + parameters: { layout: "centered" }, + decorators: [ + (Story) => ( + +
+ +
+
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +export const Connected: Story = { + args: { orgId: "org_1" }, +}; + +export const WithTokens: Story = { + args: { + orgId: "org_1", + children: , + }, +}; + +export const NothingConnected: Story = { + args: { orgId: "org_1" }, + decorators: [ + (Story) => ( + ({ configs: [] }) } }} + > +
+ +
+
+ ), + ], +}; diff --git a/ui/apps/storybook/src/stories/primitives/PhoneInput.stories.tsx b/ui/apps/storybook/src/stories/primitives/PhoneInput.stories.tsx new file mode 100644 index 00000000..1277c24b --- /dev/null +++ b/ui/apps/storybook/src/stories/primitives/PhoneInput.stories.tsx @@ -0,0 +1,57 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { useState } from "react"; +import { PhoneInput } from "@authsome/ui-components"; + +const meta: Meta = { + title: "Primitives/Phone Input", + component: PhoneInput, + parameters: { layout: "centered" }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: (args) => { + const [value, setValue] = useState(""); + const [valid, setValid] = useState(false); + return ( +
+ { + setValue(v); + setValid(isValid); + }} + /> +

+ E.164: {value || "(empty)"} +

+

+ valid: {String(valid)} +

+
+ ); + }, +}; + +export const Portugal: Story = { + ...Default, + args: { defaultCountry: "PT" }, +}; + +export const ShortList: Story = { + ...Default, + args: { defaultCountry: "PT", countries: ["PT", "ES", "GB", "US", "NG"] }, +}; + +export const Disabled: Story = { + args: { defaultCountry: "GB", disabled: true }, +}; diff --git a/ui/apps/storybook/src/stories/primitives/VerificationCodeInput.stories.tsx b/ui/apps/storybook/src/stories/primitives/VerificationCodeInput.stories.tsx new file mode 100644 index 00000000..58436531 --- /dev/null +++ b/ui/apps/storybook/src/stories/primitives/VerificationCodeInput.stories.tsx @@ -0,0 +1,60 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { useState } from "react"; +import { VerificationCodeInput } from "@authsome/ui-components"; + +const meta: Meta = { + title: "Primitives/Verification Code", + component: VerificationCodeInput, + parameters: { layout: "centered" }, +}; +export default meta; +type Story = StoryObj; + +export const Numeric: Story = { + args: { length: 6, mode: "numeric" }, +}; + +export const Alphanumeric: Story = { + args: { length: 6, mode: "alphanumeric" }, +}; + +export const Grouped: Story = { + name: "Grouped (device code)", + args: { length: 8, mode: "alphanumeric", groupSize: 4 }, +}; + +export const FourDigit: Story = { + args: { length: 4, mode: "numeric" }, +}; + +export const Invalid: Story = { + args: { length: 6, mode: "numeric", value: "483920", invalid: true }, +}; + +export const Disabled: Story = { + args: { length: 6, mode: "numeric", value: "4829", disabled: true }, +}; + +export const Controlled: Story = { + render: () => { + const [code, setCode] = useState(""); + const [done, setDone] = useState(null); + return ( +
+ +

+ value: {code || "(empty)"} +

+

+ onComplete fired with: {done ?? "(not yet)"} +

+
+ ); + }, +}; diff --git a/ui/apps/storybook/src/stories/session/AccountSummaryCard.stories.tsx b/ui/apps/storybook/src/stories/session/AccountSummaryCard.stories.tsx new file mode 100644 index 00000000..4552dc76 --- /dev/null +++ b/ui/apps/storybook/src/stories/session/AccountSummaryCard.stories.tsx @@ -0,0 +1,85 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { AccountSummaryCard } from "@authsome/ui-components"; + +const meta: Meta = { + title: "Session/Account Summary Card", + component: AccountSummaryCard, + parameters: { layout: "centered" }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +const identity = { + name: "Rex Raphael", + identifier: "rex@acme.com", + role: "Owner of Acme", +}; + +const today = () => { + const d = new Date(); + d.setHours(8, 41, 0, 0); + return d.toISOString(); +}; + +export const Default: Story = { + args: { + identity, + summary: { + lastSignInAt: today(), + ipAddress: "81.193.4.22", + device: "Chrome on macOS", + trustedDeviceCount: 2, + twoStepEnabled: true, + }, + footer: "Something look wrong? Sign out everywhere from Settings, under Security.", + }, +}; + +export const TwoStepOff: Story = { + args: { + ...Default.args, + summary: { ...Default.args!.summary!, twoStepEnabled: false }, + }, +}; + +export const TwoStepUnknown: Story = { + name: "Two-step unknown (row hidden)", + args: { + ...Default.args, + summary: { ...Default.args!.summary!, twoStepEnabled: null }, + }, +}; + +export const Yesterday: Story = { + args: { + ...Default.args, + summary: { + ...Default.args!.summary!, + lastSignInAt: new Date(Date.now() - 26 * 3600_000).toISOString(), + }, + }, +}; + +export const LongAgo: Story = { + args: { + ...Default.args, + summary: { + ...Default.args!.summary!, + lastSignInAt: new Date("2026-03-04T09:15:00Z").toISOString(), + }, + }, +}; + +export const NoRole: Story = { + args: { + ...Default.args, + identity: { name: "Rex Raphael", identifier: "rex@acme.com" }, + }, +}; diff --git a/ui/apps/storybook/src/stories/session/LastUsedAccount.stories.tsx b/ui/apps/storybook/src/stories/session/LastUsedAccount.stories.tsx new file mode 100644 index 00000000..2d359ab4 --- /dev/null +++ b/ui/apps/storybook/src/stories/session/LastUsedAccount.stories.tsx @@ -0,0 +1,64 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { LastUsedAccount } from "@authsome/ui-components"; + +const meta: Meta = { + title: "Session/Last Used Account", + component: LastUsedAccount, + parameters: { layout: "centered" }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +const rex = { + userId: "usr_1", + displayName: "Rex Raphael", + identifier: "rex@acme.com", + strategy: "oidc:kineta", + strategyLabel: "Kineta", + at: new Date().toISOString(), +}; + +export const Default: Story = { + args: { account: rex, onSelect: () => {} }, +}; + +export const NoDisplayName: Story = { + args: { + account: { ...rex, displayName: undefined }, + onSelect: () => {}, + }, +}; + +export const PasswordStrategy: Story = { + args: { + account: { ...rex, strategy: "password", strategyLabel: undefined }, + onSelect: () => {}, + }, +}; + +export const NotDismissible: Story = { + args: { account: rex, onSelect: () => {}, dismissible: false }, +}; + +export const LongIdentifier: Story = { + args: { + account: { + ...rex, + displayName: "Alexandra Constantina Rivera-Montgomery", + identifier: "alexandra.constantina.rivera@a-very-long-company-name.example", + }, + onSelect: () => {}, + }, +}; + +export const NothingRemembered: Story = { + name: "Nothing remembered (renders nothing)", + args: { account: null }, +}; diff --git a/ui/packages/components/package.json b/ui/packages/components/package.json index 58b512bb..3da7fe57 100644 --- a/ui/packages/components/package.json +++ b/ui/packages/components/package.json @@ -49,6 +49,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "input-otp": "^1.5.0", + "libphonenumber-js": "1.12.23", "lucide-react": "^1.34.0", "tailwind-merge": "^3.6.0" }, @@ -68,5 +69,8 @@ "typescript": "^5.9.3", "vite": "^8.2.4", "vitest": "^4.1.12" - } + }, + "sideEffects": [ + "**/*.css" + ] } diff --git a/ui/packages/components/src/components/account-summary-card.tsx b/ui/packages/components/src/components/account-summary-card.tsx new file mode 100644 index 00000000..e595c017 --- /dev/null +++ b/ui/packages/components/src/components/account-summary-card.tsx @@ -0,0 +1,175 @@ +"use client"; + +import * as React from "react"; +import { useLastSignIn, type LastSignInSummary } from "@authsome/ui-react"; +import { cn } from "../lib/utils"; +import { Avatar, AvatarFallback, AvatarImage } from "../primitives/avatar"; +import { Badge } from "../primitives/badge"; +import { Separator } from "../primitives/separator"; +import { Skeleton } from "../primitives/skeleton"; + +export interface AccountSummaryIdentity { + name?: string; + /** Email or phone, shown under the name. */ + identifier: string; + avatarUrl?: string; + /** Free text after the identifier, e.g. "Owner of Acme". */ + role?: string; +} + +export interface AccountSummaryCardProps { + /** Who the card is about. */ + identity: AccountSummaryIdentity; + /** + * The facts to show. Left out, the card calls useLastSignIn itself, which + * needs an AuthProvider above it. Pass one to render from your own data. + */ + summary?: LastSignInSummary | null; + /** Heading inside the card. Pass null for none. */ + title?: React.ReactNode; + /** Rendered under the rows, for a "sign out everywhere" pointer. */ + footer?: React.ReactNode; + className?: string; +} + +/** "Today 08:41", "Yesterday 23:02", "4 Mar 2026 09:15". */ +function formatSignInTime(iso: string | null): string | null { + if (!iso) return null; + const at = new Date(iso); + if (Number.isNaN(at.getTime())) return null; + + const time = at.toLocaleTimeString(undefined, { + hour: "2-digit", + minute: "2-digit", + hour12: false, + }); + + // Compare calendar days rather than elapsed hours. Something at 00:30 is + // "today" even though it was forty minutes ago, and something at 23:50 + // yesterday is "yesterday" even though it was ten minutes ago. + const startOfDay = (d: Date) => + new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime(); + const days = Math.round((startOfDay(new Date()) - startOfDay(at)) / 86_400_000); + + if (days === 0) return `Today ${time}`; + if (days === 1) return `Yesterday ${time}`; + return `${at.toLocaleDateString(undefined, { day: "numeric", month: "short", year: "numeric" })} ${time}`; +} + +function Row({ + label, + children, +}: { + label: string; + children: React.ReactNode; +}) { + return ( +
+ {label} + {children} +
+ ); +} + +function initials(identity: AccountSummaryIdentity): string { + const source = identity.name?.trim() || identity.identifier; + const parts = source.split(/[\s@._-]+/).filter(Boolean); + return (parts[0]?.[0] ?? "?").concat(parts[1]?.[0] ?? "").toUpperCase(); +} + +/** + * Who you are and when you were last here. + * + * This is reassurance, not authorization. It tells someone looking at a + * two-step prompt or a password reset that the account is the one they think + * it is, and nothing it shows decides what anyone is allowed to do. + */ +export function AccountSummaryCard({ + identity, + summary, + title = "Your account", + footer, + className, +}: AccountSummaryCardProps) { + // The hook runs either way, because hooks cannot be called conditionally. + // Its result is ignored when summary was passed, and the cost is one fetch + // a host composing the card from its own data did not need. Worth it to + // keep the component usable both ways. + const hook = useLastSignIn(); + const resolved = summary === undefined ? hook.summary : summary; + const isLoading = summary === undefined && hook.isLoading; + + const signedInAt = formatSignInTime(resolved?.lastSignInAt ?? null); + const where = resolved?.ipAddress; + + return ( +
+ {title && ( +

+ {title} +

+ )} + +
+ + {identity.avatarUrl && } + {initials(identity)} + +
+

+ {identity.name || identity.identifier} +

+

+ {identity.name ? identity.identifier : null} + {identity.name && identity.role ? " · " : null} + {identity.role} +

+
+
+ + + + {isLoading ? ( +
+ + + +
+ ) : ( +
+ {signedInAt && ( + + {where ? `${signedInAt} · ${where}` : signedInAt} + + )} + {resolved?.device && {resolved.device}} + {/* null means no endpoint answered yet, so the row stays off rather + than claiming two-step is off when nobody asked. */} + {resolved?.twoStepEnabled !== null && + resolved?.twoStepEnabled !== undefined && ( + + + {resolved.twoStepEnabled ? "On" : "Off"} + + + )} + {resolved && {resolved.trustedDeviceCount}} +
+ )} + + {footer && ( +

+ {footer} +

+ )} +
+ ); +} diff --git a/ui/packages/components/src/components/auth-card.tsx b/ui/packages/components/src/components/auth-card.tsx index a6256509..2409d0c0 100644 --- a/ui/packages/components/src/components/auth-card.tsx +++ b/ui/packages/components/src/components/auth-card.tsx @@ -37,7 +37,19 @@ const variantClasses: Record = { default: "border-border/40 shadow-sm", flat: "border-border/40 shadow-none", bordered: "border-border shadow-none", - borderless: "border-transparent shadow-none", + // Borderless is the shape the refreshed design leads with: no chrome of its + // own, so it sits flush inside a column the host app laid out. + borderless: "border-transparent bg-transparent shadow-none", +}; + +// Borderless drops the horizontal padding too. A card with no border that +// still inset its contents by 28px would look misaligned against anything +// else in the host's column. +const padding: Record = { + default: { header: "px-7 pt-7 pb-6", content: "px-7 pb-6", footer: "px-7 py-4" }, + flat: { header: "px-7 pt-7 pb-6", content: "px-7 pb-6", footer: "px-7 py-4" }, + bordered: { header: "px-7 pt-7 pb-6", content: "px-7 pb-6", footer: "px-7 py-4" }, + borderless: { header: "px-0 pt-0 pb-7", content: "px-0 pb-0", footer: "px-0 pt-6" }, }; export function AuthCard({ @@ -45,12 +57,13 @@ export function AuthCard({ description, logo, footer, - align = "center", + align = "left", variant = "default", className, children, }: AuthCardProps) { const isCenter = align === "center"; + const pad = padding[variant]; // Read the context directly rather than through useClientConfig, which // throws outside an AuthProvider. The card is also rendered standalone @@ -70,8 +83,8 @@ export function AuthCard({ return ( {resolvedLogo && ( -
- {resolvedLogo} -
+
{resolvedLogo}
)} - + {title} {description && ( - + {description} )}
- {children} + {children} {footer && ( - + {footer} )} diff --git a/ui/packages/components/src/components/device-authorization-form.tsx b/ui/packages/components/src/components/device-authorization-form.tsx index 458896e2..e5fe6b6f 100644 --- a/ui/packages/components/src/components/device-authorization-form.tsx +++ b/ui/packages/components/src/components/device-authorization-form.tsx @@ -7,12 +7,7 @@ import { CheckCircle2 } from "lucide-react"; import { cn } from "../lib/utils"; import { useCodeFromURL } from "../lib/use-code-from-url"; import { Button } from "../primitives/button"; -import { - InputOTP, - InputOTPGroup, - InputOTPSlot, - InputOTPSeparator, -} from "../primitives/otp-input"; +import { VerificationCodeInput } from "../primitives/verification-code-input"; import { AuthCard, type AuthCardAlign, type AuthCardVariant } from "./auth-card"; import { ErrorDisplay } from "./error-display"; import { LoadingSpinner } from "./loading-spinner"; @@ -42,7 +37,6 @@ export interface DeviceAuthorizationFormProps { autoSubmit?: boolean; } -const REGEXP_ALPHANUMERIC = /^[A-Za-z0-9]*$/; /** * OAuth device authorization form (RFC 8628). @@ -245,34 +239,17 @@ export function DeviceAuthorizationForm({
{/* OTP Code Input */}
- - - {Array.from({ length: halfLength }).map((_, i) => ( - - ))} - - - - {Array.from({ length: codeLength - halfLength }).map((_, i) => ( - - ))} - - + invalid={Boolean(error)} + aria-label="Device code" + />
diff --git a/ui/packages/components/src/components/email-verification-form.tsx b/ui/packages/components/src/components/email-verification-form.tsx index bff80773..002ed07a 100644 --- a/ui/packages/components/src/components/email-verification-form.tsx +++ b/ui/packages/components/src/components/email-verification-form.tsx @@ -1,15 +1,9 @@ "use client"; import * as React from "react"; -import { REGEXP_ONLY_DIGITS } from "input-otp"; import { useAuth } from "@authsome/ui-react"; import { cn } from "../lib/utils"; -import { - InputOTP, - InputOTPGroup, - InputOTPSlot, - InputOTPSeparator, -} from "../primitives/otp-input"; +import { VerificationCodeInput } from "../primitives/verification-code-input"; import { Button } from "../primitives/button"; import { AuthCard } from "./auth-card"; import { ErrorDisplay } from "./error-display"; @@ -167,26 +161,17 @@ export function EmailVerificationForm({ )}
- - - - - - - - - - - - - + invalid={Boolean(error)} + aria-label="Email verification code" + autoFocus + />
diff --git a/ui/packages/components/src/components/last-used-account.tsx b/ui/packages/components/src/components/last-used-account.tsx new file mode 100644 index 00000000..5b227dbe --- /dev/null +++ b/ui/packages/components/src/components/last-used-account.tsx @@ -0,0 +1,117 @@ +"use client"; + +import * as React from "react"; +import { X } from "lucide-react"; +import { cn } from "../lib/utils"; +import { + forgetLastAccount, + getLastAccountServerSnapshot, + getLastAccountSnapshot, + subscribeToLastAccount, + type LastUsedAccount as LastUsedAccountRecord, +} from "../lib/last-used-account"; +import { Avatar, AvatarFallback, AvatarImage } from "../primitives/avatar"; +import { Badge } from "../primitives/badge"; + +export interface LastUsedAccountProps { + /** + * The account to show. Left out, the component reads the one this browser + * remembered, and renders nothing when there is none. + */ + account?: LastUsedAccountRecord | null; + /** Clicking the chip. Give it the strategy to resume. */ + onSelect?: (account: LastUsedAccountRecord) => void; + /** Shown instead of a dismiss control when you want the chip permanent. */ + dismissible?: boolean; + /** Called after the record is forgotten, so a parent can re-render. */ + onDismiss?: () => void; + className?: string; +} + +function initials(account: LastUsedAccountRecord): string { + const source = account.displayName?.trim() || account.identifier; + const parts = source.split(/[\s@._-]+/).filter(Boolean); + return (parts[0]?.[0] ?? "?").concat(parts[1]?.[0] ?? "").toUpperCase(); +} + +/** + * The returning-visitor shortcut: who signed in last on this browser. + * + * It renders nothing at all when there is no record, which is every first + * visit and every browser that refuses to store one, so it is safe to drop + * at the top of a sign-in form unconditionally. + */ +export function LastUsedAccount({ + account, + onSelect, + dismissible = true, + onDismiss, + className, +}: LastUsedAccountProps) { + // localStorage is an external store, so it is read through the API React + // provides for one. The alternative, setting state from an effect on mount, + // costs a render pass and is what react-hooks/set-state-in-effect objects + // to. The server snapshot is always null, so the markup React renders on + // the server matches the first client render and the chip appears after + // hydration rather than causing a mismatch. + const stored = React.useSyncExternalStore( + subscribeToLastAccount, + getLastAccountSnapshot, + getLastAccountServerSnapshot, + ); + + const resolved = account === undefined ? stored : account; + if (!resolved) return null; + + const handleDismiss = () => { + forgetLastAccount(); + onDismiss?.(); + }; + + return ( +
+ + + Last used + + {dismissible && ( + + )} +
+ ); +} diff --git a/ui/packages/components/src/components/mfa-challenge-form.tsx b/ui/packages/components/src/components/mfa-challenge-form.tsx index f654d31a..7078e332 100644 --- a/ui/packages/components/src/components/mfa-challenge-form.tsx +++ b/ui/packages/components/src/components/mfa-challenge-form.tsx @@ -1,13 +1,7 @@ import * as React from "react"; -import { REGEXP_ONLY_DIGITS } from "input-otp"; import { useAuth } from "@authsome/ui-react"; import { cn } from "../lib/utils"; -import { - InputOTP, - InputOTPGroup, - InputOTPSlot, - InputOTPSeparator, -} from "../primitives/otp-input"; +import { VerificationCodeInput } from "../primitives/verification-code-input"; import { Button } from "../primitives/button"; import { Input } from "../primitives/input"; import { AuthCard } from "./auth-card"; @@ -83,9 +77,9 @@ function TOTPView({ className="grid gap-3" >
- { setCode(v); @@ -93,19 +87,10 @@ function TOTPView({ }} onComplete={(v) => void handleSubmit(v)} disabled={isSubmitting} - > - - - - - - - - - - - - + invalid={Boolean(error)} + aria-label="Two-factor code" + autoFocus + />
@@ -248,9 +233,9 @@ function SMSView() { )}
- { setCode(v); @@ -258,19 +243,10 @@ function SMSView() { }} onComplete={(v) => void handleSubmit(v)} disabled={isSubmitting} - > - - - - - - - - - - - - + invalid={Boolean(error)} + aria-label="Two-factor code" + autoFocus + />
diff --git a/ui/packages/components/src/components/social-buttons.tsx b/ui/packages/components/src/components/social-buttons.tsx index 8157702d..1fab1ab0 100644 --- a/ui/packages/components/src/components/social-buttons.tsx +++ b/ui/packages/components/src/components/social-buttons.tsx @@ -1,7 +1,7 @@ import * as React from "react"; import { cn } from "../lib/utils"; import { Button } from "../primitives/button"; -import { Separator } from "../primitives/separator"; +import { LabelledSeparator } from "../primitives/separator"; import { Tooltip, TooltipContent, @@ -264,17 +264,15 @@ function ProviderIcon({ provider }: { provider: SocialProvider }) { return ; } -export function OrDivider({ className }: { className?: string }) { - return ( -
-
- -
-
- or -
-
- ); +export function OrDivider({ + className, + label = "or", +}: { + className?: string; + /** Text on the rule. Defaults to "or". */ + label?: React.ReactNode; +}) { + return {label}; } function GridLayout({ diff --git a/ui/packages/components/src/index.ts b/ui/packages/components/src/index.ts index a239f5c7..f94d6507 100644 --- a/ui/packages/components/src/index.ts +++ b/ui/packages/components/src/index.ts @@ -11,6 +11,16 @@ export { CardFooter, } from "./primitives/card"; export { Input, type InputProps } from "./primitives/input"; +export { + InputGroup, + InputAddon, + InputGroupInput, +} from "./primitives/input-group"; +export { + PhoneInput, + type PhoneInputProps, + type PhoneCountryCode, +} from "./primitives/phone-input"; export { Label } from "./primitives/label"; export { Alert, AlertDescription } from "./primitives/alert"; export { @@ -26,7 +36,11 @@ export { DropdownMenuSeparator, DropdownMenuLabel, } from "./primitives/dropdown-menu"; -export { Separator } from "./primitives/separator"; +export { + Separator, + LabelledSeparator, + type LabelledSeparatorProps, +} from "./primitives/separator"; export { Badge, type BadgeProps, badgeVariants } from "./primitives/badge"; export { Skeleton } from "./primitives/skeleton"; export { Checkbox } from "./primitives/checkbox"; @@ -36,6 +50,12 @@ export { InputOTPSlot, InputOTPSeparator, } from "./primitives/otp-input"; +export { + VerificationCodeInput, + type VerificationCodeInputProps, + type VerificationCodeMode, + type VerificationCodeSize, +} from "./primitives/verification-code-input"; export { Dialog, DialogPortal, @@ -84,6 +104,23 @@ export { AuthCard, type AuthCardProps, type AuthCardAlign, type AuthCardVariant export { ErrorDisplay, type ErrorDisplayProps } from "./components/error-display"; export { LoadingSpinner, type LoadingSpinnerProps } from "./components/loading-spinner"; export { PasswordInput } from "./components/password-input"; +export { + LastUsedAccount, + type LastUsedAccountProps, +} from "./components/last-used-account"; +export { + AccountSummaryCard, + type AccountSummaryCardProps, + type AccountSummaryIdentity, +} from "./components/account-summary-card"; +export { + readLastAccount, + rememberLastAccount, + subscribeToLastAccount, + getLastAccountSnapshot, + forgetLastAccount, + type LastUsedAccount as LastUsedAccountRecord, +} from "./lib/last-used-account"; export { SocialButtons, OrDivider, type SocialButtonsProps, type SocialProvider, type SocialButtonLayout } from "./components/social-buttons"; export { openOAuthPopup, handleSocialLogin } from "./lib/social-login"; diff --git a/ui/packages/components/src/lib/last-used-account.ts b/ui/packages/components/src/lib/last-used-account.ts new file mode 100644 index 00000000..560311e3 --- /dev/null +++ b/ui/packages/components/src/lib/last-used-account.ts @@ -0,0 +1,157 @@ +/** + * The account that last signed in successfully on this browser. + * + * This is a convenience and never an authorisation. Nothing stored here is + * trusted, none of it is sent anywhere, and the person it names still has to + * authenticate. It exists so a returning visitor does not have to remember + * which of four providers they used last time. + * + * It lives in localStorage rather than on the server because it is a property + * of the browser, not of the account. Signing in on a laptop should not change + * what a phone offers. + */ + +const KEY = "authsome:last-account"; + +export interface LastUsedAccount { + /** The user's id, so a host can match it against its own records. */ + userId: string; + /** Display name, when the server gave one. */ + displayName?: string; + /** Email or phone, shown as-is under the name. */ + identifier: string; + avatarUrl?: string; + /** The strategy that worked: "password", "google", "oidc:kineta", ... */ + strategy: string; + /** Human-readable name for the strategy, for "signs in with Kineta". */ + strategyLabel?: string; + /** When it was written, ISO 8601. */ + at: string; +} + +/** + * How long a remembered account keeps being offered. + * + * Ninety days. Long enough that someone who signs in every few months on + * their own laptop still gets the shortcut, short enough that a library + * computer or a sold phone stops offering a stranger's name and email address + * to whoever sits down next. That record grants no access, but it is still + * someone's identity on a screen they do not control. + * + * Returns milliseconds, or Infinity to keep a record forever. + */ +export function maxAgeMs(): number { + return 90 * 24 * 60 * 60 * 1000; +} + +/** + * Reads the remembered account, or null. + * + * Every access is guarded. localStorage throws outright in some privacy modes, + * returns null when site data was cleared, and holds whatever a previous + * version of this code wrote, so a bad parse is treated the same as nothing. + */ +export function readLastAccount(): LastUsedAccount | null { + try { + const raw = globalThis.localStorage?.getItem(KEY); + if (!raw) return null; + const parsed = JSON.parse(raw) as Partial; + if (!parsed || typeof parsed.userId !== "string" || typeof parsed.identifier !== "string") { + return null; + } + const at = Date.parse(parsed.at ?? ""); + if (Number.isNaN(at)) return null; + const max = maxAgeMs(); + if (Number.isFinite(max) && Date.now() - at > max) { + forgetLastAccount(); + return null; + } + return parsed as LastUsedAccount; + } catch { + return null; + } +} + +/** Remembers an account. Called after a sign-in actually succeeds. */ +export function rememberLastAccount( + account: Omit & { at?: string }, +): void { + try { + const record: LastUsedAccount = { ...account, at: account.at ?? new Date().toISOString() }; + globalThis.localStorage?.setItem(KEY, JSON.stringify(record)); + emit(); + } catch { + // A browser that will not store it is a browser that gets no chip. That + // is the whole consequence, so there is nothing to report. + } +} + +/** + * Forgets the remembered account. + * + * Signing out does NOT call this. Being remembered is the point: the next + * visit should still know who you are. The dismiss control on the chip calls + * it, and so should a "this is not my device" affordance if a host adds one. + */ +export function forgetLastAccount(): void { + try { + globalThis.localStorage?.removeItem(KEY); + emit(); + } catch { + // Nothing to do. If it cannot be removed it could not have been read. + } +} + +// ── Store plumbing ───────────────────────────────────── +// +// The chip reads this through useSyncExternalStore, which needs a snapshot +// that is referentially stable between renders. readLastAccount() parses +// fresh JSON every call and would hand back a new object each time, so React +// would see a changed store on every render and loop. The cache below returns +// the same object until the stored string actually changes. + +let cachedRaw: string | null = null; +let cachedValue: LastUsedAccount | null = null; + +const listeners = new Set<() => void>(); + +function emit(): void { + for (const l of listeners) l(); +} + +/** Subscribes to changes, including ones made in another tab. */ +export function subscribeToLastAccount(listener: () => void): () => void { + listeners.add(listener); + const onStorage = (e: StorageEvent) => { + if (e.key === KEY || e.key === null) emit(); + }; + globalThis.addEventListener?.("storage", onStorage); + return () => { + listeners.delete(listener); + globalThis.removeEventListener?.("storage", onStorage); + }; +} + +/** The current record, stable until the stored value changes. */ +export function getLastAccountSnapshot(): LastUsedAccount | null { + let raw: string | null; + try { + raw = globalThis.localStorage?.getItem(KEY) ?? null; + } catch { + raw = null; + } + if (raw !== cachedRaw) { + cachedRaw = raw; + cachedValue = readLastAccount(); + } + return cachedValue; +} + +/** + * The snapshot React uses while rendering on the server, and for the first + * client render that has to match it. There is no localStorage there, so the + * answer is always "nothing remembered" and the chip appears after hydration. + */ +export function getLastAccountServerSnapshot(): LastUsedAccount | null { + return null; +} diff --git a/ui/packages/components/src/primitives/button.tsx b/ui/packages/components/src/primitives/button.tsx index 1d47ec68..62203857 100644 --- a/ui/packages/components/src/primitives/button.tsx +++ b/ui/packages/components/src/primitives/button.tsx @@ -5,13 +5,16 @@ import { cva, type VariantProps } from "class-variance-authority"; import { cn } from "../lib/utils"; const buttonVariants = cva( - "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-[13px] font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-3.5 [&_svg]:shrink-0", + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-[13px] font-medium transition-colors focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/40 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-3.5 [&_svg]:shrink-0", { variants: { variant: { default: "bg-primary text-primary-foreground hover:bg-primary/90", destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90", + // The border is a full-strength border-input rather than the faint + // one the dense button used. This variant carries the Google and + // Microsoft buttons, which have to read as pressable at rest. outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground", secondary: diff --git a/ui/packages/components/src/primitives/input-group.tsx b/ui/packages/components/src/primitives/input-group.tsx new file mode 100644 index 00000000..c37c5d06 --- /dev/null +++ b/ui/packages/components/src/primitives/input-group.tsx @@ -0,0 +1,83 @@ +"use client"; + +import * as React from "react"; + +import { cn } from "../lib/utils"; + +/** + * A field that looks like one input but holds several controls: a prefix, a + * suffix, a unit, a country picker. + * + * The group carries the border, the radius, the height and the focus ring, + * and everything inside it is drawn borderless and transparent. That is what + * makes a select sitting next to a text field read as one control rather + * than two that happen to be adjacent. + */ +const InputGroup = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & { invalid?: boolean } +>(({ className, invalid, ...props }, ref) => ( +
+)); +InputGroup.displayName = "InputGroup"; + +/** + * A segment attached to one end of the group: a currency symbol, a unit, a + * country picker, a button. + * + * `side` only decides which edge gets the hairline rule, so an addon knows + * where it sits without the caller styling it. + */ +const InputAddon = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & { side?: "start" | "end" } +>(({ className, side = "start", ...props }, ref) => ( +
+)); +InputAddon.displayName = "InputAddon"; + +/** + * The text field inside a group. Borderless and transparent, because the + * group draws the chrome. + */ +const InputGroupInput = React.forwardRef< + HTMLInputElement, + React.InputHTMLAttributes +>(({ className, ...props }, ref) => ( + +)); +InputGroupInput.displayName = "InputGroupInput"; + +export { InputGroup, InputAddon, InputGroupInput }; diff --git a/ui/packages/components/src/primitives/input.tsx b/ui/packages/components/src/primitives/input.tsx index 0ae05df3..f808d70d 100644 --- a/ui/packages/components/src/primitives/input.tsx +++ b/ui/packages/components/src/primitives/input.tsx @@ -13,7 +13,7 @@ const Input = React.forwardRef( void; + /** Which country the field starts on. */ + defaultCountry?: CountryCode; + /** Narrow the country list. Left out, every country libphonenumber knows. */ + countries?: CountryCode[]; + disabled?: boolean; + autoFocus?: boolean; + /** + * Placeholder for the number field. There is deliberately no default: a + * generic example is wrong for most countries, and libphonenumber only + * ships real per-country examples in a separate metadata file that is + * larger than the hint is worth. The label above the field carries the + * meaning. + */ + placeholder?: string; + /** Draws the field in the destructive colour. */ + invalid?: boolean; + id?: string; + className?: string; + "aria-label"?: string; +} + +/** "Portugal", from the country code, in the viewer's own language. */ +function countryName(code: CountryCode, display: Intl.DisplayNames | null): string { + return display?.of(code) ?? code; +} + +/** Regional-indicator pair, which renders as a flag wherever the font has one. */ +function flag(code: CountryCode): string { + return String.fromCodePoint( + ...[...code].map((c) => 0x1f1e6 - 65 + c.charCodeAt(0)), + ); +} + +/** + * A phone number field: country picker plus a national number that formats + * itself as you type. + * + * The value handed back is always E.164, because that 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. + */ +export function PhoneInput({ + value, + onChange, + defaultCountry = "US", + countries, + disabled, + autoFocus, + placeholder, + invalid, + id, + className, + "aria-label": ariaLabel, +}: PhoneInputProps) { + const list = React.useMemo( + () => (countries?.length ? countries : (getCountries() as CountryCode[])), + [countries], + ); + + // Intl.DisplayNames is missing on older engines, so names fall back to the + // two-letter code rather than the field failing to render. + const display = React.useMemo(() => { + try { + return new Intl.DisplayNames(undefined, { type: "region" }); + } catch { + return null; + } + }, []); + + const parsed = React.useMemo( + () => (value ? parsePhoneNumberFromString(value) : undefined), + [value], + ); + + const [country, setCountry] = React.useState( + parsed?.country ?? defaultCountry, + ); + + // What the person sees. Derived from the E.164 value so a controlled parent + // stays the source of truth, but held locally so that typing a space or a + // bracket does not get rewritten underneath the cursor. + const [text, setText] = React.useState(() => + parsed ? parsed.formatNational() : "", + ); + + const emit = React.useCallback( + (nationalText: string, forCountry: CountryCode) => { + const digits = nationalText.replace(/\D/g, ""); + if (!digits) { + onChange?.("", false); + return; + } + const e164 = `+${getCountryCallingCode(forCountry)}${digits}`; + const number = parsePhoneNumberFromString(e164); + onChange?.(e164, Boolean(number?.isValid())); + }, + [onChange], + ); + + const handleText = (next: string) => { + // AsYouType is stateful per instance, so it gets a fresh one each time + // rather than being reused across keystrokes. Reusing one makes deleting + // a character format against stale input. + const formatted = new AsYouType(country).input(next); + // When someone is deleting, AsYouType can re-add the separator they just + // removed and the cursor sticks. Taking the raw text while it is shorter + // than what the formatter produced avoids that. + setText(formatted.length < next.length ? next : formatted); + emit(next, country); + }; + + const handleCountry = (next: string) => { + const code = next as CountryCode; + setCountry(code); + setText(new AsYouType(code).input(text.replace(/\D/g, ""))); + emit(text, code); + }; + + const sorted = React.useMemo( + () => + [...list].sort((a, b) => + countryName(a, display).localeCompare(countryName(b, display)), + ), + [list, display], + ); + + return ( + + + + + + handleText(e.target.value)} + disabled={disabled} + autoFocus={autoFocus} + /> + + ); +} diff --git a/ui/packages/components/src/primitives/separator.tsx b/ui/packages/components/src/primitives/separator.tsx index 59446376..c7ed4910 100644 --- a/ui/packages/components/src/primitives/separator.tsx +++ b/ui/packages/components/src/primitives/separator.tsx @@ -26,4 +26,37 @@ const Separator = React.forwardRef< ); Separator.displayName = SeparatorPrimitive.Root.displayName; -export { Separator }; +export interface LabelledSeparatorProps + extends React.HTMLAttributes { + /** The text sitting on the rule, e.g. "or with email". */ + children: React.ReactNode; +} + +/** + * A rule with a label centred on it. + * + * The label is flanked by two flex-grown rules rather than laid over one + * full-width rule with an opaque background behind the text. The overlay + * version has to know the colour it sits on, which is why the old divider + * hardcoded `bg-card` and broke the moment a form was rendered borderless on + * a page background. This version never needs to know. + */ +const LabelledSeparator = React.forwardRef< + HTMLDivElement, + LabelledSeparatorProps +>(({ className, children, ...props }, ref) => ( +
+ + + {children} + + +
+)); +LabelledSeparator.displayName = "LabelledSeparator"; + +export { Separator, LabelledSeparator }; diff --git a/ui/packages/components/src/primitives/verification-code-input.tsx b/ui/packages/components/src/primitives/verification-code-input.tsx new file mode 100644 index 00000000..a8f427d1 --- /dev/null +++ b/ui/packages/components/src/primitives/verification-code-input.tsx @@ -0,0 +1,219 @@ +"use client"; + +import * as React from "react"; +import { + OTPInput, + REGEXP_ONLY_DIGITS, + REGEXP_ONLY_DIGITS_AND_CHARS, +} from "input-otp"; + +import { cn } from "../lib/utils"; + +/** What a code is allowed to contain. */ +export type VerificationCodeMode = "numeric" | "alphanumeric"; + +/** How large each box is drawn. */ +export type VerificationCodeSize = "default" | "compact"; + +export interface VerificationCodeInputProps { + /** How many characters the code has. */ + length?: number; + /** + * Which characters are accepted. "alphanumeric" also upper-cases as you + * type, because every code authsome issues is case-insensitive and a + * lowercase "b" sitting next to an upper-case "B" reads as a bug. + */ + mode?: VerificationCodeMode; + /** Controlled value. Leave it out and the component holds its own. */ + value?: string; + onChange?: (value: string) => void; + /** Fires once the last character is filled in. */ + onComplete?: (value: string) => void; + /** + * Insert a separator every n characters, for codes that are shown grouped, + * like the device flow's WDJB-MJHT. 0 renders one flat run. + */ + groupSize?: number; + autoFocus?: boolean; + disabled?: boolean; + /** Renders the boxes in the destructive colour and sets aria-invalid. */ + invalid?: boolean; + /** + * Box size. "default" is the 6-character field the sign-in screens use. + * "compact" is for longer codes. Left alone, anything over 6 characters + * picks "compact" on its own, which is what keeps an 8-character device + * code inside a 400px card. + */ + size?: VerificationCodeSize; + /** Accessible name for the group of boxes. */ + "aria-label"?: string; + className?: string; + containerClassName?: string; +} + +const patterns: Record = { + numeric: REGEXP_ONLY_DIGITS, + alphanumeric: REGEXP_ONLY_DIGITS_AND_CHARS, +}; + +/** + * A one-time code field: separated boxes, one character each. + * + * Built on `input-otp`, which keeps a single real input behind the boxes. That + * is what makes paste, mobile SMS autofill and password managers work, so the + * underlying input keeps `autocomplete="one-time-code"` and the boxes stay + * presentational. + */ +export function VerificationCodeInput({ + length = 6, + mode = "numeric", + value, + onChange, + onComplete, + groupSize = 0, + autoFocus, + disabled, + invalid, + size, + "aria-label": ariaLabel, + className, + containerClassName, +}: VerificationCodeInputProps) { + const resolvedSize: VerificationCodeSize = size ?? (length > 6 ? "compact" : "default"); + + const [internal, setInternal] = React.useState(""); + const isControlled = value !== undefined; + const current = isControlled ? value : internal; + + // Disabling the field while a code is checked blurs it, and nothing gives + // the focus back when it is re-enabled. The cost lands on exactly the + // person who needs it most: someone whose code was wrong, who now has to + // click back into the boxes before they can fix a digit. So when the field + // comes back and the code is still incomplete, it takes focus again. + const inputRef = React.useRef(null); + const wasDisabled = React.useRef(Boolean(disabled)); + React.useEffect(() => { + const nowDisabled = Boolean(disabled); + if (wasDisabled.current && !nowDisabled && current.length < length) { + inputRef.current?.focus(); + } + wasDisabled.current = nowDisabled; + }, [disabled, current, length]); + + const handleChange = React.useCallback( + (next: string) => { + const normalised = mode === "alphanumeric" ? next.toUpperCase() : next; + if (!isControlled) setInternal(normalised); + onChange?.(normalised); + }, + [isControlled, mode, onChange], + ); + + // Where the separators go. An empty set means one flat run of boxes, which + // is what every screen in the refreshed design uses. + const breaks = React.useMemo(() => { + if (!groupSize || groupSize >= length) return new Set(); + const s = new Set(); + for (let i = groupSize; i < length; i += groupSize) s.add(i); + return s; + }, [groupSize, length]); + + return ( + + onComplete(mode === "alphanumeric" ? next.toUpperCase() : next) + : undefined + } + autoFocus={autoFocus} + disabled={disabled} + // input-otp widens the field to make room for a password manager badge, + // and polls document.elementFromPoint while focused to find one. A + // one-time code is never something a password manager should offer to + // fill, the badge would sit on top of the boxes, and the polling throws + // under jsdom, so the whole mechanism is off. + pushPasswordManagerStrategy="none" + inputMode={mode === "numeric" ? "numeric" : "text"} + autoComplete="one-time-code" + aria-label={ariaLabel ?? "Verification code"} + aria-invalid={invalid || undefined} + containerClassName={cn( + // No w-full here. In a parent that sizes to its content the row would + // have nothing to be 100% of, and the boxes would collapse to + // hairlines. Left alone the row is as wide as its boxes, and when the + // parent is narrower than that the boxes shrink instead of spilling. + "flex items-center gap-2 has-disabled:opacity-50", + containerClassName, + )} + className={cn("disabled:cursor-not-allowed", className)} + render={({ slots }) => ( + <> + {slots.map((slot, i) => ( + + {breaks.has(i) && ( + + )} + + + ))} + + )} + /> + ); +} + +// A width rather than a grow factor. The box wants to be this wide, and +// flex-shrink (on by default, with min-w-0 below to let it go under +// min-content) takes it narrower when eight of them have to fit a 400px card. +const slotSizes: Record = { + default: "h-14 w-12 text-xl", + compact: "h-12 w-10 text-base", +}; + +function Slot({ + char, + hasFakeCaret, + isActive, + invalid, + size, +}: { + char: string | null; + hasFakeCaret: boolean; + isActive: boolean; + invalid?: boolean; + size: VerificationCodeSize; +}) { + return ( +
+ {char} + {hasFakeCaret && ( +
+
+
+ )} +
+ ); +} diff --git a/ui/packages/components/src/styles/globals.css b/ui/packages/components/src/styles/globals.css index b920c87a..a2b229d3 100644 --- a/ui/packages/components/src/styles/globals.css +++ b/ui/packages/components/src/styles/globals.css @@ -24,6 +24,22 @@ --radius-md: calc(var(--authsome-radius) - 2px); --radius-lg: var(--authsome-radius); --radius-xl: calc(var(--authsome-radius) + 4px); + + /* The one-time code slots render a fake caret and animate it with this. + It was referenced before it existed, so the caret sat there unblinking. */ + --animate-caret-blink: caret-blink 1.25s ease-out infinite; +} + +@keyframes caret-blink { + 0%, + 70%, + 100% { + opacity: 1; + } + 20%, + 50% { + opacity: 0; + } } @layer base { @@ -34,20 +50,20 @@ --authsome-card-foreground: 240 10% 3.9%; --authsome-popover: 0 0% 100%; --authsome-popover-foreground: 240 10% 3.9%; - --authsome-primary: 240 5.9% 10%; - --authsome-primary-foreground: 0 0% 98%; - --authsome-secondary: 240 4.8% 95.9%; - --authsome-secondary-foreground: 240 5.9% 10%; - --authsome-muted: 240 4.8% 95.9%; - --authsome-muted-foreground: 240 3.8% 46.1%; - --authsome-accent: 240 4.8% 95.9%; - --authsome-accent-foreground: 240 5.9% 10%; - --authsome-destructive: 0 84.2% 60.2%; - --authsome-destructive-foreground: 0 0% 98%; - --authsome-border: 240 5.9% 90%; - --authsome-input: 240 5.9% 90%; - --authsome-ring: 240 5.9% 10%; - --authsome-radius: 0.5rem; + --authsome-primary: 221 83% 53%; + --authsome-primary-foreground: 0 0% 100%; + --authsome-secondary: 220 14% 96%; + --authsome-secondary-foreground: 222 47% 11%; + --authsome-muted: 220 14% 96%; + --authsome-muted-foreground: 220 9% 46%; + --authsome-accent: 220 14% 96%; + --authsome-accent-foreground: 222 47% 11%; + --authsome-destructive: 0 72% 51%; + --authsome-destructive-foreground: 0 0% 100%; + --authsome-border: 220 13% 91%; + --authsome-input: 220 13% 88%; + --authsome-ring: 221 83% 53%; + --authsome-radius: 0.625rem; } .dark { @@ -57,18 +73,18 @@ --authsome-card-foreground: 0 0% 98%; --authsome-popover: 240 10% 3.9%; --authsome-popover-foreground: 0 0% 98%; - --authsome-primary: 0 0% 98%; - --authsome-primary-foreground: 240 5.9% 10%; - --authsome-secondary: 240 3.7% 15.9%; - --authsome-secondary-foreground: 0 0% 98%; - --authsome-muted: 240 3.7% 15.9%; - --authsome-muted-foreground: 240 5% 64.9%; - --authsome-accent: 240 3.7% 15.9%; - --authsome-accent-foreground: 0 0% 98%; - --authsome-destructive: 0 62.8% 30.6%; - --authsome-destructive-foreground: 0 0% 98%; - --authsome-border: 240 3.7% 15.9%; - --authsome-input: 240 3.7% 15.9%; - --authsome-ring: 240 4.9% 83.9%; + --authsome-primary: 217 91% 60%; + --authsome-primary-foreground: 222 47% 11%; + --authsome-secondary: 217 33% 17%; + --authsome-secondary-foreground: 210 40% 98%; + --authsome-muted: 217 33% 17%; + --authsome-muted-foreground: 215 20% 65%; + --authsome-accent: 217 33% 17%; + --authsome-accent-foreground: 210 40% 98%; + --authsome-destructive: 0 72% 51%; + --authsome-destructive-foreground: 0 0% 100%; + --authsome-border: 217 33% 20%; + --authsome-input: 217 33% 24%; + --authsome-ring: 217 91% 60%; } } diff --git a/ui/packages/components/src/test-support.tsx b/ui/packages/components/src/test-support.tsx index b189cec2..a6929a8a 100644 --- a/ui/packages/components/src/test-support.tsx +++ b/ui/packages/components/src/test-support.tsx @@ -11,6 +11,7 @@ import { AuthClient, AuthManager } from "@authsome/ui-core"; import type { + AuthPlugin, AuthState, ClientConfig, Session, @@ -175,6 +176,12 @@ export function stubAuth(opts: { * dependency the component keys its fetch on. */ client?: AuthClient; + /** + * Client plugins to register, so a component under test can reach one + * through usePlugin. Without this the manager has an empty registry and + * usePlugin throws PluginNotRegisteredError. + */ + plugins?: AuthPlugin[]; }): AuthContextValue { const client = opts.client ?? new AuthClient({ baseURL: BASE, fetch: opts.fetch }); @@ -186,7 +193,11 @@ export function stubAuth(opts: { return { state, - manager: new AuthManager({ baseURL: BASE, fetch: opts.fetch }), + manager: new AuthManager({ + baseURL: BASE, + fetch: opts.fetch, + plugins: opts.plugins, + }), client, user: opts.session ? user : null, session: opts.session, diff --git a/ui/packages/core/src/auth.ts b/ui/packages/core/src/auth.ts index 9db576ce..1cfb6e67 100644 --- a/ui/packages/core/src/auth.ts +++ b/ui/packages/core/src/auth.ts @@ -994,6 +994,36 @@ export class AuthManager { } } + /** + * Adopts a session this manager did not create. + * + * Some plugins mint a session of their own: `phoneAuthVerify` is the first, + * and anything that authenticates out-of-band will be the next. They come + * back with the same body `signIn` gets, and before this existed there was + * no way to hand it over, so a perfectly good session sat in a component's + * local state while the rest of the app still thought nobody was signed in. + * + * It does exactly what signing in with a password does from here on: + * persists the session (or leaves it to the cookie in cookie mode), moves + * state to authenticated, schedules the refresh, and fires `signedIn`. + * + * ```ts + * const res = await auth.plugin(phone).verify({ phone, code }); + * await auth.adoptSession(res); + * ``` + * + * The caller is trusting whatever produced that response. Only pass one + * that came from this deployment's own API. + */ + async adoptSession(res: { + session_token: string; + refresh_token: string; + expires_at?: string; + user: User; + }): Promise { + await this.handleAuthResponse(res.user, toSession(res)); + } + // ── Internals ───────────────────────────────────── private async handleAuthResponse( diff --git a/ui/packages/core/src/generated/api-client.ts b/ui/packages/core/src/generated/api-client.ts index 1c1b7884..c3a392a2 100644 --- a/ui/packages/core/src/generated/api-client.ts +++ b/ui/packages/core/src/generated/api-client.ts @@ -65,6 +65,7 @@ import type { ClientConfigToggle, CloneEnvironmentRequest, CloneEnvironmentResponse, + ConfigListResponse, Connection, Consent, ConsentDecisionBody, @@ -83,6 +84,8 @@ import type { CreateOrgRequest, CreatePlanRequest, CreateRoleRequest, + CreateSCIMConfigRequest, + CreateSCIMTokenRequest, CreateSubscriptionRequest, CreateTeamRequest, CreateWebhookRequest, @@ -107,6 +110,8 @@ import type { EnforceSettingRequest, EnrollRequest, EnrollResponse, + EnrolledFactor, + EnrollmentResponse, EntitlementResponse, EntitlementsResponse, Environment, @@ -127,6 +132,7 @@ import type { IntrospectUser, Invitation, InvitationListResponse, + InvitationPreview, InvoiceResponse, JWK, JWKSResponse, @@ -190,13 +196,16 @@ import type { Role, RoleListResponse, RotateClientSecretResponse, + RotateSCIMTokenRequest, RotateWebhookSecretResponse, + SCIMConfig, SMSSendRequest, SMSSendResponse, SMSVerifyRequest, SMSVerifyResponse, SchemaList, ScimListResponse, + ScimStatusResponse, ScopeValue, SeatsView, SelectOption, @@ -225,8 +234,11 @@ import type { Team, TeamListResponse, TenantRequest, + Token, + TokenCreatedResponse, TokenExchangeRequest, TokenExchangeResponse, + TokenListResponse, UIMetadata, UnassignRoleRequest, UnlinkAuthMethodResponse, @@ -239,6 +251,7 @@ import type { UpdateOrgRequest, UpdateRegistrationRequest, UpdateRoleRequest, + UpdateSCIMConfigRequest, UpdateSubscriptionRequest, UpdateTeamRequest, UpdateWebhookRequest, @@ -2436,6 +2449,20 @@ export class AuthClient { ); } + /** + * Read MFA enrollment + * GET /v1/mfa/enrollment + */ + async getMFAEnrollment(token: string): Promise { + const path = "/v1/mfa/enrollment"; + return this.request( + 'GET', + path, + undefined, + token, + ); + } + /** * Disable MFA * DELETE /v1/mfa/enrollment @@ -2863,6 +2890,20 @@ export class AuthClient { ); } + /** + * Get invitation + * GET /v1/orgs/invitations/{inviteToken} + */ + async getInvitation(inviteToken: string, token: string): Promise { + const path = `/v1/orgs/invitations/${inviteToken}`; + return this.request( + 'GET', + path, + undefined, + token, + ); + } + /** * Get organization * GET /v1/orgs/{orgId} @@ -2997,6 +3038,34 @@ export class AuthClient { ); } + /** + * List SCIM configurations + * GET /v1/orgs/{orgId}/scim/configs + */ + async listSCIMConfigs(orgId: string, token: string): Promise { + const path = `/v1/orgs/${orgId}/scim/configs`; + return this.request( + 'GET', + path, + undefined, + token, + ); + } + + /** + * Create SCIM configuration + * POST /v1/orgs/{orgId}/scim/configs + */ + async createSCIMConfig(orgId: string, body: CreateSCIMConfigRequest, token: string): Promise { + const path = `/v1/orgs/${orgId}/scim/configs`; + return this.request( + 'POST', + path, + body, + token, + ); + } + /** * List teams * GET /v1/orgs/{orgId}/teams @@ -3376,6 +3445,104 @@ export class AuthClient { ); } + /** + * Get SCIM configuration + * GET /v1/scim/configs/{configId} + */ + async getSCIMConfig(configId: string, token: string): Promise { + const path = `/v1/scim/configs/${configId}`; + return this.request( + 'GET', + path, + undefined, + token, + ); + } + + /** + * Update SCIM configuration + * PATCH /v1/scim/configs/{configId} + */ + async updateSCIMConfig(configId: string, body: UpdateSCIMConfigRequest, token: string): Promise { + const path = `/v1/scim/configs/${configId}`; + return this.request( + 'PATCH', + path, + body, + token, + ); + } + + /** + * Delete SCIM configuration + * DELETE /v1/scim/configs/{configId} + */ + async deleteSCIMConfig(configId: string, token: string): Promise { + const path = `/v1/scim/configs/${configId}`; + return this.request( + 'DELETE', + path, + undefined, + token, + ); + } + + /** + * List SCIM tokens + * GET /v1/scim/configs/{configId}/tokens + */ + async listSCIMTokens(configId: string, token: string): Promise { + const path = `/v1/scim/configs/${configId}/tokens`; + return this.request( + 'GET', + path, + undefined, + token, + ); + } + + /** + * Create SCIM token + * POST /v1/scim/configs/{configId}/tokens + */ + async createSCIMToken(configId: string, body: CreateSCIMTokenRequest, token: string): Promise { + const path = `/v1/scim/configs/${configId}/tokens`; + return this.request( + 'POST', + path, + body, + token, + ); + } + + /** + * Revoke SCIM token + * DELETE /v1/scim/tokens/{tokenId} + */ + async revokeSCIMToken(tokenId: string, token: string): Promise { + const path = `/v1/scim/tokens/${tokenId}`; + return this.request( + 'DELETE', + path, + undefined, + token, + ); + } + + /** + * Rotate SCIM token + * POST /v1/scim/tokens/{tokenId}/rotate + */ + async rotateSCIMToken(tokenId: string, body: RotateSCIMTokenRequest, token: string): Promise { + const path = `/v1/scim/tokens/${tokenId}/rotate`; + return this.request( + 'POST', + path, + body, + token, + ); + } + /** * List sessions * GET /v1/sessions diff --git a/ui/packages/core/src/generated/api-types.ts b/ui/packages/core/src/generated/api-types.ts index 8db70d8d..482be94d 100644 --- a/ui/packages/core/src/generated/api-types.ts +++ b/ui/packages/core/src/generated/api-types.ts @@ -459,6 +459,10 @@ export interface CloneEnvironmentResponse { webhooks_cloned: number; } +export interface ConfigListResponse { + configs: SCIMConfig[]; +} + export interface Connection { acs_url?: string; active: boolean; @@ -646,6 +650,21 @@ export interface CreateRoleRequest { slug: string; } +export interface CreateSCIMConfigRequest { + auto_create?: boolean; + auto_suspend?: boolean; + default_role?: string; + enabled?: boolean; + group_sync?: boolean; + metadata?: Record; + name: string; +} + +export interface CreateSCIMTokenRequest { + expires_at?: string; + name: string; +} + export interface CreateSubscriptionRequest { app_id: string; plan_id: string; @@ -819,6 +838,18 @@ export interface EnrollResponse { secret: string; } +export interface EnrolledFactor { + enrolled_at: string; + method: string; + verified: boolean; +} + +export interface EnrollmentResponse { + enabled: boolean; + factors: EnrolledFactor[]; + recovery_codes_remaining: number; +} + export interface EntitlementResponse { allowed: boolean; feature: string; @@ -963,6 +994,16 @@ export interface InvitationListResponse { next_cursor?: string; } +export interface InvitationPreview { + email_masked: string; + expires_at: string; + inviter_name: string; + org_id: string; + org_name: string; + org_slug?: string; + role: string; +} + export interface InvoiceResponse { currency: string; id: string; @@ -1420,10 +1461,30 @@ export interface RotateClientSecretResponse { id: string; } +export interface RotateSCIMTokenRequest { + grace_seconds?: number; + name?: string; +} + export interface RotateWebhookSecretResponse { secret: string; } +export interface SCIMConfig { + app_id: string; + auto_create: boolean; + auto_suspend: boolean; + created_at: string; + default_role: string; + enabled: boolean; + group_sync: boolean; + id: string; + metadata?: Record; + name: string; + org_id?: string; + updated_at: string; +} + export interface SMSSendRequest { phone?: string; } @@ -1454,6 +1515,10 @@ export interface ScimListResponse { totalResults: number; } +export interface ScimStatusResponse { + status: string; +} + export interface ScopeValue { enforced: boolean; scope: string; @@ -1679,6 +1744,21 @@ export interface TenantRequest { tenant_id?: string; } +export interface Token { + config_id: string; + created_at: string; + expires_at?: string; + id: string; + last_used_at?: string; + name: string; +} + +export interface TokenCreatedResponse { + base_url: string; + plaintext: string; + token?: Token; +} + export interface TokenExchangeRequest { scopes?: string[]; subject: string; @@ -1694,6 +1774,10 @@ export interface TokenExchangeResponse { token_type: string; } +export interface TokenListResponse { + tokens: Token[]; +} + export interface UIMetadata { condition?: VisibilityCondition; help_text?: string; @@ -1805,6 +1889,16 @@ export interface UpdateRoleRequest { parent_id?: string; } +export interface UpdateSCIMConfigRequest { + auto_create?: boolean; + auto_suspend?: boolean; + default_role?: string; + enabled?: boolean; + group_sync?: boolean; + metadata?: Record; + name?: string; +} + export interface UpdateSubscriptionRequest { cancel_url?: string; plan?: string; diff --git a/ui/packages/core/src/index.ts b/ui/packages/core/src/index.ts index a5c73eb6..62827c66 100644 --- a/ui/packages/core/src/index.ts +++ b/ui/packages/core/src/index.ts @@ -5,6 +5,7 @@ export * from "./client"; export { AuthManager, createLocalStorage, SESSION_STORAGE_KEY } from "./auth"; export { definePlugin, + pluginKeyName, PluginNotRegisteredError, type AuthEvent, type AuthPlugin, diff --git a/ui/packages/plugin-consent/package.json b/ui/packages/plugin-consent/package.json new file mode 100644 index 00000000..7175662f --- /dev/null +++ b/ui/packages/plugin-consent/package.json @@ -0,0 +1,70 @@ +{ + "name": "@authsome/plugin-consent", + "version": "1.5.5", + "description": "GDPR purpose consent for AuthSome: client plugin, hooks and components", + "license": "MIT", + "type": "module", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "https://github.com/xraph/authsome.git", + "directory": "ui/packages/plugin-consent" + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + }, + "./react": { + "import": "./dist/react.js", + "types": "./dist/react.d.ts" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsup", + "dev": "tsup --watch", + "typecheck": "tsc --noEmit", + "clean": "rm -rf dist", + "lint": "eslint .", + "test": "vitest run" + }, + "dependencies": { + "@authsome/ui-core": "workspace:*" + }, + "peerDependencies": { + "@authsome/ui-react": "workspace:*", + "react": "^18.0.0 || ^19.0.0", + "@authsome/ui-components": "workspace:*" + }, + "peerDependenciesMeta": { + "@authsome/ui-react": { + "optional": true + }, + "react": { + "optional": true + }, + "@authsome/ui-components": { + "optional": true + } + }, + "devDependencies": { + "@authsome/ui-react": "workspace:*", + "@testing-library/react": "^16.3.0", + "@types/react": "^19.2.18", + "jsdom": "^28.0.1", + "react": "^19.2.8", + "react-dom": "^19.2.8", + "tsup": "^8.5.1", + "typescript": "^5.9.3", + "vite": "^8.2.4", + "vitest": "^4.1.12", + "@authsome/ui-components": "workspace:*" + } +} diff --git a/ui/packages/plugin-consent/src/consent-gate.tsx b/ui/packages/plugin-consent/src/consent-gate.tsx new file mode 100644 index 00000000..7df4ab20 --- /dev/null +++ b/ui/packages/plugin-consent/src/consent-gate.tsx @@ -0,0 +1,88 @@ +"use client"; + +import * as React from "react"; +import { Button, ErrorDisplay, LoadingSpinner, cn } from "@authsome/ui-components"; +import { useConsents, useGrantConsent } from "./react"; + +export interface ConsentGateProps { + /** The purpose that has to be agreed to. */ + purpose: string; + /** The wording's version. Bump it and everyone is asked again. */ + version?: string; + title: React.ReactNode; + description?: React.ReactNode; + acceptLabel?: string; + /** Rendered once consent is in place. */ + children: React.ReactNode; + /** Rendered while the answer is unknown. Defaults to nothing. */ + fallback?: React.ReactNode; + className?: string; +} + +/** + * Holds its children back until a named purpose has been agreed to. + * + * Nothing renders while the answer is still loading, because flashing the + * gated content and then hiding it would defeat the point of gating it. + * + * When `version` is given, a consent recorded against an older version does + * not count and the prompt comes back. That is the behaviour you want when + * the wording changes in a way that matters, and the reason to bump it only + * when it does. + */ +export function ConsentGate({ + purpose, + version, + title, + description, + acceptLabel = "I agree", + children, + fallback = null, + className, +}: ConsentGateProps) { + const { consents, isLoading, error, reload } = useConsents(purpose); + const { grant, isSaving, error: grantError } = useGrantConsent(); + + const satisfied = React.useMemo( + () => + consents.some( + (c) => + c.purpose === purpose && + c.granted && + !c.revoked_at && + (version === undefined || c.version === version), + ), + [consents, purpose, version], + ); + + if (isLoading) return <>{fallback}; + if (satisfied) return <>{children}; + + return ( +
+

{title}

+ {description && ( +

+ {description} +

+ )} + + + + +
+ ); +} diff --git a/ui/packages/plugin-consent/src/consent-list.tsx b/ui/packages/plugin-consent/src/consent-list.tsx new file mode 100644 index 00000000..03d5f219 --- /dev/null +++ b/ui/packages/plugin-consent/src/consent-list.tsx @@ -0,0 +1,145 @@ +"use client"; + +import * as React from "react"; +import { + Badge, + Button, + ErrorDisplay, + LoadingSpinner, + Separator, + Skeleton, + cn, +} from "@authsome/ui-components"; +import { useConsents, useRevokeConsent } from "./react"; + +export interface ConsentListProps { + /** Readable names for purposes, keyed by purpose string. */ + labels?: Record; + /** Only show these purposes, in this order. */ + purposes?: string[]; + title?: React.ReactNode; + /** Shown when nothing has been agreed to. */ + emptyState?: React.ReactNode; + className?: string; +} + +function formatDate(iso?: string): string | null { + if (!iso) return null; + const d = new Date(iso); + return Number.isNaN(d.getTime()) + ? null + : d.toLocaleDateString(undefined, { + day: "numeric", + month: "short", + year: "numeric", + }); +} + +/** + * What you have agreed to, and a way to take it back. + * + * A revoked record stays in the list rather than disappearing, because the + * point of a consent record is that it is a history. Someone checking whether + * they ever agreed to something needs to see that they did and then stopped. + */ +export function ConsentList({ + labels, + purposes, + title = "Your permissions", + emptyState = "You have not agreed to anything yet.", + className, +}: ConsentListProps) { + const { consents, isLoading, error, reload } = useConsents(); + const { revoke, isSaving } = useRevokeConsent(); + const [pending, setPending] = React.useState(null); + + const shown = React.useMemo(() => { + if (!purposes?.length) return consents; + const order = new Map(purposes.map((p, i) => [p, i])); + return consents + .filter((c) => order.has(c.purpose)) + .sort((a, b) => (order.get(a.purpose) ?? 0) - (order.get(b.purpose) ?? 0)); + }, [consents, purposes]); + + const handleRevoke = async (purpose: string) => { + setPending(purpose); + try { + await revoke({ purpose }); + await reload(); + } catch { + // The error is surfaced by the hook and rendered below. + } finally { + setPending(null); + } + }; + + return ( +
+ {title && ( +

+ {title} +

+ )} + + + + {isLoading ? ( +
+ + +
+ ) : shown.length === 0 ? ( +

{emptyState}

+ ) : ( +
    + {shown.map((c) => { + const label = labels?.[c.purpose]; + const granted = c.granted && !c.revoked_at; + return ( +
  • +
    +

    + {label?.title ?? c.purpose} +

    + {label?.description && ( +

    + {label.description} +

    + )} +

    + {granted + ? `Agreed ${formatDate(c.granted_at) ?? ""} · version ${c.version}` + : `Withdrawn ${formatDate(c.revoked_at) ?? ""}`} +

    +
    +
    + + {granted ? "On" : "Off"} + + {granted && ( + + )} +
    +
  • + ); + })} +
+ )} + +
+ ); +} diff --git a/ui/packages/plugin-consent/src/index.ts b/ui/packages/plugin-consent/src/index.ts new file mode 100644 index 00000000..62689f30 --- /dev/null +++ b/ui/packages/plugin-consent/src/index.ts @@ -0,0 +1,80 @@ +/** + * GDPR purpose consent, the client half of authsome's `consent` server plugin. + * + * This is consent in the data-protection sense: a record that someone agreed + * to a named purpose at a given version, with a timestamp and the IP it came + * from. It has nothing to do with the OAuth "App X wants access to your + * account" screen, which belongs to `oauth2provider` and lives in + * `@authsome/plugin-oauth2`. + * + * ```ts + * import { consent } from "@authsome/plugin-consent"; + * + * const auth = new AuthManager({ baseURL, plugins: [consent()] }); + * await auth.plugin(consent).grant({ purpose: "marketing_email", version: "2026-01" }); + * ``` + */ + +import { definePlugin } from "@authsome/ui-core"; + +/** One consent record. */ +export interface ConsentRecord { + id: string; + user_id: string; + app_id: string; + /** The thing agreed to, e.g. "marketing_email". */ + purpose: string; + /** Which wording was agreed to. Bump it when the wording changes. */ + version: string; + granted: boolean; + granted_at: string; + revoked_at?: string; + ip_address: string; + created_at: string; + updated_at: string; +} + +export interface ConsentListResult { + consents: ConsentRecord[]; + next_cursor?: string; +} + +export interface GrantConsentInput { + purpose: string; + /** Record which wording was agreed to. Strongly recommended. */ + version?: string; + app_id?: string; +} + +export interface RevokeConsentInput { + purpose: string; + app_id?: string; +} + +export interface ConsentApi { + /** Every consent record for the signed-in user, newest first. */ + list(opts?: { + purpose?: string; + cursor?: string; + limit?: number; + }): Promise; + grant(input: GrantConsentInput): Promise; + revoke(input: RevokeConsentInput): Promise<{ status: string }>; +} + +export const consent = definePlugin("consent", (ctx) => ({ + list: (opts) => + ctx.request("GET", "/v1/consent", { + query: { + purpose: opts?.purpose, + cursor: opts?.cursor, + limit: opts?.limit, + }, + }), + grant: (input) => + ctx.request("POST", "/v1/consent/grant", { body: input }), + revoke: (input) => + ctx.request<{ status: string }>("POST", "/v1/consent/revoke", { + body: input, + }), +})); diff --git a/ui/packages/plugin-consent/src/plugin.test.ts b/ui/packages/plugin-consent/src/plugin.test.ts new file mode 100644 index 00000000..913e478a --- /dev/null +++ b/ui/packages/plugin-consent/src/plugin.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect } from "vitest"; +import { consent, type ConsentApi } from "./index"; +import { fakeContext } from "./test-context"; + +describe("consent plugin", () => { + it("is named after the server plugin", () => { + expect(consent.pluginName).toBe("consent"); + }); + + it("lists with the filters it was given", async () => { + const { ctx, request } = fakeContext({ consents: [] }); + await (consent().init(ctx) as ConsentApi).list({ purpose: "marketing", limit: 10 }); + expect(request).toHaveBeenCalledWith("GET", "/v1/consent", { + query: { purpose: "marketing", cursor: undefined, limit: 10 }, + }); + }); + + it("lists with no filters at all", async () => { + const { ctx, request } = fakeContext({ consents: [] }); + await (consent().init(ctx) as ConsentApi).list(); + expect(request).toHaveBeenCalledWith("GET", "/v1/consent", { + query: { purpose: undefined, cursor: undefined, limit: undefined }, + }); + }); + + it("records the version when granting", async () => { + const { ctx, request } = fakeContext({}); + await (consent().init(ctx) as ConsentApi).grant({ + purpose: "marketing", + version: "2026-01", + }); + expect(request).toHaveBeenCalledWith("POST", "/v1/consent/grant", { + body: { purpose: "marketing", version: "2026-01" }, + }); + }); + + it("revokes by purpose", async () => { + const { ctx, request } = fakeContext({ status: "ok" }); + await (consent().init(ctx) as ConsentApi).revoke({ purpose: "marketing" }); + expect(request).toHaveBeenCalledWith("POST", "/v1/consent/revoke", { + body: { purpose: "marketing" }, + }); + }); +}); diff --git a/ui/packages/plugin-consent/src/react.ts b/ui/packages/plugin-consent/src/react.ts new file mode 100644 index 00000000..40645325 --- /dev/null +++ b/ui/packages/plugin-consent/src/react.ts @@ -0,0 +1,139 @@ +/** React hooks and components for purpose consent. */ + +import { useCallback, useEffect, useState } from "react"; +import { useAuth, usePlugin } from "@authsome/ui-react"; +import { + consent, + type ConsentApi, + type ConsentRecord, + type GrantConsentInput, + type RevokeConsentInput, +} from "./index"; + +/** + * useConsents lists what the signed-in user has agreed to. + * + * ```tsx + * const { consents, isLoading, reload } = useConsents(); + * ``` + */ +export function useConsents(purpose?: string): { + consents: ConsentRecord[]; + isLoading: boolean; + error: Error | null; + reload: () => Promise; +} { + const api = usePlugin(consent); + const { session, isAuthenticated } = useAuth(); + const [consents, setConsents] = useState([]); + const [error, setError] = useState(null); + const token = session?.session_token ?? null; + + // Which token the list we hold was fetched for. Comparing it against the + // current one derives the load's progress instead of storing it, which is + // what keeps setState out of the effect below. + const [loadedFor, setLoadedFor] = useState(null); + const [isRefreshing, setIsRefreshing] = useState(false); + const isLoading = (isAuthenticated && loadedFor !== token) || isRefreshing; + + const reload = useCallback(async () => { + if (!token) return; + setIsRefreshing(true); + try { + setConsents((await api.list({ purpose })).consents); + setError(null); + setLoadedFor(token); + } catch (err) { + setError(err instanceof Error ? err : new Error(String(err))); + } finally { + setIsRefreshing(false); + } + }, [api, purpose, token]); + + // Inline rather than calling reload(), which sets state before it awaits. + // The ignore flag stops a response for a session the user already left + // from overwriting a newer list. + useEffect(() => { + if (!isAuthenticated || !token) return; + let ignore = false; + void (async () => { + try { + const res = await api.list({ purpose }); + if (!ignore) { + setConsents(res.consents); + setError(null); + } + } catch (err) { + if (!ignore) setError(err instanceof Error ? err : new Error(String(err))); + } finally { + if (!ignore) setLoadedFor(token); + } + })(); + return () => { + ignore = true; + }; + }, [api, isAuthenticated, purpose, token]); + + return { consents, isLoading, error, reload }; +} + +/** useGrantConsent records agreement to a purpose. */ +export function useGrantConsent(): { + grant: (input: GrantConsentInput) => Promise; + isSaving: boolean; + error: Error | null; +} { + const api = usePlugin(consent); + const [isSaving, setIsSaving] = useState(false); + const [error, setError] = useState(null); + + const grant = useCallback( + async (input: GrantConsentInput) => { + setIsSaving(true); + setError(null); + try { + return await api.grant(input); + } catch (err) { + setError(err instanceof Error ? err : new Error(String(err))); + throw err; + } finally { + setIsSaving(false); + } + }, + [api], + ); + + return { grant, isSaving, error }; +} + +/** useRevokeConsent withdraws agreement to a purpose. */ +export function useRevokeConsent(): { + revoke: (input: RevokeConsentInput) => Promise; + isSaving: boolean; + error: Error | null; +} { + const api = usePlugin(consent); + const [isSaving, setIsSaving] = useState(false); + const [error, setError] = useState(null); + + const revoke = useCallback( + async (input: RevokeConsentInput) => { + setIsSaving(true); + setError(null); + try { + await api.revoke(input); + } catch (err) { + setError(err instanceof Error ? err : new Error(String(err))); + throw err; + } finally { + setIsSaving(false); + } + }, + [api], + ); + + return { revoke, isSaving, error }; +} + +export { ConsentList, type ConsentListProps } from "./consent-list"; +export { ConsentGate, type ConsentGateProps } from "./consent-gate"; diff --git a/ui/packages/plugin-consent/src/test-context.ts b/ui/packages/plugin-consent/src/test-context.ts new file mode 100644 index 00000000..4b0b5952 --- /dev/null +++ b/ui/packages/plugin-consent/src/test-context.ts @@ -0,0 +1,30 @@ +import { vi, type Mock } from "vitest"; +import type { PluginContext } from "@authsome/ui-core"; + +export interface FakeContext { + ctx: PluginContext; + /** Every call the plugin made, so a test can assert on method and path. */ + request: Mock; +} + +/** + * A PluginContext that records what the plugin asked for. + * + * The return type is spelled out rather than inferred, because an inferred + * vitest Mock cannot be named from outside its own package and tsc refuses + * to emit a declaration for it. + */ +export function fakeContext(reply: unknown = {}): FakeContext { + const request = vi.fn().mockResolvedValue(reply); + return { + ctx: { + request: request as PluginContext["request"], + getState: () => ({ status: "unauthenticated" }) as never, + getClientConfig: () => null, + isServerEnabled: () => true, + getActiveOrgId: () => null, + on: () => () => {}, + }, + request, + }; +} diff --git a/ui/packages/plugin-consent/tsconfig.json b/ui/packages/plugin-consent/tsconfig.json new file mode 100644 index 00000000..eb3621e3 --- /dev/null +++ b/ui/packages/plugin-consent/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "jsx": "react-jsx", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +} diff --git a/ui/packages/plugin-consent/tsup.config.ts b/ui/packages/plugin-consent/tsup.config.ts new file mode 100644 index 00000000..5f47fa78 --- /dev/null +++ b/ui/packages/plugin-consent/tsup.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + // Two entries so a host that never touches React does not pull it in just + // by importing the plugin. + entry: ["src/index.ts", "src/react.ts"], + format: ["esm"], + dts: true, + clean: true, + sourcemap: true, + target: "es2022", + external: [ + "react", + "react-dom", + "@authsome/ui-react", + "@authsome/ui-components", + ], +}); diff --git a/ui/packages/plugin-consent/vitest.config.ts b/ui/packages/plugin-consent/vitest.config.ts new file mode 100644 index 00000000..f65ebc76 --- /dev/null +++ b/ui/packages/plugin-consent/vitest.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "vitest/config"; + +// Same shape as ui-react's config. The hook test renders, so it needs a DOM, +// and globals is on so @testing-library/react can register its cleanup. +export default defineConfig({ + test: { + environment: "jsdom", + globals: true, + include: ["src/**/*.test.{ts,tsx}"], + }, +}); diff --git a/ui/packages/plugin-oauth2/package.json b/ui/packages/plugin-oauth2/package.json new file mode 100644 index 00000000..ac4803e1 --- /dev/null +++ b/ui/packages/plugin-oauth2/package.json @@ -0,0 +1,75 @@ +{ + "name": "@authsome/plugin-oauth2", + "version": "1.5.5", + "description": "OAuth2 consent and connected apps for AuthSome: client plugin, hooks and components", + "license": "MIT", + "type": "module", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "https://github.com/xraph/authsome.git", + "directory": "ui/packages/plugin-oauth2" + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + }, + "./react": { + "import": "./dist/react.js", + "types": "./dist/react.d.ts" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsup", + "dev": "tsup --watch", + "typecheck": "tsc --noEmit", + "clean": "rm -rf dist", + "lint": "eslint .", + "test": "vitest run" + }, + "dependencies": { + "@authsome/ui-core": "workspace:*" + }, + "peerDependencies": { + "@authsome/ui-react": "workspace:*", + "react": "^18.0.0 || ^19.0.0", + "@authsome/ui-components": "workspace:*", + "lucide-react": "^1.34.0" + }, + "peerDependenciesMeta": { + "@authsome/ui-react": { + "optional": true + }, + "react": { + "optional": true + }, + "@authsome/ui-components": { + "optional": true + }, + "lucide-react": { + "optional": true + } + }, + "devDependencies": { + "@authsome/ui-react": "workspace:*", + "@testing-library/react": "^16.3.0", + "@types/react": "^19.2.18", + "jsdom": "^28.0.1", + "react": "^19.2.8", + "react-dom": "^19.2.8", + "tsup": "^8.5.1", + "typescript": "^5.9.3", + "vite": "^8.2.4", + "vitest": "^4.1.12", + "@authsome/ui-components": "workspace:*", + "lucide-react": "^1.34.0" + } +} diff --git a/ui/packages/plugin-oauth2/src/connected-apps-list.tsx b/ui/packages/plugin-oauth2/src/connected-apps-list.tsx new file mode 100644 index 00000000..4d6ff1ae --- /dev/null +++ b/ui/packages/plugin-oauth2/src/connected-apps-list.tsx @@ -0,0 +1,117 @@ +"use client"; + +import * as React from "react"; +import { + Badge, + Button, + ErrorDisplay, + LoadingSpinner, + Skeleton, + cn, +} from "@authsome/ui-components"; +import { DEFAULT_SCOPE_LABELS } from "./index"; +import { useMyGrants, useRevokeGrant } from "./react"; + +export interface ConnectedAppsListProps { + scopeLabels?: Record; + title?: React.ReactNode; + emptyState?: React.ReactNode; + className?: string; +} + +function formatDate(iso: string): string | null { + const d = new Date(iso); + return Number.isNaN(d.getTime()) + ? null + : d.toLocaleDateString(undefined, { + day: "numeric", + month: "short", + year: "numeric", + }); +} + +/** Apps you have authorised, and a way to withdraw access. */ +export function ConnectedAppsList({ + scopeLabels, + title = "Connected apps", + emptyState = "No apps have access to your account.", + className, +}: ConnectedAppsListProps) { + const { grants, isLoading, error, reload } = useMyGrants(); + const { revoke, isSaving } = useRevokeGrant(); + const [pending, setPending] = React.useState(null); + + const labels = React.useMemo( + () => ({ ...DEFAULT_SCOPE_LABELS, ...scopeLabels }), + [scopeLabels], + ); + + const handleRevoke = async (clientId: string) => { + setPending(clientId); + try { + await revoke(clientId); + await reload(); + } catch { + // Surfaced by the hook and rendered above the list. + } finally { + setPending(null); + } + }; + + return ( +
+ {title && ( +

+ {title} +

+ )} + + + + {isLoading ? ( +
+ + +
+ ) : grants.length === 0 ? ( +

{emptyState}

+ ) : ( +
    + {grants.map((g) => ( +
  • +
    +

    {g.client_name}

    +

    + Connected {formatDate(g.created_at) ?? ""} +

    +
    + {g.scopes.map((s) => ( + + {labels[s] ?? s} + + ))} +
    +
    + +
  • + ))} +
+ )} +
+ ); +} diff --git a/ui/packages/plugin-oauth2/src/index.ts b/ui/packages/plugin-oauth2/src/index.ts new file mode 100644 index 00000000..4911e204 --- /dev/null +++ b/ui/packages/plugin-oauth2/src/index.ts @@ -0,0 +1,89 @@ +/** + * OAuth2 consent and connected apps, the client half of authsome's + * `oauth2provider` server plugin. + * + * This is the "App X wants access to your account" screen and the settings + * list of apps you have authorised. Purpose consent in the data-protection + * sense is a different plugin, `@authsome/plugin-consent`. + * + * ```ts + * import { oauth2 } from "@authsome/plugin-oauth2"; + * + * const auth = new AuthManager({ baseURL, plugins: [oauth2()] }); + * const view = await auth.plugin(oauth2).getConsentRequest(cid); + * ``` + */ + +import { definePlugin } from "@authsome/ui-core"; + +/** What the authorize screen has to show. */ +export interface ConsentRequest { + /** Opaque id for this consent round. Goes back with the decision. */ + cid: string; + client_id: string; + client_name: string; + /** Raw scope strings, e.g. "openid", "profile", "offline_access". */ + scopes: string[]; + /** Must go back with the decision. */ + csrf: string; + action: string; +} + +/** Where to send the browser once a decision is recorded. */ +export interface ConsentDecisionResult { + redirect_url: string; +} + +/** An app you have authorised. */ +export interface OAuthGrant { + client_id: string; + client_name: string; + scopes: string[]; + created_at: string; + updated_at: string; +} + +export interface OAuth2Api { + /** Loads the consent screen's content for a consent id. */ + getConsentRequest(cid: string): Promise; + /** Records allow or deny. Returns where to send the browser next. */ + decide(input: { + cid: string; + csrf: string; + decision: "allow" | "deny"; + }): Promise; + /** Apps the signed-in user has authorised. */ + listGrants(): Promise<{ grants: OAuthGrant[] }>; + /** Withdraws an app's access. */ + revokeGrant(clientId: string): Promise; +} + +export const oauth2 = definePlugin("oauth2provider", (ctx) => ({ + getConsentRequest: (cid) => + ctx.request("GET", "/v1/oauth/consent", { query: { cid } }), + decide: (input) => + ctx.request("POST", "/v1/oauth/consent", { + body: input, + }), + listGrants: () => + ctx.request<{ grants: OAuthGrant[] }>("GET", "/v1/me/oauth/grants"), + revokeGrant: (clientId) => + ctx.request( + "DELETE", + `/v1/me/oauth/grants/${encodeURIComponent(clientId)}`, + ), +})); + +/** + * Readable names for the scopes authsome issues. A host passes its own map + * for anything custom, and anything unknown falls back to the raw string so + * nobody is ever asked to approve a blank line. + */ +export const DEFAULT_SCOPE_LABELS: Record = { + openid: "Confirm who you are", + profile: "See your name and profile details", + email: "See your email address", + phone: "See your phone number", + address: "See your address", + offline_access: "Stay signed in when you are away", +}; diff --git a/ui/packages/plugin-oauth2/src/oauth-consent-screen.tsx b/ui/packages/plugin-oauth2/src/oauth-consent-screen.tsx new file mode 100644 index 00000000..253ed0f1 --- /dev/null +++ b/ui/packages/plugin-oauth2/src/oauth-consent-screen.tsx @@ -0,0 +1,144 @@ +"use client"; + +import * as React from "react"; +import { Check } from "lucide-react"; +import { + AuthCard, + Button, + ErrorDisplay, + LoadingSpinner, + Skeleton, + type AuthCardAlign, + type AuthCardVariant, +} from "@authsome/ui-components"; +import { DEFAULT_SCOPE_LABELS } from "./index"; +import { useConsentDecision, useConsentRequest } from "./react"; + +export interface OAuthConsentScreenProps { + /** The `cid` query parameter the authorize endpoint redirected with. */ + cid: string | null; + /** + * Readable names for scopes, merged over the built-in map. Anything still + * unknown falls back to the raw scope string, because asking someone to + * approve a blank line is worse than showing them "custom:widgets". + */ + scopeLabels?: Record; + /** + * Where to send the browser after a decision. Defaults to assigning + * `window.location.href`, which is what a real authorize flow needs. + */ + onDecided?: (redirectUrl: string, decision: "allow" | "deny") => void; + align?: AuthCardAlign; + variant?: AuthCardVariant; + className?: string; +} + +/** + * The "App X wants access to your account" screen. + * + * Deny is a real button rather than a link out, because refusing is a + * decision the server needs to hear: it is what sends `access_denied` back + * to the client instead of leaving the request hanging. + */ +export function OAuthConsentScreen({ + cid, + scopeLabels, + onDecided, + align, + variant, + className, +}: OAuthConsentScreenProps) { + const { request, isLoading, error } = useConsentRequest(cid); + const { decide, isDeciding, error: decisionError } = useConsentDecision(); + const [choice, setChoice] = React.useState<"allow" | "deny" | null>(null); + + const labels = React.useMemo( + () => ({ ...DEFAULT_SCOPE_LABELS, ...scopeLabels }), + [scopeLabels], + ); + + const submit = async (decision: "allow" | "deny") => { + if (!request) return; + setChoice(decision); + try { + const res = await decide({ + cid: request.cid, + csrf: request.csrf, + decision, + }); + if (onDecided) onDecided(res.redirect_url, decision); + else globalThis.location?.assign(res.redirect_url); + } catch { + // Surfaced through decisionError below. + } finally { + setChoice(null); + } + }; + + if (isLoading) { + return ( + +
+ + + +
+
+ ); + } + + if (!request) { + return ( + + + + ); + } + + return ( + +
+
    + {request.scopes.map((scope) => ( +
  • + + {labels[scope] ?? scope} +
  • + ))} +
+ + + +
+ + +
+
+
+ ); +} diff --git a/ui/packages/plugin-oauth2/src/plugin.test.ts b/ui/packages/plugin-oauth2/src/plugin.test.ts new file mode 100644 index 00000000..10086d42 --- /dev/null +++ b/ui/packages/plugin-oauth2/src/plugin.test.ts @@ -0,0 +1,43 @@ +import { describe, it, expect } from "vitest"; +import { oauth2, DEFAULT_SCOPE_LABELS, type OAuth2Api } from "./index"; +import { fakeContext } from "./test-context"; + +describe("oauth2 plugin", () => { + it("is named after the oauth2provider server plugin", () => { + expect(oauth2.pluginName).toBe("oauth2provider"); + }); + + it("reads the consent request by cid", async () => { + const { ctx, request } = fakeContext({}); + await (oauth2().init(ctx) as OAuth2Api).getConsentRequest("cid_1"); + expect(request).toHaveBeenCalledWith("GET", "/v1/oauth/consent", { + query: { cid: "cid_1" }, + }); + }); + + it("sends the csrf back with the decision", async () => { + const { ctx, request } = fakeContext({ redirect_url: "https://app/cb" }); + await (oauth2().init(ctx) as OAuth2Api).decide({ + cid: "cid_1", + csrf: "tok", + decision: "allow", + }); + expect(request).toHaveBeenCalledWith("POST", "/v1/oauth/consent", { + body: { cid: "cid_1", csrf: "tok", decision: "allow" }, + }); + }); + + it("escapes a client id with awkward characters in it", async () => { + const { ctx, request } = fakeContext(undefined); + await (oauth2().init(ctx) as OAuth2Api).revokeGrant("client/one?x=1"); + expect(request).toHaveBeenCalledWith( + "DELETE", + "/v1/me/oauth/grants/client%2Fone%3Fx%3D1", + ); + }); + + it("ships readable labels for the standard scopes", () => { + expect(DEFAULT_SCOPE_LABELS.openid).toBeTruthy(); + expect(DEFAULT_SCOPE_LABELS.offline_access).toBeTruthy(); + }); +}); diff --git a/ui/packages/plugin-oauth2/src/react.ts b/ui/packages/plugin-oauth2/src/react.ts new file mode 100644 index 00000000..0ba38d3a --- /dev/null +++ b/ui/packages/plugin-oauth2/src/react.ts @@ -0,0 +1,178 @@ +/** React hooks and components for OAuth consent and connected apps. */ + +import { useCallback, useEffect, useState } from "react"; +import { useAuth, usePlugin } from "@authsome/ui-react"; +import { + oauth2, + type ConsentDecisionResult, + type ConsentRequest, + type OAuth2Api, + type OAuthGrant, +} from "./index"; + +/** + * useConsentRequest loads what an authorize screen has to show. + * + * The consent id comes from the `cid` query parameter the authorize endpoint + * redirected with. + */ +export function useConsentRequest(cid: string | null): { + request: ConsentRequest | null; + isLoading: boolean; + error: Error | null; +} { + const api = usePlugin(oauth2); + const [request, setRequest] = useState(null); + const [error, setError] = useState(null); + const [loadedFor, setLoadedFor] = useState(null); + + const isLoading = Boolean(cid) && loadedFor !== cid; + + useEffect(() => { + if (!cid) return; + let ignore = false; + void (async () => { + try { + const res = await api.getConsentRequest(cid); + if (!ignore) { + setRequest(res); + setError(null); + } + } catch (err) { + if (!ignore) setError(err instanceof Error ? err : new Error(String(err))); + } finally { + if (!ignore) setLoadedFor(cid); + } + })(); + return () => { + ignore = true; + }; + }, [api, cid]); + + return { request, isLoading, error }; +} + +/** useConsentDecision records allow or deny. */ +export function useConsentDecision(): { + decide: (input: { + cid: string; + csrf: string; + decision: "allow" | "deny"; + }) => Promise; + isDeciding: boolean; + error: Error | null; +} { + const api = usePlugin(oauth2); + const [isDeciding, setIsDeciding] = useState(false); + const [error, setError] = useState(null); + + const decide = useCallback( + async (input: { cid: string; csrf: string; decision: "allow" | "deny" }) => { + setIsDeciding(true); + setError(null); + try { + return await api.decide(input); + } catch (err) { + setError(err instanceof Error ? err : new Error(String(err))); + throw err; + } finally { + setIsDeciding(false); + } + }, + [api], + ); + + return { decide, isDeciding, error }; +} + +/** useMyGrants lists the apps the signed-in user has authorised. */ +export function useMyGrants(): { + grants: OAuthGrant[]; + isLoading: boolean; + error: Error | null; + reload: () => Promise; +} { + const api = usePlugin(oauth2); + const { session, isAuthenticated } = useAuth(); + const [grants, setGrants] = useState([]); + const [error, setError] = useState(null); + const token = session?.session_token ?? null; + const [loadedFor, setLoadedFor] = useState(null); + const [isRefreshing, setIsRefreshing] = useState(false); + const isLoading = (isAuthenticated && loadedFor !== token) || isRefreshing; + + const reload = useCallback(async () => { + if (!token) return; + setIsRefreshing(true); + try { + setGrants((await api.listGrants()).grants); + setError(null); + setLoadedFor(token); + } catch (err) { + setError(err instanceof Error ? err : new Error(String(err))); + } finally { + setIsRefreshing(false); + } + }, [api, token]); + + useEffect(() => { + if (!isAuthenticated || !token) return; + let ignore = false; + void (async () => { + try { + const res = await api.listGrants(); + if (!ignore) { + setGrants(res.grants); + setError(null); + } + } catch (err) { + if (!ignore) setError(err instanceof Error ? err : new Error(String(err))); + } finally { + if (!ignore) setLoadedFor(token); + } + })(); + return () => { + ignore = true; + }; + }, [api, isAuthenticated, token]); + + return { grants, isLoading, error, reload }; +} + +/** useRevokeGrant withdraws an app's access. */ +export function useRevokeGrant(): { + revoke: (clientId: string) => Promise; + isSaving: boolean; + error: Error | null; +} { + const api = usePlugin(oauth2); + const [isSaving, setIsSaving] = useState(false); + const [error, setError] = useState(null); + + const revoke = useCallback( + async (clientId: string) => { + setIsSaving(true); + setError(null); + try { + await api.revokeGrant(clientId); + } catch (err) { + setError(err instanceof Error ? err : new Error(String(err))); + throw err; + } finally { + setIsSaving(false); + } + }, + [api], + ); + + return { revoke, isSaving, error }; +} + +export { + OAuthConsentScreen, + type OAuthConsentScreenProps, +} from "./oauth-consent-screen"; +export { + ConnectedAppsList, + type ConnectedAppsListProps, +} from "./connected-apps-list"; diff --git a/ui/packages/plugin-oauth2/src/test-context.ts b/ui/packages/plugin-oauth2/src/test-context.ts new file mode 100644 index 00000000..4b0b5952 --- /dev/null +++ b/ui/packages/plugin-oauth2/src/test-context.ts @@ -0,0 +1,30 @@ +import { vi, type Mock } from "vitest"; +import type { PluginContext } from "@authsome/ui-core"; + +export interface FakeContext { + ctx: PluginContext; + /** Every call the plugin made, so a test can assert on method and path. */ + request: Mock; +} + +/** + * A PluginContext that records what the plugin asked for. + * + * The return type is spelled out rather than inferred, because an inferred + * vitest Mock cannot be named from outside its own package and tsc refuses + * to emit a declaration for it. + */ +export function fakeContext(reply: unknown = {}): FakeContext { + const request = vi.fn().mockResolvedValue(reply); + return { + ctx: { + request: request as PluginContext["request"], + getState: () => ({ status: "unauthenticated" }) as never, + getClientConfig: () => null, + isServerEnabled: () => true, + getActiveOrgId: () => null, + on: () => () => {}, + }, + request, + }; +} diff --git a/ui/packages/plugin-oauth2/tsconfig.json b/ui/packages/plugin-oauth2/tsconfig.json new file mode 100644 index 00000000..eb3621e3 --- /dev/null +++ b/ui/packages/plugin-oauth2/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "jsx": "react-jsx", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +} diff --git a/ui/packages/plugin-oauth2/tsup.config.ts b/ui/packages/plugin-oauth2/tsup.config.ts new file mode 100644 index 00000000..391c5d94 --- /dev/null +++ b/ui/packages/plugin-oauth2/tsup.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + // Two entries so a host that never touches React does not pull it in just + // by importing the plugin. + entry: ["src/index.ts", "src/react.ts"], + format: ["esm"], + dts: true, + clean: true, + sourcemap: true, + target: "es2022", + external: [ + "react", + "react-dom", + "@authsome/ui-react", + "@authsome/ui-components", + "lucide-react", + ], +}); diff --git a/ui/packages/plugin-oauth2/vitest.config.ts b/ui/packages/plugin-oauth2/vitest.config.ts new file mode 100644 index 00000000..f65ebc76 --- /dev/null +++ b/ui/packages/plugin-oauth2/vitest.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "vitest/config"; + +// Same shape as ui-react's config. The hook test renders, so it needs a DOM, +// and globals is on so @testing-library/react can register its cleanup. +export default defineConfig({ + test: { + environment: "jsdom", + globals: true, + include: ["src/**/*.test.{ts,tsx}"], + }, +}); diff --git a/ui/packages/plugin-organization/package.json b/ui/packages/plugin-organization/package.json new file mode 100644 index 00000000..396f7894 --- /dev/null +++ b/ui/packages/plugin-organization/package.json @@ -0,0 +1,70 @@ +{ + "name": "@authsome/plugin-organization", + "version": "1.5.5", + "description": "Organization invitations for AuthSome: client plugin, hooks and components", + "license": "MIT", + "type": "module", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "https://github.com/xraph/authsome.git", + "directory": "ui/packages/plugin-organization" + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + }, + "./react": { + "import": "./dist/react.js", + "types": "./dist/react.d.ts" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsup", + "dev": "tsup --watch", + "typecheck": "tsc --noEmit", + "clean": "rm -rf dist", + "lint": "eslint .", + "test": "vitest run" + }, + "dependencies": { + "@authsome/ui-core": "workspace:*" + }, + "peerDependencies": { + "@authsome/ui-react": "workspace:*", + "react": "^18.0.0 || ^19.0.0", + "@authsome/ui-components": "workspace:*" + }, + "peerDependenciesMeta": { + "@authsome/ui-react": { + "optional": true + }, + "react": { + "optional": true + }, + "@authsome/ui-components": { + "optional": true + } + }, + "devDependencies": { + "@authsome/ui-react": "workspace:*", + "@testing-library/react": "^16.3.0", + "@types/react": "^19.2.18", + "jsdom": "^28.0.1", + "react": "^19.2.8", + "react-dom": "^19.2.8", + "tsup": "^8.5.1", + "typescript": "^5.9.3", + "vite": "^8.2.4", + "vitest": "^4.1.12", + "@authsome/ui-components": "workspace:*" + } +} diff --git a/ui/packages/plugin-organization/src/index.ts b/ui/packages/plugin-organization/src/index.ts new file mode 100644 index 00000000..8476cefd --- /dev/null +++ b/ui/packages/plugin-organization/src/index.ts @@ -0,0 +1,120 @@ +/** + * Organization invitations, the client half of authsome's `organization` + * server plugin. + * + * ```ts + * import { organization } from "@authsome/plugin-organization"; + * + * const auth = new AuthManager({ baseURL, plugins: [organization()] }); + * const invite = await auth.plugin(organization).getInvitation(token); + * ``` + * + * `getInvitation` is unauthenticated on purpose: someone deciding whether to + * accept has not signed in yet, and often has no account at all. + */ + +import { definePlugin } from "@authsome/ui-core"; + +/** An invitation as the org's admins see it. */ +export interface Invitation { + id: string; + org_id: string; + email: string; + role: string; + inviter_id: string; + /** "pending", "accepted", "declined", "expired". */ + status: string; + expires_at: string; + created_at: string; +} + +/** + * What the person holding the token is shown before they accept. + * + * Deliberately not the stored row. It carries the org's name and the + * inviter's, which the row does not have, and leaves out the member list and + * anything else the recipient has no business seeing yet. + */ +export interface InvitationPreview { + org_id: string; + org_name: string; + org_slug?: string; + /** The inviter's display name, or their email when they have no name. */ + inviter_name: string; + role: string; + /** Partially masked, so the page can say who it is for without leaking it. */ + email_masked: string; + expires_at: string; +} + +export interface InvitationListResult { + invitations: Invitation[]; + next_cursor?: string; +} + +export interface Member { + id: string; + org_id: string; + user_id: string; + role: string; + created_at: string; + updated_at: string; +} + +export interface OrganizationApi { + /** Reads an invitation by its token. No session needed. */ + getInvitation(token: string): Promise; + /** Accepts it. Needs a session, so sign in or sign up first. */ + acceptInvitation(token: string): Promise; + declineInvitation(token: string): Promise<{ status: string }>; + listInvitations( + orgId: string, + opts?: { limit?: number; cursor?: string }, + ): Promise; + createInvitation( + orgId: string, + input: { email: string; role?: string }, + ): Promise; +} + +export const organization = definePlugin( + "organization", + (ctx) => ({ + getInvitation: (token) => + ctx.request( + "GET", + `/v1/orgs/invitations/${encodeURIComponent(token)}`, + ), + acceptInvitation: (token) => + ctx.request("POST", "/v1/orgs/invitations/accept", { + body: { token }, + }), + declineInvitation: (token) => + ctx.request<{ status: string }>("POST", "/v1/orgs/invitations/decline", { + body: { token }, + }), + listInvitations: (orgId, opts) => + ctx.request( + "GET", + `/v1/orgs/${encodeURIComponent(orgId)}/invitations`, + { query: { limit: opts?.limit, cursor: opts?.cursor } }, + ), + createInvitation: (orgId, input) => + ctx.request( + "POST", + `/v1/orgs/${encodeURIComponent(orgId)}/invitations`, + { body: input }, + ), + }), +); + +/** "in 6 days", "today", "expired". */ +export function expiresIn(iso: string): string { + const at = new Date(iso).getTime(); + if (Number.isNaN(at)) return ""; + const days = Math.ceil((at - Date.now()) / 86_400_000); + if (days < 0) return "expired"; + if (days === 0) return "today"; + if (days === 1) return "in 1 day"; + return `in ${days} days`; +} diff --git a/ui/packages/plugin-organization/src/invitation-card.tsx b/ui/packages/plugin-organization/src/invitation-card.tsx new file mode 100644 index 00000000..0aaa9da3 --- /dev/null +++ b/ui/packages/plugin-organization/src/invitation-card.tsx @@ -0,0 +1,199 @@ +"use client"; + +import * as React from "react"; +import { + AuthCard, + Avatar, + AvatarFallback, + Badge, + Button, + ErrorDisplay, + LoadingSpinner, + Skeleton, + type AuthCardAlign, + type AuthCardVariant, +} from "@authsome/ui-components"; +import { expiresIn } from "./index"; +import { + useAcceptInvitation, + useDeclineInvitation, + useInvitation, +} from "./react"; + +export interface InvitationCardProps { + /** The invitation token, usually from the URL. */ + token: string | null; + /** + * Rendered instead of the accept button when nobody is signed in. Give it + * your sign-in and sign-up controls: accepting needs a session, and this + * card deliberately does not try to create one. + */ + signInSlot?: React.ReactNode; + onAccepted?: (orgId: string) => void; + onDeclined?: () => void; + align?: AuthCardAlign; + variant?: AuthCardVariant; + className?: string; + /** Readable names for roles, keyed by role string. */ + roleLabels?: Record; + /** True when a session exists, so the card knows which controls to show. */ + isSignedIn?: boolean; +} + +function initials(name: string): string { + const parts = name.split(/[\s@._-]+/).filter(Boolean); + return (parts[0]?.[0] ?? "?").concat(parts[1]?.[0] ?? "").toUpperCase(); +} + +/** + * The page someone lands on from an invitation email. + * + * It shows who invited them, to what, as what, and when it runs out, before + * asking for anything. Accepting needs a session, so when nobody is signed + * in the card renders `signInSlot` instead of the accept button rather than + * trying to build a sign-in flow of its own. + */ +export function InvitationCard({ + token, + signInSlot, + onAccepted, + onDeclined, + align, + variant, + className, + roleLabels, + isSignedIn = false, +}: InvitationCardProps) { + const { invitation, isLoading, error } = useInvitation(token); + const { accept, isSaving: isAccepting, error: acceptError } = + useAcceptInvitation(); + const { decline, isSaving: isDeclining, error: declineError } = + useDeclineInvitation(); + const [done, setDone] = React.useState<"accepted" | "declined" | null>(null); + + if (isLoading) { + return ( + +
+ + +
+
+ ); + } + + if (!invitation) { + return ( + + + + ); + } + + if (done === "accepted") { + return ( + + + + ); + } + + if (done === "declined") { + return ( + + + + ); + } + + const expiry = expiresIn(invitation.expires_at); + const role = roleLabels?.[invitation.role] ?? invitation.role; + + return ( + +
+
+ + + {initials(invitation.inviter_name)} + + +

+ {invitation.inviter_name} invited {invitation.email_masked} +

+ + {expiry === "expired" ? "expired" : `expires ${expiry}`} + +
+ + + + {isSignedIn ? ( + + ) : ( + signInSlot + )} + + +
+
+ ); +} diff --git a/ui/packages/plugin-organization/src/invitation-list.tsx b/ui/packages/plugin-organization/src/invitation-list.tsx new file mode 100644 index 00000000..8b3d089b --- /dev/null +++ b/ui/packages/plugin-organization/src/invitation-list.tsx @@ -0,0 +1,135 @@ +"use client"; + +import * as React from "react"; +import { + Badge, + Button, + ErrorDisplay, + Input, + Label, + LoadingSpinner, + Skeleton, + cn, +} from "@authsome/ui-components"; +import { expiresIn } from "./index"; +import { useCreateInvitation, useInvitations } from "./react"; + +export interface InvitationListProps { + orgId: string | null; + /** Roles a new invitation can be sent for. */ + roles?: { value: string; label: string }[]; + title?: React.ReactNode; + emptyState?: React.ReactNode; + /** Hides the invite form, for a read-only view. */ + readOnly?: boolean; + className?: string; +} + +/** Pending invitations, and a form to send another. */ +export function InvitationList({ + orgId, + roles, + title = "Invitations", + emptyState = "No pending invitations.", + readOnly = false, + className, +}: InvitationListProps) { + const { invitations, isLoading, error, reload } = useInvitations(orgId); + const { invite, isSaving, error: inviteError } = useCreateInvitation(orgId); + const [email, setEmail] = React.useState(""); + const [role, setRole] = React.useState(roles?.[0]?.value ?? ""); + + const pending = invitations.filter((i) => i.status === "pending"); + + return ( +
+ {title && ( +

+ {title} +

+ )} + + + + {!readOnly && ( + { + e.preventDefault(); + if (!email.trim()) return; + try { + await invite({ email: email.trim(), role: role || undefined }); + setEmail(""); + await reload(); + } catch { + // Surfaced through inviteError above. + } + }} + > +
+ + setEmail(e.target.value)} + disabled={isSaving} + /> +
+ {roles && roles.length > 0 && ( + + )} + + + )} + + {isLoading ? ( +
+ + +
+ ) : pending.length === 0 ? ( +

{emptyState}

+ ) : ( +
    + {pending.map((inv) => { + const expiry = expiresIn(inv.expires_at); + return ( +
  • +
    +

    {inv.email}

    +

    {inv.role}

    +
    + + {expiry === "expired" ? "expired" : `expires ${expiry}`} + +
  • + ); + })} +
+ )} +
+ ); +} diff --git a/ui/packages/plugin-organization/src/plugin.test.ts b/ui/packages/plugin-organization/src/plugin.test.ts new file mode 100644 index 00000000..2de1230d --- /dev/null +++ b/ui/packages/plugin-organization/src/plugin.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { organization, expiresIn, type OrganizationApi } from "./index"; +import { fakeContext } from "./test-context"; + +afterEach(() => vi.useRealTimers()); + +describe("organization plugin", () => { + it("is named after the server plugin", () => { + expect(organization.pluginName).toBe("organization"); + }); + + it("reads an invitation by token without a body", async () => { + const { ctx, request } = fakeContext({}); + await (organization().init(ctx) as OrganizationApi).getInvitation("tok_1"); + expect(request).toHaveBeenCalledWith("GET", "/v1/orgs/invitations/tok_1"); + }); + + it("escapes a token with url characters in it", async () => { + const { ctx, request } = fakeContext({}); + await (organization().init(ctx) as OrganizationApi).getInvitation("a/b+c"); + expect(request).toHaveBeenCalledWith("GET", "/v1/orgs/invitations/a%2Fb%2Bc"); + }); + + it("posts the token when accepting", async () => { + const { ctx, request } = fakeContext({}); + await (organization().init(ctx) as OrganizationApi).acceptInvitation("tok_1"); + expect(request).toHaveBeenCalledWith("POST", "/v1/orgs/invitations/accept", { + body: { token: "tok_1" }, + }); + }); + + it("lists an org's invitations with paging", async () => { + const { ctx, request } = fakeContext({ invitations: [] }); + await (organization().init(ctx) as OrganizationApi).listInvitations("org_1", { + limit: 20, + }); + expect(request).toHaveBeenCalledWith("GET", "/v1/orgs/org_1/invitations", { + query: { limit: 20, cursor: undefined }, + }); + }); +}); + +describe("expiresIn", () => { + it("counts whole days ahead", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-11-10T12:00:00Z")); + expect(expiresIn("2026-11-16T12:00:00Z")).toBe("in 6 days"); + }); + + it("says expired once the moment has passed", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-11-10T12:00:00Z")); + expect(expiresIn("2026-11-09T12:00:00Z")).toBe("expired"); + }); + + it("uses the singular for one day", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-11-10T12:00:00Z")); + expect(expiresIn("2026-11-11T11:00:00Z")).toBe("in 1 day"); + }); + + it("is empty for something that is not a date", () => { + expect(expiresIn("not a date")).toBe(""); + }); +}); diff --git a/ui/packages/plugin-organization/src/react.ts b/ui/packages/plugin-organization/src/react.ts new file mode 100644 index 00000000..b76e4524 --- /dev/null +++ b/ui/packages/plugin-organization/src/react.ts @@ -0,0 +1,201 @@ +/** React hooks and components for organization invitations. */ + +import { useCallback, useEffect, useState } from "react"; +import { useAuth, usePlugin } from "@authsome/ui-react"; +import { + organization, + type Invitation, + type InvitationPreview, + type Member, + type OrganizationApi, +} from "./index"; + +/** + * useInvitation reads an invitation by its token. + * + * No session is needed, which is the point: the person deciding whether to + * accept usually has no account yet. + */ +export function useInvitation(token: string | null): { + invitation: InvitationPreview | null; + isLoading: boolean; + error: Error | null; +} { + const api = usePlugin(organization); + const [invitation, setInvitation] = useState(null); + const [error, setError] = useState(null); + const [loadedFor, setLoadedFor] = useState(null); + const isLoading = Boolean(token) && loadedFor !== token; + + useEffect(() => { + if (!token) return; + let ignore = false; + void (async () => { + try { + const res = await api.getInvitation(token); + if (!ignore) { + setInvitation(res); + setError(null); + } + } catch (err) { + if (!ignore) { + setInvitation(null); + setError(err instanceof Error ? err : new Error(String(err))); + } + } finally { + if (!ignore) setLoadedFor(token); + } + })(); + return () => { + ignore = true; + }; + }, [api, token]); + + return { invitation, isLoading, error }; +} + +/** useAcceptInvitation joins the org. Needs a session. */ +export function useAcceptInvitation(): { + accept: (token: string) => Promise; + isSaving: boolean; + error: Error | null; +} { + const api = usePlugin(organization); + const [isSaving, setIsSaving] = useState(false); + const [error, setError] = useState(null); + + const accept = useCallback( + async (token: string) => { + setIsSaving(true); + setError(null); + try { + return await api.acceptInvitation(token); + } catch (err) { + setError(err instanceof Error ? err : new Error(String(err))); + throw err; + } finally { + setIsSaving(false); + } + }, + [api], + ); + + return { accept, isSaving, error }; +} + +/** useDeclineInvitation turns it down. */ +export function useDeclineInvitation(): { + decline: (token: string) => Promise; + isSaving: boolean; + error: Error | null; +} { + const api = usePlugin(organization); + const [isSaving, setIsSaving] = useState(false); + const [error, setError] = useState(null); + + const decline = useCallback( + async (token: string) => { + setIsSaving(true); + setError(null); + try { + await api.declineInvitation(token); + } catch (err) { + setError(err instanceof Error ? err : new Error(String(err))); + throw err; + } finally { + setIsSaving(false); + } + }, + [api], + ); + + return { decline, isSaving, error }; +} + +/** useInvitations lists an org's pending invitations. Admin side. */ +export function useInvitations(orgId: string | null): { + invitations: Invitation[]; + isLoading: boolean; + error: Error | null; + reload: () => Promise; +} { + const api = usePlugin(organization); + const { session, isAuthenticated } = useAuth(); + const [invitations, setInvitations] = useState([]); + const [error, setError] = useState(null); + const token = session?.session_token ?? null; + const key = token && orgId ? `${token}:${orgId}` : null; + const [loadedFor, setLoadedFor] = useState(null); + const [isRefreshing, setIsRefreshing] = useState(false); + const isLoading = (isAuthenticated && Boolean(key) && loadedFor !== key) || isRefreshing; + + const reload = useCallback(async () => { + if (!orgId || !key) return; + setIsRefreshing(true); + try { + setInvitations((await api.listInvitations(orgId)).invitations); + setError(null); + setLoadedFor(key); + } catch (err) { + setError(err instanceof Error ? err : new Error(String(err))); + } finally { + setIsRefreshing(false); + } + }, [api, key, orgId]); + + useEffect(() => { + if (!isAuthenticated || !orgId || !key) return; + let ignore = false; + void (async () => { + try { + const res = await api.listInvitations(orgId); + if (!ignore) { + setInvitations(res.invitations); + setError(null); + } + } catch (err) { + if (!ignore) setError(err instanceof Error ? err : new Error(String(err))); + } finally { + if (!ignore) setLoadedFor(key); + } + })(); + return () => { + ignore = true; + }; + }, [api, isAuthenticated, key, orgId]); + + return { invitations, isLoading, error, reload }; +} + +/** useCreateInvitation invites someone. */ +export function useCreateInvitation(orgId: string | null): { + invite: (input: { email: string; role?: string }) => Promise; + isSaving: boolean; + error: Error | null; +} { + const api = usePlugin(organization); + const [isSaving, setIsSaving] = useState(false); + const [error, setError] = useState(null); + + const invite = useCallback( + async (input: { email: string; role?: string }) => { + if (!orgId) throw new Error("useCreateInvitation: no organization id"); + setIsSaving(true); + setError(null); + try { + return await api.createInvitation(orgId, input); + } catch (err) { + setError(err instanceof Error ? err : new Error(String(err))); + throw err; + } finally { + setIsSaving(false); + } + }, + [api, orgId], + ); + + return { invite, isSaving, error }; +} + +export { InvitationCard, type InvitationCardProps } from "./invitation-card"; +export { InvitationList, type InvitationListProps } from "./invitation-list"; diff --git a/ui/packages/plugin-organization/src/test-context.ts b/ui/packages/plugin-organization/src/test-context.ts new file mode 100644 index 00000000..4b0b5952 --- /dev/null +++ b/ui/packages/plugin-organization/src/test-context.ts @@ -0,0 +1,30 @@ +import { vi, type Mock } from "vitest"; +import type { PluginContext } from "@authsome/ui-core"; + +export interface FakeContext { + ctx: PluginContext; + /** Every call the plugin made, so a test can assert on method and path. */ + request: Mock; +} + +/** + * A PluginContext that records what the plugin asked for. + * + * The return type is spelled out rather than inferred, because an inferred + * vitest Mock cannot be named from outside its own package and tsc refuses + * to emit a declaration for it. + */ +export function fakeContext(reply: unknown = {}): FakeContext { + const request = vi.fn().mockResolvedValue(reply); + return { + ctx: { + request: request as PluginContext["request"], + getState: () => ({ status: "unauthenticated" }) as never, + getClientConfig: () => null, + isServerEnabled: () => true, + getActiveOrgId: () => null, + on: () => () => {}, + }, + request, + }; +} diff --git a/ui/packages/plugin-organization/tsconfig.json b/ui/packages/plugin-organization/tsconfig.json new file mode 100644 index 00000000..eb3621e3 --- /dev/null +++ b/ui/packages/plugin-organization/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "jsx": "react-jsx", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +} diff --git a/ui/packages/plugin-organization/tsup.config.ts b/ui/packages/plugin-organization/tsup.config.ts new file mode 100644 index 00000000..5f47fa78 --- /dev/null +++ b/ui/packages/plugin-organization/tsup.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + // Two entries so a host that never touches React does not pull it in just + // by importing the plugin. + entry: ["src/index.ts", "src/react.ts"], + format: ["esm"], + dts: true, + clean: true, + sourcemap: true, + target: "es2022", + external: [ + "react", + "react-dom", + "@authsome/ui-react", + "@authsome/ui-components", + ], +}); diff --git a/ui/packages/plugin-organization/vitest.config.ts b/ui/packages/plugin-organization/vitest.config.ts new file mode 100644 index 00000000..f65ebc76 --- /dev/null +++ b/ui/packages/plugin-organization/vitest.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "vitest/config"; + +// Same shape as ui-react's config. The hook test renders, so it needs a DOM, +// and globals is on so @testing-library/react can register its cleanup. +export default defineConfig({ + test: { + environment: "jsdom", + globals: true, + include: ["src/**/*.test.{ts,tsx}"], + }, +}); diff --git a/ui/packages/plugin-phone/package.json b/ui/packages/plugin-phone/package.json new file mode 100644 index 00000000..9e207dc3 --- /dev/null +++ b/ui/packages/plugin-phone/package.json @@ -0,0 +1,70 @@ +{ + "name": "@authsome/plugin-phone", + "version": "1.5.5", + "description": "Phone sign-in for AuthSome: client plugin and React hooks", + "license": "MIT", + "type": "module", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "https://github.com/xraph/authsome.git", + "directory": "ui/packages/plugin-phone" + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + }, + "./react": { + "import": "./dist/react.js", + "types": "./dist/react.d.ts" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsup", + "dev": "tsup --watch", + "typecheck": "tsc --noEmit", + "clean": "rm -rf dist", + "lint": "eslint .", + "test": "vitest run" + }, + "dependencies": { + "@authsome/ui-core": "workspace:*" + }, + "peerDependencies": { + "@authsome/ui-react": "workspace:*", + "react": "^18.0.0 || ^19.0.0", + "@authsome/ui-components": "workspace:*" + }, + "peerDependenciesMeta": { + "@authsome/ui-react": { + "optional": true + }, + "react": { + "optional": true + }, + "@authsome/ui-components": { + "optional": true + } + }, + "devDependencies": { + "@authsome/ui-react": "workspace:*", + "@testing-library/react": "^16.3.0", + "@types/react": "^19.2.18", + "jsdom": "^28.0.1", + "react": "^19.2.8", + "react-dom": "^19.2.8", + "tsup": "^8.5.1", + "typescript": "^5.9.3", + "vite": "^8.2.4", + "vitest": "^4.1.12", + "@authsome/ui-components": "workspace:*" + } +} diff --git a/ui/packages/plugin-phone/src/index.ts b/ui/packages/plugin-phone/src/index.ts new file mode 100644 index 00000000..46c73835 --- /dev/null +++ b/ui/packages/plugin-phone/src/index.ts @@ -0,0 +1,72 @@ +/** + * Phone sign-in, the client half of authsome's `phone` server plugin. + * + * ```ts + * import { phone } from "@authsome/plugin-phone"; + * + * const auth = new AuthManager({ baseURL, plugins: [phone()] }); + * const sms = auth.plugin(phone); + * + * await sms.start({ phone: "+351912345678" }); + * const res = await sms.verify({ phone: "+351912345678", code: "482917" }); + * await auth.adoptSession(res); + * ``` + * + * Both endpoints are unauthenticated, because this is how someone signs in + * for the first time. `verify` returns a real session, which the manager + * adopts: the plugin does not sign anyone in by itself, so a host can decide + * what happens between the code being right and the session being live. + */ + +import { definePlugin } from "@authsome/ui-core"; + +/** What `start` sends. `app_id` is only needed for a multi-app deployment. */ +export interface PhoneStartInput { + phone: string; + app_id?: string; +} + +/** What the server says when a code is on its way. */ +export interface PhoneStartResult { + /** Server-side status string, e.g. "sent". */ + status: string; + /** Seconds until the code stops working. */ + expires_in: number; +} + +export interface PhoneVerifyInput { + phone: string; + code: string; + app_id?: string; +} + +/** + * A real session. Hand it to `AuthManager.adoptSession` to sign the person + * in. `new_user` is true when this number had no account until now, which is + * the hook a host needs for an onboarding step. + */ +export interface PhoneVerifyResult { + session_token: string; + refresh_token: string; + expires_at: string; + new_user: boolean; + user?: unknown; +} + +export interface PhoneApi { + /** Sends a code to the number. */ + start(input: PhoneStartInput): Promise; + /** Exchanges a code for a session. */ + verify(input: PhoneVerifyInput): Promise; +} + +/** + * The phone plugin. Register it on the manager, then look it up with + * `auth.plugin(phone)`. + */ +export const phone = definePlugin("phone", (ctx) => ({ + start: (input) => + ctx.request("POST", "/v1/phone/start", { body: input }), + verify: (input) => + ctx.request("POST", "/v1/phone/verify", { body: input }), +})); diff --git a/ui/packages/plugin-phone/src/phone-sign-in-form.tsx b/ui/packages/plugin-phone/src/phone-sign-in-form.tsx new file mode 100644 index 00000000..ff041a9b --- /dev/null +++ b/ui/packages/plugin-phone/src/phone-sign-in-form.tsx @@ -0,0 +1,196 @@ +"use client"; + +import * as React from "react"; +import { + AuthCard, + Button, + ErrorDisplay, + Label, + LoadingSpinner, + PhoneInput, + type PhoneCountryCode, + VerificationCodeInput, + type AuthCardAlign, + type AuthCardVariant, +} from "@authsome/ui-components"; +import { usePhoneAuth } from "./react"; + +export interface PhoneSignInFormProps { + /** Fired once the code is right and the session has been adopted. */ + onSuccess?: (result: { newUser: boolean }) => void; + /** Shown on the number step. */ + title?: string; + description?: string; + /** Only needed on a multi-app deployment. */ + appId?: string; + /** How many digits the code has. Match your server's setting. */ + codeLength?: number; + /** Country the number field starts on. */ + defaultCountry?: PhoneCountryCode; + /** Narrow the country list, e.g. ["PT", "ES", "GB"]. */ + countries?: PhoneCountryCode[]; + align?: AuthCardAlign; + variant?: AuthCardVariant; + className?: string; + /** Rendered under the form, for a "use email instead" link. */ + footer?: React.ReactNode; +} + +/** + * Phone sign-in in two steps: a number, then the code that was texted to it. + * + * The code step auto-submits once the last digit lands, because a six-digit + * code read off a phone has exactly one correct ending and making someone + * press a button after it is busywork. The button stays for anyone pasting + * or correcting. + */ +export function PhoneSignInForm({ + onSuccess, + title = "Sign in with your phone", + description = "We'll text you a code.", + appId, + codeLength = 6, + defaultCountry = "US", + countries, + align, + variant, + className, + footer, +}: PhoneSignInFormProps) { + const { start, verify, isSending, isVerifying, error } = usePhoneAuth(); + const [step, setStep] = React.useState<"phone" | "code">("phone"); + // E.164, which is what the API takes. isValid gates the button, so nobody + // can send a code to half a number. + const [phoneNumber, setPhoneNumber] = React.useState(""); + const [isValidNumber, setIsValidNumber] = React.useState(false); + const [code, setCode] = React.useState(""); + + const sendCode = React.useCallback(async () => { + if (!isValidNumber) return; + try { + await start({ phone: phoneNumber, app_id: appId }); + setStep("code"); + } catch { + // usePhoneAuth already put it in `error`, which ErrorDisplay renders. + } + }, [appId, isValidNumber, phoneNumber, start]); + + const submitCode = React.useCallback( + async (value: string) => { + if (value.length !== codeLength || isVerifying) return; + try { + const res = await verify({ + phone: phoneNumber, + code: value, + app_id: appId, + }); + onSuccess?.({ newUser: res.new_user }); + } catch { + // Leave the code in place. Clearing it on a wrong answer costs + // someone who mistyped one digit the other five. + } + }, + [appId, codeLength, isVerifying, onSuccess, phoneNumber, verify], + ); + + if (step === "code") { + return ( + +
{ + e.preventDefault(); + void submitCode(code); + }} + className="grid gap-3" + > +
+ void submitCode(v)} + disabled={isVerifying} + invalid={Boolean(error)} + aria-label="Phone verification code" + autoFocus + /> +
+ + + + + + + +
+ ); + } + + return ( + +
{ + e.preventDefault(); + void sendCode(); + }} + className="grid gap-3" + > +
+ + { + setPhoneNumber(next); + setIsValidNumber(valid); + }} + disabled={isSending} + autoFocus + /> +
+ + + + + +
+ ); +} diff --git a/ui/packages/plugin-phone/src/plugin.test.ts b/ui/packages/plugin-phone/src/plugin.test.ts new file mode 100644 index 00000000..7648b5c1 --- /dev/null +++ b/ui/packages/plugin-phone/src/plugin.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect, vi } from "vitest"; +import { phone, type PhoneApi } from "./index"; + +/** A PluginContext that records what the plugin asked for. */ +function fakeContext(reply: unknown) { + const request = vi.fn().mockResolvedValue(reply); + return { + ctx: { + request, + getState: () => ({ status: "unauthenticated" }) as never, + getClientConfig: () => null, + isServerEnabled: () => true, + getActiveOrgId: () => null, + on: () => () => {}, + }, + request, + }; +} + +describe("phone plugin", () => { + it("is named after the server plugin", () => { + expect(phone.pluginName).toBe("phone"); + expect(phone().name).toBe("phone"); + }); + + it("posts the number to the start route", async () => { + const { ctx, request } = fakeContext({ status: "sent", expires_in: 300 }); + const api: PhoneApi = phone().init(ctx); + + const res = await api.start({ phone: "+351912345678" }); + + expect(request).toHaveBeenCalledWith("POST", "/v1/phone/start", { + body: { phone: "+351912345678" }, + }); + expect(res.expires_in).toBe(300); + }); + + it("posts the code to the verify route and returns the session", async () => { + const session = { + session_token: "s", + refresh_token: "r", + expires_at: "2026-01-01T00:00:00Z", + new_user: true, + }; + const { ctx, request } = fakeContext(session); + const api: PhoneApi = phone().init(ctx); + + const res = await api.verify({ phone: "+351912345678", code: "482917" }); + + expect(request).toHaveBeenCalledWith("POST", "/v1/phone/verify", { + body: { phone: "+351912345678", code: "482917" }, + }); + expect(res.new_user).toBe(true); + }); + + it("passes app_id through when a deployment needs one", async () => { + const { ctx, request } = fakeContext({ status: "sent", expires_in: 60 }); + await phone().init(ctx).start({ phone: "+15551234567", app_id: "app_2" }); + + expect(request).toHaveBeenCalledWith("POST", "/v1/phone/start", { + body: { phone: "+15551234567", app_id: "app_2" }, + }); + }); + + it("lets a failure from the transport reach the caller", async () => { + const boom = new Error("429"); + const ctx = { + request: vi.fn().mockRejectedValue(boom), + getState: () => ({ status: "unauthenticated" }) as never, + getClientConfig: () => null, + isServerEnabled: () => true, + getActiveOrgId: () => null, + on: () => () => {}, + }; + + await expect(phone().init(ctx).start({ phone: "+1" })).rejects.toBe(boom); + }); +}); diff --git a/ui/packages/plugin-phone/src/react.ts b/ui/packages/plugin-phone/src/react.ts new file mode 100644 index 00000000..7813ba91 --- /dev/null +++ b/ui/packages/plugin-phone/src/react.ts @@ -0,0 +1,101 @@ +/** + * React hooks for phone sign-in. + * + * Imported from `@authsome/plugin-phone/react`, so a host that does not use + * React never loads them. + */ + +import { useCallback, useState } from "react"; +import { useAuth, usePlugin } from "@authsome/ui-react"; +import { + phone, + type PhoneApi, + type PhoneStartInput, + type PhoneVerifyInput, + type PhoneVerifyResult, +} from "./index"; + +export interface UsePhoneAuth { + /** Sends a code. Resolves to how long it is good for, in seconds. */ + start: (input: PhoneStartInput) => Promise; + /** + * Checks the code. On success the session is adopted, so `useAuth()` reports + * the person as signed in by the time this resolves. + */ + verify: (input: PhoneVerifyInput) => Promise; + isSending: boolean; + isVerifying: boolean; + /** The last failure from either call, cleared when the next one starts. */ + error: Error | null; +} + +/** + * usePhoneAuth drives the two-step phone flow. + * + * ```tsx + * const { start, verify, isSending, error } = usePhoneAuth(); + * ``` + * + * Needs `phone()` in the provider's plugins. Without it `usePlugin` throws + * PluginNotRegisteredError, which names the plugin you forgot. + */ +export function usePhoneAuth(): UsePhoneAuth { + const api = usePlugin(phone); + const { manager } = useAuth(); + const [isSending, setIsSending] = useState(false); + const [isVerifying, setIsVerifying] = useState(false); + const [error, setError] = useState(null); + + const start = useCallback( + async (input: PhoneStartInput) => { + setIsSending(true); + setError(null); + try { + const res = await api.start(input); + return res.expires_in; + } catch (err) { + setError(err instanceof Error ? err : new Error(String(err))); + throw err; + } finally { + setIsSending(false); + } + }, + [api], + ); + + const verify = useCallback( + async (input: PhoneVerifyInput) => { + setIsVerifying(true); + setError(null); + try { + const res = await api.verify(input); + // Adopting here rather than leaving it to the caller is the whole + // point: a correct code that does not sign anyone in is a bug every + // host would otherwise have to fix for themselves. + await manager.adoptSession({ + session_token: res.session_token, + refresh_token: res.refresh_token, + expires_at: res.expires_at, + // The server returns the user on this response. The cast is here + // because the generated type marks it optional for the one case + // where a deployment suppresses it; adoptSession needs it. + user: res.user as Parameters[0]["user"], + }); + return res; + } catch (err) { + setError(err instanceof Error ? err : new Error(String(err))); + throw err; + } finally { + setIsVerifying(false); + } + }, + [api, manager], + ); + + return { start, verify, isSending, isVerifying, error }; +} + +export { + PhoneSignInForm, + type PhoneSignInFormProps, +} from "./phone-sign-in-form"; diff --git a/ui/packages/plugin-phone/tsconfig.json b/ui/packages/plugin-phone/tsconfig.json new file mode 100644 index 00000000..eb3621e3 --- /dev/null +++ b/ui/packages/plugin-phone/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "jsx": "react-jsx", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +} diff --git a/ui/packages/plugin-phone/tsup.config.ts b/ui/packages/plugin-phone/tsup.config.ts new file mode 100644 index 00000000..5f47fa78 --- /dev/null +++ b/ui/packages/plugin-phone/tsup.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + // Two entries so a host that never touches React does not pull it in just + // by importing the plugin. + entry: ["src/index.ts", "src/react.ts"], + format: ["esm"], + dts: true, + clean: true, + sourcemap: true, + target: "es2022", + external: [ + "react", + "react-dom", + "@authsome/ui-react", + "@authsome/ui-components", + ], +}); diff --git a/ui/packages/plugin-phone/vitest.config.ts b/ui/packages/plugin-phone/vitest.config.ts new file mode 100644 index 00000000..f65ebc76 --- /dev/null +++ b/ui/packages/plugin-phone/vitest.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "vitest/config"; + +// Same shape as ui-react's config. The hook test renders, so it needs a DOM, +// and globals is on so @testing-library/react can register its cleanup. +export default defineConfig({ + test: { + environment: "jsdom", + globals: true, + include: ["src/**/*.test.{ts,tsx}"], + }, +}); diff --git a/ui/packages/plugin-scim/package.json b/ui/packages/plugin-scim/package.json new file mode 100644 index 00000000..1161c800 --- /dev/null +++ b/ui/packages/plugin-scim/package.json @@ -0,0 +1,70 @@ +{ + "name": "@authsome/plugin-scim", + "version": "1.5.5", + "description": "SCIM provisioning administration for AuthSome: client plugin, hooks and components", + "license": "MIT", + "type": "module", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "https://github.com/xraph/authsome.git", + "directory": "ui/packages/plugin-scim" + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + }, + "./react": { + "import": "./dist/react.js", + "types": "./dist/react.d.ts" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsup", + "dev": "tsup --watch", + "typecheck": "tsc --noEmit", + "clean": "rm -rf dist", + "lint": "eslint .", + "test": "vitest run" + }, + "dependencies": { + "@authsome/ui-core": "workspace:*" + }, + "peerDependencies": { + "@authsome/ui-react": "workspace:*", + "react": "^18.0.0 || ^19.0.0", + "@authsome/ui-components": "workspace:*" + }, + "peerDependenciesMeta": { + "@authsome/ui-react": { + "optional": true + }, + "react": { + "optional": true + }, + "@authsome/ui-components": { + "optional": true + } + }, + "devDependencies": { + "@authsome/ui-react": "workspace:*", + "@testing-library/react": "^16.3.0", + "@types/react": "^19.2.18", + "jsdom": "^28.0.1", + "react": "^19.2.8", + "react-dom": "^19.2.8", + "tsup": "^8.5.1", + "typescript": "^5.9.3", + "vite": "^8.2.4", + "vitest": "^4.1.12", + "@authsome/ui-components": "workspace:*" + } +} diff --git a/ui/packages/plugin-scim/src/index.ts b/ui/packages/plugin-scim/src/index.ts new file mode 100644 index 00000000..65acc593 --- /dev/null +++ b/ui/packages/plugin-scim/src/index.ts @@ -0,0 +1,153 @@ +/** + * SCIM administration, the client half of authsome's `scim` server plugin. + * + * This is the dashboard side: connect an identity provider, mint the bearer + * token it will authenticate with, rotate it, revoke it. It is not the SCIM + * 2.0 protocol itself, which the IdP calls server to server with one of + * these tokens and which no browser ever touches. + * + * ```ts + * import { scim } from "@authsome/plugin-scim"; + * + * const auth = new AuthManager({ baseURL, plugins: [scim()] }); + * const { configs } = await auth.plugin(scim).listConfigs(orgId); + * ``` + * + * Every route here needs an organization owner or admin. + */ + +import { definePlugin } from "@authsome/ui-core"; + +/** One identity provider connection. */ +export interface ScimConfig { + id: string; + app_id: string; + org_id: string; + /** What this connection is called, usually the IdP's name. */ + name: string; + /** Whether provisioning is accepted right now. */ + enabled: boolean; + /** Create users the IdP pushes for the first time. */ + auto_create: boolean; + /** Suspend users the IdP deactivates. */ + auto_suspend: boolean; + /** Sync SCIM groups to teams. */ + group_sync: boolean; + /** Org role given to provisioned users. */ + default_role: string; + metadata?: Record; + created_at: string; + updated_at: string; +} + +/** + * A token record. The token itself is not here: it exists once, in the + * response that minted it, and the store keeps only a hash. + */ +export interface ScimToken { + id: string; + config_id: string; + name: string; + last_used_at?: string; + expires_at?: string; + created_at: string; +} + +/** What comes back when a token is minted or rotated. */ +export interface ScimTokenCreated { + token: ScimToken; + /** Shown once and never again. */ + plaintext: string; + /** The SCIM 2.0 base URL to paste into the identity provider. */ + base_url: string; +} + +export interface CreateScimConfigInput { + name: string; + enabled?: boolean; + auto_create?: boolean; + auto_suspend?: boolean; + group_sync?: boolean; + default_role?: string; + metadata?: Record; +} + +/** Every field optional: anything left out is left alone. */ +export type UpdateScimConfigInput = Partial; + +export interface ScimApi { + listConfigs(orgId: string): Promise<{ configs: ScimConfig[] }>; + createConfig(orgId: string, input: CreateScimConfigInput): Promise; + getConfig(configId: string): Promise; + updateConfig(configId: string, input: UpdateScimConfigInput): Promise; + deleteConfig(configId: string): Promise<{ status: string }>; + listTokens(configId: string): Promise<{ tokens: ScimToken[] }>; + createToken( + configId: string, + input: { name: string; expires_at?: string }, + ): Promise; + /** + * Mints a replacement. `grace_seconds` keeps the old token working for a + * while so an IdP can be updated without a gap in provisioning. Zero + * revokes the old one at once. + */ + rotateToken( + tokenId: string, + input?: { name?: string; grace_seconds?: number }, + ): Promise; + revokeToken(tokenId: string): Promise<{ status: string }>; +} + +const enc = encodeURIComponent; + +export const scim = definePlugin("scim", (ctx) => ({ + listConfigs: (orgId) => + ctx.request<{ configs: ScimConfig[] }>( + "GET", + `/v1/orgs/${enc(orgId)}/scim/configs`, + ), + createConfig: (orgId, input) => + ctx.request("POST", `/v1/orgs/${enc(orgId)}/scim/configs`, { + body: input, + }), + getConfig: (configId) => + ctx.request("GET", `/v1/scim/configs/${enc(configId)}`), + updateConfig: (configId, input) => + ctx.request("PATCH", `/v1/scim/configs/${enc(configId)}`, { + body: input, + }), + deleteConfig: (configId) => + ctx.request<{ status: string }>( + "DELETE", + `/v1/scim/configs/${enc(configId)}`, + ), + listTokens: (configId) => + ctx.request<{ tokens: ScimToken[] }>( + "GET", + `/v1/scim/configs/${enc(configId)}/tokens`, + ), + createToken: (configId, input) => + ctx.request( + "POST", + `/v1/scim/configs/${enc(configId)}/tokens`, + { body: input }, + ), + rotateToken: (tokenId, input) => + ctx.request( + "POST", + `/v1/scim/tokens/${enc(tokenId)}/rotate`, + { body: input ?? {} }, + ), + revokeToken: (tokenId) => + ctx.request<{ status: string }>( + "DELETE", + `/v1/scim/tokens/${enc(tokenId)}`, + ), +})); + +/** True when a token has an expiry that has already passed. */ +export function isTokenExpired(token: ScimToken, now = Date.now()): boolean { + if (!token.expires_at) return false; + const at = Date.parse(token.expires_at); + return !Number.isNaN(at) && at <= now; +} diff --git a/ui/packages/plugin-scim/src/plugin.test.ts b/ui/packages/plugin-scim/src/plugin.test.ts new file mode 100644 index 00000000..1f86cdc8 --- /dev/null +++ b/ui/packages/plugin-scim/src/plugin.test.ts @@ -0,0 +1,77 @@ +import { describe, it, expect } from "vitest"; +import { scim, isTokenExpired, type ScimApi, type ScimToken } from "./index"; +import { fakeContext } from "./test-context"; + +const token = (over: Partial = {}): ScimToken => ({ + id: "t1", + config_id: "c1", + name: "Okta", + created_at: "2026-01-01T00:00:00Z", + ...over, +}); + +describe("scim plugin", () => { + it("is named after the server plugin", () => { + expect(scim.pluginName).toBe("scim"); + }); + + it("lists an org's configurations", async () => { + const { ctx, request } = fakeContext({ configs: [] }); + await (scim().init(ctx) as ScimApi).listConfigs("org_1"); + expect(request).toHaveBeenCalledWith("GET", "/v1/orgs/org_1/scim/configs"); + }); + + it("escapes ids in paths", async () => { + const { ctx, request } = fakeContext({}); + await (scim().init(ctx) as ScimApi).getConfig("c/1"); + expect(request).toHaveBeenCalledWith("GET", "/v1/scim/configs/c%2F1"); + }); + + it("sends a partial body when updating", async () => { + const { ctx, request } = fakeContext({}); + await (scim().init(ctx) as ScimApi).updateConfig("c1", { enabled: false }); + expect(request).toHaveBeenCalledWith("PATCH", "/v1/scim/configs/c1", { + body: { enabled: false }, + }); + }); + + it("rotates with a grace window", async () => { + const { ctx, request } = fakeContext({}); + await (scim().init(ctx) as ScimApi).rotateToken("t1", { grace_seconds: 60 }); + expect(request).toHaveBeenCalledWith("POST", "/v1/scim/tokens/t1/rotate", { + body: { grace_seconds: 60 }, + }); + }); + + it("sends an empty body when rotating with no options", async () => { + const { ctx, request } = fakeContext({}); + await (scim().init(ctx) as ScimApi).rotateToken("t1"); + expect(request).toHaveBeenCalledWith("POST", "/v1/scim/tokens/t1/rotate", { + body: {}, + }); + }); +}); + +describe("isTokenExpired", () => { + it("is false for a token with no expiry", () => { + expect(isTokenExpired(token())).toBe(false); + }); + + it("is true once the expiry has passed", () => { + expect( + isTokenExpired(token({ expires_at: "2026-01-02T00:00:00Z" }), + Date.parse("2026-02-01T00:00:00Z")), + ).toBe(true); + }); + + it("is false before the expiry", () => { + expect( + isTokenExpired(token({ expires_at: "2026-03-01T00:00:00Z" }), + Date.parse("2026-02-01T00:00:00Z")), + ).toBe(false); + }); + + it("treats an unparseable expiry as not expired", () => { + expect(isTokenExpired(token({ expires_at: "soon" }))).toBe(false); + }); +}); diff --git a/ui/packages/plugin-scim/src/react.ts b/ui/packages/plugin-scim/src/react.ts new file mode 100644 index 00000000..d1db0262 --- /dev/null +++ b/ui/packages/plugin-scim/src/react.ts @@ -0,0 +1,204 @@ +/** React hooks and components for SCIM administration. */ + +import { useCallback, useEffect, useState } from "react"; +import { useAuth, usePlugin } from "@authsome/ui-react"; +import { + scim, + type CreateScimConfigInput, + type ScimApi, + type ScimConfig, + type ScimToken, + type ScimTokenCreated, + type UpdateScimConfigInput, +} from "./index"; + +/** useScimConfigs lists an organization's identity provider connections. */ +export function useScimConfigs(orgId: string | null): { + configs: ScimConfig[]; + isLoading: boolean; + error: Error | null; + reload: () => Promise; +} { + const api = usePlugin(scim); + const { session, isAuthenticated } = useAuth(); + const [configs, setConfigs] = useState([]); + const [error, setError] = useState(null); + const token = session?.session_token ?? null; + const key = token && orgId ? `${token}:${orgId}` : null; + const [loadedFor, setLoadedFor] = useState(null); + const [isRefreshing, setIsRefreshing] = useState(false); + const isLoading = + (isAuthenticated && Boolean(key) && loadedFor !== key) || isRefreshing; + + const reload = useCallback(async () => { + if (!orgId || !key) return; + setIsRefreshing(true); + try { + setConfigs((await api.listConfigs(orgId)).configs); + setError(null); + setLoadedFor(key); + } catch (err) { + setError(err instanceof Error ? err : new Error(String(err))); + } finally { + setIsRefreshing(false); + } + }, [api, key, orgId]); + + useEffect(() => { + if (!isAuthenticated || !orgId || !key) return; + let ignore = false; + void (async () => { + try { + const res = await api.listConfigs(orgId); + if (!ignore) { + setConfigs(res.configs); + setError(null); + } + } catch (err) { + if (!ignore) setError(err instanceof Error ? err : new Error(String(err))); + } finally { + if (!ignore) setLoadedFor(key); + } + })(); + return () => { + ignore = true; + }; + }, [api, isAuthenticated, key, orgId]); + + return { configs, isLoading, error, reload }; +} + +/** useScimTokens lists the tokens for one connection. */ +export function useScimTokens(configId: string | null): { + tokens: ScimToken[]; + isLoading: boolean; + error: Error | null; + reload: () => Promise; +} { + const api = usePlugin(scim); + const { session, isAuthenticated } = useAuth(); + const [tokens, setTokens] = useState([]); + const [error, setError] = useState(null); + const token = session?.session_token ?? null; + const key = token && configId ? `${token}:${configId}` : null; + const [loadedFor, setLoadedFor] = useState(null); + const [isRefreshing, setIsRefreshing] = useState(false); + const isLoading = + (isAuthenticated && Boolean(key) && loadedFor !== key) || isRefreshing; + + const reload = useCallback(async () => { + if (!configId || !key) return; + setIsRefreshing(true); + try { + setTokens((await api.listTokens(configId)).tokens); + setError(null); + setLoadedFor(key); + } catch (err) { + setError(err instanceof Error ? err : new Error(String(err))); + } finally { + setIsRefreshing(false); + } + }, [api, configId, key]); + + useEffect(() => { + if (!isAuthenticated || !configId || !key) return; + let ignore = false; + void (async () => { + try { + const res = await api.listTokens(configId); + if (!ignore) { + setTokens(res.tokens); + setError(null); + } + } catch (err) { + if (!ignore) setError(err instanceof Error ? err : new Error(String(err))); + } finally { + if (!ignore) setLoadedFor(key); + } + })(); + return () => { + ignore = true; + }; + }, [api, configId, isAuthenticated, key]); + + return { tokens, isLoading, error, reload }; +} + +/** One shape for the writes, because they all behave the same way. */ +function useScimMutation( + run: (api: ScimApi, ...args: Args) => Promise, +): { mutate: (...args: Args) => Promise; isSaving: boolean; error: Error | null } { + const api = usePlugin(scim); + const [isSaving, setIsSaving] = useState(false); + const [error, setError] = useState(null); + + const mutate = useCallback( + async (...args: Args) => { + setIsSaving(true); + setError(null); + try { + return await run(api, ...args); + } catch (err) { + setError(err instanceof Error ? err : new Error(String(err))); + throw err; + } finally { + setIsSaving(false); + } + }, + [api, run], + ); + + return { mutate, isSaving, error }; +} + +export function useCreateScimConfig() { + const { mutate, isSaving, error } = useScimMutation( + (api, orgId: string, input: CreateScimConfigInput) => + api.createConfig(orgId, input), + ); + return { create: mutate, isSaving, error }; +} + +export function useUpdateScimConfig() { + const { mutate, isSaving, error } = useScimMutation( + (api, configId: string, input: UpdateScimConfigInput) => + api.updateConfig(configId, input), + ); + return { update: mutate, isSaving, error }; +} + +export function useDeleteScimConfig() { + const { mutate, isSaving, error } = useScimMutation( + (api, configId: string) => api.deleteConfig(configId), + ); + return { remove: mutate, isSaving, error }; +} + +export function useGenerateScimToken() { + const { mutate, isSaving, error } = useScimMutation( + (api, configId: string, input: { name: string; expires_at?: string }) => + api.createToken(configId, input), + ); + return { generate: mutate as (...a: [string, { name: string; expires_at?: string }]) => Promise, isSaving, error }; +} + +export function useRotateScimToken() { + const { mutate, isSaving, error } = useScimMutation( + (api, tokenId: string, input?: { name?: string; grace_seconds?: number }) => + api.rotateToken(tokenId, input), + ); + return { rotate: mutate, isSaving, error }; +} + +export function useRevokeScimToken() { + const { mutate, isSaving, error } = useScimMutation( + (api, tokenId: string) => api.revokeToken(tokenId), + ); + return { revoke: mutate, isSaving, error }; +} + +export { + ScimConnectionCard, + type ScimConnectionCardProps, +} from "./scim-connection-card"; +export { ScimTokenList, type ScimTokenListProps } from "./scim-token-list"; diff --git a/ui/packages/plugin-scim/src/scim-connection-card.tsx b/ui/packages/plugin-scim/src/scim-connection-card.tsx new file mode 100644 index 00000000..da6b0ef8 --- /dev/null +++ b/ui/packages/plugin-scim/src/scim-connection-card.tsx @@ -0,0 +1,216 @@ +"use client"; + +import * as React from "react"; +import { + Badge, + Button, + ErrorDisplay, + Input, + Label, + LoadingSpinner, + Separator, + Skeleton, + Switch, + cn, +} from "@authsome/ui-components"; +import type { ScimConfig } from "./index"; +import { useScimConfigs, useUpdateScimConfig } from "./react"; + +export interface ScimConnectionCardProps { + orgId: string | null; + /** Which connection to show. Defaults to the first one. */ + configId?: string; + title?: React.ReactNode; + /** Rendered under the settings, usually the token list. */ + children?: React.ReactNode; + /** Shown when the org has no connection yet. */ + emptyState?: React.ReactNode; + className?: string; +} + +function Row({ label, hint, children }: { + label: string; + hint?: string; + children: React.ReactNode; +}) { + return ( +
+
+

{label}

+ {hint &&

{hint}

} +
+
{children}
+
+ ); +} + +/** + * One identity provider connection: the base URL to paste into the IdP, and + * the switches that decide what a push is allowed to do. + * + * The base URL sits at the top with a copy button because it is the thing + * someone is on this page to fetch. Everything else is settings they will + * touch once. + */ +export function ScimConnectionCard({ + orgId, + configId, + title = "Provisioning", + children, + emptyState = "No identity provider is connected yet.", + className, +}: ScimConnectionCardProps) { + const { configs, isLoading, error, reload } = useScimConfigs(orgId); + const { update, isSaving, error: updateError } = useUpdateScimConfig(); + const [copied, setCopied] = React.useState(false); + + const config: ScimConfig | undefined = configId + ? configs.find((c) => c.id === configId) + : configs[0]; + + const toggle = async (field: keyof ScimConfig, value: boolean) => { + if (!config) return; + try { + await update(config.id, { [field]: value }); + await reload(); + } catch { + // Surfaced through updateError below. + } + }; + + const copy = async (text: string) => { + try { + await globalThis.navigator?.clipboard?.writeText(text); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + // A browser that refuses clipboard access still shows the URL, which + // is selectable. Nothing to report. + } + }; + + return ( +
+ {title && ( +

+ {title} +

+ )} + + + + {isLoading ? ( +
+ + +
+ ) : !config ? ( +

{emptyState}

+ ) : ( + <> +
+
+

{config.name}

+

+ Provisioned users join as {config.default_role || "member"} +

+
+ + {config.enabled ? "Connected" : "Paused"} + +
+ + + +
+ +
+ e.currentTarget.select()} + /> + +
+

+ Paste this and a token into your identity provider. +

+
+ + + +
+ + void toggle("enabled", v)} + /> + + + void toggle("auto_create", v)} + /> + + + void toggle("auto_suspend", v)} + /> + + + void toggle("group_sync", v)} + /> + +
+ + {isSaving && ( +

+ Saving +

+ )} + + {children && ( + <> + + {children} + + )} + + )} +
+ ); +} diff --git a/ui/packages/plugin-scim/src/scim-token-list.tsx b/ui/packages/plugin-scim/src/scim-token-list.tsx new file mode 100644 index 00000000..8749c263 --- /dev/null +++ b/ui/packages/plugin-scim/src/scim-token-list.tsx @@ -0,0 +1,266 @@ +"use client"; + +import * as React from "react"; +import { + Badge, + Button, + ErrorDisplay, + Input, + Label, + LoadingSpinner, + Skeleton, + cn, +} from "@authsome/ui-components"; +import { isTokenExpired, type ScimTokenCreated } from "./index"; +import { + useGenerateScimToken, + useRevokeScimToken, + useRotateScimToken, + useScimTokens, +} from "./react"; + +export interface ScimTokenListProps { + configId: string | null; + title?: React.ReactNode; + emptyState?: React.ReactNode; + /** Seconds the replaced token keeps working after a rotate. */ + rotateGraceSeconds?: number; + className?: string; +} + +function formatDate(iso?: string): string { + if (!iso) return "never"; + const d = new Date(iso); + return Number.isNaN(d.getTime()) + ? "never" + : d.toLocaleDateString(undefined, { + day: "numeric", + month: "short", + year: "numeric", + }); +} + +/** + * Shows a freshly minted token, once. + * + * It is deliberately loud and deliberately not dismissible by accident: the + * plaintext exists nowhere else, and someone who closes this without copying + * it has to rotate to get another. + */ +function NewTokenBanner({ + created, + onDone, +}: { + created: ScimTokenCreated; + onDone: () => void; +}) { + const [copied, setCopied] = React.useState(false); + return ( +
+

Copy this token now

+

+ It will not be shown again. If you lose it, rotate to get another. +

+
+ e.currentTarget.select()} + /> + +
+ +
+ ); +} + +/** A connection's tokens, with mint, rotate and revoke. */ +export function ScimTokenList({ + configId, + title = "Tokens", + emptyState = "No tokens yet. Create one to connect your identity provider.", + rotateGraceSeconds = 3600, + className, +}: ScimTokenListProps) { + const { tokens, isLoading, error, reload } = useScimTokens(configId); + const { generate, isSaving: isGenerating, error: generateError } = + useGenerateScimToken(); + const { rotate, isSaving: isRotating, error: rotateError } = + useRotateScimToken(); + const { revoke, isSaving: isRevoking, error: revokeError } = + useRevokeScimToken(); + + const [name, setName] = React.useState(""); + const [created, setCreated] = React.useState(null); + const [pending, setPending] = React.useState(null); + const [confirmRevoke, setConfirmRevoke] = React.useState(null); + + const anyError = generateError ?? rotateError ?? revokeError ?? error; + + return ( +
+ {title && ( +

+ {title} +

+ )} + + + + {created && ( + setCreated(null)} /> + )} + +
{ + e.preventDefault(); + if (!configId || !name.trim()) return; + try { + setCreated(await generate(configId, { name: name.trim() })); + setName(""); + await reload(); + } catch { + // Surfaced through generateError. + } + }} + > +
+ + setName(e.target.value)} + disabled={isGenerating} + /> +
+ +
+ + {isLoading ? ( +
+ + +
+ ) : tokens.length === 0 ? ( +

{emptyState}

+ ) : ( +
    + {tokens.map((t) => { + const expired = isTokenExpired(t); + return ( +
  • +
    +
    +

    {t.name}

    +

    + Last used {formatDate(t.last_used_at)} · expires{" "} + {formatDate(t.expires_at)} +

    +
    +
    + {expired && ( + + expired + + )} + + +
    +
    + + {confirmRevoke === t.id && ( +
    +

    + Revoke {t.name}? Any identity provider using it stops + provisioning straight away. +

    +
    + + +
    +
    + )} +
  • + ); + })} +
+ )} +
+ ); +} diff --git a/ui/packages/plugin-scim/src/test-context.ts b/ui/packages/plugin-scim/src/test-context.ts new file mode 100644 index 00000000..4b0b5952 --- /dev/null +++ b/ui/packages/plugin-scim/src/test-context.ts @@ -0,0 +1,30 @@ +import { vi, type Mock } from "vitest"; +import type { PluginContext } from "@authsome/ui-core"; + +export interface FakeContext { + ctx: PluginContext; + /** Every call the plugin made, so a test can assert on method and path. */ + request: Mock; +} + +/** + * A PluginContext that records what the plugin asked for. + * + * The return type is spelled out rather than inferred, because an inferred + * vitest Mock cannot be named from outside its own package and tsc refuses + * to emit a declaration for it. + */ +export function fakeContext(reply: unknown = {}): FakeContext { + const request = vi.fn().mockResolvedValue(reply); + return { + ctx: { + request: request as PluginContext["request"], + getState: () => ({ status: "unauthenticated" }) as never, + getClientConfig: () => null, + isServerEnabled: () => true, + getActiveOrgId: () => null, + on: () => () => {}, + }, + request, + }; +} diff --git a/ui/packages/plugin-scim/tsconfig.json b/ui/packages/plugin-scim/tsconfig.json new file mode 100644 index 00000000..eb3621e3 --- /dev/null +++ b/ui/packages/plugin-scim/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "jsx": "react-jsx", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +} diff --git a/ui/packages/plugin-scim/tsup.config.ts b/ui/packages/plugin-scim/tsup.config.ts new file mode 100644 index 00000000..5f47fa78 --- /dev/null +++ b/ui/packages/plugin-scim/tsup.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + // Two entries so a host that never touches React does not pull it in just + // by importing the plugin. + entry: ["src/index.ts", "src/react.ts"], + format: ["esm"], + dts: true, + clean: true, + sourcemap: true, + target: "es2022", + external: [ + "react", + "react-dom", + "@authsome/ui-react", + "@authsome/ui-components", + ], +}); diff --git a/ui/packages/plugin-scim/vitest.config.ts b/ui/packages/plugin-scim/vitest.config.ts new file mode 100644 index 00000000..f65ebc76 --- /dev/null +++ b/ui/packages/plugin-scim/vitest.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "vitest/config"; + +// Same shape as ui-react's config. The hook test renders, so it needs a DOM, +// and globals is on so @testing-library/react can register its cleanup. +export default defineConfig({ + test: { + environment: "jsdom", + globals: true, + include: ["src/**/*.test.{ts,tsx}"], + }, +}); diff --git a/ui/packages/react/src/hooks.ts b/ui/packages/react/src/hooks.ts index b4c4c2e8..3bdec6fc 100644 --- a/ui/packages/react/src/hooks.ts +++ b/ui/packages/react/src/hooks.ts @@ -206,3 +206,127 @@ export function useClientConfig(): { const { clientConfig, isConfigLoaded } = useAuth(); return { config: clientConfig, isLoaded: isConfigLoaded }; } + +// ── Last sign-in ─────────────────────────────────────── + +/** + * What the account summary shows. This is public API, because + * `` takes it as a prop so a host can render the card from + * data of its own. + */ +export interface LastSignInSummary { + /** When the current session began, ISO 8601, or null when unknown. */ + lastSignInAt: string | null; + /** The IP that session came from. There is no geo lookup server-side yet. */ + ipAddress: string | null; + /** Browser and OS of the device most recently seen, when known. */ + device: string | null; + /** How many devices are marked trusted. */ + trustedDeviceCount: number; + /** + * Whether a second factor is enrolled. null means "not known yet": there is + * no endpoint that answers it, so this stays null until one exists. + */ + twoStepEnabled: boolean | null; +} + +/** Narrows one untyped session record. `sessions` is Record[]. */ +function readSessionRecord(raw: Record): { + createdAt: string | null; + ip: string | null; +} { + const str = (k: string): string | null => { + const v = raw[k]; + return typeof v === "string" && v.length > 0 ? v : null; + }; + return { createdAt: str("created_at"), ip: str("ip_address") }; +} + +/** + * useLastSignIn reports when and where this account last signed in. + * + * ```tsx + * const { summary, isLoading } = useLastSignIn(); + * ``` + * + * It is a reassurance surface, not an authorization one. Nothing here decides + * what the user can do, so a host that cannot load it should carry on. + */ +export function useLastSignIn(): { + summary: LastSignInSummary | null; + isLoading: boolean; + reload: () => Promise; +} { + const { client, session, isAuthenticated } = useAuth(); + const [summary, setSummary] = useState(null); + const token = session?.session_token ?? null; + + // Same shape as useOrganizations: which token the data we hold was fetched + // for, compared against the current one, so the automatic load's progress is + // derived rather than stored and no setState happens inside the effect. + const [loadedFor, setLoadedFor] = useState(null); + const [isRefreshing, setIsRefreshing] = useState(false); + + const isLoading = (isAuthenticated && loadedFor !== token) || isRefreshing; + + const fetchSummary = useCallback( + async (sessionToken: string): Promise => { + // Both calls are independent and neither blocks the other. A failure in + // either is not worth failing the card over, so each falls back. + const [sessions, devices] = await Promise.all([ + client.listSessions(sessionToken, 1).catch(() => null), + client.listDevices(sessionToken).catch(() => null), + ]); + + const newest = sessions?.sessions?.[0] + ? readSessionRecord(sessions.sessions[0]) + : null; + + const seen = [...(devices?.devices ?? [])].sort((a, b) => + (b.last_seen_at ?? "").localeCompare(a.last_seen_at ?? ""), + )[0]; + + return { + lastSignInAt: newest?.createdAt ?? seen?.last_seen_at ?? null, + ipAddress: newest?.ip ?? seen?.ip_address ?? null, + device: + [seen?.browser, seen?.os].filter(Boolean).join(" on ") || seen?.name || null, + trustedDeviceCount: (devices?.devices ?? []).filter((d) => d.trusted).length, + twoStepEnabled: null, + }; + }, + [client], + ); + + const load = useCallback(async () => { + if (!session) return; + setIsRefreshing(true); + try { + setSummary(await fetchSummary(session.session_token)); + setLoadedFor(session.session_token); + } finally { + setIsRefreshing(false); + } + }, [fetchSummary, session]); + + // Inline rather than calling load(), which sets state before it awaits. The + // ignore flag stops a response for a session the user already left from + // overwriting a newer one. + useEffect(() => { + if (!isAuthenticated || !session) return; + let ignore = false; + void (async () => { + try { + const next = await fetchSummary(session.session_token); + if (!ignore) setSummary(next); + } finally { + if (!ignore) setLoadedFor(session.session_token); + } + })(); + return () => { + ignore = true; + }; + }, [isAuthenticated, session, fetchSummary]); + + return { summary, isLoading, reload: load }; +} diff --git a/ui/packages/react/src/index.ts b/ui/packages/react/src/index.ts index b6e25c56..9e68f202 100644 --- a/ui/packages/react/src/index.ts +++ b/ui/packages/react/src/index.ts @@ -25,7 +25,16 @@ export type { // Context + hooks export { AuthProvider, useAuth, AuthContext, type AuthProviderProps, type AuthContextValue } from "./context"; -export { useUser, useOrganizations, useSessionToken, useClientConfig, usePlugin, useActiveOrg } from "./hooks"; +export { + useUser, + useOrganizations, + useSessionToken, + useClientConfig, + usePlugin, + useActiveOrg, + useLastSignIn, + type LastSignInSummary, +} from "./hooks"; // Headless components export { diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml index e0140950..c50feb01 100644 --- a/ui/pnpm-lock.yaml +++ b/ui/pnpm-lock.yaml @@ -46,6 +46,24 @@ importers: apps/storybook: dependencies: + '@authsome/plugin-consent': + specifier: workspace:* + version: link:../../packages/plugin-consent + '@authsome/plugin-oauth2': + specifier: workspace:* + version: link:../../packages/plugin-oauth2 + '@authsome/plugin-organization': + specifier: workspace:* + version: link:../../packages/plugin-organization + '@authsome/plugin-phone': + specifier: workspace:* + version: link:../../packages/plugin-phone + '@authsome/plugin-scim': + specifier: workspace:* + version: link:../../packages/plugin-scim + '@authsome/plugin-subscription': + specifier: workspace:* + version: link:../../packages/plugin-subscription '@authsome/ui-components': specifier: workspace:* version: link:../../packages/components @@ -158,6 +176,9 @@ importers: input-otp: specifier: ^1.5.0 version: 1.5.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + libphonenumber-js: + specifier: 1.12.23 + version: 1.12.23 lucide-react: specifier: ^1.34.0 version: 1.34.0(react@19.2.8) @@ -257,6 +278,209 @@ importers: specifier: ^4.1.12 version: 4.1.12(@types/node@26.3.0)(jsdom@28.1.0)(vite@8.3.5(@types/node@26.3.0)(esbuild@0.28.2)(jiti@2.7.0)) + packages/plugin-consent: + dependencies: + '@authsome/ui-core': + specifier: workspace:* + version: link:../core + devDependencies: + '@authsome/ui-components': + specifier: workspace:* + version: link:../components + '@authsome/ui-react': + specifier: workspace:* + version: link:../react + '@testing-library/react': + specifier: ^16.3.0 + version: 16.3.2(@testing-library/dom@10.4.0)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@types/react': + specifier: ^19.2.18 + version: 19.2.18 + jsdom: + specifier: ^28.0.1 + version: 28.1.0 + react: + specifier: ^19.2.8 + version: 19.2.8 + react-dom: + specifier: ^19.2.8 + version: 19.2.8(react@19.2.8) + tsup: + specifier: ^8.5.1 + version: 8.5.1(jiti@2.7.0)(postcss@8.5.26)(typescript@5.9.3) + typescript: + specifier: ^5.9.3 + version: 5.9.3 + vite: + specifier: ^8.2.4 + version: 8.3.5(@types/node@26.3.0)(esbuild@0.28.2)(jiti@2.7.0) + vitest: + specifier: ^4.1.12 + version: 4.1.12(@types/node@26.3.0)(jsdom@28.1.0)(vite@8.3.5(@types/node@26.3.0)(esbuild@0.28.2)(jiti@2.7.0)) + + packages/plugin-oauth2: + dependencies: + '@authsome/ui-core': + specifier: workspace:* + version: link:../core + devDependencies: + '@authsome/ui-components': + specifier: workspace:* + version: link:../components + '@authsome/ui-react': + specifier: workspace:* + version: link:../react + '@testing-library/react': + specifier: ^16.3.0 + version: 16.3.2(@testing-library/dom@10.4.0)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@types/react': + specifier: ^19.2.18 + version: 19.2.18 + jsdom: + specifier: ^28.0.1 + version: 28.1.0 + lucide-react: + specifier: ^1.34.0 + version: 1.34.0(react@19.2.8) + react: + specifier: ^19.2.8 + version: 19.2.8 + react-dom: + specifier: ^19.2.8 + version: 19.2.8(react@19.2.8) + tsup: + specifier: ^8.5.1 + version: 8.5.1(jiti@2.7.0)(postcss@8.5.26)(typescript@5.9.3) + typescript: + specifier: ^5.9.3 + version: 5.9.3 + vite: + specifier: ^8.2.4 + version: 8.3.5(@types/node@26.3.0)(esbuild@0.28.2)(jiti@2.7.0) + vitest: + specifier: ^4.1.12 + version: 4.1.12(@types/node@26.3.0)(jsdom@28.1.0)(vite@8.3.5(@types/node@26.3.0)(esbuild@0.28.2)(jiti@2.7.0)) + + packages/plugin-organization: + dependencies: + '@authsome/ui-core': + specifier: workspace:* + version: link:../core + devDependencies: + '@authsome/ui-components': + specifier: workspace:* + version: link:../components + '@authsome/ui-react': + specifier: workspace:* + version: link:../react + '@testing-library/react': + specifier: ^16.3.0 + version: 16.3.2(@testing-library/dom@10.4.0)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@types/react': + specifier: ^19.2.18 + version: 19.2.18 + jsdom: + specifier: ^28.0.1 + version: 28.1.0 + react: + specifier: ^19.2.8 + version: 19.2.8 + react-dom: + specifier: ^19.2.8 + version: 19.2.8(react@19.2.8) + tsup: + specifier: ^8.5.1 + version: 8.5.1(jiti@2.7.0)(postcss@8.5.26)(typescript@5.9.3) + typescript: + specifier: ^5.9.3 + version: 5.9.3 + vite: + specifier: ^8.2.4 + version: 8.3.5(@types/node@26.3.0)(esbuild@0.28.2)(jiti@2.7.0) + vitest: + specifier: ^4.1.12 + version: 4.1.12(@types/node@26.3.0)(jsdom@28.1.0)(vite@8.3.5(@types/node@26.3.0)(esbuild@0.28.2)(jiti@2.7.0)) + + packages/plugin-phone: + dependencies: + '@authsome/ui-core': + specifier: workspace:* + version: link:../core + devDependencies: + '@authsome/ui-components': + specifier: workspace:* + version: link:../components + '@authsome/ui-react': + specifier: workspace:* + version: link:../react + '@testing-library/react': + specifier: ^16.3.0 + version: 16.3.2(@testing-library/dom@10.4.0)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@types/react': + specifier: ^19.2.18 + version: 19.2.18 + jsdom: + specifier: ^28.0.1 + version: 28.1.0 + react: + specifier: ^19.2.8 + version: 19.2.8 + react-dom: + specifier: ^19.2.8 + version: 19.2.8(react@19.2.8) + tsup: + specifier: ^8.5.1 + version: 8.5.1(jiti@2.7.0)(postcss@8.5.26)(typescript@5.9.3) + typescript: + specifier: ^5.9.3 + version: 5.9.3 + vite: + specifier: ^8.2.4 + version: 8.3.5(@types/node@26.3.0)(esbuild@0.28.2)(jiti@2.7.0) + vitest: + specifier: ^4.1.12 + version: 4.1.12(@types/node@26.3.0)(jsdom@28.1.0)(vite@8.3.5(@types/node@26.3.0)(esbuild@0.28.2)(jiti@2.7.0)) + + packages/plugin-scim: + dependencies: + '@authsome/ui-core': + specifier: workspace:* + version: link:../core + devDependencies: + '@authsome/ui-components': + specifier: workspace:* + version: link:../components + '@authsome/ui-react': + specifier: workspace:* + version: link:../react + '@testing-library/react': + specifier: ^16.3.0 + version: 16.3.2(@testing-library/dom@10.4.0)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@types/react': + specifier: ^19.2.18 + version: 19.2.18 + jsdom: + specifier: ^28.0.1 + version: 28.1.0 + react: + specifier: ^19.2.8 + version: 19.2.8 + react-dom: + specifier: ^19.2.8 + version: 19.2.8(react@19.2.8) + tsup: + specifier: ^8.5.1 + version: 8.5.1(jiti@2.7.0)(postcss@8.5.26)(typescript@5.9.3) + typescript: + specifier: ^5.9.3 + version: 5.9.3 + vite: + specifier: ^8.2.4 + version: 8.3.5(@types/node@26.3.0)(esbuild@0.28.2)(jiti@2.7.0) + vitest: + specifier: ^4.1.12 + version: 4.1.12(@types/node@26.3.0)(jsdom@28.1.0)(vite@8.3.5(@types/node@26.3.0)(esbuild@0.28.2)(jiti@2.7.0)) + packages/plugin-subscription: dependencies: '@authsome/ui-core': @@ -2738,6 +2962,9 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} + libphonenumber-js@1.12.23: + resolution: {integrity: sha512-RN3q3gImZ91BvRDYjWp7ICz3gRn81mW5L4SW+2afzNCC0I/nkXstBgZThQGTE3S/9q5J90FH4dP+TXx8NhdZKg==} + lightningcss-android-arm64@1.32.0: resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} engines: {node: '>= 12.0.0'} @@ -6002,6 +6229,8 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 + libphonenumber-js@1.12.23: {} + lightningcss-android-arm64@1.32.0: optional: true