浏览代码

Merge branch 'main' of https://github.com/fosrl/pangolin

Owen Schwartz 7 月之前
父节点
当前提交
907504eefb

+ 7 - 0
config.example.yml

@@ -37,5 +37,12 @@ email:
     smtp_pass: aaaaaaaaaaaaaaaaaa
     no_reply: no-reply@example.io
 
+users:
+    server_admin:
+        email: admin@example.com
+        password: Password123!
+
 flags:
     require_email_verification: true
+    disable_signup_without_invite: true
+    disable_user_create_org: true

+ 42 - 0
server/auth/checkValidInvite.ts

@@ -0,0 +1,42 @@
+import db from "@server/db";
+import { UserInvite, userInvites } from "@server/db/schema";
+import { isWithinExpirationDate } from "oslo";
+import { verifyPassword } from "./password";
+import { eq } from "drizzle-orm";
+
+export async function checkValidInvite({
+    inviteId,
+    token
+}: {
+    inviteId: string;
+    token: string;
+}): Promise<{ error?: string; existingInvite?: UserInvite }> {
+    const existingInvite = await db
+        .select()
+        .from(userInvites)
+        .where(eq(userInvites.inviteId, inviteId))
+        .limit(1);
+
+    if (!existingInvite.length) {
+        return {
+            error: "Invite ID or token is invalid"
+        };
+    }
+
+    if (!isWithinExpirationDate(new Date(existingInvite[0].expiresAt))) {
+        return {
+            error: "Invite has expired"
+        };
+    }
+
+    const validToken = await verifyPassword(token, existingInvite[0].tokenHash);
+    if (!validToken) {
+        return {
+            error: "Invite ID or token is invalid"
+        };
+    }
+
+    return {
+        existingInvite: existingInvite[0]
+    };
+}

+ 15 - 2
server/config.ts

@@ -62,12 +62,17 @@ const environmentSchema = z.object({
             no_reply: z.string().email().optional()
         })
         .optional(),
+    users: z.object({
+        server_admin: z.object({
+            email: z.string().email(),
+            password: z.string()
+        })
+    }),
     flags: z
         .object({
-            allow_org_subdomain_changing: z.boolean().optional(),
             require_email_verification: z.boolean().optional(),
             disable_signup_without_invite: z.boolean().optional(),
-            require_signup_secret: z.boolean().optional()
+            disable_user_create_org: z.boolean().optional()
         })
         .optional()
 });
@@ -156,5 +161,13 @@ process.env.SESSION_COOKIE_NAME = parsedConfig.data.server.session_cookie_name;
 process.env.RESOURCE_SESSION_COOKIE_NAME =
     parsedConfig.data.server.resource_session_cookie_name;
 process.env.EMAIL_ENABLED = parsedConfig.data.email ? "true" : "false";
+process.env.DISABLE_SIGNUP_WITHOUT_INVITE = parsedConfig.data.flags
+    ?.disable_signup_without_invite
+    ? "true"
+    : "false";
+process.env.DISABLE_USER_CREATE_ORG = parsedConfig.data.flags
+    ?.disable_user_create_org
+    ? "true"
+    : "false";
 
 export default parsedConfig.data;

+ 4 - 1
server/db/schema.ts

@@ -89,7 +89,10 @@ export const users = sqliteTable("user", {
     emailVerified: integer("emailVerified", { mode: "boolean" })
         .notNull()
         .default(false),
-    dateCreated: text("dateCreated").notNull()
+    dateCreated: text("dateCreated").notNull(),
+    serverAdmin: integer("serverAdmin", { mode: "boolean" })
+        .notNull()
+        .default(false)
 });
 
 export const newts = sqliteTable("newt", {

+ 8 - 11
server/emails/templates/VerifyEmailCode.tsx

@@ -7,7 +7,7 @@ import {
     Preview,
     Section,
     Text,
-    Tailwind,
+    Tailwind
 } from "@react-email/components";
 import * as React from "react";
 
@@ -20,7 +20,7 @@ interface VerifyEmailProps {
 export const VerifyEmail = ({
     username,
     verificationCode,
-    verifyLink,
+    verifyLink
 }: VerifyEmailProps) => {
     const previewText = `Verify your email, ${username}`;
 
@@ -33,10 +33,10 @@ export const VerifyEmail = ({
                     theme: {
                         extend: {
                             colors: {
-                                primary: "#F97317",
-                            },
-                        },
-                    },
+                                primary: "#F97317"
+                            }
+                        }
+                    }
                 }}
             >
                 <Body className="font-sans">
@@ -48,11 +48,8 @@ export const VerifyEmail = ({
                             Hi {username || "there"},
                         </Text>
                         <Text className="text-base text-gray-700 mt-2">
-                            You’ve requested to verify your email. Please{" "}
-                            <a href={verifyLink} className="text-primary">
-                                click here
-                            </a>{" "}
-                            to verify your email, then enter the following code:
+                            You’ve requested to verify your email. Please use
+                            the code below to complete the verification process upon logging in.
                         </Text>
                         <Section className="text-center my-6">
                             <Text className="inline-block bg-primary text-xl font-bold text-white py-2 px-4 border border-gray-300 rounded-xl">

+ 46 - 12
server/routers/auth/signup.ts

@@ -16,16 +16,19 @@ import {
     createSession,
     generateId,
     generateSessionToken,
-    serializeSessionCookie,
+    serializeSessionCookie
 } from "@server/auth";
 import { ActionsEnum } from "@server/auth/actions";
 import config from "@server/config";
 import logger from "@server/logger";
 import { hashPassword } from "@server/auth/password";
+import { checkValidInvite } from "@server/auth/checkValidInvite";
 
 export const signupBodySchema = z.object({
     email: z.string().email(),
     password: passwordSchema,
+    inviteToken: z.string().optional(),
+    inviteId: z.string().optional()
 });
 
 export type SignUpBody = z.infer<typeof signupBodySchema>;
@@ -50,11 +53,39 @@ export async function signup(
         );
     }
 
-    const { email, password } = parsedBody.data;
+    const { email, password, inviteToken, inviteId } = parsedBody.data;
+
+    logger.debug("signup", { email, password, inviteToken, inviteId });
 
     const passwordHash = await hashPassword(password);
     const userId = generateId(15);
 
+    if (config.flags?.disable_signup_without_invite) {
+        if (!inviteToken || !inviteId) {
+            return next(
+                createHttpError(
+                    HttpCode.BAD_REQUEST,
+                    "Signups are disabled without an invite code"
+                )
+            );
+        }
+
+        const { error, existingInvite } = await checkValidInvite({
+            token: inviteToken,
+            inviteId
+        });
+
+        if (error) {
+            return next(createHttpError(HttpCode.BAD_REQUEST, error));
+        }
+
+        if (!existingInvite) {
+            return next(
+                createHttpError(HttpCode.BAD_REQUEST, "Invite does not exist")
+            );
+        }
+    }
+
     try {
         const existing = await db
             .select()
@@ -89,12 +120,15 @@ export async function signup(
 
             if (diff < 2) {
                 // If the user was created less than 2 hours ago, we don't want to create a new user
-                return next(
-                    createHttpError(
-                        HttpCode.BAD_REQUEST,
-                        "A verification email was already sent to this email address. Please check your email for the verification code."
-                    )
-                );
+                return response<SignUpResponse>(res, {
+                    data: {
+                        emailVerificationRequired: true
+                    },
+                    success: true,
+                    error: false,
+                    message: `A user with that email address already exists. We sent an email to ${email} with a verification code.`,
+                    status: HttpCode.OK
+                });
             } else {
                 // If the user was created more than 2 hours ago, we want to delete the old user and create a new one
                 await db.delete(users).where(eq(users.userId, user.userId));
@@ -105,7 +139,7 @@ export async function signup(
             userId: userId,
             email: email,
             passwordHash,
-            dateCreated: moment().toISOString(),
+            dateCreated: moment().toISOString()
         });
 
         // give the user their default permissions:
@@ -125,12 +159,12 @@ export async function signup(
 
             return response<SignUpResponse>(res, {
                 data: {
-                    emailVerificationRequired: true,
+                    emailVerificationRequired: true
                 },
                 success: true,
                 error: false,
                 message: `User created successfully. We sent an email to ${email} with a verification code.`,
-                status: HttpCode.OK,
+                status: HttpCode.OK
             });
         }
 
@@ -139,7 +173,7 @@ export async function signup(
             success: true,
             error: false,
             message: "User created successfully",
-            status: HttpCode.OK,
+            status: HttpCode.OK
         });
     } catch (e) {
         if (e instanceof SqliteError && e.code === "SQLITE_CONSTRAINT_UNIQUE") {

+ 12 - 0
server/routers/org/createOrg.ts

@@ -28,6 +28,18 @@ export async function createOrg(
     next: NextFunction
 ): Promise<any> {
     try {
+        // should this be in a middleware?
+        if (config.flags?.disable_user_create_org) {
+            if (!req.user?.serverAdmin) {
+                return next(
+                    createHttpError(
+                        HttpCode.FORBIDDEN,
+                        "Only server admins can create organizations"
+                    )
+                );
+            }
+        }
+
         const parsedBody = createOrgSchema.safeParse(req.body);
         if (!parsedBody.success) {
             return next(

+ 16 - 34
server/routers/user/acceptInvite.ts

@@ -11,6 +11,7 @@ import logger from "@server/logger";
 import { fromError } from "zod-validation-error";
 import { isWithinExpirationDate } from "oslo";
 import { verifyPassword } from "@server/auth/password";
+import { checkValidInvite } from "@server/auth/checkValidInvite";
 
 const acceptInviteBodySchema = z
     .object({
@@ -42,44 +43,25 @@ export async function acceptInvite(
 
         const { token, inviteId } = parsedBody.data;
 
-        const existingInvite = await db
-            .select()
-            .from(userInvites)
-            .where(eq(userInvites.inviteId, inviteId))
-            .limit(1);
-
-        if (!existingInvite.length) {
-            return next(
-                createHttpError(
-                    HttpCode.BAD_REQUEST,
-                    "Invite ID or token is invalid"
-                )
-            );
-        }
+        const { error, existingInvite } = await checkValidInvite({
+            token,
+            inviteId
+        });
 
-        if (!isWithinExpirationDate(new Date(existingInvite[0].expiresAt))) {
-            return next(
-                createHttpError(HttpCode.BAD_REQUEST, "Invite has expired")
-            );
+        if (error) {
+            return next(createHttpError(HttpCode.BAD_REQUEST, error));
         }
 
-        const validToken = await verifyPassword(
-            token,
-            existingInvite[0].tokenHash
-        );
-        if (!validToken) {
+        if (!existingInvite) {
             return next(
-                createHttpError(
-                    HttpCode.BAD_REQUEST,
-                    "Invite ID or token is invalid"
-                )
+                createHttpError(HttpCode.BAD_REQUEST, "Invite does not exist")
             );
         }
 
         const existingUser = await db
             .select()
             .from(users)
-            .where(eq(users.email, existingInvite[0].email))
+            .where(eq(users.email, existingInvite.email))
             .limit(1);
         if (!existingUser.length) {
             return next(
@@ -90,7 +72,7 @@ export async function acceptInvite(
             );
         }
 
-        if (req.user && req.user.email !== existingInvite[0].email) {
+        if (req.user && req.user.email !== existingInvite.email) {
             return next(
                 createHttpError(
                     HttpCode.BAD_REQUEST,
@@ -104,7 +86,7 @@ export async function acceptInvite(
         const existingRole = await db
             .select()
             .from(roles)
-            .where(eq(roles.roleId, existingInvite[0].roleId))
+            .where(eq(roles.roleId, existingInvite.roleId))
             .limit(1);
         if (existingRole.length) {
             roleId = existingRole[0].roleId;
@@ -122,8 +104,8 @@ export async function acceptInvite(
             // add the user to the org
             await trx.insert(userOrgs).values({
                 userId: existingUser[0].userId,
-                orgId: existingInvite[0].orgId,
-                roleId: existingInvite[0].roleId
+                orgId: existingInvite.orgId,
+                roleId: existingInvite.roleId
             });
 
             // delete the invite
@@ -131,9 +113,9 @@ export async function acceptInvite(
                 .delete(userInvites)
                 .where(eq(userInvites.inviteId, inviteId));
         });
-        
+
         return response<AcceptInviteResponse>(res, {
-            data: { accepted: true, orgId: existingInvite[0].orgId },
+            data: { accepted: true, orgId: existingInvite.orgId },
             success: true,
             error: false,
             message: "Invite accepted",

+ 2 - 1
server/routers/user/getUser.ts

@@ -15,6 +15,7 @@ async function queryUser(userId: string) {
             email: users.email,
             twoFactorEnabled: users.twoFactorEnabled,
             emailVerified: users.emailVerified,
+            serverAdmin: users.serverAdmin
         })
         .from(users)
         .where(eq(users.userId, userId))
@@ -56,7 +57,7 @@ export async function getUser(
             success: true,
             error: false,
             message: "User retrieved successfully",
-            status: HttpCode.OK,
+            status: HttpCode.OK
         });
     } catch (error) {
         logger.error(error);

+ 3 - 3
server/setup/copyInConfig.ts

@@ -7,9 +7,9 @@ import logger from "@server/logger";
 export async function copyInConfig() {
     // create a url from config.app.base_url and get the hostname
     const domain = new URL(config.app.base_url).hostname;
-    
+
     // update the domain on all of the orgs where the domain is not equal to the new domain
     // TODO: eventually each org could have a unique domain that we do not want to overwrite, so this will be unnecessary
     await db.update(orgs).set({ domain }).where(ne(orgs.domain, domain));
-    logger.debug("Updated orgs with new domain");
-}
+    logger.info(`Updated orgs with new domain (${domain})`);
+}

+ 3 - 3
server/setup/ensureActions.ts

@@ -36,7 +36,7 @@ export async function ensureActions() {
                     defaultRoles.map((role) => ({
                         roleId: role.roleId!,
                         actionId,
-                        orgId: role.orgId!,
+                        orgId: role.orgId!
                     }))
                 )
                 .execute();
@@ -68,7 +68,7 @@ export async function createAdminRole(orgId: string) {
             orgId,
             isAdmin: true,
             name: "Admin",
-            description: "Admin role with the most permissions",
+            description: "Admin role with the most permissions"
         })
         .returning({ roleId: roles.roleId })
         .execute();
@@ -92,7 +92,7 @@ export async function createAdminRole(orgId: string) {
             actionIds.map((action) => ({
                 roleId,
                 actionId: action.actionId,
-                orgId,
+                orgId
             }))
         )
         .execute();

+ 12 - 5
server/setup/index.ts

@@ -2,10 +2,17 @@ import { ensureActions } from "./ensureActions";
 import { copyInConfig } from "./copyInConfig";
 import logger from "@server/logger";
 import { runMigrations } from "./migrations";
+import { setupServerAdmin } from "./setupServerAdmin";
 
 export async function runSetupFunctions() {
-    logger.info(`Setup for version ${process.env.APP_VERSION}`);
-    await runMigrations(); // run the migrations       
-    await ensureActions(); // make sure all of the actions are in the db and the roles
-    await copyInConfig(); // copy in the config to the db as needed
-}
+    try {
+        logger.info(`Setup for version ${process.env.APP_VERSION}`);
+        await runMigrations(); // run the migrations
+        await copyInConfig(); // copy in the config to the db as needed
+        await setupServerAdmin();
+        await ensureActions(); // make sure all of the actions are in the db and the roles
+    } catch (error) {
+        logger.error("Error running setup functions", error);
+        process.exit(1);
+    }
+}

+ 86 - 0
server/setup/setupServerAdmin.ts

@@ -0,0 +1,86 @@
+import { generateId, invalidateAllSessions } from "@server/auth";
+import { hashPassword, verifyPassword } from "@server/auth/password";
+import { passwordSchema } from "@server/auth/passwordSchema";
+import config from "@server/config";
+import db from "@server/db";
+import { users } from "@server/db/schema";
+import logger from "@server/logger";
+import { eq } from "drizzle-orm";
+import moment from "moment";
+import { fromError } from "zod-validation-error";
+
+export async function setupServerAdmin() {
+    const {
+        server_admin: { email, password }
+    } = config.users;
+
+    const parsed = passwordSchema.safeParse(password);
+
+    if (!parsed.success) {
+        throw Error(
+            `Invalid server admin password: ${fromError(parsed.error).toString()}`
+        );
+    }
+
+    const passwordHash = await hashPassword(password);
+
+    await db.transaction(async (trx) => {
+        try {
+            const [existing] = await trx
+                .select()
+                .from(users)
+                .where(eq(users.email, email));
+
+            if (existing) {
+                const passwordChanged = !(await verifyPassword(
+                    password,
+                    existing.passwordHash
+                ));
+
+                if (passwordChanged) {
+                    await trx
+                        .update(users)
+                        .set({ passwordHash })
+                        .where(eq(users.email, email));
+
+                    // this isn't using the transaction, but it's probably fine
+                    await invalidateAllSessions(existing.userId);
+
+                    logger.info(`Server admin (${email}) password updated`);
+                }
+
+                if (existing.serverAdmin) {
+                    return;
+                }
+
+                await trx.update(users).set({ serverAdmin: false });
+
+                await trx
+                    .update(users)
+                    .set({
+                        serverAdmin: true
+                    })
+                    .where(eq(users.email, email));
+
+                logger.info(`Server admin (${email}) updated`);
+                return;
+            }
+
+            const userId = generateId(15);
+
+            await db.insert(users).values({
+                userId: userId,
+                email: email,
+                passwordHash,
+                dateCreated: moment().toISOString(),
+                serverAdmin: true,
+                emailVerified: true
+            });
+
+            logger.info(`Server admin (${email}) created`);
+        } catch (e) {
+            logger.error(e);
+            trx.rollback();
+        }
+    });
+}

+ 35 - 13
src/app/auth/login/page.tsx

@@ -3,6 +3,7 @@ import Link from "next/link";
 import { redirect } from "next/navigation";
 import { cache } from "react";
 import DashboardLoginForm from "./DashboardLoginForm";
+import { Mail } from "lucide-react";
 
 export const dynamic = "force-dynamic";
 
@@ -13,27 +14,48 @@ export default async function Page(props: {
     const getUser = cache(verifySession);
     const user = await getUser();
 
+    const isInvite = searchParams?.redirect?.includes("/invite");
+
+    const signUpDisabled = process.env.DISABLE_SIGNUP_WITHOUT_INVITE === "true";
+
     if (user) {
         redirect("/");
     }
 
     return (
         <>
+            {isInvite && (
+                <div className="border rounded-md p-3 mb-4">
+                    <div className="flex flex-col items-center">
+                        <Mail className="w-12 h-12 mb-4 text-primary" />
+                        <h2 className="text-2xl font-bold mb-2 text-center">
+                            Looks like you've been invited!
+                        </h2>
+                        <p className="text-center">
+                            To accept the invite, you must login or create an
+                            account.
+                        </p>
+                    </div>
+                </div>
+            )}
+
             <DashboardLoginForm redirect={searchParams.redirect as string} />
 
-            <p className="text-center text-muted-foreground mt-4">
-                Don't have an account?{" "}
-                <Link
-                    href={
-                        !searchParams.redirect
-                            ? `/auth/signup`
-                            : `/auth/signup?redirect=${searchParams.redirect}`
-                    }
-                    className="underline"
-                >
-                    Sign up
-                </Link>
-            </p>
+            {(!signUpDisabled || isInvite) && (
+                <p className="text-center text-muted-foreground mt-4">
+                    Don't have an account?{" "}
+                    <Link
+                        href={
+                            !searchParams.redirect
+                                ? `/auth/signup`
+                                : `/auth/signup?redirect=${searchParams.redirect}`
+                        }
+                        className="underline"
+                    >
+                        Sign up
+                    </Link>
+                </p>
+            )}
         </>
     );
 }

+ 5 - 1
src/app/auth/signup/SignupForm.tsx

@@ -32,6 +32,8 @@ import { useEnvContext } from "@app/hooks/useEnvContext";
 
 type SignupFormProps = {
     redirect?: string;
+    inviteId?: string;
+    inviteToken?: string;
 };
 
 const formSchema = z
@@ -45,7 +47,7 @@ const formSchema = z
         message: "Passwords do not match",
     });
 
-export default function SignupForm({ redirect }: SignupFormProps) {
+export default function SignupForm({ redirect, inviteId, inviteToken }: SignupFormProps) {
     const router = useRouter();
 
     const api = createApiClient(useEnvContext());
@@ -70,6 +72,8 @@ export default function SignupForm({ redirect }: SignupFormProps) {
             .put<AxiosResponse<SignUpResponse>>("/auth/signup", {
                 email,
                 password,
+                inviteId,
+                inviteToken
             })
             .catch((e) => {
                 console.error(e);

+ 43 - 3
src/app/auth/signup/page.tsx

@@ -1,25 +1,65 @@
 import SignupForm from "@app/app/auth/signup/SignupForm";
 import { verifySession } from "@app/lib/auth/verifySession";
+import { Mail } from "lucide-react";
 import Link from "next/link";
 import { redirect } from "next/navigation";
 import { cache } from "react";
 
-export const dynamic = 'force-dynamic';
+export const dynamic = "force-dynamic";
 
 export default async function Page(props: {
-    searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
+    searchParams: Promise<{ redirect: string | undefined }>;
 }) {
     const searchParams = await props.searchParams;
     const getUser = cache(verifySession);
     const user = await getUser();
 
+    const isInvite = searchParams?.redirect?.includes("/invite");
+
+    if (process.env.DISABLE_SIGNUP_WITHOUT_INVITE === "true" && !isInvite) {
+        redirect("/");
+    }
+
     if (user) {
         redirect("/");
     }
 
+    let inviteId;
+    let inviteToken;
+    if (searchParams.redirect && isInvite) {
+        const parts = searchParams.redirect.split("token=");
+        if (parts.length) {
+            const token = parts[1];
+            const tokenParts = token.split("-");
+            if (tokenParts.length === 2) {
+                inviteId = tokenParts[0];
+                inviteToken = tokenParts[1];
+            }
+        }
+    }
+
     return (
         <>
-            <SignupForm redirect={searchParams.redirect as string} />
+            {isInvite && (
+                <div className="border rounded-md p-3 mb-4">
+                    <div className="flex flex-col items-center">
+                        <Mail className="w-12 h-12 mb-4 text-primary" />
+                        <h2 className="text-2xl font-bold mb-2 text-center">
+                            Looks like you've been invited!
+                        </h2>
+                        <p className="text-center">
+                            To accept the invite, you must login or create an
+                            account.
+                        </p>
+                    </div>
+                </div>
+            )}
+
+            <SignupForm
+                redirect={searchParams.redirect as string}
+                inviteToken={inviteToken}
+                inviteId={inviteId}
+            />
 
             <p className="text-center text-muted-foreground mt-4">
                 Already have an account?{" "}

+ 1 - 1
src/app/invite/page.tsx

@@ -21,7 +21,7 @@ export default async function InvitePage(props: {
     const user = await verifySession();
 
     if (!user) {
-        redirect(`/?redirect=/invite?token=${params.token}`);
+        redirect(`/auth/signup?redirect=/invite?token=${params.token}`);
     }
 
     const parts = tokenParam.split("-");

+ 4 - 1
src/app/layout.tsx

@@ -37,7 +37,10 @@ export default async function RootLayout({
                             SERVER_EXTERNAL_PORT: process.env
                                 .SERVER_EXTERNAL_PORT as string,
                             ENVIRONMENT: process.env.ENVIRONMENT as string,
-                            EMAIL_ENABLED: process.env.EMAIL_ENABLED as string
+                            EMAIL_ENABLED: process.env.EMAIL_ENABLED as string,
+                            // optional
+                            DISABLE_USER_CREATE_ORG: process.env.DISABLE_USER_CREATE_ORG,
+                            DISABLE_SIGNUP_WITHOUT_INVITE: process.env.DISABLE_SIGNUP_WITHOUT_INVITE,
                         }}
                     >
                         {children}

+ 0 - 36
src/app/profile/general/layout_.tsx

@@ -1,36 +0,0 @@
-import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
-import { SidebarSettings } from "@app/components/SidebarSettings";
-import { verifySession } from "@app/lib/auth/verifySession";
-import UserProvider from "@app/providers/UserProvider";
-import { redirect } from "next/navigation";
-import { cache } from "react";
-
-type ProfileGeneralProps = {
-    children: React.ReactNode;
-};
-
-export default async function GeneralSettingsPage({
-    children
-}: ProfileGeneralProps) {
-    const getUser = cache(verifySession);
-    const user = await getUser();
-
-    if (!user) {
-        redirect(`/?redirect=/profile/general`);
-    }
-
-    const sidebarNavItems = [
-        {
-            title: "Authentication",
-            href: `/{orgId}/settings/general`
-        }
-    ];
-
-    return (
-        <>
-            <UserProvider user={user}>
-                {children}
-            </UserProvider>
-        </>
-    );
-}

+ 0 - 14
src/app/profile/general/page.tsx

@@ -1,14 +0,0 @@
-"use client";
-
-import { useState } from "react";
-import Enable2FaForm from "../../../components/Enable2FaForm";
-
-export default function ProfileGeneralPage() {
-    const [open, setOpen] = useState(true);
-
-    return (
-        <>
-            {/* <Enable2FaForm open={open} setOpen={setOpen} /> */}
-        </>
-    );
-}

+ 0 - 14
src/app/profile/general/page_.tsx

@@ -1,14 +0,0 @@
-"use client";
-
-import { useState } from "react";
-import Enable2FaForm from "@app/components/Enable2FaForm";
-
-export default function ProfileGeneralPage() {
-    const [open, setOpen] = useState(true);
-
-    return (
-        <>
-            <Enable2FaForm open={open} setOpen={setOpen} />
-        </>
-    );
-}

+ 0 - 74
src/app/profile/layout_.tsx

@@ -1,74 +0,0 @@
-import { Metadata } from "next";
-import { verifySession } from "@app/lib/auth/verifySession";
-import { redirect } from "next/navigation";
-import { cache } from "react";
-import Header from "@app/components/Header";
-import { internal } from "@app/api";
-import { AxiosResponse } from "axios";
-import { ListOrgsResponse } from "@server/routers/org";
-import { authCookieHeader } from "@app/api/cookies";
-import { TopbarNav } from "@app/components/TopbarNav";
-import { Settings } from "lucide-react";
-
-export const dynamic = "force-dynamic";
-
-export const metadata: Metadata = {
-    title: `User Settings - Pangolin`,
-    description: ""
-};
-
-const topNavItems = [
-    {
-        title: "User Settings",
-        href: "/profile/general",
-        icon: <Settings className="h-4 w-4" />
-    }
-];
-
-interface SettingsLayoutProps {
-    children: React.ReactNode;
-    params: Promise<{}>;
-}
-
-export default async function SettingsLayout(props: SettingsLayoutProps) {
-    const { children } = props;
-
-    const getUser = cache(verifySession);
-    const user = await getUser();
-
-    if (!user) {
-        redirect(`/`);
-    }
-
-    const cookie = await authCookieHeader();
-
-    let orgs: ListOrgsResponse["orgs"] = [];
-    try {
-        const getOrgs = cache(() =>
-            internal.get<AxiosResponse<ListOrgsResponse>>(`/orgs`, cookie)
-        );
-        const res = await getOrgs();
-        if (res && res.data.data.orgs) {
-            orgs = res.data.data.orgs;
-        }
-    } catch (e) {
-        console.error("Error fetching orgs", e);
-    }
-
-    return (
-        <>
-            <div className="w-full border-b bg-neutral-100 dark:bg-neutral-800 select-none sm:px-0 px-3 fixed top-0 z-10">
-                <div className="container mx-auto flex flex-col content-between">
-                    <div className="my-4">
-                        <Header email={user.email} orgs={orgs} />
-                    </div>
-                    <TopbarNav items={topNavItems} />
-                </div>
-            </div>
-
-            <div className="container mx-auto sm:px-0 px-3 pt-[165px]">
-                {children}
-            </div>
-        </>
-    );
-}

+ 0 - 5
src/app/profile/page_.tsx

@@ -1,5 +0,0 @@
-import { redirect } from "next/navigation";
-
-export default async function ProfilePage() {
-    redirect("/profile/general");
-}

+ 9 - 1
src/app/setup/layout.tsx

@@ -22,5 +22,13 @@ export default async function SetupLayout({
         redirect("/?redirect=/setup");
     }
 
-    return <div className="w-full max-w-2xl mx-auto p-3 md:mt-32">{children}</div>;
+    if (
+        !(process.env.DISABLE_USER_CREATE_ORG === "false" || user.serverAdmin)
+    ) {
+        redirect("/");
+    }
+
+    return (
+        <div className="w-full max-w-2xl mx-auto p-3 md:mt-32">{children}</div>
+    );
 }

+ 7 - 1
src/components/Disable2FaForm.tsx

@@ -96,12 +96,18 @@ export default function Disable2FaForm({ open, setOpen }: Disable2FaProps) {
         setLoading(false);
     };
 
+    function reset() {
+        disableForm.reset();
+        setStep("password");
+        setLoading(false);
+    }
+
     return (
         <Credenza
             open={open}
             onOpenChange={(val) => {
                 setOpen(val);
-                setLoading(false);
+                reset();
             }}
         >
             <CredenzaContent>

+ 14 - 1
src/components/Enable2FaForm.tsx

@@ -154,12 +154,25 @@ export default function Enable2FaForm({ open, setOpen }: Enable2FaProps) {
         }
     };
 
+    function reset() {
+        setLoading(false);
+        setStep(1);
+        setSecretKey("");
+        setSecretUri("");
+        setVerificationCode("");
+        setError("");
+        setSuccess(false);
+        setBackupCodes([]);
+        enableForm.reset();
+        confirmForm.reset();
+    }
+
     return (
         <Credenza
             open={open}
             onOpenChange={(val) => {
                 setOpen(val);
-                setLoading(false);
+                reset();
             }}
         >
             <CredenzaContent>

+ 30 - 14
src/components/Header.tsx

@@ -67,7 +67,9 @@ export function Header({ orgId, orgs }: HeaderProps) {
 
     const router = useRouter();
 
-    const api = createApiClient(useEnvContext());
+    const { env } = useEnvContext();
+
+    const api = createApiClient({ env });
 
     function getInitials() {
         return user.email.substring(0, 2).toUpperCase();
@@ -126,6 +128,11 @@ export function Header({ orgId, orgs }: HeaderProps) {
                                         {user.email}
                                     </p>
                                 </div>
+                                {user.serverAdmin && (
+                                    <p className="text-xs leading-none text-muted-foreground mt-2">
+                                        Server Admin
+                                    </p>
+                                )}
                             </DropdownMenuLabel>
                             <DropdownMenuSeparator />
                             {!user.twoFactorEnabled && (
@@ -237,19 +244,28 @@ export function Header({ orgId, orgs }: HeaderProps) {
                                     <CommandEmpty>
                                         No organizations found.
                                     </CommandEmpty>
-                                    <CommandGroup heading="Create">
-                                        <CommandList>
-                                            <CommandItem
-                                                onSelect={(currentValue) => {
-                                                    router.push("/setup");
-                                                }}
-                                            >
-                                                <Plus className="mr-2 h-4 w-4" />
-                                                New Organization
-                                            </CommandItem>
-                                        </CommandList>
-                                    </CommandGroup>
-                                    <CommandSeparator />
+                                    {(env.DISABLE_USER_CREATE_ORG === "false" ||
+                                        user.serverAdmin) && (
+                                        <>
+                                            <CommandGroup heading="Create">
+                                                <CommandList>
+                                                    <CommandItem
+                                                        onSelect={(
+                                                            currentValue
+                                                        ) => {
+                                                            router.push(
+                                                                "/setup"
+                                                            );
+                                                        }}
+                                                    >
+                                                        <Plus className="mr-2 h-4 w-4" />
+                                                        New Organization
+                                                    </CommandItem>
+                                                </CommandList>
+                                            </CommandGroup>
+                                            <CommandSeparator />
+                                        </>
+                                    )}
                                     <CommandGroup heading="Organizations">
                                         <CommandList>
                                             {orgs.map((org) => (

+ 3 - 1
src/components/LoginForm.tsx

@@ -57,7 +57,9 @@ const mfaSchema = z.object({
 export default function LoginForm({ redirect, onLogin }: LoginFormProps) {
     const router = useRouter();
 
-    const api = createApiClient(useEnvContext());
+    const { env } = useEnvContext();
+
+    const api = createApiClient({ env });
 
     const [error, setError] = useState<string | null>(null);
     const [loading, setLoading] = useState(false);

+ 2 - 0
src/lib/types/env.ts

@@ -3,4 +3,6 @@ export type env = {
     NEXT_PORT: string;
     ENVIRONMENT: string;
     EMAIL_ENABLED: string;
+    DISABLE_SIGNUP_WITHOUT_INVITE?: string;
+    DISABLE_USER_CREATE_ORG?: string;
 };