Skip to content

Commit

Permalink
Pocketbase Auth with Realtime Data
Browse files Browse the repository at this point in the history
  • Loading branch information
jonshipman committed Jan 10, 2024
1 parent 4530bd9 commit 73dba07
Show file tree
Hide file tree
Showing 24 changed files with 870 additions and 0 deletions.
30 changes: 30 additions & 0 deletions remix-auth-pocketbase/.eslintrc.cjs
@@ -0,0 +1,30 @@
/* eslint-env es6 */
const OFF = 0;
const WARN = 1;
const ERROR = 2;

/** @type {import('eslint').Linter.Config} */
module.exports = {
root: true,
extends: ["@remix-run/eslint-config/internal", "plugin:markdown/recommended"],
plugins: ["markdown"],
settings: {
"import/internal-regex": "^~/",
},
ignorePatterns: ["pocketbase/**"],
rules: {
"prefer-let/prefer-let": OFF,
"prefer-const": WARN,

"import/order": [
ERROR,
{
alphabetize: { caseInsensitive: true, order: "asc" },
groups: ["builtin", "external", "internal", "parent", "sibling"],
"newlines-between": "always",
},
],

"react/jsx-no-leaked-render": [WARN, { validStrategies: ["ternary"] }],
},
};
6 changes: 6 additions & 0 deletions remix-auth-pocketbase/.gitignore
@@ -0,0 +1,6 @@
node_modules

/.cache
/build
/public/build
.env
49 changes: 49 additions & 0 deletions remix-auth-pocketbase/README.md
@@ -0,0 +1,49 @@
# Pocketbase example

This is an example showing a basic integration of Remix with [Pocketbase](https://pocketbase.io/).

## Example

### Getting started

First, install dependencies in both the root folder (right here)

```bash
npm i
```

Then, start both the Remix and Pocketbase with

```bash
npm run dev
```

### Pocketbase

In this example, a Pocketbase instance will be downloaded to `pocketbase/`. Using the migration framework, an admin user and app user will be created. A `realtime_example` collection will be created and supported with `pocketbase/pb_hooks/realtime.pb.js` by a `cronAdd` function. __In order for the email verification and forgot-password emails to work, you will need to setup SMTP in the Pocketbase admin.__ You can also manually verify new accounts in the Pocketbase admin for testing.

> Note that in a real app, you'd likely not have your admin password commited in a migration. This is for demo purposes only.
#### Administration Panel

Pocketbase's administration panel is at [http://localhost:8090/_](http://localhost:8090/_).

<pre>
# Credentials
Email: <strong>pocketbase@remix.example</strong>
Password: <strong>Passw0rd</strong>
</pre>

### Remix

The Remix app is at http://localhost:3000. The following routes are provided:

- __/__ - with links to the below
- __/login__ - populated with the test user by default
- __/register__ - populated with `2+pocketbase@remix.example` by default
- __/forgot-password__ - populated with the test user's email by default
- __/admin__ - accessible only after login and count is auto updated by way of Pocketbase's Realtime API

There are two Pocketbase files, `pb.server.ts` and `pb.client.ts`. `pb.server.ts` handles the connection to the server for the auth and setting the cookies for persistence. It can also be used in the `loader` functions to prepopulate data on the server. `pb.client.ts` creates a new Pocketbase instance for the client. It uses the cookie setup on server for authenticating. You can use the client export for `useEffect` hooks or the realtime data API. `admin.tsx` has an example of loading data on the server and the realtime API.

You may want to implement a `Content Security Policy` as this setup requires `httpOnly: false` set on the Pocketbase cookie to share between the server and client. This demo does not cover CSP.
8 changes: 8 additions & 0 deletions remix-auth-pocketbase/app/pb.client.ts
@@ -0,0 +1,8 @@
import Pocketbase from "pocketbase";

export let pb: Pocketbase | null = null;

if (typeof window !== "undefined") {
pb = new Pocketbase(window.ENV.POCKETBASE_URL);
pb.authStore.loadFromCookie(document.cookie);
}
41 changes: 41 additions & 0 deletions remix-auth-pocketbase/app/pb.server.ts
@@ -0,0 +1,41 @@
import { redirect } from "@remix-run/node";
import Pocketbase from "pocketbase";

export function getPocketbase(request?: Request) {
const pb = new Pocketbase(
process.env.POCKETBASE_URL || "http://localhost:8090",
);

if (request) {
pb.authStore.loadFromCookie(request.headers.get("cookie") || "");
} else {
pb.authStore.loadFromCookie("");
}

return pb;
}

export function getUser(pb: Pocketbase) {
if (pb.authStore.model) {
return structuredClone(pb.authStore.model);
}

return null;
}

export function createSession(redirectTo: string, pb: Pocketbase) {
return redirect(redirectTo, {
headers: {
"set-cookie": pb.authStore.exportToCookie({
secure: redirectTo.startsWith("https:"),
httpOnly: false,
}),
},
});
}

export function destroySession(pb: Pocketbase) {
pb.authStore.clear();

return createSession("/", pb);
}
50 changes: 50 additions & 0 deletions remix-auth-pocketbase/app/root.tsx
@@ -0,0 +1,50 @@
import { cssBundleHref } from "@remix-run/css-bundle";
import type { LinksFunction } from "@remix-run/node";
import { json } from "@remix-run/node";
import {
Links,
LiveReload,
Meta,
Outlet,
Scripts,
ScrollRestoration,
useLoaderData,
} from "@remix-run/react";

export const links: LinksFunction = () => [
...(cssBundleHref ? [{ rel: "stylesheet", href: cssBundleHref }] : []),
];

export async function loader() {
return json({
ENV: {
POCKETBASE_URL: process.env.POCKETBASE_URL || "http://localhost:8090",
},
});
}

export default function App() {
const data = useLoaderData<typeof loader>();

return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
</head>
<body>
<Outlet />
<script
dangerouslySetInnerHTML={{
__html: `window.ENV = ${JSON.stringify(data.ENV)}`,
}}
/>
<ScrollRestoration />
<Scripts />
<LiveReload />
</body>
</html>
);
}
37 changes: 37 additions & 0 deletions remix-auth-pocketbase/app/routes/_index.tsx
@@ -0,0 +1,37 @@
import type { LoaderFunctionArgs, MetaFunction } from "@remix-run/node";
import { json } from "@remix-run/node";
import { Link, useLoaderData } from "@remix-run/react";

import { getPocketbase, getUser } from "~/pb.server";

export const meta: MetaFunction = () => {
return [
{ title: "New Remix App" },
{ name: "description", content: "Welcome to Remix!" },
];
};

export async function loader({ request }: LoaderFunctionArgs) {
const pb = getPocketbase(request);
const user = getUser(pb);

return json({ user });
}

export default function Index() {
const data = useLoaderData<typeof loader>();

return (
<div style={{ display: "flex", gap: "1rem" }}>
{data.user ? (
<Link to="/logout">Logout</Link>
) : (
<>
<Link to="/login">Login</Link>
<Link to="/register">Register</Link>
<Link to="/forgot-password">Forgot Password</Link>
</>
)}
</div>
);
}
60 changes: 60 additions & 0 deletions remix-auth-pocketbase/app/routes/admin.tsx
@@ -0,0 +1,60 @@
import type { LoaderFunctionArgs } from "@remix-run/node";
import { json } from "@remix-run/node";
import { Link, useLoaderData } from "@remix-run/react";
import { useEffect, useState } from "react";

import { pb } from "~/pb.client";
import { createSession, getPocketbase, getUser } from "~/pb.server";

export async function loader({ request }: LoaderFunctionArgs) {
const pb = getPocketbase(request);
const user = getUser(pb);

const redirectUrl = "/admin";

if (!user) {
return createSession("/", pb);
}

let realtime_example = null;

try {
realtime_example = await pb.collection("realtime_example").getFullList();
} catch (_) {}

return json({ redirectUrl, user, realtime_example });
}

export default function Admin() {
const loaderData = useLoaderData<typeof loader>();
const [count, setCount] = useState(
loaderData.realtime_example?.[0]?.count || 0,
);

useEffect(() => {
pb?.collection("realtime_example").subscribe("*", (data) => {
setCount(data.record.count);
});

return () => {
pb?.collection("realtime_example").unsubscribe("*");
};
}, [setCount]);

return (
<div>
<div>Hello {loaderData.user.name || loaderData.user.email}</div>
<div style={{ display: "flex", gap: "1rem", margin: "1rem 0" }}>
<Link to="/logout" reloadDocument>
Logout
</Link>

<Link to="/">Home</Link>
</div>

<div>
Realtime Data Demo: <span>{count}</span>
</div>
</div>
);
}
70 changes: 70 additions & 0 deletions remix-auth-pocketbase/app/routes/forgot-password.tsx
@@ -0,0 +1,70 @@
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node";
import { json } from "@remix-run/node";
import { Form, Link, useActionData } from "@remix-run/react";
import { ClientResponseError } from "pocketbase";

import { createSession, getPocketbase, getUser } from "~/pb.server";

interface ForgotPasswordRequestData {
email: string;
}

export async function action({ request }: ActionFunctionArgs) {
const pb = getPocketbase(request);

const result = (await request.formData()) as unknown as Iterable<
[ForgotPasswordRequestData, FormDataEntryValue]
>;
const data: ForgotPasswordRequestData = Object.fromEntries(result);

try {
await pb.collection("users").requestPasswordReset(data.email);

return json({
success: true,
error: false,
message: "An email has been sent to reset your password!",
});
} catch (error) {
if (error instanceof ClientResponseError) {
return json({ success: false, error: true, message: error.message });
}
}
}

export async function loader({ request }: LoaderFunctionArgs) {
const pb = getPocketbase(request);
const user = getUser(pb);

const redirectUrl = "/admin";

if (user) return createSession(redirectUrl, pb);

return json({ redirectUrl, user });
}

export default function Login() {
const actionData = useActionData<typeof action>();

return (
<Form method="post">
{actionData?.error ? <div>{actionData.message}</div> : null}
{actionData?.success ? (
<div style={{ color: "green" }}>{actionData.message}</div>
) : null}
<div>
<label htmlFor="email">Email</label>
<input
type="email"
name="email"
id="email"
defaultValue="pocketbase@remix.example"
/>
</div>

<button>Forgot Password</button>

<Link to="/login">Login</Link>
</Form>
);
}

0 comments on commit 73dba07

Please sign in to comment.