Skip to content

Commit

Permalink
Add session management and user authentication, update dependencies
Browse files Browse the repository at this point in the history
  • Loading branch information
Tiscs committed Dec 26, 2023
1 parent 9ffb578 commit 88d3735
Show file tree
Hide file tree
Showing 18 changed files with 5,007 additions and 5,621 deletions.
39 changes: 39 additions & 0 deletions app/clients/grpc.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import invariant from "tiny-invariant";
import { createGrpcTransport } from "@connectrpc/connect-node";
import { createPromiseClient } from "@connectrpc/connect";
import { singleton } from "~/singleton.server";
import { SnowflakeService, PasswordService, DateTimeService } from "@proto/utils/v1/utils_connect";
import { UsersService } from "@proto/iam/v1beta/users_connect";
import { TokensService } from "@proto/iam/v1beta/tokens_connect";
import { StateStoreService } from "@proto/state/v1beta/store_connect";

invariant(process.env.GOMMERCE_GRPC_ENDPOINT, "environment variable GOMMERCE_GRPC_ENDPOINT is required.");

declare global {
namespace NodeJS {
interface ProcessEnv {
GOMMERCE_GRPC_ENDPOINT: string;
}
}
}

export const transport = singleton("grpc_transport", () => {
return createGrpcTransport({
baseUrl: process.env.GOMMERCE_GRPC_ENDPOINT,
useBinaryFormat: true,
httpVersion: "2",
interceptors: [],
});
});

// utils/v1
export const snowflakeServiceClient = createPromiseClient(SnowflakeService, transport);
export const passwordServiceClient = createPromiseClient(PasswordService, transport);
export const dateTimeServiceClient = createPromiseClient(DateTimeService, transport);

// iam/v1beta
export const usersServiceClient = createPromiseClient(UsersService, transport);
export const tokensServiceClient = createPromiseClient(TokensService, transport);

// state/v1beta
export const stateStoreServiceClient = createPromiseClient(StateStoreService, transport);
58 changes: 58 additions & 0 deletions app/clients/state.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import type { SessionData, SessionStorage, SessionIdStorageStrategy } from "@remix-run/node";
import { createSessionStorage } from "@remix-run/node";
import { stateStoreServiceClient as stateStore, snowflakeServiceClient as snowflake } from "~/clients/grpc.server";

interface CookieSessionStorageOptions {
bucket?: string;
cookie?: SessionIdStorageStrategy["cookie"];
}

export function createStateSessionStorage<Data = SessionData, FlashData = Data>(
options?: CookieSessionStorageOptions,
): SessionStorage<Data, FlashData> {
const bucket = options?.bucket || "sessions";
const encoder = new TextEncoder();
const decoder = new TextDecoder();
const upsert = async (id: string, data: string, expires?: Date): Promise<void> => {
const key = `${bucket}:${id}`;
const metadata: { [key: string]: string } = {};
if (expires) {
metadata["ttlInSeconds"] = (expires.getTime() - Date.now()).toString(10);
}
await stateStore.setState(
{
key,
data: encoder.encode(data),
metadata,
contentType: "application/json",
},
{ headers: { Authorization: `Basic ${process.env.GOMMERCE_CLIENT_TOKEN}` } },
);
};
return createSessionStorage<Data, FlashData>({
cookie: options?.cookie,
async createData(data, expires) {
const { value: id } = await snowflake.nextHex({});
await upsert(id, JSON.stringify(data || null), expires);
return id;
},
async readData(id) {
const key = `${bucket}:${id}`;
const { data } = await stateStore.getState(
{ key },
{ headers: { Authorization: `Basic ${process.env.GOMMERCE_CLIENT_TOKEN}` } },
);
return data && data.length > 0 ? JSON.parse(decoder.decode(data)) : null;
},
async updateData(id, data, expires) {
await upsert(id, JSON.stringify(data || null), expires);
},
async deleteData(id) {
const key = `${bucket}:${id}`;
await stateStore.delState(
{ key },
{ headers: { Authorization: `Basic ${process.env.GOMMERCE_CLIENT_TOKEN}` } },
);
},
});
}
4 changes: 2 additions & 2 deletions app/entry.server.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { PassThrough } from "node:stream";
import type { AppLoadContext, EntryContext } from "@remix-run/node";
import { createReadableStreamFromReadable } from "@remix-run/node";
import { RemixServer } from "@remix-run/react";
import isbot from "isbot";
import { isbot } from "isbot";
import { renderToPipeableStream } from "react-dom/server";

const ABORT_DELAY = 5_000;
Expand All @@ -21,7 +21,7 @@ export default function handleRequest(
remixContext: EntryContext,
loadContext: AppLoadContext,
) {
return isbot(request.headers.get("user-agent"))
return isbot(request.headers.get("user-agent") || "server") // if there's no user agent, it's probably a bot
? handleBotRequest(request, responseStatusCode, responseHeaders, remixContext)
: handleBrowserRequest(request, responseStatusCode, responseHeaders, remixContext);
}
Expand Down
132 changes: 132 additions & 0 deletions app/partials/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { useEffect } from "react";
import { Form, Link, NavLink, useLocation, Outlet } from "@remix-run/react";
import { clsx } from "clsx";
import { useAuthorize } from "~/secure";
import {
IconChevronDown,
IconLogout,
IconLogin,
IconUser,
IconUserScan,
IconShoppingCartCog,
IconLockAccess,
IconHome,
} from "@tabler/icons-react";
import { useHandleData } from "~/utils/hooks";

export type LayoutOptions = {
useSidebar?: boolean;
};

export default function Frame(props: { context?: unknown }) {
const { user } = useAuthorize();
const location = useLocation();
const handleData = useHandleData<{ layout?: { useSidebar?: boolean } }>(-1);
useEffect(() => {
// close dropdowns when the location changes
if (typeof window != "undefined" && document.activeElement instanceof HTMLElement) {
document.activeElement.blur();
}
}, [location]);
return (
<>
<div className="navbar sticky top-0 border-b border-base-content/5 bg-base-100 bg-opacity-75 backdrop-blur">
<div className="navbar-start">
<Link to="/" className="btn btn-ghost text-xl">
<span className="uppercase">Gommerce</span>
</Link>
</div>
<div className="navbar-center"></div>
<div className="navbar-end">
<NavLink to={"/login"} className={clsx("btn btn-ghost", { hidden: !!user })}>
<IconLogin size="1em" />
Login
</NavLink>
<div id="user-dropdown" className={clsx("dropdown dropdown-end", { hidden: !user })}>
<div tabIndex={0} role="button" className="btn btn-ghost">
<IconUser size="1em" />
{user?.displayName ?? "Anonymous"}
<IconChevronDown size="1em" />
</div>
<ul
tabIndex={0}
className="menu dropdown-content w-52 rounded-box border border-base-content/5 bg-base-100 bg-opacity-95 shadow-2xl"
>
<li>
<NavLink to={"/profile"}>
<IconUserScan size="1em" />
Profile
</NavLink>
</li>
<li>
<Form action="/logout" method="post" className="hidden">
<input id="navbar-logout-submit" type="submit" />
</Form>
<label htmlFor="navbar-logout-submit" role="button">
<IconLogout size="1em" />
Logout
</label>
</li>
</ul>
</div>
</div>
</div>
{handleData?.layout?.useSidebar !== false ? (
<div>
<aside className="fixed bottom-0 top-16 hidden w-72 overflow-auto md:block">
<div className="p-4">
<ul className="menu w-full rounded-box">
<li>
<NavLink to="/" className="flex items-center gap-2">
<IconHome size="1em" />
Home
</NavLink>
</li>
<li>
<span className="menu-title flex select-none items-center gap-2">
<IconUser size="1em" />
User
</span>
<ul>
<li>
<NavLink to="/profile">Profile</NavLink>
</li>
</ul>
</li>
<li>
<span className="menu-title flex select-none items-center gap-2">
<IconLockAccess size="1em" />
IAM
</span>
<ul>
<li>
<NavLink to="/iam/users">Users</NavLink>
</li>
<li>
<NavLink to="/iam/roles">Roles</NavLink>
</li>
</ul>
</li>
<li>
<span className="menu-title flex select-none items-center gap-2">
<IconShoppingCartCog size="1em" /> SKU
</span>
<ul></ul>
</li>
</ul>
</div>
</aside>
<main className="md:ml-72">
<div className="p-4">
<Outlet />
</div>
</main>
</div>
) : (
<main>
<Outlet />
</main>
)}
</>
);
}
64 changes: 61 additions & 3 deletions app/root.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,35 @@
import type { LinksFunction } from "@remix-run/node";
import type { LinksFunction, LoaderFunctionArgs } from "@remix-run/node";
import { cssBundleHref } from "@remix-run/css-bundle";
import { Links, LiveReload, Meta, Outlet, Scripts, ScrollRestoration } from "@remix-run/react";
import {
Links,
LiveReload,
Meta,
Scripts,
ScrollRestoration,
isRouteErrorResponse,
useLoaderData,
useRouteError,
} from "@remix-run/react";
import { authorize } from "~/secure.server";
import Layout from "~/partials/layout";
import globalStylesUrl from "~/styles/global.css";

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

export async function loader({ request }: LoaderFunctionArgs) {
const identity = await authorize(request);
return {
user: identity?.user,
scope: identity?.scope,
env: { GOMMERCE_GRPC_ENDPOINT: process.env.GOMMERCE_GRPC_ENDPOINT },
};
}

export default function App() {
const { env } = useLoaderData<typeof loader>();
return (
<html lang="en">
<head>
Expand All @@ -18,7 +39,44 @@ export default function App() {
<Links />
</head>
<body>
<Outlet />
<Layout /> {/* <Layout /> is a wrapper for the <Outlet /> */}
<ScrollRestoration />
<script dangerouslySetInnerHTML={{ __html: `window.env = ${JSON.stringify(env)};` }} />
<Scripts />
<LiveReload />
</body>
</html>
);
}

export function ErrorBoundary() {
const error = useRouteError();
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
</head>
<body className="prose max-w-none p-4">
{isRouteErrorResponse(error) ? (
<>
<h1>
{error.status} {error.statusText}
</h1>
<p>{error.data}</p>
</>
) : error instanceof Error ? (
<>
<h1>Error</h1>
<p>{error.message}</p>
<p>The stack trace is:</p>
<pre>{error.stack}</pre>
</>
) : (
<h1>Unknown Error</h1>
)}
<ScrollRestoration />
<Scripts />
<LiveReload />
Expand Down
4 changes: 3 additions & 1 deletion app/routes/_index.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import type { MetaFunction } from "@remix-run/node";
import { useAuthorize } from "~/secure";

export const meta: MetaFunction = () => {
return [{ title: "Gommerce" }, { name: "description", content: "An open source e-commerce system written in go." }];
};

export default function Index() {
return <> </>;
const { user } = useAuthorize();
return <h1>Welcome to Gommerce, {user?.displayName || "Guest"}</h1>;
}
9 changes: 9 additions & 0 deletions app/routes/iam.roles.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import type { MetaFunction } from "@remix-run/node";

export const meta: MetaFunction = ({ matches }) => {
return [{ title: "IAM Roles" }];
};

export default function Index() {
return <></>;
}
9 changes: 9 additions & 0 deletions app/routes/iam.users.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import type { MetaFunction } from "@remix-run/node";

export const meta: MetaFunction = ({ matches }) => {
return [{ title: "IAM Users" }];
};

export default function Index() {
return <></>;
}
Loading

0 comments on commit 88d3735

Please sign in to comment.