From 4a1e869e581b477b7d58f25fc0639f0da0bd81e7 Mon Sep 17 00:00:00 2001 From: Milo Schwartz Date: Wed, 25 Dec 2024 15:54:32 -0500 Subject: [PATCH] setup server admin --- config.example.yml | 7 ++ server/auth/checkValidInvite.ts | 42 ++++++++++ server/config.ts | 17 +++- server/db/schema.ts | 5 +- server/emails/templates/VerifyEmailCode.tsx | 19 ++--- server/routers/auth/signup.ts | 58 +++++++++++--- server/routers/org/createOrg.ts | 12 +++ server/routers/user/acceptInvite.ts | 52 ++++--------- server/routers/user/getUser.ts | 3 +- server/setup/copyInConfig.ts | 6 +- server/setup/ensureActions.ts | 6 +- server/setup/index.ts | 14 +++- server/setup/setupServerAdmin.ts | 86 +++++++++++++++++++++ src/app/auth/login/page.tsx | 48 ++++++++---- src/app/auth/signup/SignupForm.tsx | 6 +- src/app/auth/signup/page.tsx | 46 ++++++++++- src/app/invite/page.tsx | 2 +- src/app/layout.tsx | 5 +- src/app/profile/general/layout_.tsx | 36 --------- src/app/profile/general/page.tsx | 14 ---- src/app/profile/general/page_.tsx | 14 ---- src/app/profile/layout_.tsx | 74 ------------------ src/app/profile/page_.tsx | 5 -- src/app/setup/layout.tsx | 10 ++- src/components/Disable2FaForm.tsx | 8 +- src/components/Enable2FaForm.tsx | 15 +++- src/components/Header.tsx | 44 +++++++---- src/components/LoginForm.tsx | 4 +- src/lib/types/env.ts | 2 + 29 files changed, 409 insertions(+), 251 deletions(-) create mode 100644 server/auth/checkValidInvite.ts create mode 100644 server/setup/setupServerAdmin.ts delete mode 100644 src/app/profile/general/layout_.tsx delete mode 100644 src/app/profile/general/page.tsx delete mode 100644 src/app/profile/general/page_.tsx delete mode 100644 src/app/profile/layout_.tsx delete mode 100644 src/app/profile/page_.tsx diff --git a/config.example.yml b/config.example.yml index d0cbcec5..c3179941 100644 --- a/config.example.yml +++ b/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 diff --git a/server/auth/checkValidInvite.ts b/server/auth/checkValidInvite.ts new file mode 100644 index 00000000..0965b590 --- /dev/null +++ b/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] + }; +} diff --git a/server/config.ts b/server/config.ts index 8d78cbc0..13e6c14f 100644 --- a/server/config.ts +++ b/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; diff --git a/server/db/schema.ts b/server/db/schema.ts index e784c523..5a535c8e 100644 --- a/server/db/schema.ts +++ b/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", { diff --git a/server/emails/templates/VerifyEmailCode.tsx b/server/emails/templates/VerifyEmailCode.tsx index fc8978ed..9adab19b 100644 --- a/server/emails/templates/VerifyEmailCode.tsx +++ b/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" + } + } + } }} > @@ -48,11 +48,8 @@ export const VerifyEmail = ({ Hi {username || "there"}, - You’ve requested to verify your email. Please{" "} - - click here - {" "} - 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.
diff --git a/server/routers/auth/signup.ts b/server/routers/auth/signup.ts index 0dbf2f4a..10815da3 100644 --- a/server/routers/auth/signup.ts +++ b/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; @@ -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(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(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") { diff --git a/server/routers/org/createOrg.ts b/server/routers/org/createOrg.ts index 8562b1d7..9d717979 100644 --- a/server/routers/org/createOrg.ts +++ b/server/routers/org/createOrg.ts @@ -28,6 +28,18 @@ export async function createOrg( next: NextFunction ): Promise { 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( diff --git a/server/routers/user/acceptInvite.ts b/server/routers/user/acceptInvite.ts index 3c3b720b..16c05b80 100644 --- a/server/routers/user/acceptInvite.ts +++ b/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" - ) - ); - } - - if (!isWithinExpirationDate(new Date(existingInvite[0].expiresAt))) { - return next( - createHttpError(HttpCode.BAD_REQUEST, "Invite has expired") - ); - } - - const validToken = await verifyPassword( + const { error, existingInvite } = await checkValidInvite({ token, - existingInvite[0].tokenHash - ); - if (!validToken) { + inviteId + }); + + if (error) { + return next(createHttpError(HttpCode.BAD_REQUEST, error)); + } + + 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; @@ -121,15 +103,15 @@ export async function acceptInvite( // add the user to the org await db.insert(userOrgs).values({ userId: existingUser[0].userId, - orgId: existingInvite[0].orgId, - roleId: existingInvite[0].roleId + orgId: existingInvite.orgId, + roleId: existingInvite.roleId }); // delete the invite await db.delete(userInvites).where(eq(userInvites.inviteId, inviteId)); return response(res, { - data: { accepted: true, orgId: existingInvite[0].orgId }, + data: { accepted: true, orgId: existingInvite.orgId }, success: true, error: false, message: "Invite accepted", diff --git a/server/routers/user/getUser.ts b/server/routers/user/getUser.ts index 3a710458..b692ee6e 100644 --- a/server/routers/user/getUser.ts +++ b/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); diff --git a/server/setup/copyInConfig.ts b/server/setup/copyInConfig.ts index 264bdcc3..790166c7 100644 --- a/server/setup/copyInConfig.ts +++ b/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.info("Updated orgs with new domain"); -} \ No newline at end of file + logger.info(`Updated orgs with new domain (${domain})`); +} diff --git a/server/setup/ensureActions.ts b/server/setup/ensureActions.ts index c83a0a6b..b713e6f2 100644 --- a/server/setup/ensureActions.ts +++ b/server/setup/ensureActions.ts @@ -34,7 +34,7 @@ export async function ensureActions() { defaultRoles.map((role) => ({ roleId: role.roleId!, actionId, - orgId: role.orgId!, + orgId: role.orgId! })) ) .execute(); @@ -62,7 +62,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(); @@ -82,7 +82,7 @@ export async function createAdminRole(orgId: string) { actionIds.map((action) => ({ roleId, actionId: action.actionId, - orgId, + orgId })) ) .execute(); diff --git a/server/setup/index.ts b/server/setup/index.ts index ee7eae98..f3b0e44f 100644 --- a/server/setup/index.ts +++ b/server/setup/index.ts @@ -1,7 +1,15 @@ import { ensureActions } from "./ensureActions"; import { copyInConfig } from "./copyInConfig"; +import { setupServerAdmin } from "./setupServerAdmin"; +import logger from "@server/logger"; export async function runSetupFunctions() { - 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 -} \ No newline at end of file + try { + 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); + } +} diff --git a/server/setup/setupServerAdmin.ts b/server/setup/setupServerAdmin.ts new file mode 100644 index 00000000..a0b6696e --- /dev/null +++ b/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(); + } + }); +} diff --git a/src/app/auth/login/page.tsx b/src/app/auth/login/page.tsx index 4508d753..3b3b1289 100644 --- a/src/app/auth/login/page.tsx +++ b/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 && ( +
+
+ +

+ Looks like you've been invited! +

+

+ To accept the invite, you must login or create an + account. +

+
+
+ )} + -

- Don't have an account?{" "} - - Sign up - -

+ {(!signUpDisabled || isInvite) && ( +

+ Don't have an account?{" "} + + Sign up + +

+ )} ); } diff --git a/src/app/auth/signup/SignupForm.tsx b/src/app/auth/signup/SignupForm.tsx index b574ba4e..d2194770 100644 --- a/src/app/auth/signup/SignupForm.tsx +++ b/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>("/auth/signup", { email, password, + inviteId, + inviteToken }) .catch((e) => { console.error(e); diff --git a/src/app/auth/signup/page.tsx b/src/app/auth/signup/page.tsx index 785a168c..e3e8fe98 100644 --- a/src/app/auth/signup/page.tsx +++ b/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 ( <> - + {isInvite && ( +
+
+ +

+ Looks like you've been invited! +

+

+ To accept the invite, you must login or create an + account. +

+
+
+ )} + +

Already have an account?{" "} diff --git a/src/app/invite/page.tsx b/src/app/invite/page.tsx index a02e5862..7af05536 100644 --- a/src/app/invite/page.tsx +++ b/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("-"); diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 2271bcff..d9792294 100644 --- a/src/app/layout.tsx +++ b/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} diff --git a/src/app/profile/general/layout_.tsx b/src/app/profile/general/layout_.tsx deleted file mode 100644 index 947b3338..00000000 --- a/src/app/profile/general/layout_.tsx +++ /dev/null @@ -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 ( - <> - - {children} - - - ); -} diff --git a/src/app/profile/general/page.tsx b/src/app/profile/general/page.tsx deleted file mode 100644 index 9c85c0bb..00000000 --- a/src/app/profile/general/page.tsx +++ /dev/null @@ -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 ( - <> - {/* */} - - ); -} diff --git a/src/app/profile/general/page_.tsx b/src/app/profile/general/page_.tsx deleted file mode 100644 index 26ab15fc..00000000 --- a/src/app/profile/general/page_.tsx +++ /dev/null @@ -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 ( - <> - - - ); -} diff --git a/src/app/profile/layout_.tsx b/src/app/profile/layout_.tsx deleted file mode 100644 index f2d73776..00000000 --- a/src/app/profile/layout_.tsx +++ /dev/null @@ -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: - } -]; - -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>(`/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 ( - <> -

-
-
-
-
- -
-
- -
- {children} -
- - ); -} diff --git a/src/app/profile/page_.tsx b/src/app/profile/page_.tsx deleted file mode 100644 index f1dafa49..00000000 --- a/src/app/profile/page_.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { redirect } from "next/navigation"; - -export default async function ProfilePage() { - redirect("/profile/general"); -} diff --git a/src/app/setup/layout.tsx b/src/app/setup/layout.tsx index 139c1701..0e32ee0a 100644 --- a/src/app/setup/layout.tsx +++ b/src/app/setup/layout.tsx @@ -22,5 +22,13 @@ export default async function SetupLayout({ redirect("/?redirect=/setup"); } - return
{children}
; + if ( + !(process.env.DISABLE_USER_CREATE_ORG === "false" || user.serverAdmin) + ) { + redirect("/"); + } + + return ( +
{children}
+ ); } diff --git a/src/components/Disable2FaForm.tsx b/src/components/Disable2FaForm.tsx index e3e9f168..68e51a67 100644 --- a/src/components/Disable2FaForm.tsx +++ b/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 ( { setOpen(val); - setLoading(false); + reset(); }} > diff --git a/src/components/Enable2FaForm.tsx b/src/components/Enable2FaForm.tsx index 0bdaf1fd..4e66f23a 100644 --- a/src/components/Enable2FaForm.tsx +++ b/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 ( { setOpen(val); - setLoading(false); + reset(); }} > diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 4c8adeaa..88faebde 100644 --- a/src/components/Header.tsx +++ b/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}

+ {user.serverAdmin && ( +

+ Server Admin +

+ )} {!user.twoFactorEnabled && ( @@ -237,19 +244,28 @@ export function Header({ orgId, orgs }: HeaderProps) { No organizations found. - - - { - router.push("/setup"); - }} - > - - New Organization - - - - + {(env.DISABLE_USER_CREATE_ORG === "false" || + user.serverAdmin) && ( + <> + + + { + router.push( + "/setup" + ); + }} + > + + New Organization + + + + + + )} {orgs.map((org) => ( diff --git a/src/components/LoginForm.tsx b/src/components/LoginForm.tsx index 0d9402f3..90567bd4 100644 --- a/src/components/LoginForm.tsx +++ b/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(null); const [loading, setLoading] = useState(false); diff --git a/src/lib/types/env.ts b/src/lib/types/env.ts index 33e7d79d..d0a26f94 100644 --- a/src/lib/types/env.ts +++ b/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; };