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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions docs/auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ interface AuthProvider {
logout: (params?: Record<string, unknown>) => Promise<AuthActionResult>;
check: (params?: Record<string, unknown>) => Promise<CheckResult>;
getIdentity: () => Promise<Identity | null>;
// UI-only hints; API, RLS, and action handlers must authorize independently.
getPermissions?: (params?: Record<string, unknown>) => Promise<unknown>;
register?: (params: Record<string, unknown>) => Promise<AuthActionResult>;
forgotPassword?: (params: Record<string, unknown>) => Promise<AuthActionResult>;
Expand Down Expand Up @@ -83,11 +84,19 @@ mutate(error); // Calls authProvider.onError → may redirect or logout

### `usePermissions()`

`usePermissions()` is a client-side rendering helper. It can hide or disable UI, but APIs, data providers, and database policies must enforce authorization independently.

```typescript
const { data, isLoading, error } = usePermissions<string[]>();
// data: whatever authProvider.getPermissions() returns
const { raw, has, can, isLoading, error } = usePermissions<string[]>();
// raw: whatever trusted authProvider.getPermissions() resolver returns (UI hints only)
// has/can: exact client-side UI checks
```

The built-in Supabase and SSO providers expose `getPermissions()`, but it returns `null`
until the application configures a trusted resolver. Resolver values may change labels,
navigation, or disabled controls only. API, RLS, and action handlers must independently
authenticate and authorize every request.

## Auth Pages

Built-in glassmorphism auth pages:
Expand Down
20 changes: 13 additions & 7 deletions docs/src/content/docs/hooks/auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,19 +36,25 @@ const { isAuthenticated, isLoading } = useIsAuthenticated();

### `usePermissions<T>()`

`usePermissions()` is a client-side rendering helper. It can hide or disable UI, but APIs, data providers, and database policies must enforce authorization independently.

```typescript
const { raw, has, can, isLoading, refetch } = usePermissions<string[]>();
const permissionHints = usePermissions<string[]>();

// Check specific permission
if (has('admin')) { /* ... */ }
// Change navigation or a disabled control from a UI hint.
if (permissionHints.has('admin')) { /* ... */ }

// Check resource:action permission
if (can('posts', 'edit')) { /* ... */ }
// Read a UI hint using the resource:action naming convention.
if (permissionHints.can('posts', 'edit')) { /* ... */ }

// Session-level refresh (e.g. after role upgrade)
await refetch();
await permissionHints.refetch();
```

The built-in Supabase and SSO providers expose `getPermissions()`, but it returns `null` until
the application configures a trusted resolver. Resolver values are UI hints only; do not use
these browser-visible values as an API, RLS, or action authorization decision. The backend must
authenticate and authorize every request.

### `useOnError()`

```typescript
Expand Down
21 changes: 19 additions & 2 deletions docs/src/content/docs/providers/auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ interface AuthProvider {
logout: (params?: Record<string, unknown>) => Promise<AuthActionResult>;
check: (params?: Record<string, unknown>) => Promise<CheckResult>;
getIdentity: () => Promise<Identity | null>;
// UI-only hints; API, RLS, and action handlers must authorize independently.
getPermissions?: (params?: Record<string, unknown>) => Promise<unknown>;
register?: (params: Record<string, unknown>) => Promise<AuthActionResult>;
forgotPassword?: (params: Record<string, unknown>) => Promise<AuthActionResult>;
Expand All @@ -21,6 +22,8 @@ interface AuthProvider {
}
```

`getPermissions` is intentionally a UI hint hook. It should return permissions only when the application provides a trusted resolver; it must not replace API, backend, or database authorization.

## Auth Hooks

| Hook | Purpose |
Expand All @@ -33,7 +36,7 @@ interface AuthProvider {
| `useGetIdentity()` | Get current user info |
| `useIsAuthenticated()` | Check auth status |
| `useOnError()` | Handle API errors (401→logout) |
| `usePermissions()` | Get user permissions |
| `usePermissions()` | Get UI-only permission hints |

### Usage

Expand All @@ -42,6 +45,11 @@ const { mutate: login, isPending } = useLogin();
await login({ email: 'user@example.com', password: 'secret' });
```

The built-in Supabase and SSO providers expose `getPermissions()`, but it returns `null` until
the application configures a trusted resolver. Resolver values may change labels, navigation,
or disabled controls, but browser-visible values never authorize API, RLS, or action requests;
the backend must enforce those separately.

## Auth Pages

Built-in glassmorphism auth pages included:
Expand Down Expand Up @@ -91,10 +99,19 @@ export const mockAuthProvider: AuthProvider = {

```typescript
import { createSupabaseAuthProvider } from '@svadmin/supabase';
const authProvider = createSupabaseAuthProvider(supabaseClient);
const authProvider = createSupabaseAuthProvider(supabaseClient, {
getPermissions: async ({ client }) => {
const { data, error } = await client
.from('effective_permission_grants')
.select('permission');
if (error) throw error;
return data.map((grant) => grant.permission);
},
});
```

`@supacloud/js` does not change the auth flow. Keep using the official Supabase client with `createSupabaseAuthProvider()`, and layer any task APIs separately through [`@svadmin/supabase/supacloud`](/providers/supacloud).
Do not use user-editable metadata as an authorization fact.

### Appwrite

Expand Down
18 changes: 14 additions & 4 deletions docs/src/content/docs/providers/sso.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@ interface SSOConfig {
refreshLock?: RefreshLock;
/** Injectable fetch implementation for tests and SSR runtimes. */
fetcher?: typeof fetch;
/** Trusted permission resolver for UI hints. */
getPermissions?: (context: SSOPermissionResolverContext) => Promise<unknown> | unknown;
}
```

Expand Down Expand Up @@ -114,13 +116,21 @@ const authProvider = createSSOAuthProvider({
});
```

### Permissions from ID Token
### Trusted Permissions Resolver

The provider automatically extracts `roles`, `groups`, or `permissions` claims from the ID token via `getPermissions()`.
`getPermissions()` returns `null` unless you configure a trusted resolver. Use the resolver to call your application authorization endpoint or another backend-controlled source. ID Token claims are not turned into browser permissions; this is a UI hint only. API endpoints must independently validate the token and enforce authorization.

```typescript
const permissions = await authProvider.getPermissions();
// → ['admin', 'editor'] (from ID token claims)
const authProvider = createSSOAuthProvider({
issuer: 'https://your-tenant.okta.com',
clientId: 'abc',
redirectUri: '/callback',
getPermissions: async ({ createAuthenticatedFetch }) => {
const response = await createAuthenticatedFetch()(new URL('/api/me/permissions', window.location.origin));
if (!response.ok) throw new Error('Failed to load permissions');
return response.json();
},
});
```

### Calling Protected APIs
Expand Down
19 changes: 12 additions & 7 deletions docs/src/content/docs/zh-cn/hooks/auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,19 +36,24 @@ const { isAuthenticated, isLoading } = useIsAuthenticated();

### `usePermissions<T>()`

`usePermissions()` 只是客户端渲染辅助。它可以隐藏或禁用 UI,但 API、DataProvider 和数据库策略必须独立执行授权。

```typescript
const { raw, has, can, isLoading, refetch } = usePermissions<string[]>();
const permissionHints = usePermissions<string[]>();

// 检查特定权限
if (has('admin')) { /* ... */ }
// 使用 UI 提示调整导航或禁用控件。
if (permissionHints.has('admin')) { /* ... */ }

// 检查资源:操作权限
if (can('posts', 'edit')) { /* ... */ }
// 使用 resource:action 命名读取 UI 提示。
if (permissionHints.can('posts', 'edit')) { /* ... */ }

// 重新获取权限(比如角色升级后)
await refetch();
await permissionHints.refetch();
```

内置 Supabase 与 SSO Provider 会提供 `getPermissions()`,但在应用配置可信 resolver 前返回
`null`。resolver 的返回值只能作为 UI 提示,绝不能将浏览器中的值作为 API、RLS 或动作授权
决定;后端必须认证并授权每一个请求。

### `useOnError()`

```typescript
Expand Down
20 changes: 18 additions & 2 deletions docs/src/content/docs/zh-cn/providers/auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ interface AuthProvider {
logout: (params?: Record<string, unknown>) => Promise<AuthActionResult>;
check: (params?: Record<string, unknown>) => Promise<CheckResult>;
getIdentity: () => Promise<Identity | null>;
// 仅限 UI 提示;API、RLS 和动作处理器必须独立授权。
getPermissions?: (params?: Record<string, unknown>) => Promise<unknown>;
register?: (params: Record<string, unknown>) => Promise<AuthActionResult>;
forgotPassword?: (params: Record<string, unknown>) => Promise<AuthActionResult>;
Expand All @@ -21,6 +22,8 @@ interface AuthProvider {
}
```

`getPermissions` 只作为 UI hint。只有应用提供可信 resolver 时才应返回权限;它不能替代 API、后端或数据库授权。

## 认证 Hook

| Hook | 用途 |
Expand All @@ -33,7 +36,7 @@ interface AuthProvider {
| `useGetIdentity()` | 获取当前用户信息 |
| `useIsAuthenticated()` | 检查认证状态 |
| `useOnError()` | 处理 API 错误(401→登出) |
| `usePermissions()` | 获取用户权限 |
| `usePermissions()` | 获取仅限 UI 的权限提示 |

### 用法

Expand All @@ -42,6 +45,10 @@ const { mutate: login, isPending } = useLogin();
await login({ email: 'user@example.com', password: 'secret' });
```

内置 Supabase 与 SSO Provider 会提供 `getPermissions()`,但在应用配置可信 resolver 前返回
`null`。resolver 的返回值可用于调整标签、导航或禁用控件,但浏览器中的值绝不能授权 API、
RLS 或动作请求;后端必须独立强制授权。

## 认证页面

内置毛玻璃风格认证页面:
Expand Down Expand Up @@ -91,10 +98,19 @@ export const mockAuthProvider: AuthProvider = {

```typescript
import { createSupabaseAuthProvider } from '@svadmin/supabase';
const authProvider = createSupabaseAuthProvider(supabaseClient);
const authProvider = createSupabaseAuthProvider(supabaseClient, {
getPermissions: async ({ client }) => {
const { data, error } = await client
.from('effective_permission_grants')
.select('permission');
if (error) throw error;
return data.map((grant) => grant.permission);
},
});
```

`@supacloud/js` 不会改变认证流程。认证部分仍然建议继续使用官方 Supabase 客户端配合 `createSupabaseAuthProvider()`,任务相关 API 再通过 [`@svadmin/supabase/supacloud`](/zh-cn/providers/supacloud) 单独组合接入。
不要把用户可修改的 metadata 当成授权事实。

### Appwrite

Expand Down
18 changes: 14 additions & 4 deletions docs/src/content/docs/zh-cn/providers/sso.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ interface SSOConfig {
refreshBuffer?: number;
/** 额外授权请求参数,例如 audience 或 prompt */
authorizationParams?: Record<string, string>;
/** 可信权限 resolver,仅用于 UI hint */
getPermissions?: (context: SSOPermissionResolverContext) => Promise<unknown> | unknown;
}
```

Expand Down Expand Up @@ -98,13 +100,21 @@ const authProvider = createSSOAuthProvider({
});
```

### 从 ID Token 提取权限
### 可信权限 Resolver

自动从 ID Token 的 `roles`、`groups`、`permissions` claim 中提取权限信息
未配置可信 resolver 时,`getPermissions()` 返回 `null`。ID Token claims 不会自动转化为浏览器权限。请在 resolver 中调用应用自己的授权接口或后端控制的数据源。结果仅用于 UI 提示;API 端点必须独立验证令牌并强制授权

```typescript
const permissions = await authProvider.getPermissions();
// → ['admin', 'editor'](来自 ID Token claims)
const authProvider = createSSOAuthProvider({
issuer: 'https://your-tenant.okta.com',
clientId: 'abc',
redirectUri: '/callback',
getPermissions: async ({ createAuthenticatedFetch }) => {
const response = await createAuthenticatedFetch()(new URL('/api/me/permissions', window.location.origin));
if (!response.ok) throw new Error('Failed to load permissions');
return response.json();
},
});
```

### 调用受保护 API
Expand Down
11 changes: 7 additions & 4 deletions packages/core/src/auth-hooks.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,11 +269,14 @@ export function useOnError() {
// ─── usePermissions ──────────────────────────────────────────

/**
* Fetches permissions from authProvider.getPermissions().
* Returns a reactive object with convenience methods for permission checks.
*
* Fetches UI-only hints from authProvider.getPermissions().
* Returns a reactive UI helper with convenience methods for permission checks.
* The result can change labels, navigation, and disabled controls, but it runs
* in the browser and never authorizes API, RLS, or action requests. Those
* requests must be independently authenticated and authorized by the backend.
*
* Supports `refetch()` for session-level permission refresh (e.g., after role change).
*
*
* @example
* ```svelte
* <script>
Expand Down
25 changes: 25 additions & 0 deletions packages/core/src/permissions.feature-gate.test.svelte.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest';
import { createFeatureGate } from './permissions.svelte';

describe('createFeatureGate', () => {
it('requires exact permissions', () => {
const canEditPosts = createFeatureGate({
permissions: ['posts:edit'],
});

expect(canEditPosts({ role: 'admin', permissions: ['posts:edit'] })).toBe(true);
expect(canEditPosts({ role: 'admin', permissions: ['posts:*'] })).toBe(false);
expect(canEditPosts({ role: 'admin', permissions: ['*'] })).toBe(false);
});

it('enforces role hierarchy when provided', () => {
const canModerate = createFeatureGate({
minRole: 'editor',
roleHierarchy: ['admin', 'editor', 'viewer'],
});

expect(canModerate({ role: 'admin', permissions: [] })).toBe(true);
expect(canModerate({ role: 'editor', permissions: [] })).toBe(true);
expect(canModerate({ role: 'viewer', permissions: [] })).toBe(false);
});
});
28 changes: 12 additions & 16 deletions packages/core/src/permissions.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,9 @@ export interface CanResult {


/**
* Formal Access Control Provider interface.
* Implement this to define your authorization logic.
* UI access-control integration point.
* It can mirror a backend policy decision for navigation and controls, but browser
* checks never authorize an API request. The backend must enforce the same policy.
*
* @example
* ```ts
Expand Down Expand Up @@ -78,8 +79,8 @@ export function getAccessControlOptions() {
}

/**
* Async access check.
* Supports both single capability requests or an array of batched checks.
* UI access check supporting single capabilities or a batch.
* Its result can change browser presentation but never authorizes the backend action.
*/
export async function canAccessAsync(params: CanParams[]): Promise<CanResult[]>;
export async function canAccessAsync(resource: string, action: Action, params?: Record<string, unknown>, meta?: Record<string, unknown>): Promise<CanResult>;
Expand All @@ -100,7 +101,7 @@ export async function canAccessAsync(resourceOrBatch: string | CanParams[], acti
// ─── Feature Gate ─────────────────────────────────────────────

export interface FeatureGateConfig {
/** 允许访问的角色列表 */
/** 仅用于前端展示的角色列表。 */
roles?: string[];
/**
* 需要的最低角色 (含以上所有角色)。
Expand All @@ -112,18 +113,20 @@ export interface FeatureGateConfig {
* 由调用方按自身业务定义,框架不预设任何角色。
*/
roleHierarchy?: string[];
/** 需要的权限列表 (全部匹配) */
/** 仅用于前端展示的权限列表 (全部匹配) */
permissions?: string[];
}

/** 浏览器中的角色和权限提示,不是授权凭据。 */
export interface FeatureGateUser {
role: string;
permissions: string[];
}

/**
* 创建功能门控函数 — 基于角色和权限判断用户是否可访问某功能。
* 不预设任何角色层级,由调用方完全定义。
* 创建仅用于前端展示的功能门控函数。
* 只做客户端 UI 门控;输入可被篡改,后端必须独立完成令牌验证和操作授权。
* 不预设任何角色层级,也不解释通配符权限。
*
* @example
* ```ts
Expand Down Expand Up @@ -157,14 +160,7 @@ export function createFeatureGate(config: FeatureGateConfig): (user: FeatureGate
}

if (config.permissions && config.permissions.length > 0) {
const hasAll = config.permissions.every((permission) => {
if (user.permissions.includes("*")) return true;
if (user.permissions.includes(permission)) return true;
const [resource] = permission.split(":");
if (resource && user.permissions.includes(`${resource}:*`)) return true;
return false;
});
if (!hasAll) return false;
if (!config.permissions.every((permission) => user.permissions.includes(permission))) return false;
}

return true;
Expand Down
Loading