diff --git a/.changeset/client-permissions.md b/.changeset/client-permissions.md new file mode 100644 index 0000000..904b187 --- /dev/null +++ b/.changeset/client-permissions.md @@ -0,0 +1,10 @@ +--- +'@seamless-auth/client': minor +'@seamless-auth/react': minor +'@seamless-auth/react-native': minor +'@seamless-auth/angular': minor +'@seamless-auth/vue': minor +'@seamless-auth/svelte': minor +--- + +Add `hasPermission`, `hasAllPermissions`, `hasRole` and `hasOrganizationRole`, which read the permissions and roles the `/users/me` user carries, and a `usePermissions` hook in `@seamless-auth/react`. A permission is held if the instance or the active organization grants it, and `{ organization: true }` counts only the active organization, matching the server's `requirePermission`. Permission and organization role matching is exact, instance roles keep their scoped matching, and an organization role never satisfies an instance role check. Fields an older API leaves out answer false. These are for showing and hiding UI only: the server must still enforce. They reflect the last `/users/me` read, so a change reaches the client when the user is next refreshed. diff --git a/.changeset/types-0-38-0.md b/.changeset/types-0-38-0.md new file mode 100644 index 0000000..6b3d815 --- /dev/null +++ b/.changeset/types-0-38-0.md @@ -0,0 +1,7 @@ +--- +'@seamless-auth/client': patch +'@seamless-auth/react': patch +'@seamless-auth/react-native': patch +--- + +Depend on `@seamless-auth/types` `^0.38.0`, which adds permissions and organization roles to the `/users/me` user. diff --git a/AGENTS.md b/AGENTS.md index a903cf0..e6ec808 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -125,7 +125,10 @@ that package rather than redeclaring shapes. Rules for this dependency: - types only. Import with `import type` so the package's Zod dependency never - reaches the browser bundle. There is a `Record` in + reaches the browser bundle. The one runtime import is the Zod-free + `@seamless-auth/types/permission/matching` in `packages/client/src/permissions.ts`, + which is external in the client build and mapped in each Jest config. Nothing + checks this automatically, so never import the package root at runtime. There is a `Record` in `packages/client/src/client/errors.ts` that exists for exactly this reason: it is a compile-time membership check standing in for the upstream runtime list. - keep the SDK's own export names. Adopters import `Credential` from this @@ -158,6 +161,7 @@ Runtime exports currently include: - `useAuthClient` - `usePasskeySupport` - `hasScopedRole` and `roleGrantsAccess` +- `hasPermission`, `hasAllPermissions`, `hasRole`, `hasOrganizationRole`, and `usePermissions` (React only). `hasRole` is instance roles with scoped matching, unlike the exact `useAuth().hasRole` - `encodePrfSalt`, `extractPasskeyPrfResult`, and `isPasskeyPrfSupported` - `SeamlessAuthError`, `getOAuthErrorCode`, `getPasskeyPolicyErrorCode`, and `getWebAuthnErrorDetail` @@ -220,6 +224,9 @@ This repository is an npm workspace. Its published packages include: - `/auth` request construction - `src/scopedRoles.ts` - role matching, kept byte-for-byte with `@seamless-auth/types/role/matching` +- `src/permissions.ts` + - UI-only checks over the `/users/me` user: exact permission and organization role + matching, scoped instance roles, false for anything an older API leaves out - `src/types.ts` - aliases of the wire contract in `@seamless-auth/types`, not hand-written shapes @@ -232,6 +239,8 @@ This repository is an npm workspace. Its published packages include: - creates a memoized client from provider configuration - `src/hooks/usePasskeySupport.ts` - exposes browser support detection for passkeys +- `src/hooks/usePermissions.ts` + - permission and role checks bound to the provider's current user - `src/AuthRoutes.tsx` - bundles the prebuilt auth route flow - `src/routes.ts` diff --git a/package-lock.json b/package-lock.json index afb18f6..6faeab8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4545,9 +4545,9 @@ "link": true }, "node_modules/@seamless-auth/types": { - "version": "0.36.0", - "resolved": "https://registry.npmjs.org/@seamless-auth/types/-/types-0.36.0.tgz", - "integrity": "sha512-AxIPhwRegsKEtqi92BnOFv2TVpLsv/xGaJcQx4BUV7JlQSBMa0UUa8xyjJyTQP9m8YlI5O1gM09N2DRo9Ck/sQ==", + "version": "0.38.0", + "resolved": "https://registry.npmjs.org/@seamless-auth/types/-/types-0.38.0.tgz", + "integrity": "sha512-qALF5GFQEv+9yucQdn179cpTdEMMH/PjtZWAHatWk1qUEOzb1ElOqoBZgC4/THAk6u4aLgDyuDt/dCD/BzloqA==", "license": "Apache-2.0", "dependencies": { "zod": "^4.5.0" @@ -18781,7 +18781,7 @@ "version": "0.8.3", "license": "Apache-2.0", "dependencies": { - "@seamless-auth/types": "^0.36.0", + "@seamless-auth/types": "^0.38.0", "@simplewebauthn/browser": "^13.1.0", "libphonenumber-js": "^1.12.7" }, @@ -18796,7 +18796,7 @@ "license": "Apache-2.0", "dependencies": { "@seamless-auth/client": "^0.8.3", - "@seamless-auth/types": "^0.36.0", + "@seamless-auth/types": "^0.38.0", "@simplewebauthn/browser": "^13.1.0", "libphonenumber-js": "^1.12.7" }, @@ -18821,7 +18821,7 @@ "license": "Apache-2.0", "dependencies": { "@seamless-auth/client": "^0.8.3", - "@seamless-auth/types": "^0.36.0" + "@seamless-auth/types": "^0.38.0" }, "engines": { "node": ">=22", diff --git a/packages/angular/README.md b/packages/angular/README.md index f778d49..9acbf20 100644 --- a/packages/angular/README.md +++ b/packages/angular/README.md @@ -158,6 +158,14 @@ Also: `hasRole`, `hasScopedRole`, `markSignedIn`, `whenSettled()` (resolves once fetch for your own API that carries the cookies to `apiHost` and `trustedOrigins` only), `client` (the headless client for everything else, such as OTP and magic link calls), and `ports`. +### Permissions and organization roles + +`hasPermission(user, required, { organization? })`, `hasAllPermissions`, `hasRole` (instance roles, scoped +matching) and `hasOrganizationRole` (active organization, exact matching) are exported for showing +and hiding UI. They are for UI only: the server must still enforce. They reflect the last +`/users/me` read, so a change reaches the client when the user is next refreshed. See the +[`@seamless-auth/client` README](../client/README.md#permissions-and-roles). + ## Built-in screens `@seamless-auth/angular/routes` exports `seamlessAuthRoutes` and the standalone components behind diff --git a/packages/angular/jest.config.ts b/packages/angular/jest.config.ts index ce5a7fe..434c28f 100644 --- a/packages/angular/jest.config.ts +++ b/packages/angular/jest.config.ts @@ -13,13 +13,21 @@ export default { rootDir: '.', setupFilesAfterEnv: ['/tests/setup.ts'], transform: { + '/node_modules/@seamless-auth/types/.+\\.js$': [ + 'ts-jest', + { tsconfig: { allowJs: true } }, + ], '^.+\\.(ts|mjs|js|html)$': [ 'jest-preset-angular', { tsconfig: '/tsconfig.spec.json', stringifyContentPathRegex: '\\.html$' }, ], }, - transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'], + transformIgnorePatterns: ['node_modules/(?!(.*\\.mjs$|@seamless-auth/types/))'], moduleNameMapper: { + // `@seamless-auth/types` ships ESM under the `import` condition alone, which + // Jest's CommonJS resolver cannot read. + '^@seamless-auth/types/permission/matching$': + '/../../node_modules/@seamless-auth/types/dist/schemas/permission/matching.js', // Tests run against the sources, so a change in the core is exercised here // without a build in between. '^@seamless-auth/angular$': '/src/index.ts', diff --git a/packages/angular/src/index.ts b/packages/angular/src/index.ts index 1c0e584..f99b482 100644 --- a/packages/angular/src/index.ts +++ b/packages/angular/src/index.ts @@ -17,6 +17,10 @@ export { getOAuthErrorCode, getPasskeyPolicyErrorCode, getWebAuthnErrorDetail, + hasAllPermissions, + hasOrganizationRole, + hasPermission, + hasRole, hasScopedRole, isUnauthenticated, roleGrantsAccess, @@ -33,9 +37,11 @@ export type { OAuthRedirectPort, Organization, PasskeyPort, + PermissionCheckOptions, RefreshSessionOptions, SeamlessAuthClient, SeamlessAuthResult, StepUpStatus, User, + UserAuthorization, } from '@seamless-auth/client'; diff --git a/packages/client/README.md b/packages/client/README.md index da045ac..4c8e5e8 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -35,6 +35,9 @@ npm install @seamless-auth/client - PRF helpers for passkey-derived secrets. - `hasScopedRole` / `roleGrantsAccess`, the same role matching the auth API and server adapters apply. +- `hasPermission`, `hasAllPermissions`, `hasRole` and `hasOrganizationRole`, which + read the `/users/me` user for showing and hiding UI. See + [Permissions and roles](#permissions-and-roles). - Flows: the step logic behind the bundled sign-in screens, so every binding behaves the same and none reimplements it. See below. @@ -106,6 +109,25 @@ and `startOAuthSignIn` reports it as a provider that would not start. A custom `OAuthRedirectPort` can apply the same check with `assertNavigableAuthorizationUrl(url)`. +## Permissions and roles + +**For UI only. The server must still enforce.** These read the last `/users/me` +response, so a change reaches the client when the user is next refreshed. The +client never decodes a token. + +```ts +hasPermission(user, 'reports:read'); // instance or active organization grant +hasPermission(user, 'projects:write', { organization: true }); // active organization only +hasAllPermissions(user, ['reports:read', 'projects:write']); +hasRole(user, 'admin:read'); // instance roles, scoped like hasScopedRole +hasOrganizationRole(user, ['owner', 'admin']); // active organization roles, exact +``` + +Permission and organization role matching is exact, with no wildcards or implied +grants. An organization `admin` never satisfies `hasRole('admin')`. A field an older +API leaves out means unknown, and every check against it, or against a missing +user, answers `false`. + ## Transport `createSeamlessAuthClient` takes a `transport` option: diff --git a/packages/client/jest.config.ts b/packages/client/jest.config.ts index dd7e60d..23c1f1f 100644 --- a/packages/client/jest.config.ts +++ b/packages/client/jest.config.ts @@ -11,10 +11,19 @@ export default { rootDir: '.', setupFilesAfterEnv: ['/../../jest.setup.ts'], transform: { + '/node_modules/@seamless-auth/types/.+\\.js$': [ + 'ts-jest', + { tsconfig: { allowJs: true } }, + ], '^.+\\.(t|j)sx?$': ['ts-jest', { useESM: true, tsconfig: '/tsconfig.json' }], }, + transformIgnorePatterns: ['/node_modules/(?!@seamless-auth/types/)'], extensionsToTreatAsEsm: ['.ts', '.tsx'], moduleNameMapper: { + // `@seamless-auth/types` ships ESM under the `import` condition alone, which + // Jest's CommonJS resolver cannot read. + '^@seamless-auth/types/permission/matching$': + '/../../node_modules/@seamless-auth/types/dist/schemas/permission/matching.js', '^@/(.*)$': '/src/$1', }, testMatch: ['/tests/**/*.(test|spec).[tj]s?(x)'], diff --git a/packages/client/package.json b/packages/client/package.json index 881244a..ebd136d 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -42,7 +42,7 @@ "provenance": true }, "dependencies": { - "@seamless-auth/types": "^0.36.0", + "@seamless-auth/types": "^0.38.0", "@simplewebauthn/browser": "^13.1.0", "libphonenumber-js": "^1.12.7" }, diff --git a/packages/client/rollup.config.js b/packages/client/rollup.config.js index 6bf48dc..9dc546a 100644 --- a/packages/client/rollup.config.js +++ b/packages/client/rollup.config.js @@ -16,7 +16,11 @@ export default [ format: 'esm', sourcemap: true, }, - external: ['@simplewebauthn/browser', 'libphonenumber-js'], + external: [ + '@seamless-auth/types/permission/matching', + '@simplewebauthn/browser', + 'libphonenumber-js', + ], plugins: [ alias({ entries: [{ find: '@', replacement: path.resolve(__dirname, 'src') }], diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 6cdf3df..150b8c2 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -11,6 +11,7 @@ export * from './client/webauthnPrf'; export * from './client/webauthnSupport'; export * from './fetchWithAuth'; export * from './flows'; +export * from './permissions'; export * from './ports/browserPasskeys'; export * from './ports/oauthRedirect'; export * from './ports/passkeys'; diff --git a/packages/client/src/permissions.ts b/packages/client/src/permissions.ts new file mode 100644 index 0000000..ab1a2b4 --- /dev/null +++ b/packages/client/src/permissions.ts @@ -0,0 +1,95 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in the project root for full license information + */ + +// This subpath has no Zod import. The root of `@seamless-auth/types` does, so it +// stays `import type` everywhere in this package. +import { + hasAllPermissions as includesAllOf, + hasPermission as includesAnyOf, +} from '@seamless-auth/types/permission/matching'; + +import { hasScopedRole } from './scopedRoles'; +import type { User } from './types'; + +/** + * The fields these helpers read from the signed-in user. An older API leaves the + * permission and organization fields out, which means unknown rather than none, + * so every check against them answers false. + */ +export type UserAuthorization = Partial< + Pick +>; + +export interface PermissionCheckOptions { + /** + * `true` counts only what the active organization grants, leaving out + * instance-wide permissions. By default a permission is held if either grants it. + */ + organization?: boolean; +} + +function heldPermissions( + user: UserAuthorization | null | undefined, + options: PermissionCheckOptions | undefined +): unknown[] { + const list = (value: unknown): unknown[] => (Array.isArray(value) ? value : []); + const organization = list(user?.organizationPermissions); + + return options?.organization + ? organization + : [...list(user?.permissions), ...organization]; +} + +/** + * Whether the user holds at least one of the required permissions. Matching is + * exact: `invoices:write` does not satisfy `invoices:read`. + * + * For showing and hiding UI only. The server must still enforce. + */ +export function hasPermission( + user: UserAuthorization | null | undefined, + required: string | string[], + options?: PermissionCheckOptions +): boolean { + return includesAnyOf(heldPermissions(user, options), required); +} + +/** + * Whether the user holds every required permission. Requiring nothing answers + * false. For showing and hiding UI only. The server must still enforce. + */ +export function hasAllPermissions( + user: UserAuthorization | null | undefined, + required: string | string[], + options?: PermissionCheckOptions +): boolean { + return includesAllOf(heldPermissions(user, options), required); +} + +/** + * Whether the user's instance roles satisfy at least one required role, with the + * scoped matching of `hasScopedRole`. Organization roles never count: an + * organization `admin` is not an instance `admin`. Use `hasOrganizationRole` for + * those. For showing and hiding UI only. The server must still enforce. + */ +export function hasRole( + user: UserAuthorization | null | undefined, + required: string | string[] +): boolean { + return hasScopedRole(user?.roles, required); +} + +/** + * Whether the user holds at least one of the required roles in the active + * organization. Matching is exact and instance roles never count. For showing and + * hiding UI only. The server must still enforce. + */ +export function hasOrganizationRole( + user: UserAuthorization | null | undefined, + required: string | string[] +): boolean { + return includesAnyOf(user?.organizationRoles, required); +} diff --git a/packages/client/tests/permissions.test.ts b/packages/client/tests/permissions.test.ts new file mode 100644 index 0000000..f3a2657 --- /dev/null +++ b/packages/client/tests/permissions.test.ts @@ -0,0 +1,148 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in the project root for full license information + */ + +import { + hasAllPermissions, + hasOrganizationRole, + hasPermission, + hasRole, +} from '../src/permissions'; +import type { User } from '../src/types'; + +// What an API older than the permission fields sends for `user`. +const olderApiUser: User = { + id: 'user-1', + email: 'user@example.com', + phone: null, + roles: ['user'], + activeOrganizationId: 'org-1', +}; + +const user: User = { + ...olderApiUser, + permissions: ['reports:read'], + organizationRoles: ['admin'], + organizationPermissions: ['projects:write'], +}; + +describe('hasPermission', () => { + it('answers false for a missing user', () => { + expect(hasPermission(null, 'reports:read')).toBe(false); + expect(hasPermission(undefined, 'reports:read')).toBe(false); + }); + + it('answers false rather than throwing for an older API response', () => { + expect(hasPermission(olderApiUser, 'reports:read')).toBe(false); + expect(hasPermission(olderApiUser, 'reports:read', { organization: true })).toBe( + false + ); + }); + + it('ignores a field that is not a list', () => { + const malformed = { + permissions: 'reports:read', + organizationPermissions: { 0: 'reports:read' }, + } as unknown as User; + + expect(hasPermission(malformed, 'reports:read')).toBe(false); + }); + + it('matches exactly, with no wildcards or implied grants', () => { + const holder = { permissions: ['invoices:write', 'admin', 'billing:*'] }; + + expect(hasPermission(holder, 'invoices:write')).toBe(true); + expect(hasPermission(holder, 'invoices:read')).toBe(false); + expect(hasPermission(holder, 'admin:read')).toBe(false); + expect(hasPermission(holder, 'billing:read')).toBe(false); + expect(hasPermission(holder, 'invoices')).toBe(false); + }); + + it('counts instance and organization permissions by default', () => { + expect(hasPermission(user, 'reports:read')).toBe(true); + expect(hasPermission(user, 'projects:write')).toBe(true); + expect(hasPermission(user, 'projects:read')).toBe(false); + }); + + it('counts only the active organization with the organization option', () => { + const options = { organization: true } as const; + + expect(hasPermission(user, 'projects:write', options)).toBe(true); + expect(hasPermission(user, 'reports:read', options)).toBe(false); + expect(hasPermission(user, 'reports:read', { organization: false })).toBe(true); + }); + + it('passes when any of several permissions is held', () => { + expect(hasPermission(user, ['nope', 'projects:write'])).toBe(true); + expect(hasPermission(user, ['nope', 'also:nope'])).toBe(false); + expect(hasPermission(user, [])).toBe(false); + }); +}); + +describe('hasAllPermissions', () => { + it('needs every permission, across instance and organization grants', () => { + expect(hasAllPermissions(user, ['reports:read', 'projects:write'])).toBe(true); + expect(hasAllPermissions(user, ['reports:read', 'nope'])).toBe(false); + }); + + it('respects the organization option', () => { + expect( + hasAllPermissions(user, ['reports:read', 'projects:write'], { + organization: true, + }) + ).toBe(false); + expect(hasAllPermissions(user, 'projects:write', { organization: true })).toBe(true); + }); + + it('refuses an empty requirement and missing fields', () => { + expect(hasAllPermissions(user, [])).toBe(false); + expect(hasAllPermissions(olderApiUser, 'reports:read')).toBe(false); + expect(hasAllPermissions(null, 'reports:read')).toBe(false); + }); +}); + +describe('hasRole', () => { + it('uses scoped matching on instance roles', () => { + const holder = { roles: ['admin:write'] }; + + expect(hasRole(holder, 'admin:write')).toBe(true); + expect(hasRole(holder, 'admin:read')).toBe(true); + expect(hasRole(holder, 'admin')).toBe(false); + expect(hasRole({ roles: ['admin'] }, ['nope', 'admin:read'])).toBe(true); + }); + + it('does not let an organization role satisfy an instance check', () => { + expect(hasRole(user, 'admin')).toBe(false); + expect(hasRole(user, 'admin:read')).toBe(false); + }); + + it('answers false for a missing user or roles', () => { + expect(hasRole(null, 'user')).toBe(false); + expect(hasRole({}, 'user')).toBe(false); + }); +}); + +describe('hasOrganizationRole', () => { + it('matches organization roles exactly', () => { + expect(hasOrganizationRole(user, 'admin')).toBe(true); + expect(hasOrganizationRole(user, ['owner', 'admin'])).toBe(true); + expect(hasOrganizationRole(user, 'owner')).toBe(false); + expect(hasOrganizationRole(user, 'admin:read')).toBe(false); + expect( + hasOrganizationRole({ organizationRoles: ['admin:write'] }, 'admin:read') + ).toBe(false); + }); + + it('does not let an instance role satisfy an organization check', () => { + expect( + hasOrganizationRole({ roles: ['admin'], organizationRoles: [] }, 'admin') + ).toBe(false); + }); + + it('answers false for a missing user or an older API response', () => { + expect(hasOrganizationRole(null, 'admin')).toBe(false); + expect(hasOrganizationRole(olderApiUser, 'admin')).toBe(false); + }); +}); diff --git a/packages/client/tests/wireTypes.test.ts b/packages/client/tests/wireTypes.test.ts index d0529d4..2c2322b 100644 --- a/packages/client/tests/wireTypes.test.ts +++ b/packages/client/tests/wireTypes.test.ts @@ -45,6 +45,25 @@ describe('wire types match what the API sends', () => { expect([phone, roles, wrong]).toHaveLength(3); }); + it('keeps the permission fields optional, since an older API leaves them out', () => { + const fromOlderApi: User = { + id: 'user-1', + email: 'user@example.com', + phone: null, + roles: [], + }; + const permissions: string[] | undefined = fromOlderApi.permissions; + const organizationRoles: string[] | undefined = fromOlderApi.organizationRoles; + const organizationPermissions: string[] | undefined = + fromOlderApi.organizationPermissions; + + expect([permissions, organizationRoles, organizationPermissions]).toEqual([ + undefined, + undefined, + undefined, + ]); + }); + it('types organization timestamps as strings', () => { const createdAt: string = '' as Organization['createdAt']; diff --git a/packages/react-native/README.md b/packages/react-native/README.md index b85aa0c..d39a583 100644 --- a/packages/react-native/README.md +++ b/packages/react-native/README.md @@ -126,6 +126,14 @@ client has the recovery code and account recovery methods. After a passkey is en from a recovery session the client refreshes the stored tokens, so the new access token no longer carries the recovery claim. See the client README for the full list. +### Permissions and organization roles + +`hasPermission`, `hasAllPermissions`, `hasRole` (instance roles, scoped matching) and +`hasOrganizationRole` (active organization, exact matching) take the user first, such as +`hasPermission(useAuth().user, 'reports:read')`. They are for UI only: the server must still enforce. +They reflect the last `/users/me` read, so a change reaches the client when the user is next +refreshed. See the [`@seamless-auth/client` README](../client/README.md#permissions-and-roles). + ### Passkeys Native passkeys need the relying party to be an associated domain of the app: diff --git a/packages/react-native/jest.config.ts b/packages/react-native/jest.config.ts index 205319f..8d79973 100644 --- a/packages/react-native/jest.config.ts +++ b/packages/react-native/jest.config.ts @@ -11,10 +11,19 @@ export default { rootDir: '.', setupFilesAfterEnv: ['/../../jest.setup.ts'], transform: { + '/node_modules/@seamless-auth/types/.+\\.js$': [ + 'ts-jest', + { tsconfig: { allowJs: true } }, + ], '^.+\\.(t|j)sx?$': ['ts-jest', { useESM: true, tsconfig: '/tsconfig.json' }], }, + transformIgnorePatterns: ['/node_modules/(?!@seamless-auth/types/)'], extensionsToTreatAsEsm: ['.ts', '.tsx'], moduleNameMapper: { + // `@seamless-auth/types` ships ESM under the `import` condition alone, which + // Jest's CommonJS resolver cannot read. + '^@seamless-auth/types/permission/matching$': + '/../../node_modules/@seamless-auth/types/dist/schemas/permission/matching.js', '^@/(.*)$': '/src/$1', '^@seamless-auth/client$': '/../client/src/index.ts', }, diff --git a/packages/react-native/package.json b/packages/react-native/package.json index 47a2fb0..9e62595 100644 --- a/packages/react-native/package.json +++ b/packages/react-native/package.json @@ -46,7 +46,7 @@ }, "dependencies": { "@seamless-auth/client": "^0.8.3", - "@seamless-auth/types": "^0.36.0" + "@seamless-auth/types": "^0.38.0" }, "sideEffects": false } diff --git a/packages/react-native/src/index.ts b/packages/react-native/src/index.ts index 0194490..1fc9e89 100644 --- a/packages/react-native/src/index.ts +++ b/packages/react-native/src/index.ts @@ -38,6 +38,10 @@ export { getOAuthErrorCode, getPasskeyPolicyErrorCode, getWebAuthnErrorDetail, + hasAllPermissions, + hasOrganizationRole, + hasPermission, + hasRole, hasScopedRole, isUnauthenticated, PasskeyCeremonyError, @@ -72,6 +76,7 @@ export type { PasskeyPrfInput, PasskeyPrfResult, PasskeyRegistrationData, + PermissionCheckOptions, PublicSystemConfigResult, RegisterInput, RegisterPasskeyOptions, @@ -88,5 +93,6 @@ export type { TotpEnrollmentStartResult, TotpStatus, User, + UserAuthorization, WebAuthnErrorDetail, } from '@seamless-auth/client'; diff --git a/packages/react/README.md b/packages/react/README.md index afdb29e..e0c1eb0 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -20,6 +20,8 @@ New to Seamless Auth? The [self-hosted quickstart](https://docs.seamlessauth.com - `createSeamlessAuthClient()` - `useAuthClient()` - `usePasskeySupport()` +- `usePermissions()`, `hasPermission()`, `hasAllPermissions()`, `hasRole()` and `hasOrganizationRole()` + for showing and hiding UI (see [Permissions and organization roles](#permissions-and-organization-roles)) - `hasScopedRole()` and `roleGrantsAccess()` - `SeamlessAuthError`, the error type carried on a failed result - `getOAuthErrorCode()`, which reads the known OAuth callback failure codes off that error @@ -390,6 +392,56 @@ hasScopedRole('admin:write'); // true for admin or admin:write The package also exports standalone `hasScopedRole(roles, required)` and `roleGrantsAccess(...)` helpers for code that is not inside `AuthProvider`. +### Permissions and organization roles + +`usePermissions()` gates UI on the permissions and roles the signed-in user carries. + +> **For UI only. The server must still enforce.** Hiding a button does not protect what it calls. +> Guard the route on your server as well. These checks reflect the last `/users/me` read, so a +> change reaches the client when the user is next refreshed: on `refreshSession()`, after a +> sign-in or organization switch, or on the next page load. The client never decodes a token. + +```tsx +import { usePermissions } from '@seamless-auth/react'; + +function ReportsLink() { + const { hasPermission, loading } = usePermissions(); + + if (loading || !hasPermission('reports:read')) return null; + + return Reports; +} +``` + +| Check | Reads | Matching | +| ------------------------------------------------- | ----------------------------------------------------- | ------------------------ | +| `hasPermission(required)` | `permissions` and `organizationPermissions` | exact, any of `required` | +| `hasPermission(required, { organization: true })` | `organizationPermissions` only | exact, any of `required` | +| `hasAllPermissions(required, options?)` | the same, with the same `organization` option | exact, all of `required` | +| `hasRole(required)` | `roles`, the instance roles | scoped (`hasScopedRole`) | +| `hasOrganizationRole(required)` | `organizationRoles`, roles in the active organization | exact, any of `required` | + +- Permission matching is exact, with no wildcards or implied grants: `invoices:write` does not + satisfy `invoices:read`. A permission is held if either the instance or the active organization + grants it, the same union the server guard uses by default. The `organization` option leaves out + instance-wide grants. +- Instance and organization roles stay apart. An organization `admin` does not satisfy + `hasRole('admin')`, and an instance `admin` does not satisfy `hasOrganizationRole('admin')`. +- An API older than these fields leaves them out, which means unknown rather than none. Every check + then answers `false`, as it does while signed out or loading. The hook's `permissions`, + `organizationRoles` and `organizationPermissions` stay `undefined` in that case, so you can tell + unknown from an empty list. +- The organization fields describe the active organization only. If a page is about a specific + organization, compare its id with `activeOrganizationId` as well. + +The hook also returns `roles`, `permissions`, `organizationRoles`, `organizationPermissions`, +`activeOrganizationId` and `loading`. The same four checks are exported as standalone functions that +take the user first, such as `hasPermission(user, 'reports:read')`. Server code imports those from +`@seamless-auth/client`. + +`hasRole` from this hook uses scoped matching, like `hasScopedRole`. `useAuth().hasRole` remains the +exact check described in [Scoped roles](#scoped-roles), and answers `undefined` while signed out. + ### Step-up authentication Use step-up authentication before sensitive actions that should require a fresh user verification, such as deleting an account, changing MFA settings, or viewing recovery material. diff --git a/packages/react/jest.config.ts b/packages/react/jest.config.ts index 16fb012..ae9b94c 100644 --- a/packages/react/jest.config.ts +++ b/packages/react/jest.config.ts @@ -11,10 +11,19 @@ export default { rootDir: '.', setupFilesAfterEnv: ['/../../jest.setup.ts'], transform: { + '/node_modules/@seamless-auth/types/.+\\.js$': [ + 'ts-jest', + { tsconfig: { allowJs: true } }, + ], '^.+\\.(t|j)sx?$': ['ts-jest', { useESM: true, tsconfig: '/tsconfig.json' }], }, + transformIgnorePatterns: ['/node_modules/(?!@seamless-auth/types/)'], extensionsToTreatAsEsm: ['.ts', '.tsx'], moduleNameMapper: { + // `@seamless-auth/types` ships ESM under the `import` condition alone, which + // Jest's CommonJS resolver cannot read. + '^@seamless-auth/types/permission/matching$': + '/../../node_modules/@seamless-auth/types/dist/schemas/permission/matching.js', '\\.(css|less|scss|sass)$': 'identity-obj-proxy', '^@/(.*)$': '/src/$1', // Tests run against the client source, so a change there is exercised here diff --git a/packages/react/package.json b/packages/react/package.json index 9eb001d..5261267 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -57,7 +57,7 @@ }, "dependencies": { "@seamless-auth/client": "^0.8.3", - "@seamless-auth/types": "^0.36.0", + "@seamless-auth/types": "^0.38.0", "@simplewebauthn/browser": "^13.1.0", "libphonenumber-js": "^1.12.7" }, diff --git a/packages/react/src/hooks/usePermissions.ts b/packages/react/src/hooks/usePermissions.ts new file mode 100644 index 0000000..636faca --- /dev/null +++ b/packages/react/src/hooks/usePermissions.ts @@ -0,0 +1,68 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in the project root for full license information + */ + +import { useMemo } from 'react'; + +import { + hasAllPermissions, + hasOrganizationRole, + hasPermission, + hasRole, + type PermissionCheckOptions, + type User, +} from '@seamless-auth/client'; +import { useAuth } from '@/AuthProvider'; + +export interface UsePermissionsResult { + /** True while the session is being read. Every check answers false until then. */ + loading: boolean; + /** Undefined when signed out. */ + roles: User['roles'] | undefined; + /** Undefined when signed out or when the API is too old to send it. */ + permissions: User['permissions']; + /** Undefined when signed out or when the API is too old to send it. */ + organizationRoles: User['organizationRoles']; + /** Undefined when signed out or when the API is too old to send it. */ + organizationPermissions: User['organizationPermissions']; + activeOrganizationId: User['activeOrganizationId']; + hasPermission: ( + required: string | string[], + options?: PermissionCheckOptions + ) => boolean; + hasAllPermissions: ( + required: string | string[], + options?: PermissionCheckOptions + ) => boolean; + /** Instance roles only, with scoped matching. */ + hasRole: (required: string | string[]) => boolean; + /** Roles in the active organization only, matched exactly. */ + hasOrganizationRole: (required: string | string[]) => boolean; +} + +/** + * The signed-in user's permissions and roles as of the last `/users/me` read, for + * showing and hiding UI. The server must still enforce. + */ +export const usePermissions = (): UsePermissionsResult => { + const { user, loading } = useAuth(); + + return useMemo( + () => ({ + loading, + roles: user?.roles, + permissions: user?.permissions, + organizationRoles: user?.organizationRoles, + organizationPermissions: user?.organizationPermissions, + activeOrganizationId: user?.activeOrganizationId, + hasPermission: (required, options) => hasPermission(user, required, options), + hasAllPermissions: (required, options) => + hasAllPermissions(user, required, options), + hasRole: required => hasRole(user, required), + hasOrganizationRole: required => hasOrganizationRole(user, required), + }), + [user, loading] + ); +}; diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 4acc280..9421db3 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -85,8 +85,17 @@ import { useAuthClient } from '@/hooks/useAuthClient'; import { useAuthorizedFetch } from '@/hooks/useAuthorizedFetch'; import { hasNonPasskeyLoginMethod, useLoginMethods } from '@/hooks/useLoginMethods'; import { usePasskeySupport } from '@/hooks/usePasskeySupport'; +import { usePermissions, type UsePermissionsResult } from '@/hooks/usePermissions'; import { useRecoveryOptions } from '@/hooks/useRecoveryOptions'; -import { hasScopedRole, roleGrantsAccess } from '@seamless-auth/client'; +import { + hasAllPermissions, + hasOrganizationRole, + hasPermission, + hasRole, + hasScopedRole, + roleGrantsAccess, +} from '@seamless-auth/client'; +import type { PermissionCheckOptions, UserAuthorization } from '@seamless-auth/client'; import { formatTotpSecret, qrCode, @@ -124,7 +133,11 @@ export { getPasskeyPolicyErrorCode, getRecoveryErrorCode, getWebAuthnErrorDetail, + hasAllPermissions, hasNonPasskeyLoginMethod, + hasOrganizationRole, + hasPermission, + hasRole, hasScopedRole, isEnrollmentRequired, isPasskeyPrfSupported, @@ -146,6 +159,7 @@ export { useAuthorizedFetch, useLoginMethods, usePasskeySupport, + usePermissions, useRecoveryOptions, }; export type { @@ -190,6 +204,7 @@ export type { PasskeyPrfInput, PasskeyPrfResult, PasskeyRegistrationData, + PermissionCheckOptions, PublicSystemConfigResult, QrCode, RecoveryCodesResult, @@ -214,5 +229,7 @@ export type { TransportOptions, UpdateOrganizationInput, User, + UserAuthorization, + UsePermissionsResult, WebAuthnErrorDetail, }; diff --git a/packages/react/tests/usePermissions.test.tsx b/packages/react/tests/usePermissions.test.tsx new file mode 100644 index 0000000..d391a66 --- /dev/null +++ b/packages/react/tests/usePermissions.test.tsx @@ -0,0 +1,158 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in the project root for full license information + */ + +import { act, render, renderHook, screen, waitFor } from '@testing-library/react'; +import React from 'react'; + +import { AuthProvider, useAuth } from '@/AuthProvider'; +import { usePermissions } from '@/hooks/usePermissions'; +import { + createFetchTransport, + createFetchWithAuth, +} from '../../client/src/fetchWithAuth'; + +jest.mock('../../client/src/fetchWithAuth'); + +const fetchWithAuth = jest.fn(); +(createFetchWithAuth as jest.Mock).mockReturnValue(fetchWithAuth); +(createFetchTransport as jest.Mock).mockImplementation(() => ({ + fetch: fetchWithAuth, + authorizedFetch: jest.fn(), + mode: 'cookie', + clearTokens: jest.fn(), +})); + +const baseUser = { + id: 'user-1', + email: 'user@example.com', + phone: null, + roles: ['user'], + activeOrganizationId: 'org-1', +}; + +function respondWithUser(user: object | null) { + fetchWithAuth.mockImplementation(async (path: string) => + path === 'users/me' && user + ? { ok: true, json: async () => ({ user, credentials: [] }) } + : { ok: false, status: 401, json: async () => ({ error: 'unauthenticated' }) } + ); +} + +const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +); + +async function renderSettled() { + const view = renderHook(() => ({ permissions: usePermissions(), auth: useAuth() }), { + wrapper, + }); + await waitFor(() => expect(view.result.current.permissions.loading).toBe(false)); + return view; +} + +describe('usePermissions', () => { + beforeEach(() => { + fetchWithAuth.mockReset(); + window.localStorage.clear(); + }); + + it('throws outside AuthProvider, as useAuth does', () => { + const consoleError = jest.spyOn(console, 'error').mockImplementation(() => {}); + + expect(() => renderHook(() => usePermissions())).toThrow(/AuthProvider/); + + consoleError.mockRestore(); + }); + + it('reads permissions and roles from the current user', async () => { + respondWithUser({ + ...baseUser, + permissions: ['reports:read'], + organizationRoles: ['admin'], + organizationPermissions: ['projects:write'], + }); + + const { result } = await renderSettled(); + const permissions = result.current.permissions; + + expect(permissions.permissions).toEqual(['reports:read']); + expect(permissions.organizationRoles).toEqual(['admin']); + expect(permissions.organizationPermissions).toEqual(['projects:write']); + expect(permissions.activeOrganizationId).toBe('org-1'); + + expect(permissions.hasPermission('reports:read')).toBe(true); + expect(permissions.hasPermission('reports:read', { organization: true })).toBe(false); + expect(permissions.hasAllPermissions(['reports:read', 'projects:write'])).toBe(true); + expect(permissions.hasOrganizationRole('admin')).toBe(true); + expect(permissions.hasRole('admin')).toBe(false); + expect(permissions.hasRole('user')).toBe(true); + }); + + it('answers false and leaves the fields undefined for an older API', async () => { + respondWithUser(baseUser); + + const { result } = await renderSettled(); + const permissions = result.current.permissions; + + expect(permissions.permissions).toBeUndefined(); + expect(permissions.organizationRoles).toBeUndefined(); + expect(permissions.organizationPermissions).toBeUndefined(); + expect(permissions.hasPermission('reports:read')).toBe(false); + expect(permissions.hasAllPermissions('reports:read')).toBe(false); + expect(permissions.hasOrganizationRole('admin')).toBe(false); + }); + + it('answers false when signed out', async () => { + respondWithUser(null); + + const { result } = await renderSettled(); + const permissions = result.current.permissions; + + expect(permissions.roles).toBeUndefined(); + expect(permissions.hasPermission('reports:read')).toBe(false); + expect(permissions.hasRole('user')).toBe(false); + expect(permissions.hasOrganizationRole('admin')).toBe(false); + }); + + it('keeps the same answer until the user is next refreshed', async () => { + respondWithUser({ ...baseUser, permissions: ['reports:read'] }); + + const { result } = await renderSettled(); + expect(result.current.permissions.hasPermission('reports:read')).toBe(true); + + respondWithUser({ ...baseUser, permissions: [] }); + expect(result.current.permissions.hasPermission('reports:read')).toBe(true); + + await act(async () => { + await result.current.auth.refreshSession({ background: true }); + }); + + expect(result.current.permissions.hasPermission('reports:read')).toBe(false); + }); + + it('keeps a stable result between renders with the same user', async () => { + respondWithUser(baseUser); + + const { result, rerender } = await renderSettled(); + const first = result.current.permissions; + + rerender(); + + expect(result.current.permissions).toBe(first); + }); + + it('lets an application gate UI on a permission', async () => { + const Gate = () => { + const { hasPermission } = usePermissions(); + return hasPermission('reports:read') ?

Reports

:

No access

; + }; + + respondWithUser({ ...baseUser, permissions: ['reports:read'] }); + render(, { wrapper }); + + expect(await screen.findByText('Reports')).toBeInTheDocument(); + }); +}); diff --git a/packages/svelte/README.md b/packages/svelte/README.md index 6f7b10d..7a8d373 100644 --- a/packages/svelte/README.md +++ b/packages/svelte/README.md @@ -147,6 +147,14 @@ fetch for your own API that carries the cookies to `apiHost` and `trustedOrigins (the headless client for everything else, such as OTP and magic link calls), `ports`, and `destroy()`. +### Permissions and organization roles + +`hasPermission(user, required, { organization? })`, `hasAllPermissions`, `hasRole` (instance roles, scoped +matching) and `hasOrganizationRole` (active organization, exact matching) are exported for showing +and hiding UI. They are for UI only: the server must still enforce. They reflect the last +`/users/me` read, so a change reaches the client when the user is next refreshed. See the +[`@seamless-auth/client` README](../client/README.md#permissions-and-roles). + ## Guards `@seamless-auth/svelte/kit` exports `load` functions. They wait for the session to be read before diff --git a/packages/svelte/src/index.ts b/packages/svelte/src/index.ts index 3338ad5..0b4c819 100644 --- a/packages/svelte/src/index.ts +++ b/packages/svelte/src/index.ts @@ -35,6 +35,10 @@ export { getOAuthErrorCode, getPasskeyPolicyErrorCode, getWebAuthnErrorDetail, + hasAllPermissions, + hasOrganizationRole, + hasPermission, + hasRole, hasScopedRole, isUnauthenticated, roleGrantsAccess, @@ -51,10 +55,12 @@ export type { OAuthRedirectPort, Organization, PasskeyPort, + PermissionCheckOptions, RefreshSessionOptions, SeamlessAuthClient, SeamlessAuthResult, StepUpStatus, TotpStatus, User, + UserAuthorization, } from '@seamless-auth/client'; diff --git a/packages/vue/README.md b/packages/vue/README.md index 4d02db5..57ed928 100644 --- a/packages/vue/README.md +++ b/packages/vue/README.md @@ -119,6 +119,14 @@ Also: `hasRole`, `hasScopedRole`, `markSignedIn`, `whenSettled()` (resolves once fetch for your own API that carries the cookies to `apiHost` and `trustedOrigins` only), `client` (the headless client for everything else, such as OTP and magic link calls), and `ports`. +### Permissions and organization roles + +`hasPermission(user, required, { organization? })`, `hasAllPermissions`, `hasRole` (instance roles, scoped +matching) and `hasOrganizationRole` (active organization, exact matching) are exported for showing +and hiding UI. They are for UI only: the server must still enforce. They reflect the last +`/users/me` read, so a change reaches the client when the user is next refreshed. See the +[`@seamless-auth/client` README](../client/README.md#permissions-and-roles). + ## Guards `@seamless-auth/vue/router` exports guards for `beforeEnter` or `router.beforeEach`. They wait for the diff --git a/packages/vue/jest.config.ts b/packages/vue/jest.config.ts index c26d1dc..66fea3d 100644 --- a/packages/vue/jest.config.ts +++ b/packages/vue/jest.config.ts @@ -13,10 +13,19 @@ export default { rootDir: '.', setupFilesAfterEnv: ['/../../jest.setup.ts'], transform: { + '/node_modules/@seamless-auth/types/.+\\.js$': [ + 'ts-jest', + { tsconfig: { allowJs: true } }, + ], '^.+\\.(t|j)sx?$': ['ts-jest', { useESM: true, tsconfig: '/tsconfig.json' }], }, + transformIgnorePatterns: ['/node_modules/(?!@seamless-auth/types/)'], extensionsToTreatAsEsm: ['.ts'], moduleNameMapper: { + // `@seamless-auth/types` ships ESM under the `import` condition alone, which + // Jest's CommonJS resolver cannot read. + '^@seamless-auth/types/permission/matching$': + '/../../node_modules/@seamless-auth/types/dist/schemas/permission/matching.js', '^@shared-styles/seamless-auth\\.css$': '/tests/styleStub.ts', '^@/(.*)$': '/src/$1', // Tests run against the client source, so a change in the core is exercised diff --git a/packages/vue/src/index.ts b/packages/vue/src/index.ts index 65ca5ce..7de327d 100644 --- a/packages/vue/src/index.ts +++ b/packages/vue/src/index.ts @@ -14,6 +14,10 @@ export { getOAuthErrorCode, getPasskeyPolicyErrorCode, getWebAuthnErrorDetail, + hasAllPermissions, + hasOrganizationRole, + hasPermission, + hasRole, hasScopedRole, isUnauthenticated, roleGrantsAccess, @@ -30,10 +34,12 @@ export type { OAuthRedirectPort, Organization, PasskeyPort, + PermissionCheckOptions, RefreshSessionOptions, SeamlessAuthClient, SeamlessAuthResult, StepUpStatus, TotpStatus, User, + UserAuthorization, } from '@seamless-auth/client';