Skip to content
Open
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
52 changes: 51 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ I got tired of copy-pasting this code around, so I made a library. Maybe you'll
- Manages login/logout state
- Stores tokens in localStorage (or wherever you want)
- Gives you hooks to check if someone's logged in
- Provides optional UI templates (LoginForm, LogoutButton, AuthGate)
- Protects routes that need authentication
- Handles token refresh when you tell it to

Expand All @@ -27,7 +28,6 @@ I got tired of copy-pasting this code around, so I made a library. Maybe you'll
- No OAuth flows (build that yourself)
- No cookie auth (we use tokens)
- No automatic background token refresh (you call refresh when needed)
- No fancy UI components
- No backend (you need to build your own API)

## Install
Expand Down Expand Up @@ -62,6 +62,31 @@ function App() {

That's it. Now you can use the hooks anywhere. Pretty simple right?

## Prebuilt UI templates

If you want the boilerplate UI handled for you, the library ships with simple, optional UI components:

```jsx
import { AuthGate, LoginForm, LogoutButton } from 'authbase-react';

function App() {
return (
<AuthProvider config={authConfig}>
<AuthGate>
<LogoutButton />
<YourApp />
</AuthGate>
</AuthProvider>
);
}
```

- `AuthGate` protects everything inside it and shows a login form by default when unauthenticated.
- `LoginForm` is a minimal sign-in form wired to `signIn`.
- `LogoutButton` signs the user out with one click.

These templates are styled with Tailwind (shadcn-ui inspired), so your app should already have Tailwind configured to see the intended design and animations.

## Make a login form

```jsx
Expand Down Expand Up @@ -201,6 +226,9 @@ Response: {
user: object
}
```
Failure cases:
- 401/403 for invalid credentials (will surface as an error in the auth state)
- 4xx/5xx for API issues (will surface as an error in the auth state)

**Refresh endpoint (optional):**
```
Expand All @@ -210,13 +238,18 @@ Response: {
access_token: string
}
```
Failure cases:
- 401/403 for expired/invalid refresh token (will transition to unauthenticated)
- 4xx/5xx for API issues (will surface as an error in the auth state)

**Logout endpoint (optional):**
```
POST /auth/logout
Headers: Authorization: Bearer {access_token}
Response: 204
```
Failure cases:
- 4xx/5xx errors are captured but local auth state still clears

That's all we support right now. If your API looks different, this library won't work for you (yet). Sorry bout that.

Expand Down Expand Up @@ -251,6 +284,23 @@ const {

**useIsAuthenticated()** - Just a boolean, yep

## State machine overview

Authbase-react is intentionally deterministic. These are the only possible states and transitions:

**States**
- `idle` → initial state before storage is checked
- `loading` → a login/logout/refresh/init is in progress
- `authenticated` → user + access token are present
- `unauthenticated` → no valid session
- `error` → an operation failed (error is stored in state)

**Common transitions**
- `idle` → `loading` → `authenticated | unauthenticated`
- `unauthenticated` → `loading` → `authenticated` (login success)
- `authenticated` → `loading` → `unauthenticated` (logout)
- `authenticated` → `loading` → `authenticated | unauthenticated` (refresh)

## Philosophy

This library is intentionally boring. There's no clever tricks, no abstractions, no "magic". It's just a state machine that stores some data. Boring is good sometimes.
Expand Down
40 changes: 40 additions & 0 deletions src/components/AuthGate.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
// Auth gate - protects the entire tree and renders a default login form

import React from 'react';
import { useAuth } from '../hooks/useAuth';
import { LoginForm } from './LoginForm';

interface AuthGateProps {
children: React.ReactNode;
loginFallback?: React.ReactNode;
loadingFallback?: React.ReactNode;
errorFallback?: React.ReactNode;
}

export function AuthGate({
children,
loginFallback,
loadingFallback = null,
errorFallback,
}: AuthGateProps) {
const { isAuthenticated, isLoading, error } = useAuth();
const defaultLogin = (
<div className="flex min-h-screen items-center justify-center bg-slate-50 p-6">
<LoginForm />
</div>
);

if (isLoading) {
return <>{loadingFallback}</>;
}

if (error) {
return <>{errorFallback ?? loginFallback ?? defaultLogin}</>;
}

if (!isAuthenticated) {
return <>{loginFallback ?? defaultLogin}</>;
}

return <>{children}</>;
}
90 changes: 90 additions & 0 deletions src/components/LoginForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
// Prebuilt login form - keeps auth UI boilerplate small

import React, { useState } from 'react';
import { useAuth } from '../hooks/useAuth';

interface LoginFormProps {
identifierLabel?: string;
secretLabel?: string;
submitLabel?: string;
className?: string;
onSuccess?: () => void;
onError?: (error: Error) => void;
}

export function LoginForm({
identifierLabel = 'Email',
secretLabel = 'Password',
submitLabel = 'Sign in',
className,
onSuccess,
onError,
}: LoginFormProps) {
const { signIn, isLoading, error } = useAuth();
const [identifier, setIdentifier] = useState('');
const [secret, setSecret] = useState('');

const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();

try {
await signIn(identifier, secret);
onSuccess?.();
} catch (caught) {
onError?.(caught as Error);
}
};

return (
<form
className={[
'w-full max-w-sm space-y-4 rounded-2xl border border-slate-200/70 bg-white/80 p-6 shadow-sm',
'backdrop-blur transition-all duration-300 hover:-translate-y-0.5 hover:shadow-lg',
className,
]
.filter(Boolean)
.join(' ')}
onSubmit={handleSubmit}
>
<div className="space-y-1">
<label className="text-sm font-medium text-slate-700">
{identifierLabel}
</label>
<input
className="w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm outline-none transition focus:border-slate-400 focus:ring-2 focus:ring-slate-300/60"
type="text"
value={identifier}
onChange={(event) => setIdentifier(event.target.value)}
autoComplete="username"
/>
</div>
<div className="space-y-1">
<label className="text-sm font-medium text-slate-700">
{secretLabel}
</label>
<input
className="w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm outline-none transition focus:border-slate-400 focus:ring-2 focus:ring-slate-300/60"
type="password"
value={secret}
onChange={(event) => setSecret(event.target.value)}
autoComplete="current-password"
/>
</div>
<button
className="inline-flex w-full items-center justify-center gap-2 rounded-md bg-slate-900 px-4 py-2 text-sm font-medium text-white shadow-sm transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md disabled:cursor-not-allowed disabled:opacity-60"
type="submit"
disabled={isLoading}
>
{isLoading && (
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white/40 border-t-white" />
)}
{submitLabel}
</button>
{error && (
<p role="alert" className="text-sm text-rose-600 animate-pulse">
{error.message}
</p>
)}
</form>
);
}
40 changes: 40 additions & 0 deletions src/components/LogoutButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
// Prebuilt logout button - quick sign out UI

import React from 'react';
import { useAuth } from '../hooks/useAuth';

interface LogoutButtonProps {
label?: string;
className?: string;
onSignedOut?: () => void;
}

export function LogoutButton({ label = 'Sign out', className, onSignedOut }: LogoutButtonProps) {
const { signOut, isLoading } = useAuth();

const handleClick = async () => {
await signOut();
onSignedOut?.();
};

return (
<button
type="button"
className={[
'inline-flex items-center justify-center gap-2 rounded-full border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-700 shadow-sm',
'transition-all duration-200 hover:-translate-y-0.5 hover:border-slate-300 hover:shadow-md',
'disabled:cursor-not-allowed disabled:opacity-60',
className,
]
.filter(Boolean)
.join(' ')}
onClick={handleClick}
disabled={isLoading}
>
{isLoading && (
<span className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-slate-400/40 border-t-slate-700" />
)}
{label}
</button>
);
}
21 changes: 12 additions & 9 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
// Public API - only export what consumers need, keep it simple

export { AuthProvider } from './AuthProvider';
export { RequireAuth } from './components/RequireAuth';
export { useAuth } from './hooks/useAuth';
export { useUser } from './hooks/useUser';
export { useIsAuthenticated } from './hooks/useIsAuthenticated';

export type { AuthConfig, User } from './types';
// Public API - only export what consumers need, keep it simple

export { AuthProvider } from './AuthProvider';
export { RequireAuth } from './components/RequireAuth';
export { AuthGate } from './components/AuthGate';
export { LoginForm } from './components/LoginForm';
export { LogoutButton } from './components/LogoutButton';
export { useAuth } from './hooks/useAuth';
export { useUser } from './hooks/useUser';
export { useIsAuthenticated } from './hooks/useIsAuthenticated';

export type { AuthConfig, User } from './types';