Skip to main content

Refine integration

@taruvi/refine-providers connects a Refine 5 app to TaruviBase. Refine's data hooks read and write your data tables, its auth hooks use TaruviBase hosted sign-in, and useCan and <CanAccess> ask your access policies what to show.

This guide covers @taruvi/refine-providers 1.3.7. It uses your app's copy of @taruvi/sdk, a peer dependency, which must be 1.5.4 or later.

Before you begin#

You need:

  • A React app with @refinedev/core 5.
  • A TaruviBase app, with TARUVI_SITE_URL and TARUVI_APP_SLUG from Settings → Connect.
  • For the example below, a tasks table, as in the quickstart.

Install the packages#

npm install @refinedev/core @taruvi/refine-providers @taruvi/sdk

Create the TaruviBase client#

The providers share one @taruvi/sdk client. This Vite example reads public build variables; use your framework's equivalent:

src/taruvi-client.ts
import {Client} from '@taruvi/sdk';

export const taruvi = new Client({
apiUrl: import.meta.env.VITE_TARUVI_SITE_URL,
appSlug: import.meta.env.VITE_TARUVI_APP_SLUG,
});

Import this module when the app starts. Constructing the client is what captures the session token when TaruviBase returns a user from sign-in.

Register the providers and routes#

Use dataProvider as Refine's default and name the others; each resource picks one with meta.dataProviderName. This example uses React Router through @refinedev/react-router, and keeps every page except sign-in behind <Authenticated>:

npm install @refinedev/react-router react-router
src/App.tsx
import {Authenticated, Refine} from '@refinedev/core';
import routerProvider, {CatchAllNavigate, NavigateToResource} from '@refinedev/react-router';
import {BrowserRouter, Outlet, Route, Routes} from 'react-router';
import {
accessControlProvider,
appDataProvider,
authProvider,
dataProvider,
storageDataProvider,
userDataProvider,
} from '@taruvi/refine-providers';
import {taruvi} from './taruvi-client';
import {Documents} from './pages/documents';
import {LoginPage} from './pages/login';
import {OpenTasks} from './pages/tasks';

const dataProviders = {
default: dataProvider(taruvi),
storage: storageDataProvider(taruvi),
app: appDataProvider(taruvi),
user: userDataProvider(taruvi),
};

// Policy kind for each resource; see "Show only what users may do".
const policyKinds: Record<string, string> = {
tasks: 'datatable:tasks',
documents: 'storage:documents',
};

export function App() {
return (
<BrowserRouter>
<Refine
routerProvider={routerProvider}
dataProvider={dataProviders}
authProvider={authProvider(taruvi)}
accessControlProvider={accessControlProvider(taruvi, {
entityType: (resource) => policyKinds[resource],
})}
resources={[
{name: 'tasks', list: '/tasks'},
{name: 'documents', list: '/documents', meta: {dataProviderName: 'storage'}},
{name: 'users', meta: {dataProviderName: 'user'}},
]}
>
<Routes>
<Route
element={
<Authenticated key="app" fallback={<CatchAllNavigate to="/login" />}>
<Outlet />
</Authenticated>
}
>
<Route index element={<NavigateToResource resource="tasks" />} />
<Route path="/tasks" element={<OpenTasks />} />
<Route path="/documents" element={<Documents />} />
</Route>
<Route
element={
<Authenticated key="sign-in" fallback={<Outlet />}>
<NavigateToResource />
</Authenticated>
}
>
<Route path="/login" element={<LoginPage />} />
</Route>
</Routes>
</Refine>
</BrowserRouter>
);
}

Create the providers once, outside the component, so they aren't rebuilt on every render. The provider reference lists the resources and meta options each provider supports.

Use the product examples#

The product guides select these providers explicitly:

dataProviderNameOperations
defaultDatabase records and queries
storageStorage objects and supported file operations
appFunction and Analytics execution, Secrets reads, app roles and settings
userSite-user records and their role/app reads

Import hooks from @refinedev/core and initialize them inside a component beneath the configured <Refine>. Product snippets show the hook and its operation parameters. Invoke mutation calls from an event handler, rather than while rendering the component.

In Refine 5, useOne exposes its record as result, and useList exposes result.data and result.total; their loading/error state is in query. Data mutation hooks expose mutate, mutateAsync, and mutation, including mutation.isPending, mutation.error, and mutation.data.

Add a sign-in page#

authProvider doesn't render a form. Its login() sends the browser to TaruviBase hosted sign-in, and the user comes back already signed in. <CatchAllNavigate> sends signed-out users to /login?to=PATH; this page returns them to that path afterward:

src/pages/login.tsx
import {useLogin} from '@refinedev/core';
import type {LoginParams} from '@taruvi/refine-providers';
import {useSearchParams} from 'react-router';

export function LoginPage() {
const {mutate: login, isPending} = useLogin<LoginParams>();
const [searchParams] = useSearchParams();

// Only return to a page on this app. TaruviBase adds the session to the
// callback address, so never let it point elsewhere.
const target = new URL(searchParams.get('to') ?? '/', window.location.origin);
const callbackUrl = target.origin === window.location.origin ? target.href : window.location.origin;

return (
<button type="button" disabled={isPending} onClick={() => login({callbackUrl})}>
Sign in with TaruviBase
</button>
);
}

callbackUrl must be an absolute address. useLogout() clears the session, sends the user through TaruviBase sign-out, and returns to /login. If TaruviBase doesn't accept your app's address for the return trip, the user confirms sign-out on TaruviBase's page instead. See Authenticate with the JavaScript SDK for what happens during the redirect, and for the app addresses TaruviBase accepts requests from.

Read and write data#

The data hooks work as they do with any Refine data provider:

src/pages/tasks.tsx
import {useDelete, useList, useUpdate} from '@refinedev/core';

type Task = {id: string; title: string; done: boolean};

export function OpenTasks() {
const {
result: {data: tasks, total},
query: {isLoading},
} = useList<Task>({
resource: 'tasks',
filters: [{field: 'done', operator: 'eq', value: false}],
sorters: [{field: 'title', order: 'asc'}],
pagination: {currentPage: 1, pageSize: 20},
});
const {mutate: update} = useUpdate<Task>();
const {mutate: remove} = useDelete<Task>();

if (isLoading) return <p>Loading…</p>;
return (
<ul aria-label={`${total} open tasks`}>
{tasks.map((task) => (
<li key={task.id}>
{task.title}
<button type="button" onClick={() => update({resource: 'tasks', id: task.id, values: {done: true}})}>
Done
</button>
<button type="button" onClick={() => remove({resource: 'tasks', id: task.id})}>
Delete
</button>
</li>
))}
</ul>
);
}

meta adds TaruviBase features to a query, such as related records, full-text search, and per-row permission hints:

useList({
resource: 'tasks',
meta: {populate: ['assignee'], search: 'launch', allowedActions: ['update', 'delete']},
});

Filters. A filter whose value is undefined or null is skipped, as Refine's useTable does, except in a filtered delete, where it fails the request. An operator TaruviBase doesn't support, such as Refine's eqs, fails the request, including inside and/or groups. Array, range, and full-text operators aren't in Refine's operator type; wrap those filters in toRefineFilters():

import {toRefineFilters} from '@taruvi/refine-providers';

useList({
resource: 'tasks',
filters: toRefineFilters([{field: 'tags', operator: 'acontains', value: ['launch']}]),
});

Work with files#

Point a resource at a bucket with storageDataProvider. List rows are storage objects: their id is a number, and file_path is the object path. Pass the path, not the id, to useOne, useUpdate, and useDelete:

src/pages/documents.tsx
import {useCreate, useDelete, useList} from '@refinedev/core';
import type {HttpError} from '@refinedev/core';
import type {StorageUploadVariables} from '@taruvi/refine-providers';
import type {StorageObject} from '@taruvi/sdk';

export function Documents() {
const {result: {data: files}} = useList<StorageObject>({
resource: 'documents',
pagination: {currentPage: 1, pageSize: 50},
});
const {mutate: upload} = useCreate<StorageObject, HttpError, StorageUploadVariables>();
const {mutate: remove} = useDelete<StorageObject>();

return (
<>
<input
type="file"
onChange={(event) => {
const file = event.target.files?.[0];
if (file) upload({resource: 'documents', values: {files: [file], paths: [`uploads/${file.name}`]}});
}}
/>
<ul>
{files.map((file) => (
<li key={file.id}>
{file.filename}
<button type="button" onClick={() => remove({resource: 'documents', id: file.file_path})}>
Delete
</button>
</li>
))}
</ul>
</>
);
}

For a folder view, list with meta: {mode: 'browse', prefix: 'uploads/'}. Its rows are folders and files; a file's object path is in path. A delete that TaruviBase skips, because the object is missing or a policy denies it, fails with the SDK's NotFoundError or ForbiddenError.

Show only what users may do#

accessControlProvider answers useCan, <CanAccess>, and Refine's action buttons with your access policies. It batches the checks on a page into one request.

Each check needs the policy's kind, which names the resource type and the resource:

ResourceKindActions
Data tabledatatable:TABLEread, create, update, delete
Storage bucketstorage:BUCKETread, create, update, delete
Functionfunction:SLUGexecute
Saved queryquery:SLUGexecute

Refine's list and show are sent as read, edit as update, and clone as create. The provider picks the kind in this order:

  1. params.entityType on the check.
  2. meta.entityType on the Refine resource. Refine passes it from its action buttons and from <CanAccess> without params, but not from useCan.
  3. The entityType option, as in App.tsx above. It covers every check.
  4. The resource name, which doesn't match a TaruviBase kind on its own.
src/components/if-can-delete.tsx
import {CanAccess} from '@refinedev/core';
import type {ReactNode} from 'react';

export function IfCanDelete({taskId, children}: {taskId: string; children: ReactNode}) {
return (
<CanAccess resource="tasks" action="delete" params={{id: taskId}}>
{children}
</CanAccess>
);
}

The check sends params as the resource's attributes. A rule that depends on a record's fields, such as "only the owner may delete", sees only what you pass. For list pages, request meta.allowedActions instead: TaruviBase then decides each row on the server and adds _allowed_actions to it.

Test as an app user

Checks for your own TaruviBase Console account allow every action, so buttons never hide for you. Sign in as a site user with the role you want to test.

UI checks don't enforce anything

useCan and <CanAccess> only decide what to render. TaruviBase enforces your policies on every request, whether or not the provider is registered.

Handle errors#

Provider calls reject with the SDK's error classes, and Refine hooks surface them in their error state. When a request fails with 401, authProvider sends the user to sign in again. A 403 leaves the user signed in: they lack permission for that action. The JavaScript SDK error reference lists the classes.

Next steps#