Skip to content

Commit

Permalink
Add roles and admin route protection
Browse files Browse the repository at this point in the history
  • Loading branch information
evadecker committed Sep 18, 2024
1 parent 528e060 commit 52659e9
Show file tree
Hide file tree
Showing 15 changed files with 115 additions and 11 deletions.
8 changes: 8 additions & 0 deletions convex/_generated/api.d.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
/* prettier-ignore-start */

/* eslint-disable */
/**
* Generated `api` utility.
Expand All @@ -17,10 +19,12 @@ import type * as auth from "../auth.js";
import type * as constants from "../constants.js";
import type * as formFields from "../formFields.js";
import type * as forms from "../forms.js";
import type * as helpers from "../helpers.js";
import type * as http from "../http.js";
import type * as quests from "../quests.js";
import type * as users from "../users.js";
import type * as usersQuests from "../usersQuests.js";
import type * as validators from "../validators.js";

/**
* A utility for referencing Convex functions in your app's API.
Expand All @@ -35,10 +39,12 @@ declare const fullApi: ApiFromModules<{
constants: typeof constants;
formFields: typeof formFields;
forms: typeof forms;
helpers: typeof helpers;
http: typeof http;
quests: typeof quests;
users: typeof users;
usersQuests: typeof usersQuests;
validators: typeof validators;
}>;
export declare const api: FilterApi<
typeof fullApi,
Expand All @@ -48,3 +54,5 @@ export declare const internal: FilterApi<
typeof fullApi,
FunctionReference<any, "internal">
>;

/* prettier-ignore-end */
4 changes: 4 additions & 0 deletions convex/_generated/api.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
/* prettier-ignore-start */

/* eslint-disable */
/**
* Generated `api` utility.
Expand All @@ -20,3 +22,5 @@ import { anyApi } from "convex/server";
*/
export const api = anyApi;
export const internal = anyApi;

/* prettier-ignore-end */
4 changes: 4 additions & 0 deletions convex/_generated/dataModel.d.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
/* prettier-ignore-start */

/* eslint-disable */
/**
* Generated data model types.
Expand Down Expand Up @@ -58,3 +60,5 @@ export type Id<TableName extends TableNames | SystemTableNames> =
* `mutationGeneric` to make them type-safe.
*/
export type DataModel = DataModelFromSchemaDefinition<typeof schema>;

/* prettier-ignore-end */
4 changes: 4 additions & 0 deletions convex/_generated/server.d.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
/* prettier-ignore-start */

/* eslint-disable */
/**
* Generated utilities for implementing server-side Convex query and mutation functions.
Expand Down Expand Up @@ -140,3 +142,5 @@ export type DatabaseReader = GenericDatabaseReader<DataModel>;
* for the guarantees Convex provides your functions.
*/
export type DatabaseWriter = GenericDatabaseWriter<DataModel>;

/* prettier-ignore-end */
4 changes: 4 additions & 0 deletions convex/_generated/server.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
/* prettier-ignore-start */

/* eslint-disable */
/**
* Generated utilities for implementing server-side Convex query and mutation functions.
Expand Down Expand Up @@ -87,3 +89,5 @@ export const internalAction = internalActionGeneric;
* @returns The wrapped endpoint function. Route a URL path to this function in `convex/http.js`.
*/
export const httpAction = httpActionGeneric;

/* prettier-ignore-end */
27 changes: 27 additions & 0 deletions convex/auth.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import Resend from "@auth/core/providers/resend";
import { convexAuth } from "@convex-dev/auth/server";
import type { MutationCtx } from "./_generated/server";
import { getUserByEmail } from "./users";

export const { auth, signIn, signOut, store } = convexAuth({
providers: [
Expand All @@ -8,4 +10,29 @@ export const { auth, signIn, signOut, store } = convexAuth({
from: process.env.AUTH_EMAIL ?? "Namesake <[email protected]>",
}),
],

callbacks: {
async createOrUpdateUser(ctx: MutationCtx, args) {
// Handle merging updated fields into existing user
if (args.existingUserId) {
return args.existingUserId;
}

// Handle account linking
if (args.profile.email) {
const existingUser = await getUserByEmail(ctx, {
email: args.profile.email,
});
if (existingUser) return existingUser._id;
}

// Create a new user with defaults
return ctx.db.insert("users", {
email: args.profile.email,
emailVerified: args.profile.emailVerified ?? false,
role: "user",
theme: "system",
});
},
},
});
2 changes: 2 additions & 0 deletions convex/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,5 @@ export enum JURISDICTIONS {
}

export type Theme = "system" | "light" | "dark";

export type Role = "user" | "editor" | "admin";
8 changes: 5 additions & 3 deletions convex/schema.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { authTables } from "@convex-dev/auth/server";
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
import { jurisdiction, theme } from "./validators";
import { jurisdiction, role, theme } from "./validators";

export default defineSchema({
...authTables,
Expand Down Expand Up @@ -87,6 +87,7 @@ export default defineSchema({
/**
* Represents a user of Namesake.
* @param name - The user's preferred first name.
* @param role - The user's role: "admin", "editor", or "user".
* @param image - A URL to the user's profile picture.
* @param email - The user's email address.
* @param emailVerificationTime - Time in ms since epoch when the user verified their email.
Expand All @@ -96,12 +97,13 @@ export default defineSchema({
*/
users: defineTable({
name: v.optional(v.string()),
role: role,
image: v.optional(v.string()),
email: v.optional(v.string()),
emailVerificationTime: v.optional(v.number()),
emailVerified: v.boolean(),
isAnonymous: v.optional(v.boolean()),
isMinor: v.optional(v.boolean()),
theme: v.optional(theme),
theme: theme,
}).index("email", ["email"]),

/**
Expand Down
22 changes: 22 additions & 0 deletions convex/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,25 @@ export const getCurrentUser = userQuery({
},
});

export const getCurrentUserRole = userQuery({
args: {},
handler: async (ctx) => {
const user = await ctx.db.get(ctx.userId);
if (!user) throw new Error("User not found");
return user.role;
},
});

export const getUserByEmail = query({
args: { email: v.string() },
handler: async (ctx, args) => {
return await ctx.db
.query("users")
.withIndex("email", (q) => q.eq("email", args.email))
.first();
},
});

export const setCurrentUserName = userMutation({
args: { name: v.optional(v.string()) },
handler: async (ctx, args) => {
Expand All @@ -41,6 +60,9 @@ export const setUserTheme = userMutation({
},
});

// TODO: This throws an error when deleting own account
// Implement RLS check for whether this is the user's own account
// or a different account being deleted by an admin
export const deleteCurrentUser = userMutation({
args: {},
handler: async (ctx) => {
Expand Down
6 changes: 6 additions & 0 deletions convex/validators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,9 @@ export const theme = v.union(
v.literal("light"),
v.literal("dark"),
);

export const role = v.union(
v.literal("user"),
v.literal("editor"),
v.literal("admin"),
);
8 changes: 5 additions & 3 deletions src/components/shared/AppHeader.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
import { useAuthActions } from "@convex-dev/auth/react";
import { RiAccountCircleFill } from "@remixicon/react";
import { Authenticated, Unauthenticated } from "convex/react";
import { Authenticated, Unauthenticated, useQuery } from "convex/react";
import { Button, Link, Menu, MenuItem, MenuTrigger } from "..";
import { api } from "../../../convex/_generated/api";

export const AppHeader = () => {
const { signOut } = useAuthActions();
const role = useQuery(api.users.getCurrentUserRole);
const isAdmin = role === "admin";

return (
<div className="flex gap-4 items-center w-screen py-3 px-4 border-b border-gray-dim">
<Link href={{ to: "/" }}>Namesake</Link>
<Authenticated>
{/* TODO: Gate this by role */}
<Link href={{ to: "/admin" }}>Admin</Link>
{isAdmin && <Link href={{ to: "/admin" }}>Admin</Link>}
</Authenticated>
<div className="ml-auto">
<Authenticated>
Expand Down
8 changes: 6 additions & 2 deletions src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@ import "./styles/index.css";
import { ConvexAuthProvider } from "@convex-dev/auth/react";
import { RiErrorWarningLine } from "@remixicon/react";
import { RouterProvider, createRouter } from "@tanstack/react-router";
import { ConvexReactClient, useConvexAuth } from "convex/react";
import { ConvexReactClient, useConvexAuth, useQuery } from "convex/react";
import { ThemeProvider } from "next-themes";
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { HelmetProvider } from "react-helmet-async";
import { api } from "../convex/_generated/api";
import { Empty } from "./components";
import { routeTree } from "./routeTree.gen";

Expand All @@ -17,6 +18,7 @@ const router = createRouter({
context: {
title: undefined!,
auth: undefined!,
role: undefined!,
},
defaultNotFoundComponent: () => (
<Empty
Expand All @@ -36,7 +38,9 @@ declare module "@tanstack/react-router" {
const InnerApp = () => {
const title = "Namesake";
const auth = useConvexAuth();
return <RouterProvider router={router} context={{ title, auth }} />;
const role = useQuery(api.users.getCurrentUserRole);

return <RouterProvider router={router} context={{ title, auth, role }} />;
};

const rootElement = document.getElementById("root")!;
Expand Down
2 changes: 2 additions & 0 deletions src/routes/__root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
} from "@tanstack/react-router";
import type { ConvexAuthState } from "convex/react";
import { RouterProvider } from "react-aria-components";
import type { Role } from "../../convex/constants";
import { AppHeader } from "../components";

declare module "react-aria-components" {
Expand All @@ -19,6 +20,7 @@ declare module "react-aria-components" {
interface RouterContext {
title: string;
auth: ConvexAuthState;
role: Role;
}

export const Route = createRootRouteWithContext<RouterContext>()({
Expand Down
15 changes: 12 additions & 3 deletions src/routes/admin/route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,24 @@ import {
RiSignpostFill,
RiSignpostLine,
} from "@remixicon/react";
import { Outlet, createFileRoute } from "@tanstack/react-router";
import { Outlet, createFileRoute, redirect } from "@tanstack/react-router";
import { Container, Nav } from "../../components";

export const Route = createFileRoute("/admin")({
beforeLoad: ({ context }) => {
const isAdmin = context.role === "admin";

if (!isAdmin) {
throw redirect({
to: "/",
statusCode: 401,
replace: true,
});
}
},
component: AdminRoute,
});

// TODO: Protect this route for admins only

function AdminRoute() {
return (
<div className="flex flex-1 max-w-screen">
Expand Down
4 changes: 4 additions & 0 deletions src/routes/settings/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,9 @@ function SettingsRoute() {
};

// Account deletion
const clearLocalStorage = () => {
localStorage.removeItem("theme");
};
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const deleteAccount = useMutation(api.users.deleteCurrentUser);

Expand Down Expand Up @@ -146,6 +149,7 @@ function SettingsRoute() {
<Button
variant="destructive"
onPress={() => {
clearLocalStorage();
deleteAccount();
signOut();
}}
Expand Down

0 comments on commit 52659e9

Please sign in to comment.