Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Pocketbase Auth with Realtime Data #409

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
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
30 changes: 30 additions & 0 deletions remix-auth-pocketbase/.eslintrc.cjs
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
node_modules

/.cache
/build
/public/build
.env
49 changes: 49 additions & 0 deletions remix-auth-pocketbase/README.md
Original file line number Diff line number Diff line change
@@ -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>[email protected]</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 `[email protected]` 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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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="[email protected]"
/>
</div>

<button>Forgot Password</button>

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