Skip to main content

Authentication

Rhino provides a complete authentication and organization management flow for React applications via a set of dedicated hooks. This page covers every auth-related hook in the @rhino-dev/rhino-react library, including useAuth, useOrganization, useOwner, and useOrganizationExists.

useAuth()​

The primary hook for authentication state and actions. It reads the API token from storage (localStorage on web, AsyncStorage on React Native), exposes login/logout functions, and manages the active organization.

src/hooks/useAuth.ts
const { token, isAuthenticated, login, logout, setOrganization, setRouteGroup } = useAuth();

Return Values​

PropertyTypeDescription
tokenstring | nullCurrent API token held in storage. null when not authenticated.
isAuthenticatedbooleantrue if a token exists, false otherwise. Follows sessions started by useRegister and ended by a 401 response.
login(email, password, options?)(email: string, password: string, options?: { routeGroup?: string | null }) => Promise<LoginResult>Authenticates the user and writes the returned token to storage before resolving. Accepts an optional per-call routeGroup (see Group-Aware Auth).
logout(options?)(options?: { routeGroup?: string | null }) => Promise<void>Calls the logout endpoint, then clears the token, user, persisted organization and route group from storage -- even if the request fails. It does not navigate; react to isAuthenticated turning false.
setOrganization(slug)(slug: string) => voidPersists the given organization slug in storage for subsequent API requests.
setRouteGroup(group)(group: string | null) => voidPersists (or clears, when falsy) the active route group and notifies listeners. See Group-Aware Auth.

LoginResult Type​

The login function returns a LoginResult object describing the outcome of the authentication attempt:

src/types.ts
interface LoginResult {
success: boolean;
user?: any;
organization?: { slug: string };
organization_slug?: string;
route_group?: string | null;
token?: string | null;
error?: string;
status?: number;
}
  • success -- true if login succeeded, false otherwise.
  • user -- The authenticated user object (when successful).
  • organization -- The user's default organization object, including its slug.
  • organization_slug -- Shorthand for the organization slug (convenience field).
  • route_group -- The route group this login resolved to (the value the backend echoes back, or the group you logged in with). null for the default/global auth path. See Group-Aware Auth.
  • token -- The bearer token of the session that was started. It is already in storage when login() resolves.
  • error -- An error message string when success is false.
  • status -- The HTTP status of the login response. On failure it tells wrong credentials (401) from a denied group membership (403).

Auth Flow​

  1. User calls login(email, password)
  2. Rhino sends POST /api/auth/login to your backend
  3. Server returns a token, user data, and organization slug
  4. The client writes the token, user and organization to storage before login() resolves
  5. All subsequent API requests include the Authorization: Bearer {token} header automatically
  6. On a 401 response from any other request, the session ends: the token and user are removed from storage, AuthProvider resets, and onUnauthorized runs (see Handling 403 vs 401)

Because the token is stored before the promise resolves, a request issued right after await login() is already authenticated -- you do not need to wait for a re-render:

src/screens/LoginScreen.tsx
import { useQueryClient } from '@tanstack/react-query';
import { useAuth, modelKeys, fetchModelIndex } from '@rhino-dev/rhino-react';

const { login } = useAuth();
const queryClient = useQueryClient();

const result = await login(email, password);
if (result.success) {
// Authorization: Bearer {result.token} is already attached
await queryClient.prefetchQuery({
queryKey: modelKeys.index('trips', {}),
queryFn: () => fetchModelIndex('trips'),
});
}

A rejected login does not end a session. A 401 from /auth/login (or /{routeGroup}/auth/login) leaves storage alone and does not call onUnauthorized; login() returns { success: false, status: 401, error } for you to show.

info

The token is persisted in storage, so authentication survives page refreshes (and app restarts on React Native). Call logout() to explicitly clear it.

Login Component Example​

A full login page with loading state and error handling:

src/pages/LoginPage.tsx
import { useAuth } from '@rhino-dev/rhino-react';
import { useState } from 'react';

function LoginPage() {
const { login, isAuthenticated } = useAuth();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);

const handleSubmit = async (e) => {
e.preventDefault();
setLoading(true);
setError('');

const result = await login(email, password);

if (result.success) {
// Redirect to dashboard or org
window.location.href = `/orgs/${result.organization_slug}/dashboard`;
} else {
setError(result.error || 'Login failed');
}

setLoading(false);
};

if (isAuthenticated) {
return <Navigate to="/dashboard" />;
}

return (
<form onSubmit={handleSubmit}>
<input
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
placeholder="Email"
/>
<input
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
placeholder="Password"
/>
{error && <p style={{ color: 'red' }}>{error}</p>}
<button type="submit" disabled={loading}>
{loading ? 'Logging in...' : 'Log In'}
</button>
</form>
);
}

Logout Example​

src/components/LogoutButton.tsx
function LogoutButton() {
const { logout } = useAuth();
return <button onClick={logout}>Log Out</button>;
}
tip

logout() clears the session but does not navigate. Route on isAuthenticated -- a protected-route wrapper on web, a conditional navigator on React Native -- and the user lands on your login screen when it turns false.


Group-Aware Auth​

A Rhino backend can register the auth route set per route group (see the server's Route Groups docs). The client mirrors this: tell it which group is signing in and it builds group-scoped auth URLs.

  • A prefix-based group exposes its auth under /{group}/auth/* — e.g. routeGroup: 'driver' makes login hit POST /api/driver/auth/login.
  • With no routeGroup, every auth URL is byte-for-byte the legacy /auth/* path. The feature is fully opt-in and backward compatible.
Domain-based groups need no routeGroup

A domain-based group serves the plain /api/auth/* set on its own host, so the host already scopes it — you do not set routeGroup for domain/subdomain groups. routeGroup is only for prefix-based groups.

Configuring the group​

There are two equivalent ways to register a route group with the client.

src/main.tsx
import { configureApi, AuthProvider } from '@rhino-dev/rhino-react';

// Option A — configure the API client once at startup
configureApi({
baseURL: '/api',
routeGroup: 'driver', // auth URLs become /api/driver/auth/*
onForbidden: (error) => { // see "Handling 403" below
console.warn(error.response?.data?.message);
},
});

// Option B — pass it to the provider (it registers the group with the client)
<AuthProvider routeGroup="driver">{children}</AuthProvider>;

configureApi accepts (in addition to baseURL / onUnauthorized):

OptionTypeDescription
routeGroupstring | nullRoute group used to build group-aware auth URLs. When set, auth paths become /{routeGroup}/auth/*; pass null to clear a previously configured group.
onForbidden(error) => voidCallback fired on a 403 response (authenticated but not a member of the group). The token is not cleared.

login / logout with a per-call group​

login and logout accept a per-call routeGroup that overrides the configured one. The resolved group is persisted under the route_group storage key (cleared on logout) and exposed via useRouteGroup().

const { login, logout } = useAuth();

// POST /api/admin/auth/login — overrides the provider/configured group
const result = await login(email, password, { routeGroup: 'admin' });
result.route_group; // 'admin'

await logout(); // also clears the persisted route group

useRouteGroup()​

Returns the active route group from storage. It mirrors useOrganization() and stays in sync across tabs on web (and in-memory on React Native).

import { useRouteGroup } from '@rhino-dev/rhino-react';

const routeGroup = useRouteGroup(); // 'admin' after a group-aware login, else null

The group is persisted on a group-aware login (and by useRegister / useAcceptInvitation when the backend echoes a route_group), and cleared on logout.

Handling 403 vs 401​

The two responses mean different things and are handled differently:

StatusMeaningClient behavior
401 Unauthorized (any request except login)Missing/expired tokentoken and user are removed from storage; AuthProvider resets (isAuthenticated: false, token: null); then onUnauthorized runs (default on web: redirect to /).
401 Unauthorized (from /auth/login or /{routeGroup}/auth/login)Wrong credentialsNothing is cleared and onUnauthorized does not run; login() returns { success: false, status: 401 }.
403 ForbiddenAuthenticated, but not a member of the requested route group (membership denial, when the backend has enforce_group_membership on)Token is kept; onForbidden(error) runs so you can surface the denial without logging the user out.

AuthProvider learns about the 401 through the 'token' event on the events adapter, so every component reading useAuth() re-renders logged out. Navigation that follows isAuthenticated needs no extra wiring, and there is no need to mirror auth state in your own store.

configureApi({
onForbidden: (error) => {
// The user is logged in but cannot enter this group — show a message,
// not a logout.
toast(error.response?.data?.message ?? 'You do not have access to this area.');
},
});

Group-aware action hooks​

Three mutation hooks complete the group-aware auth flow. Each respects the configured routeGroup and accepts a per-call routeGroup override; the URLs are built the same way as login (/{group}/auth/*, or /auth/* by default).

HookRequestPayload
useRegister()POST {authBase}/auth/register{ token, name, email, password, password_confirmation, routeGroup? }
usePasswordRecover()POST {authBase}/auth/password/recover{ email, routeGroup? }
useResetPassword()POST {authBase}/auth/password/reset{ token, email, password, password_confirmation, routeGroup? }
import { useRegister, usePasswordRecover, useResetPassword } from '@rhino-dev/rhino-react';

const register = useRegister();
await register.mutateAsync({ token, name, email, password, password_confirmation });

const recover = usePasswordRecover();
await recover.mutateAsync({ email });

const reset = useResetPassword();
await reset.mutateAsync({ token, email, password, password_confirmation });

A successful useRegister starts a session exactly like login(): the token, user and organization the backend returns are written to storage before the mutation resolves, and AuthProvider becomes authenticated. A request issued right after await register.mutateAsync(...) carries the bearer token. If the response has no token, the session is left untouched. useRegister also persists the route_group the backend echoes back (like a group-aware login), so useRouteGroup() is populated after an invitation-accept registration.

useAcceptInvitation does not start a session -- the accept endpoint issues no token. See Invitations.


Tenancy and Data URLs​

Auth URLs follow routeGroup. Data URLs -- every CRUD and query hook -- follow two more options: tenancy, which says whether the organization is a path segment, and routeGroupInDataPath, which puts the route group in front of it.

src/config.ts
import { configureApi } from '@rhino-dev/rhino-react';

// A driver app talking to a prefix route group with no tenant
configureApi({
baseURL: 'https://api.example.com/api',
tenancy: 'none',
routeGroup: 'driver',
routeGroupInDataPath: true,
});
// useModelIndex('trips') -> GET /api/driver/trips
// login() -> POST /api/driver/auth/login
OptionValuesEffect on data URLs
tenancy'path' (default)The organization is a path segment: /{organization}/{model}. An organization is required.
'subdomain'The organization is carried by the host ({organization}.example.com): /{model}. No organization is required; you may still setOrganization for display.
'none'There is no organization: /{model}. No organization is required, and a stored one is ignored.
routeGroupInDataPathfalse (default)The route group shapes auth URLs only.
trueThe configured routeGroup is prepended to data URLs.

Set tenancy with configureApi({ tenancy }) or <AuthProvider tenancy="…"> (which accepts the same three values). The resulting URLs, with a base URL of /api:

SetupConfigurationuseModelIndex('trips')
Path tenancy (default)--/api/{organization}/trips
Subdomain tenancytenancy: 'subdomain'/api/trips on {organization}.example.com
No tenancytenancy: 'none'/api/trips
Prefix group, no organizationtenancy: 'none', routeGroup: 'driver', routeGroupInDataPath: true/api/driver/trips
Prefix group with organizationtenancy: 'path', routeGroup: 'client', routeGroupInDataPath: true/api/client/{organization}/trips
Prefix group on a subdomain tenanttenancy: 'subdomain', routeGroup: 'driver', routeGroupInDataPath: true/api/driver/trips on the tenant host

The same base applies to every data hook: index, show (/{id}), computed (/computed), trashed (/trashed), restore (/{id}/restore), force delete (/{id}/force-delete), audit (/{id}/audit), nested operations (/nested) and useModelInfinite. routeGroupInDataPath has no effect without a routeGroup, and it never changes auth URLs.

Tenant-only hooks. Organizations, roles and invitations belong to an organization, so useOwner, useOrganizationExists, useUserRole, useInvitations, useInviteUser, useResendInvitation and useCancelInvitation always carry the organization segment, whatever the tenancy -- prefixed with the route group when routeGroupInDataPath is on (/api/client/{organization}/invitations). Without an organization they request nothing. useAcceptInvitation always posts to the fixed /api/invitations/accept.

The matching server setup

tenancy: 'none' with routeGroupInDataPath: true talks to a prefix route group with no tenant boundary -- on Laravel, a group declared 'tenant' => false in config/rhino.php. See the server's Multi-Tenancy -- Route Groups Without a Tenant Boundary and Route Groups.


useOrganization()​

Returns the current organization slug -- the organization_slug key in storage -- and re-renders when it changes through setOrganization() (across tabs on web, in-memory on React Native).

src/hooks/useOrganization.ts
import { useOrganization } from '@rhino-dev/rhino-react';

const organization = useOrganization();
// Returns: 'acme-corp' or null

How It Works​

The organization slug is automatically included in all API requests made by Rhino hooks in the default 'path' tenancy. You do not need to pass it manually to CRUD or query hooks. login() stores the first organization the backend returns; switch it with setOrganization(slug).

info

The hook does not read the URL. If your routes carry the organization (e.g. /orgs/:organization/*), call setOrganization(params.organization) when the route changes so the hooks follow it.


useOwner(options?)​

Fetches the current organization's full data from the API, with support for eager-loading related resources via the includes option. This is built on top of React Query, so it returns the standard { data, isLoading, error } pattern.

src/hooks/useOwner.ts
const { data: organization, isLoading, error } = useOwner({
includes: ['users', 'roles'],
});

Parameters​

OptionTypeDescription
includesstring[]Optional. An array of relationship names to eager-load with the organization.
slugstringOptional. Override the organization slug instead of using the one from useOrganization().

Response Shape​

Response
// Example response:
{
id: 1,
name: "Acme Corp",
slug: "acme-corp",
users: [
{ id: 1, name: "John", pivot: { role_id: 1 } },
{ id: 2, name: "Jane", pivot: { role_id: 2 } }
],
roles: [
{ id: 1, name: "Admin" },
{ id: 2, name: "Member" }
]
}

Organization Dashboard Example​

src/components/OrgDashboard.tsx
function OrgDashboard() {
const { data: org, isLoading } = useOwner({ includes: ['users'] });

if (isLoading) return <div>Loading...</div>;

return (
<div>
<h1>{org.name}</h1>
<p>Slug: {org.slug}</p>
<h2>Members ({org.users?.length})</h2>
<ul>
{org.users?.map(user => (
<li key={user.id}>
{user.name} — Role ID: {user.pivot?.role_id}
</li>
))}
</ul>
</div>
);
}
tip

Use the includes parameter to avoid N+1 queries. Load all the relationships you need in a single request rather than making separate API calls.


useOrganizationExists(slug?)​

Checks whether a given organization slug already exists. This is particularly useful for registration and organization creation flows where you need to validate slug availability in real time.

src/hooks/useOrganizationExists.ts
const { exists, isLoading, organization } = useOrganizationExists('acme-corp');

Return Values​

PropertyTypeDescription
existsbooleantrue if the slug is already taken, false if available.
isLoadingbooleantrue while the check is in progress.
organizationobject | nullThe organization data if it exists, null otherwise.

Slug Availability Example​

src/components/CreateOrgForm.tsx
function CreateOrgForm() {
const [slug, setSlug] = useState('');
const { exists, isLoading } = useOrganizationExists(slug);

return (
<div>
<input
value={slug}
onChange={e => setSlug(e.target.value)}
placeholder="org-slug"
/>
{isLoading && <span>Checking...</span>}
{!isLoading && slug && (
exists
? <span style={{ color: 'red' }}>Already taken</span>
: <span style={{ color: 'green' }}>Available!</span>
)}
</div>
);
}
info

The hook debounces the API call internally, so it is safe to call on every keystroke without flooding your server with requests.


Common Patterns​

Protected Route​

Combine useAuth and useOrganization to guard routes that require both authentication and an active organization:

src/components/ProtectedRoute.tsx
function ProtectedRoute({ children }) {
const { isAuthenticated } = useAuth();
const organization = useOrganization();

if (!isAuthenticated) {
return <Navigate to="/login" />;
}

if (!organization) {
return <Navigate to="/select-organization" />;
}

return children;
}

// Usage:
<Route
path="/orgs/:organization/dashboard"
element={
<ProtectedRoute>
<Dashboard />
</ProtectedRoute>
}
/>

Organization Switching​

Allow users to switch between organizations they belong to:

src/components/OrgSwitcher.tsx
function OrgSwitcher({ organizations }) {
const { setOrganization } = useAuth();

const handleSwitch = (slug) => {
setOrganization(slug);
window.location.href = `/orgs/${slug}/dashboard`;
};

return (
<select onChange={e => handleSwitch(e.target.value)}>
{organizations.map(org => (
<option key={org.slug} value={org.slug}>
{org.name}
</option>
))}
</select>
);
}
tip

After calling setOrganization, use a full page navigation (window.location.href) rather than a client-side route push. This ensures all cached queries are refreshed with the new organization context.

Full Auth + Org Setup​

A complete example wiring login, organization selection, and protected content together:

src/App.tsx
import { RhinoProvider, useAuth, useOrganization, useOwner } from '@rhino-dev/rhino-react';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';

function App() {
return (
<BrowserRouter>
<RhinoProvider baseUrl="https://api.example.com">
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route
path="/orgs/:organization/dashboard"
element={
<ProtectedRoute>
<Dashboard />
</ProtectedRoute>
}
/>
<Route path="*" element={<Navigate to="/login" />} />
</Routes>
</RhinoProvider>
</BrowserRouter>
);
}

function Dashboard() {
const { logout } = useAuth();
const { data: org, isLoading } = useOwner({ includes: ['users'] });

if (isLoading) return <div>Loading...</div>;

return (
<div>
<header>
<h1>{org.name}</h1>
<button onClick={logout}>Log Out</button>
</header>
<p>Welcome! You have {org.users?.length} team members.</p>
</div>
);
}