Skip to content

Commit c733021

Browse files
jonshipmanmachour
authored andcommitted
Pocketbase Auth with Realtime Data
1 parent 7c087e4 commit c733021

24 files changed

+870
-0
lines changed

remix-auth-pocketbase/.eslintrc.cjs

+30
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
/* eslint-env es6 */
2+
const OFF = 0;
3+
const WARN = 1;
4+
const ERROR = 2;
5+
6+
/** @type {import('eslint').Linter.Config} */
7+
module.exports = {
8+
root: true,
9+
extends: ["@remix-run/eslint-config/internal", "plugin:markdown/recommended"],
10+
plugins: ["markdown"],
11+
settings: {
12+
"import/internal-regex": "^~/",
13+
},
14+
ignorePatterns: ["pocketbase/**"],
15+
rules: {
16+
"prefer-let/prefer-let": OFF,
17+
"prefer-const": WARN,
18+
19+
"import/order": [
20+
ERROR,
21+
{
22+
alphabetize: { caseInsensitive: true, order: "asc" },
23+
groups: ["builtin", "external", "internal", "parent", "sibling"],
24+
"newlines-between": "always",
25+
},
26+
],
27+
28+
"react/jsx-no-leaked-render": [WARN, { validStrategies: ["ternary"] }],
29+
},
30+
};

remix-auth-pocketbase/.gitignore

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
node_modules
2+
3+
/.cache
4+
/build
5+
/public/build
6+
.env

remix-auth-pocketbase/README.md

+49
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
# Pocketbase example
2+
3+
This is an example showing a basic integration of Remix with [Pocketbase](https://pocketbase.io/).
4+
5+
## Example
6+
7+
### Getting started
8+
9+
First, install dependencies in both the root folder (right here)
10+
11+
```bash
12+
npm i
13+
```
14+
15+
Then, start both the Remix and Pocketbase with
16+
17+
```bash
18+
npm run dev
19+
```
20+
21+
### Pocketbase
22+
23+
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.
24+
25+
> Note that in a real app, you'd likely not have your admin password commited in a migration. This is for demo purposes only.
26+
27+
#### Administration Panel
28+
29+
Pocketbase's administration panel is at [http://localhost:8090/_](http://localhost:8090/_).
30+
31+
<pre>
32+
# Credentials
33+
Email: <strong>pocketbase@remix.example</strong>
34+
Password: <strong>Passw0rd</strong>
35+
</pre>
36+
37+
### Remix
38+
39+
The Remix app is at http://localhost:3000. The following routes are provided:
40+
41+
- __/__ - with links to the below
42+
- __/login__ - populated with the test user by default
43+
- __/register__ - populated with `2+pocketbase@remix.example` by default
44+
- __/forgot-password__ - populated with the test user's email by default
45+
- __/admin__ - accessible only after login and count is auto updated by way of Pocketbase's Realtime API
46+
47+
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.
48+
49+
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
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import Pocketbase from "pocketbase";
2+
3+
export let pb: Pocketbase | null = null;
4+
5+
if (typeof window !== "undefined") {
6+
pb = new Pocketbase(window.ENV.POCKETBASE_URL);
7+
pb.authStore.loadFromCookie(document.cookie);
8+
}
+41
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { redirect } from "@remix-run/node";
2+
import Pocketbase from "pocketbase";
3+
4+
export function getPocketbase(request?: Request) {
5+
const pb = new Pocketbase(
6+
process.env.POCKETBASE_URL || "http://localhost:8090",
7+
);
8+
9+
if (request) {
10+
pb.authStore.loadFromCookie(request.headers.get("cookie") || "");
11+
} else {
12+
pb.authStore.loadFromCookie("");
13+
}
14+
15+
return pb;
16+
}
17+
18+
export function getUser(pb: Pocketbase) {
19+
if (pb.authStore.model) {
20+
return structuredClone(pb.authStore.model);
21+
}
22+
23+
return null;
24+
}
25+
26+
export function createSession(redirectTo: string, pb: Pocketbase) {
27+
return redirect(redirectTo, {
28+
headers: {
29+
"set-cookie": pb.authStore.exportToCookie({
30+
secure: redirectTo.startsWith("https:"),
31+
httpOnly: false,
32+
}),
33+
},
34+
});
35+
}
36+
37+
export function destroySession(pb: Pocketbase) {
38+
pb.authStore.clear();
39+
40+
return createSession("/", pb);
41+
}

remix-auth-pocketbase/app/root.tsx

+50
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { cssBundleHref } from "@remix-run/css-bundle";
2+
import type { LinksFunction } from "@remix-run/node";
3+
import { json } from "@remix-run/node";
4+
import {
5+
Links,
6+
LiveReload,
7+
Meta,
8+
Outlet,
9+
Scripts,
10+
ScrollRestoration,
11+
useLoaderData,
12+
} from "@remix-run/react";
13+
14+
export const links: LinksFunction = () => [
15+
...(cssBundleHref ? [{ rel: "stylesheet", href: cssBundleHref }] : []),
16+
];
17+
18+
export async function loader() {
19+
return json({
20+
ENV: {
21+
POCKETBASE_URL: process.env.POCKETBASE_URL || "http://localhost:8090",
22+
},
23+
});
24+
}
25+
26+
export default function App() {
27+
const data = useLoaderData<typeof loader>();
28+
29+
return (
30+
<html lang="en">
31+
<head>
32+
<meta charSet="utf-8" />
33+
<meta name="viewport" content="width=device-width, initial-scale=1" />
34+
<Meta />
35+
<Links />
36+
</head>
37+
<body>
38+
<Outlet />
39+
<script
40+
dangerouslySetInnerHTML={{
41+
__html: `window.ENV = ${JSON.stringify(data.ENV)}`,
42+
}}
43+
/>
44+
<ScrollRestoration />
45+
<Scripts />
46+
<LiveReload />
47+
</body>
48+
</html>
49+
);
50+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import type { LoaderFunctionArgs, MetaFunction } from "@remix-run/node";
2+
import { json } from "@remix-run/node";
3+
import { Link, useLoaderData } from "@remix-run/react";
4+
5+
import { getPocketbase, getUser } from "~/pb.server";
6+
7+
export const meta: MetaFunction = () => {
8+
return [
9+
{ title: "New Remix App" },
10+
{ name: "description", content: "Welcome to Remix!" },
11+
];
12+
};
13+
14+
export async function loader({ request }: LoaderFunctionArgs) {
15+
const pb = getPocketbase(request);
16+
const user = getUser(pb);
17+
18+
return json({ user });
19+
}
20+
21+
export default function Index() {
22+
const data = useLoaderData<typeof loader>();
23+
24+
return (
25+
<div style={{ display: "flex", gap: "1rem" }}>
26+
{data.user ? (
27+
<Link to="/logout">Logout</Link>
28+
) : (
29+
<>
30+
<Link to="/login">Login</Link>
31+
<Link to="/register">Register</Link>
32+
<Link to="/forgot-password">Forgot Password</Link>
33+
</>
34+
)}
35+
</div>
36+
);
37+
}
+60
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import type { LoaderFunctionArgs } from "@remix-run/node";
2+
import { json } from "@remix-run/node";
3+
import { Link, useLoaderData } from "@remix-run/react";
4+
import { useEffect, useState } from "react";
5+
6+
import { pb } from "~/pb.client";
7+
import { createSession, getPocketbase, getUser } from "~/pb.server";
8+
9+
export async function loader({ request }: LoaderFunctionArgs) {
10+
const pb = getPocketbase(request);
11+
const user = getUser(pb);
12+
13+
const redirectUrl = "/admin";
14+
15+
if (!user) {
16+
return createSession("/", pb);
17+
}
18+
19+
let realtime_example = null;
20+
21+
try {
22+
realtime_example = await pb.collection("realtime_example").getFullList();
23+
} catch (_) {}
24+
25+
return json({ redirectUrl, user, realtime_example });
26+
}
27+
28+
export default function Admin() {
29+
const loaderData = useLoaderData<typeof loader>();
30+
const [count, setCount] = useState(
31+
loaderData.realtime_example?.[0]?.count || 0,
32+
);
33+
34+
useEffect(() => {
35+
pb?.collection("realtime_example").subscribe("*", (data) => {
36+
setCount(data.record.count);
37+
});
38+
39+
return () => {
40+
pb?.collection("realtime_example").unsubscribe("*");
41+
};
42+
}, [setCount]);
43+
44+
return (
45+
<div>
46+
<div>Hello {loaderData.user.name || loaderData.user.email}</div>
47+
<div style={{ display: "flex", gap: "1rem", margin: "1rem 0" }}>
48+
<Link to="/logout" reloadDocument>
49+
Logout
50+
</Link>
51+
52+
<Link to="/">Home</Link>
53+
</div>
54+
55+
<div>
56+
Realtime Data Demo: <span>{count}</span>
57+
</div>
58+
</div>
59+
);
60+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node";
2+
import { json } from "@remix-run/node";
3+
import { Form, Link, useActionData } from "@remix-run/react";
4+
import { ClientResponseError } from "pocketbase";
5+
6+
import { createSession, getPocketbase, getUser } from "~/pb.server";
7+
8+
interface ForgotPasswordRequestData {
9+
email: string;
10+
}
11+
12+
export async function action({ request }: ActionFunctionArgs) {
13+
const pb = getPocketbase(request);
14+
15+
const result = (await request.formData()) as unknown as Iterable<
16+
[ForgotPasswordRequestData, FormDataEntryValue]
17+
>;
18+
const data: ForgotPasswordRequestData = Object.fromEntries(result);
19+
20+
try {
21+
await pb.collection("users").requestPasswordReset(data.email);
22+
23+
return json({
24+
success: true,
25+
error: false,
26+
message: "An email has been sent to reset your password!",
27+
});
28+
} catch (error) {
29+
if (error instanceof ClientResponseError) {
30+
return json({ success: false, error: true, message: error.message });
31+
}
32+
}
33+
}
34+
35+
export async function loader({ request }: LoaderFunctionArgs) {
36+
const pb = getPocketbase(request);
37+
const user = getUser(pb);
38+
39+
const redirectUrl = "/admin";
40+
41+
if (user) return createSession(redirectUrl, pb);
42+
43+
return json({ redirectUrl, user });
44+
}
45+
46+
export default function Login() {
47+
const actionData = useActionData<typeof action>();
48+
49+
return (
50+
<Form method="post">
51+
{actionData?.error ? <div>{actionData.message}</div> : null}
52+
{actionData?.success ? (
53+
<div style={{ color: "green" }}>{actionData.message}</div>
54+
) : null}
55+
<div>
56+
<label htmlFor="email">Email</label>
57+
<input
58+
type="email"
59+
name="email"
60+
id="email"
61+
defaultValue="pocketbase@remix.example"
62+
/>
63+
</div>
64+
65+
<button>Forgot Password</button>
66+
67+
<Link to="/login">Login</Link>
68+
</Form>
69+
);
70+
}

0 commit comments

Comments
 (0)