diff --git a/messages/en-US.json b/messages/en-US.json index 679b8b46..7e96ed0c 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -958,6 +958,8 @@ "licenseTierProfessionalRequiredDescription": "This feature is only available in the Professional Edition.", "actionGetOrg": "Get Organization", "actionUpdateOrg": "Update Organization", + "actionUpdateUser": "Update User", + "actionGetUser": "Get User", "actionGetOrgUser": "Get Organization User", "actionListOrgDomains": "List Organization Domains", "actionCreateSite": "Create Site", @@ -1132,5 +1134,9 @@ "initialSetupTitle": "Initial Server Setup", "initialSetupDescription": "Create the intial server admin account. Only one server admin can exist. You can always change these credentials later.", "createAdminAccount": "Create Admin Account", - "setupErrorCreateAdmin": "An error occurred while creating the server admin account." + "setupErrorCreateAdmin": "An error occurred while creating the server admin account.", + "twoFactor": "Two-Factor Authentication", + "otpSetupDescription": "Secure your account with an extra layer of protection", + "adminEnabled2FaOnYourAccount": "Your administrator has enabled two-factor authentication for {email}. Please complete the setup process to continue.", + "continueToApplication": "Continue to Application" } diff --git a/server/auth/actions.ts b/server/auth/actions.ts index 08c86321..f68202da 100644 --- a/server/auth/actions.ts +++ b/server/auth/actions.ts @@ -56,6 +56,8 @@ export enum ActionsEnum { // removeUserAction = "removeUserAction", // removeUserSite = "removeUserSite", getOrgUser = "getOrgUser", + updateUser = "updateUser", + getUser = "getUser", setResourcePassword = "setResourcePassword", setResourcePincode = "setResourcePincode", setResourceWhitelist = "setResourceWhitelist", diff --git a/server/db/pg/schema.ts b/server/db/pg/schema.ts index cb641974..a1608e2b 100644 --- a/server/db/pg/schema.ts +++ b/server/db/pg/schema.ts @@ -121,6 +121,7 @@ export const users = pgTable("user", { }), passwordHash: varchar("passwordHash"), twoFactorEnabled: boolean("twoFactorEnabled").notNull().default(false), + twoFactorSetupRequested: boolean("twoFactorSetupRequested").default(false), twoFactorSecret: varchar("twoFactorSecret"), emailVerified: boolean("emailVerified").notNull().default(false), dateCreated: varchar("dateCreated").notNull(), diff --git a/server/db/sqlite/schema.ts b/server/db/sqlite/schema.ts index b587d1c7..255400bf 100644 --- a/server/db/sqlite/schema.ts +++ b/server/db/sqlite/schema.ts @@ -125,6 +125,9 @@ export const users = sqliteTable("user", { twoFactorEnabled: integer("twoFactorEnabled", { mode: "boolean" }) .notNull() .default(false), + twoFactorSetupRequested: integer("twoFactorSetupRequested", { + mode: "boolean" + }).default(false), twoFactorSecret: text("twoFactorSecret"), emailVerified: integer("emailVerified", { mode: "boolean" }) .notNull() diff --git a/server/lib/totp.ts b/server/lib/totp.ts new file mode 100644 index 00000000..d9f819ab --- /dev/null +++ b/server/lib/totp.ts @@ -0,0 +1,10 @@ +import { alphabet, generateRandomString } from "oslo/crypto"; + +export async function generateBackupCodes(): Promise { + const codes = []; + for (let i = 0; i < 10; i++) { + const code = generateRandomString(6, alphabet("0-9", "A-Z", "a-z")); + codes.push(code); + } + return codes; +} diff --git a/server/routers/auth/completeTotpSetup.ts b/server/routers/auth/completeTotpSetup.ts deleted file mode 100644 index 397f356b..00000000 --- a/server/routers/auth/completeTotpSetup.ts +++ /dev/null @@ -1,179 +0,0 @@ -import { Request, Response, NextFunction } from "express"; -import createHttpError from "http-errors"; -import { z } from "zod"; -import { fromError } from "zod-validation-error"; -import HttpCode from "@server/types/HttpCode"; -import { response } from "@server/lib"; -import { db } from "@server/db"; -import { twoFactorBackupCodes, users } from "@server/db"; -import { eq, and } from "drizzle-orm"; -import { alphabet, generateRandomString } from "oslo/crypto"; -import { hashPassword, verifyPassword } from "@server/auth/password"; -import { verifyTotpCode } from "@server/auth/totp"; -import logger from "@server/logger"; -import { sendEmail } from "@server/emails"; -import TwoFactorAuthNotification from "@server/emails/templates/TwoFactorAuthNotification"; -import config from "@server/lib/config"; -import { UserType } from "@server/types/UserTypes"; - -export const completeTotpSetupBody = z - .object({ - email: z.string().email(), - password: z.string(), - code: z.string() - }) - .strict(); - -export type CompleteTotpSetupBody = z.infer; - -export type CompleteTotpSetupResponse = { - valid: boolean; - backupCodes?: string[]; -}; - -export async function completeTotpSetup( - req: Request, - res: Response, - next: NextFunction -): Promise { - const parsedBody = completeTotpSetupBody.safeParse(req.body); - - if (!parsedBody.success) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - fromError(parsedBody.error).toString() - ) - ); - } - - const { email, password, code } = parsedBody.data; - - try { - // Find the user by email - const [user] = await db - .select() - .from(users) - .where(and(eq(users.email, email), eq(users.type, UserType.Internal))) - .limit(1); - - if (!user) { - return next( - createHttpError( - HttpCode.UNAUTHORIZED, - "Invalid credentials" - ) - ); - } - - // Verify password - const validPassword = await verifyPassword(password, user.passwordHash!); - if (!validPassword) { - return next( - createHttpError( - HttpCode.UNAUTHORIZED, - "Invalid credentials" - ) - ); - } - - // Check if 2FA is enabled but not yet completed - if (!user.twoFactorEnabled) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - "Two-factor authentication is not required for this user" - ) - ); - } - - if (!user.twoFactorSecret) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - "User has not started two-factor authentication setup" - ) - ); - } - - // Verify the TOTP code - const valid = await verifyTotpCode( - code, - user.twoFactorSecret, - user.userId - ); - - if (!valid) { - if (config.getRawConfig().app.log_failed_attempts) { - logger.info( - `Two-factor authentication code is incorrect. Email: ${email}. IP: ${req.ip}.` - ); - } - return next( - createHttpError( - HttpCode.BAD_REQUEST, - "Invalid two-factor authentication code" - ) - ); - } - - // Generate backup codes and finalize setup - let codes: string[] = []; - await db.transaction(async (trx) => { - // Note: We don't set twoFactorEnabled to true here because it's already true - // We just need to generate backup codes since the setup is now complete - - const backupCodes = await generateBackupCodes(); - codes = backupCodes; - for (const code of backupCodes) { - const hash = await hashPassword(code); - - await trx.insert(twoFactorBackupCodes).values({ - userId: user.userId, - codeHash: hash - }); - } - }); - - // Send notification email - sendEmail( - TwoFactorAuthNotification({ - email: user.email!, - enabled: true - }), - { - to: user.email!, - from: config.getRawConfig().email?.no_reply, - subject: "Two-factor authentication enabled" - } - ); - - return response(res, { - data: { - valid: true, - backupCodes: codes - }, - success: true, - error: false, - message: "Two-factor authentication setup completed successfully", - status: HttpCode.OK - }); - } catch (error) { - logger.error(error); - return next( - createHttpError( - HttpCode.INTERNAL_SERVER_ERROR, - "Failed to complete two-factor authentication setup" - ) - ); - } -} - -async function generateBackupCodes(): Promise { - const codes = []; - for (let i = 0; i < 10; i++) { - const code = generateRandomString(6, alphabet("0-9", "A-Z", "a-z")); - codes.push(code); - } - return codes; -} \ No newline at end of file diff --git a/server/routers/auth/index.ts b/server/routers/auth/index.ts index 2b45a140..6955e16c 100644 --- a/server/routers/auth/index.ts +++ b/server/routers/auth/index.ts @@ -3,8 +3,6 @@ export * from "./signup"; export * from "./logout"; export * from "./verifyTotp"; export * from "./requestTotpSecret"; -export * from "./setupTotpSecret"; -export * from "./completeTotpSetup"; export * from "./disable2fa"; export * from "./verifyEmail"; export * from "./requestEmailVerificationCode"; diff --git a/server/routers/auth/login.ts b/server/routers/auth/login.ts index 5080846a..4589e25e 100644 --- a/server/routers/auth/login.ts +++ b/server/routers/auth/login.ts @@ -21,10 +21,7 @@ import { UserType } from "@server/types/UserTypes"; export const loginBodySchema = z .object({ - email: z - .string() - .toLowerCase() - .email(), + email: z.string().toLowerCase().email(), password: z.string(), code: z.string().optional() }) @@ -38,8 +35,6 @@ export type LoginResponse = { twoFactorSetupRequired?: boolean; }; -export const dynamic = "force-dynamic"; - export async function login( req: Request, res: Response, @@ -110,18 +105,20 @@ export async function login( ); } - if (existingUser.twoFactorEnabled) { - // If 2FA is enabled but no secret exists, force setup - if (!existingUser.twoFactorSecret) { - return response(res, { - data: { twoFactorSetupRequired: true }, - success: true, - error: false, - message: "Two-factor authentication setup required", - status: HttpCode.ACCEPTED - }); - } + if ( + existingUser.twoFactorSetupRequested && + !existingUser.twoFactorEnabled + ) { + return response(res, { + data: { twoFactorSetupRequired: true }, + success: true, + error: false, + message: "Two-factor authentication setup required", + status: HttpCode.ACCEPTED + }); + } + if (existingUser.twoFactorEnabled) { if (!code) { return response<{ codeRequested: boolean }>(res, { data: { codeRequested: true }, @@ -134,7 +131,7 @@ export async function login( const validOTP = await verifyTotpCode( code, - existingUser.twoFactorSecret, + existingUser.twoFactorSecret!, existingUser.userId ); diff --git a/server/routers/auth/requestTotpSecret.ts b/server/routers/auth/requestTotpSecret.ts index 2de35412..475e2dac 100644 --- a/server/routers/auth/requestTotpSecret.ts +++ b/server/routers/auth/requestTotpSecret.ts @@ -7,16 +7,19 @@ import HttpCode from "@server/types/HttpCode"; import { response } from "@server/lib"; import { db } from "@server/db"; import { User, users } from "@server/db"; -import { eq } from "drizzle-orm"; +import { eq, and } from "drizzle-orm"; import { createTOTPKeyURI } from "oslo/otp"; import logger from "@server/logger"; import { verifyPassword } from "@server/auth/password"; import { unauthorized } from "@server/auth/unauthorizedResponse"; import { UserType } from "@server/types/UserTypes"; +import { verifySession } from "@server/auth/sessions/verifySession"; +import config from "@server/lib/config"; export const requestTotpSecretBody = z .object({ - password: z.string() + password: z.string(), + email: z.string().email().optional() }) .strict(); @@ -43,9 +46,42 @@ export async function requestTotpSecret( ); } - const { password } = parsedBody.data; + const { password, email } = parsedBody.data; - const user = req.user as User; + const { user: sessionUser, session: existingSession } = await verifySession(req); + + let user: User | null = sessionUser; + if (!existingSession) { + if (!email) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Email is required for two-factor authentication setup" + ) + ); + } + const [res] = await db + .select() + .from(users) + .where( + and(eq(users.type, UserType.Internal), eq(users.email, email)) + ); + user = res; + } + + if (!user) { + if (config.getRawConfig().app.log_failed_attempts) { + logger.info( + `Username or password incorrect. Email: ${email}. IP: ${req.ip}.` + ); + } + return next( + createHttpError( + HttpCode.UNAUTHORIZED, + "Username or password is incorrect" + ) + ); + } if (user.type !== UserType.Internal) { return next( @@ -57,7 +93,10 @@ export async function requestTotpSecret( } try { - const validPassword = await verifyPassword(password, user.passwordHash!); + const validPassword = await verifyPassword( + password, + user.passwordHash! + ); if (!validPassword) { return next(unauthorized()); } diff --git a/server/routers/auth/setupTotpSecret.ts b/server/routers/auth/setupTotpSecret.ts deleted file mode 100644 index 89807e8e..00000000 --- a/server/routers/auth/setupTotpSecret.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { Request, Response, NextFunction } from "express"; -import createHttpError from "http-errors"; -import { z } from "zod"; -import { fromError } from "zod-validation-error"; -import { encodeHex } from "oslo/encoding"; -import HttpCode from "@server/types/HttpCode"; -import { response } from "@server/lib"; -import { db } from "@server/db"; -import { User, users } from "@server/db"; -import { eq, and } from "drizzle-orm"; -import { createTOTPKeyURI } from "oslo/otp"; -import logger from "@server/logger"; -import { verifyPassword } from "@server/auth/password"; -import { UserType } from "@server/types/UserTypes"; - -export const setupTotpSecretBody = z - .object({ - email: z.string().email(), - password: z.string() - }) - .strict(); - -export type SetupTotpSecretBody = z.infer; - -export type SetupTotpSecretResponse = { - secret: string; - uri: string; -}; - -export async function setupTotpSecret( - req: Request, - res: Response, - next: NextFunction -): Promise { - const parsedBody = setupTotpSecretBody.safeParse(req.body); - - if (!parsedBody.success) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - fromError(parsedBody.error).toString() - ) - ); - } - - const { email, password } = parsedBody.data; - - try { - // Find the user by email - const [user] = await db - .select() - .from(users) - .where(and(eq(users.email, email), eq(users.type, UserType.Internal))) - .limit(1); - - if (!user) { - return next( - createHttpError( - HttpCode.UNAUTHORIZED, - "Invalid credentials" - ) - ); - } - - // Verify password - const validPassword = await verifyPassword(password, user.passwordHash!); - if (!validPassword) { - return next( - createHttpError( - HttpCode.UNAUTHORIZED, - "Invalid credentials" - ) - ); - } - - // Check if 2FA is enabled but no secret exists (forced setup scenario) - if (!user.twoFactorEnabled) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - "Two-factor authentication is not required for this user" - ) - ); - } - - if (user.twoFactorSecret) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - "User has already completed two-factor authentication setup" - ) - ); - } - - // Generate new TOTP secret - const hex = crypto.getRandomValues(new Uint8Array(20)); - const secret = encodeHex(hex); - const uri = createTOTPKeyURI("Pangolin", user.email!, hex); - - // Save the secret to the database - await db - .update(users) - .set({ - twoFactorSecret: secret - }) - .where(eq(users.userId, user.userId)); - - return response(res, { - data: { - secret, - uri - }, - success: true, - error: false, - message: "TOTP secret generated successfully", - status: HttpCode.OK - }); - } catch (error) { - logger.error(error); - return next( - createHttpError( - HttpCode.INTERNAL_SERVER_ERROR, - "Failed to generate TOTP secret" - ) - ); - } -} \ No newline at end of file diff --git a/server/routers/auth/verifyTotp.ts b/server/routers/auth/verifyTotp.ts index 70018a7d..4aca24ae 100644 --- a/server/routers/auth/verifyTotp.ts +++ b/server/routers/auth/verifyTotp.ts @@ -6,18 +6,22 @@ import HttpCode from "@server/types/HttpCode"; import { response } from "@server/lib"; import { db } from "@server/db"; import { twoFactorBackupCodes, User, users } from "@server/db"; -import { eq } from "drizzle-orm"; -import { alphabet, generateRandomString } from "oslo/crypto"; -import { hashPassword } from "@server/auth/password"; +import { eq, and } from "drizzle-orm"; +import { hashPassword, verifyPassword } from "@server/auth/password"; import { verifyTotpCode } from "@server/auth/totp"; import logger from "@server/logger"; import { sendEmail } from "@server/emails"; import TwoFactorAuthNotification from "@server/emails/templates/TwoFactorAuthNotification"; import config from "@server/lib/config"; import { UserType } from "@server/types/UserTypes"; +import { generateBackupCodes } from "@server/lib/totp"; +import { verifySession } from "@server/auth/sessions/verifySession"; +import { unauthorized } from "@server/auth/unauthorizedResponse"; export const verifyTotpBody = z .object({ + email: z.string().email().optional(), + password: z.string().optional(), code: z.string() }) .strict(); @@ -45,38 +49,83 @@ export async function verifyTotp( ); } - const { code } = parsedBody.data; - - const user = req.user as User; - - if (user.type !== UserType.Internal) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - "Two-factor authentication is not supported for external users" - ) - ); - } - - if (user.twoFactorEnabled) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - "Two-factor authentication is already enabled" - ) - ); - } - - if (!user.twoFactorSecret) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - "User has not requested two-factor authentication" - ) - ); - } + const { code, email, password } = parsedBody.data; try { + const { user: sessionUser, session: existingSession } = + await verifySession(req); + + let user: User | null = sessionUser; + if (!existingSession) { + if (!email || !password) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Email and password are required for two-factor authentication" + ) + ); + } + const [res] = await db + .select() + .from(users) + .where( + and( + eq(users.type, UserType.Internal), + eq(users.email, email) + ) + ); + user = res; + } + + if (!user) { + if (config.getRawConfig().app.log_failed_attempts) { + logger.info( + `Username or password incorrect. Email: ${email}. IP: ${req.ip}.` + ); + } + return next( + createHttpError( + HttpCode.UNAUTHORIZED, + "Username or password is incorrect" + ) + ); + } + + const validPassword = await verifyPassword( + password, + user.passwordHash! + ); + if (!validPassword) { + return next(unauthorized()); + } + + if (user.type !== UserType.Internal) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Two-factor authentication is not supported for external users" + ) + ); + } + + if (user.twoFactorEnabled) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Two-factor authentication is already enabled" + ) + ); + } + + if (!user.twoFactorSecret) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "User has not requested two-factor authentication" + ) + ); + } + const valid = await verifyTotpCode( code, user.twoFactorSecret, @@ -89,7 +138,9 @@ export async function verifyTotp( await db.transaction(async (trx) => { await trx .update(users) - .set({ twoFactorEnabled: true }) + .set({ + twoFactorEnabled: true + }) .where(eq(users.userId, user.userId)); const backupCodes = await generateBackupCodes(); @@ -153,12 +204,3 @@ export async function verifyTotp( ); } } - -async function generateBackupCodes(): Promise { - const codes = []; - for (let i = 0; i < 10; i++) { - const code = generateRandomString(6, alphabet("0-9", "A-Z", "a-z")); - codes.push(code); - } - return codes; -} diff --git a/server/routers/external.ts b/server/routers/external.ts index c764fa6d..9111046e 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -476,6 +476,7 @@ unauthenticated.get("/resource/:resourceId/auth", resource.getResourceAuthInfo); unauthenticated.get("/user", verifySessionMiddleware, user.getUser); authenticated.get("/users", verifyUserIsServerAdmin, user.adminListUsers); +authenticated.get("/user/:userId", verifyUserIsServerAdmin, user.adminGetUser); authenticated.delete( "/user/:userId", verifyUserIsServerAdmin, @@ -490,11 +491,10 @@ authenticated.put( ); authenticated.get("/org/:orgId/user/:userId", verifyOrgAccess, user.getOrgUser); -authenticated.patch( - "/org/:orgId/user/:userId/2fa", - verifyOrgAccess, - verifyUserAccess, - verifyUserHasAction(ActionsEnum.getOrgUser), + +authenticated.post( + "/user/:userId/2fa", + verifyUserIsServerAdmin, user.updateUser2FA ); @@ -719,14 +719,8 @@ authRouter.post("/login", auth.login); authRouter.post("/logout", auth.logout); authRouter.post("/newt/get-token", getToken); -authRouter.post("/2fa/enable", verifySessionUserMiddleware, auth.verifyTotp); -authRouter.post( - "/2fa/request", - verifySessionUserMiddleware, - auth.requestTotpSecret -); -authRouter.post("/2fa/setup", auth.setupTotpSecret); -authRouter.post("/2fa/complete-setup", auth.completeTotpSetup); +authRouter.post("/2fa/enable", auth.verifyTotp); +authRouter.post("/2fa/request", auth.requestTotpSecret); authRouter.post("/2fa/disable", verifySessionUserMiddleware, auth.disable2fa); authRouter.post("/verify-email", verifySessionMiddleware, auth.verifyEmail); diff --git a/server/routers/integration.ts b/server/routers/integration.ts index fc66a88d..51604a11 100644 --- a/server/routers/integration.ts +++ b/server/routers/integration.ts @@ -381,6 +381,20 @@ authenticated.get( user.getOrgUser ); +authenticated.post( + "/user/:userId/2fa", + verifyApiKeyIsRoot, + verifyApiKeyHasAction(ActionsEnum.updateUser), + user.updateUser2FA +); + +authenticated.get( + "/user/:userId", + verifyApiKeyIsRoot, + verifyApiKeyHasAction(ActionsEnum.getUser), + user.adminGetUser +); + authenticated.get( "/org/:orgId/users", verifyApiKeyOrgAccess, diff --git a/server/routers/user/adminGetUser.ts b/server/routers/user/adminGetUser.ts new file mode 100644 index 00000000..0a961bec --- /dev/null +++ b/server/routers/user/adminGetUser.ts @@ -0,0 +1,94 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { idp, users } from "@server/db"; +import { eq } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { OpenAPITags, registry } from "@server/openApi"; + +const adminGetUserSchema = z + .object({ + userId: z.string().min(1) + }) + .strict(); + +registry.registerPath({ + method: "get", + path: "/user/{userId}", + description: "Get a user by ID.", + tags: [OpenAPITags.User], + request: { + params: adminGetUserSchema + }, + responses: {} +}); + +async function queryUser(userId: string) { + const [user] = await db + .select({ + userId: users.userId, + email: users.email, + username: users.username, + name: users.name, + type: users.type, + twoFactorEnabled: users.twoFactorEnabled, + twoFactorSetupRequested: users.twoFactorSetupRequested, + emailVerified: users.emailVerified, + serverAdmin: users.serverAdmin, + idpName: idp.name, + idpId: users.idpId, + dateCreated: users.dateCreated + }) + .from(users) + .leftJoin(idp, eq(users.idpId, idp.idpId)) + .where(eq(users.userId, userId)) + .limit(1); + return user; +} + +export type AdminGetUserResponse = NonNullable< + Awaited> +>; + +export async function adminGetUser( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = adminGetUserSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid user ID") + ); + } + const { userId } = parsedParams.data; + + const user = await queryUser(userId); + + if (!user) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `User with ID ${userId} not found` + ) + ); + } + + return response(res, { + data: user, + success: true, + error: false, + message: "User retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/user/adminListUsers.ts b/server/routers/user/adminListUsers.ts index cb1e21fb..308b9def 100644 --- a/server/routers/user/adminListUsers.ts +++ b/server/routers/user/adminListUsers.ts @@ -37,7 +37,9 @@ async function queryUsers(limit: number, offset: number) { serverAdmin: users.serverAdmin, type: users.type, idpName: idp.name, - idpId: users.idpId + idpId: users.idpId, + twoFactorEnabled: users.twoFactorEnabled, + twoFactorSetupRequested: users.twoFactorSetupRequested }) .from(users) .leftJoin(idp, eq(users.idpId, idp.idpId)) diff --git a/server/routers/user/updateUser2FA.ts b/server/routers/user/adminUpdateUser2FA.ts similarity index 52% rename from server/routers/user/updateUser2FA.ts rename to server/routers/user/adminUpdateUser2FA.ts index 845eaa0c..becd2091 100644 --- a/server/routers/user/updateUser2FA.ts +++ b/server/routers/user/adminUpdateUser2FA.ts @@ -8,32 +8,30 @@ import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; -import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions"; import { OpenAPITags, registry } from "@server/openApi"; const updateUser2FAParamsSchema = z .object({ - userId: z.string(), - orgId: z.string() + userId: z.string() }) .strict(); const updateUser2FABodySchema = z .object({ - twoFactorEnabled: z.boolean() + twoFactorSetupRequested: z.boolean() }) .strict(); export type UpdateUser2FAResponse = { userId: string; - twoFactorEnabled: boolean; + twoFactorRequested: boolean; }; registry.registerPath({ - method: "patch", - path: "/org/{orgId}/user/{userId}/2fa", - description: "Update a user's 2FA status within an organization.", - tags: [OpenAPITags.Org, OpenAPITags.User], + method: "post", + path: "/user/{userId}/2fa", + description: "Update a user's 2FA status.", + tags: [OpenAPITags.User], request: { params: updateUser2FAParamsSchema, body: { @@ -73,73 +71,57 @@ export async function updateUser2FA( ); } - const { userId, orgId } = parsedParams.data; - const { twoFactorEnabled } = parsedBody.data; - - if (!req.userOrg) { - return next( - createHttpError( - HttpCode.FORBIDDEN, - "You do not have access to this organization" - ) - ); - } - - // Check if user has permission to update other users' 2FA - const hasPermission = await checkUserActionPermission( - ActionsEnum.getOrgUser, - req - ); - if (!hasPermission) { - return next( - createHttpError( - HttpCode.FORBIDDEN, - "User does not have permission to update other users' 2FA settings" - ) - ); - } + const { userId } = parsedParams.data; + const { twoFactorSetupRequested } = parsedBody.data; // Verify the user exists in the organization const existingUser = await db .select() - .from(userOrgs) - .where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId))) + .from(users) + .where(eq(users.userId, userId)) .limit(1); if (existingUser.length === 0) { + return next(createHttpError(HttpCode.NOT_FOUND, "User not found")); + } + + if (existingUser[0].type !== "internal") { return next( createHttpError( - HttpCode.NOT_FOUND, - "User not found or does not belong to the specified organization" + HttpCode.BAD_REQUEST, + "Two-factor authentication is not supported for external users" ) ); } - // Update the user's 2FA status - const updatedUser = await db - .update(users) - .set({ - twoFactorEnabled, - // If disabling 2FA, also clear the secret - twoFactorSecret: twoFactorEnabled ? undefined : null - }) - .where(eq(users.userId, userId)) - .returning({ userId: users.userId, twoFactorEnabled: users.twoFactorEnabled }); + logger.debug(`Updating 2FA for user ${userId} to ${twoFactorSetupRequested}`); - if (updatedUser.length === 0) { - return next( - createHttpError( - HttpCode.NOT_FOUND, - "User not found" - ) - ); + if (twoFactorSetupRequested) { + await db + .update(users) + .set({ + twoFactorSetupRequested: true, + }) + .where(eq(users.userId, userId)); + } else { + await db + .update(users) + .set({ + twoFactorSetupRequested: false, + twoFactorEnabled: false, + twoFactorSecret: null + }) + .where(eq(users.userId, userId)); } return response(res, { - data: updatedUser[0], + data: { + userId: existingUser[0].userId, + twoFactorRequested: twoFactorSetupRequested + }, success: true, error: false, - message: `2FA ${twoFactorEnabled ? 'enabled' : 'disabled'} for user successfully`, + message: `2FA ${twoFactorSetupRequested ? "enabled" : "disabled"} for user successfully`, status: HttpCode.OK }); } catch (error) { @@ -148,4 +130,4 @@ export async function updateUser2FA( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } -} \ No newline at end of file +} diff --git a/server/routers/user/index.ts b/server/routers/user/index.ts index ed8a1769..6d342ad3 100644 --- a/server/routers/user/index.ts +++ b/server/routers/user/index.ts @@ -7,7 +7,9 @@ export * from "./acceptInvite"; export * from "./getOrgUser"; export * from "./adminListUsers"; export * from "./adminRemoveUser"; +export * from "./adminGetUser"; export * from "./listInvitations"; export * from "./removeInvitation"; export * from "./createOrgUser"; -export * from "./updateUser2FA"; +export * from "./adminUpdateUser2FA"; +export * from "./adminGetUser"; diff --git a/src/app/[orgId]/settings/access/users/UsersTable.tsx b/src/app/[orgId]/settings/access/users/UsersTable.tsx index 017ab875..d3ee404e 100644 --- a/src/app/[orgId]/settings/access/users/UsersTable.tsx +++ b/src/app/[orgId]/settings/access/users/UsersTable.tsx @@ -34,7 +34,6 @@ export type UserRow = { status: string; role: string; isOwner: boolean; - isTwoFactorEnabled: boolean; }; type UsersTableProps = { @@ -171,39 +170,6 @@ export default function UsersTable({ users: u }: UsersTableProps) { ); } }, - { - accessorKey: "isTwoFactorEnabled", - header: ({ column }) => { - return ( - - ); - }, - cell: ({ row }) => { - const userRow = row.original; - - return ( -
- {userRow.isTwoFactorEnabled && ( - - {t('enabled')} - - ) || ( - - {t('disabled')} - - )} -
- ); - } - }, { id: "actions", cell: ({ row }) => { diff --git a/src/app/[orgId]/settings/access/users/[userId]/access-controls/page.tsx b/src/app/[orgId]/settings/access/users/[userId]/access-controls/page.tsx index 2929f75b..82999ad2 100644 --- a/src/app/[orgId]/settings/access/users/[userId]/access-controls/page.tsx +++ b/src/app/[orgId]/settings/access/users/[userId]/access-controls/page.tsx @@ -27,7 +27,6 @@ import { ListRolesResponse } from "@server/routers/role"; import { userOrgUserContext } from "@app/hooks/useOrgUserContext"; import { useParams } from "next/navigation"; import { Button } from "@app/components/ui/button"; -import { Checkbox } from "@app/components/ui/checkbox"; import { SettingsContainer, SettingsSection, @@ -44,14 +43,14 @@ import { useEnvContext } from "@app/hooks/useEnvContext"; import { useTranslations } from "next-intl"; export default function AccessControlsPage() { - const { orgUser: user, updateOrgUser } = userOrgUserContext(); + const { orgUser: user } = userOrgUserContext(); + const api = createApiClient(useEnvContext()); const { orgId } = useParams(); const [loading, setLoading] = useState(false); const [roles, setRoles] = useState<{ roleId: number; name: string }[]>([]); - const [enable2FA, setEnable2FA] = useState(user.twoFactorEnabled || false); const t = useTranslations(); @@ -97,8 +96,7 @@ export default function AccessControlsPage() { async function onSubmit(values: z.infer) { setLoading(true); - // Update user role - const roleRes = await api + const res = await api .post< AxiosResponse >(`/role/${values.roleId}/add/${user.userId}`) @@ -111,34 +109,9 @@ export default function AccessControlsPage() { t('accessRoleErrorAddDescription') ) }); - return null; }); - // Update 2FA status if it changed - if (enable2FA !== user.twoFactorEnabled) { - const twoFARes = await api - .patch(`/org/${orgId}/user/${user.userId}/2fa`, { - twoFactorEnabled: enable2FA - }) - .catch((e) => { - toast({ - variant: "destructive", - title: "Error updating 2FA", - description: formatAxiosError( - e, - "Failed to update 2FA status" - ) - }); - return null; - }); - - if (twoFARes && twoFARes.status === 200) { - // Update the user context with the new 2FA status - updateOrgUser({ twoFactorEnabled: enable2FA }); - } - } - - if (roleRes && roleRes.status === 200) { + if (res && res.status === 200) { toast({ variant: "default", title: t('userSaved'), @@ -197,36 +170,6 @@ export default function AccessControlsPage() { )} /> - -
-
- - setEnable2FA( - e as boolean - ) - } - /> - -
-

- When enabled, the user will be required to set up their authenticator app on their next login. - {user.twoFactorEnabled && ( - This user currently has 2FA enabled. - )} -

-
- - @@ -243,8 +186,6 @@ export default function AccessControlsPage() { - - ); } diff --git a/src/app/[orgId]/settings/access/users/page.tsx b/src/app/[orgId]/settings/access/users/page.tsx index 49637f71..27b227fa 100644 --- a/src/app/[orgId]/settings/access/users/page.tsx +++ b/src/app/[orgId]/settings/access/users/page.tsx @@ -81,8 +81,7 @@ export default async function UsersPage(props: UsersPageProps) { idpName: user.idpName || t('idpNameInternal'), status: t('userConfirmed'), role: user.isOwner ? t('accessRoleOwner') : user.roleName || t('accessRoleMember'), - isOwner: user.isOwner || false, - isTwoFactorEnabled: user.twoFactorEnabled || false, + isOwner: user.isOwner || false }; }); diff --git a/src/app/admin/users/AdminUsersTable.tsx b/src/app/admin/users/AdminUsersTable.tsx index fdb09f8a..6c5e4613 100644 --- a/src/app/admin/users/AdminUsersTable.tsx +++ b/src/app/admin/users/AdminUsersTable.tsx @@ -3,7 +3,7 @@ import { ColumnDef } from "@tanstack/react-table"; import { UsersDataTable } from "./AdminUsersDataTable"; import { Button } from "@app/components/ui/button"; -import { ArrowRight, ArrowUpDown } from "lucide-react"; +import { ArrowRight, ArrowUpDown, MoreHorizontal } from "lucide-react"; import { useRouter } from "next/navigation"; import { useState } from "react"; import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; @@ -12,6 +12,12 @@ import { formatAxiosError } from "@app/lib/api"; import { createApiClient } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { useTranslations } from "next-intl"; +import { + DropdownMenu, + DropdownMenuItem, + DropdownMenuContent, + DropdownMenuTrigger +} from "@app/components/ui/dropdown-menu"; export type GlobalUserRow = { id: string; @@ -22,6 +28,8 @@ export type GlobalUserRow = { idpId: number | null; idpName: string; dateCreated: string; + twoFactorEnabled: boolean | null; + twoFactorSetupRequested: boolean | null; }; type Props = { @@ -41,11 +49,11 @@ export default function UsersTable({ users }: Props) { const deleteUser = (id: string) => { api.delete(`/user/${id}`) .catch((e) => { - console.error(t('userErrorDelete'), e); + console.error(t("userErrorDelete"), e); toast({ variant: "destructive", - title: t('userErrorDelete'), - description: formatAxiosError(e, t('userErrorDelete')) + title: t("userErrorDelete"), + description: formatAxiosError(e, t("userErrorDelete")) }); }) .then(() => { @@ -84,7 +92,7 @@ export default function UsersTable({ users }: Props) { column.toggleSorting(column.getIsSorted() === "asc") } > - {t('username')} + {t("username")} ); @@ -100,7 +108,7 @@ export default function UsersTable({ users }: Props) { column.toggleSorting(column.getIsSorted() === "asc") } > - {t('email')} + {t("email")} ); @@ -116,7 +124,7 @@ export default function UsersTable({ users }: Props) { column.toggleSorting(column.getIsSorted() === "asc") } > - {t('name')} + {t("name")} ); @@ -132,28 +140,85 @@ export default function UsersTable({ users }: Props) { column.toggleSorting(column.getIsSorted() === "asc") } > - {t('identityProvider')} + {t("identityProvider")} ); } }, + { + accessorKey: "twoFactorEnabled", + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + const userRow = row.original; + + return ( +
+ + {userRow.twoFactorEnabled || + userRow.twoFactorSetupRequested ? ( + + {t("enabled")} + + ) : ( + {t("disabled")} + )} + +
+ ); + } + }, { id: "actions", cell: ({ row }) => { const r = row.original; return ( <> -
+
+ + + + + + { + setSelected(r); + setIsDeleteModalOpen(true); + }} + > + {t("delete")} + + +
@@ -174,26 +239,27 @@ export default function UsersTable({ users }: Props) { dialog={

- {t('userQuestionRemove', {selectedUser: selected?.email || selected?.name || selected?.username})} + {t("userQuestionRemove", { + selectedUser: + selected?.email || + selected?.name || + selected?.username + })}

- - {t('userMessageRemove')} - + {t("userMessageRemove")}

-

- {t('userMessageConfirm')} -

+

{t("userMessageConfirm")}

} - buttonText={t('userDeleteConfirm')} + buttonText={t("userDeleteConfirm")} onConfirm={async () => deleteUser(selected!.id)} string={ selected.email || selected.name || selected.username } - title={t('userDeleteServer')} + title={t("userDeleteServer")} /> )} diff --git a/src/app/admin/users/[userId]/general/page.tsx b/src/app/admin/users/[userId]/general/page.tsx new file mode 100644 index 00000000..ae720a6f --- /dev/null +++ b/src/app/admin/users/[userId]/general/page.tsx @@ -0,0 +1,134 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { SwitchInput } from "@app/components/SwitchInput"; +import { Button } from "@app/components/ui/button"; +import { toast } from "@app/hooks/useToast"; +import { formatAxiosError } from "@app/lib/api"; +import { createApiClient } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { useTranslations } from "next-intl"; +import { useParams } from "next/navigation"; +import { + SettingsContainer, + SettingsSection, + SettingsSectionHeader, + SettingsSectionTitle, + SettingsSectionDescription, + SettingsSectionBody, + SettingsSectionForm +} from "@app/components/Settings"; +import { UserType } from "@server/types/UserTypes"; + +export default function GeneralPage() { + const { userId } = useParams(); + const api = createApiClient(useEnvContext()); + const t = useTranslations(); + + const [loadingData, setLoadingData] = useState(true); + const [loading, setLoading] = useState(false); + const [twoFactorEnabled, setTwoFactorEnabled] = useState(false); + const [userType, setUserType] = useState(null); + + useEffect(() => { + // Fetch current user 2FA status + const fetchUserData = async () => { + setLoadingData(true); + try { + const response = await api.get(`/user/${userId}`); + if (response.status === 200) { + const userData = response.data.data; + setTwoFactorEnabled( + userData.twoFactorEnabled || + userData.twoFactorSetupRequested + ); + setUserType(userData.type); + } + } catch (error) { + console.error("Failed to fetch user data:", error); + toast({ + variant: "destructive", + title: t("userErrorDelete"), + description: formatAxiosError(error, t("userErrorDelete")) + }); + } + setLoadingData(false); + }; + + fetchUserData(); + }, [userId]); + + const handleTwoFactorToggle = (enabled: boolean) => { + setTwoFactorEnabled(enabled); + }; + + const handleSaveSettings = async () => { + setLoading(true); + + try { + console.log("twoFactorEnabled", twoFactorEnabled); + await api.post(`/user/${userId}/2fa`, { + twoFactorSetupRequested: twoFactorEnabled + }); + + setTwoFactorEnabled(twoFactorEnabled); + } catch (error) { + toast({ + variant: "destructive", + title: t("otpErrorEnable"), + description: formatAxiosError( + error, + t("otpErrorEnableDescription") + ) + }); + } finally { + setLoading(false); + } + }; + + if (loadingData) { + return null; + } + + return ( + <> + + + + + {t("general")} + + + {t("userDescription2")} + + + + + +
+ +
+
+
+
+
+ +
+ +
+ + ); +} diff --git a/src/app/admin/users/[userId]/layout.tsx b/src/app/admin/users/[userId]/layout.tsx new file mode 100644 index 00000000..062b40d8 --- /dev/null +++ b/src/app/admin/users/[userId]/layout.tsx @@ -0,0 +1,55 @@ +import { internal } from "@app/lib/api"; +import { AxiosResponse } from "axios"; +import { redirect } from "next/navigation"; +import { authCookieHeader } from "@app/lib/api/cookies"; +import { AdminGetUserResponse } from "@server/routers/user/adminGetUser"; +import { HorizontalTabs } from "@app/components/HorizontalTabs"; +import { cache } from "react"; +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import { getTranslations } from 'next-intl/server'; + +interface UserLayoutProps { + children: React.ReactNode; + params: Promise<{ userId: string }>; +} + +export default async function UserLayoutProps(props: UserLayoutProps) { + const params = await props.params; + + const { children } = props; + + const t = await getTranslations(); + + let user = null; + try { + const getUser = cache(async () => + internal.get>( + `/user/${params.userId}`, + await authCookieHeader() + ) + ); + const res = await getUser(); + user = res.data.data; + } catch { + redirect(`/admin/users`); + } + + const navItems = [ + { + title: t('general'), + href: "/admin/users/{userId}/general" + } + ]; + + return ( + <> + + + {children} + + + ); +} \ No newline at end of file diff --git a/src/app/admin/users/[userId]/page.tsx b/src/app/admin/users/[userId]/page.tsx new file mode 100644 index 00000000..edf5aaed --- /dev/null +++ b/src/app/admin/users/[userId]/page.tsx @@ -0,0 +1,8 @@ +import { redirect } from "next/navigation"; + +export default async function UserPage(props: { + params: Promise<{ userId: string }>; +}) { + const { userId } = await props.params; + redirect(`/admin/users/${userId}/general`); +} \ No newline at end of file diff --git a/src/app/admin/users/page.tsx b/src/app/admin/users/page.tsx index 1e29a311..e9673374 100644 --- a/src/app/admin/users/page.tsx +++ b/src/app/admin/users/page.tsx @@ -38,7 +38,9 @@ export default async function UsersPage(props: PageProps) { idpId: row.idpId, idpName: row.idpName || t('idpNameInternal'), dateCreated: row.dateCreated, - serverAdmin: row.serverAdmin + serverAdmin: row.serverAdmin, + twoFactorEnabled: row.twoFactorEnabled, + twoFactorSetupRequested: row.twoFactorSetupRequested }; }); diff --git a/src/app/auth/2fa/setup/page.tsx b/src/app/auth/2fa/setup/page.tsx index 2f77aaa5..64a6cf57 100644 --- a/src/app/auth/2fa/setup/page.tsx +++ b/src/app/auth/2fa/setup/page.tsx @@ -1,29 +1,7 @@ "use client"; -import { useState, useEffect } from "react"; +import { useEffect } from "react"; import { useRouter, useSearchParams } from "next/navigation"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { AlertCircle, CheckCircle2 } from "lucide-react"; -import { createApiClient } from "@app/lib/api"; -import { useEnvContext } from "@app/hooks/useEnvContext"; -import { AxiosResponse } from "axios"; -import { - RequestTotpSecretResponse, - VerifyTotpResponse -} from "@server/routers/auth"; -import { z } from "zod"; -import { useForm } from "react-hook-form"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage -} from "@app/components/ui/form"; import { Card, CardContent, @@ -31,124 +9,31 @@ import { CardHeader, CardTitle } from "@/components/ui/card"; -import { toast } from "@app/hooks/useToast"; -import { formatAxiosError } from "@app/lib/api"; -import CopyTextBox from "@app/components/CopyTextBox"; -import { QRCodeCanvas } from "qrcode.react"; +import TwoFactorSetupForm from "@app/components/TwoFactorSetupForm"; import { useTranslations } from "next-intl"; +import { cleanRedirect } from "@app/lib/cleanRedirect"; export default function Setup2FAPage() { const router = useRouter(); const searchParams = useSearchParams(); const redirect = searchParams?.get("redirect"); const email = searchParams?.get("email"); - - const [step, setStep] = useState(1); - const [secretKey, setSecretKey] = useState(""); - const [secretUri, setSecretUri] = useState(""); - const [loading, setLoading] = useState(false); - const [backupCodes, setBackupCodes] = useState([]); - const api = createApiClient(useEnvContext()); const t = useTranslations(); // Redirect to login if no email is provided useEffect(() => { if (!email) { - router.push('/auth/login'); + router.push("/auth/login"); } }, [email, router]); - const enableSchema = z.object({ - password: z.string().min(1, { message: t('passwordRequired') }) - }); - - const confirmSchema = z.object({ - code: z.string().length(6, { message: t('pincodeInvalid') }) - }); - - const enableForm = useForm>({ - resolver: zodResolver(enableSchema), - defaultValues: { - password: "" - } - }); - - const confirmForm = useForm>({ - resolver: zodResolver(confirmSchema), - defaultValues: { - code: "" - } - }); - - const request2fa = async (values: z.infer) => { - if (!email) return; - - setLoading(true); - - const res = await api - .post>( - `/auth/2fa/setup`, - { - email: email, - password: values.password - } - ) - .catch((e) => { - toast({ - title: t('otpErrorEnable'), - description: formatAxiosError( - e, - t('otpErrorEnableDescription') - ), - variant: "destructive" - }); - }); - - if (res && res.data.data.secret) { - setSecretKey(res.data.data.secret); - setSecretUri(res.data.data.uri); - setStep(2); - } - - setLoading(false); - }; - - const confirm2fa = async (values: z.infer) => { - if (!email) return; - - setLoading(true); - - const { password } = enableForm.getValues(); - - const res = await api - .post>(`/auth/2fa/complete-setup`, { - email: email, - password: password, - code: values.code - }) - .catch((e) => { - toast({ - title: t('otpErrorEnable'), - description: formatAxiosError( - e, - t('otpErrorEnableDescription') - ), - variant: "destructive" - }); - }); - - if (res && res.data.data.valid) { - setBackupCodes(res.data.data.backupCodes || []); - setStep(3); - } - - setLoading(false); - }; - const handleComplete = () => { + console.log("2FA setup complete", redirect, email); if (redirect) { - router.push(redirect); + const cleanUrl = cleanRedirect(redirect); + console.log("Redirecting to:", cleanUrl); + router.push(cleanUrl); } else { router.push("/"); } @@ -158,132 +43,20 @@ export default function Setup2FAPage() {
- {t('otpSetup')} + {t("otpSetup")} - Your administrator has enabled two-factor authentication for {email}. - Please complete the setup process to continue. + {t("adminEnabled2FaOnYourAccount", { email: email || "your account" })} - {step === 1 && ( -
- -
- ( - - {t('password')} - - - - - - )} - /> -
- -
- - )} - - {step === 2 && ( -
-

- {t('otpSetupScanQr')} -

-
- -
-
- - -
- -
- - ( - - - {t('otpSetupSecretCode')} - - - - - - - )} - /> - - - -
- )} - - {step === 3 && ( -
- -

- {t('otpSetupSuccess')} -

-

- {t('otpSetupSuccessStoreBackupCodes')} -

- - {backupCodes.length > 0 && ( -
- - -
- )} - - -
- )} +
); -} \ No newline at end of file +} diff --git a/src/components/Enable2FaDialog.tsx b/src/components/Enable2FaDialog.tsx new file mode 100644 index 00000000..0fca0085 --- /dev/null +++ b/src/components/Enable2FaDialog.tsx @@ -0,0 +1,89 @@ +"use client"; + +import { useState, useRef } from "react"; +import { Button } from "@/components/ui/button"; +import { + Credenza, + CredenzaBody, + CredenzaClose, + CredenzaContent, + CredenzaDescription, + CredenzaFooter, + CredenzaHeader, + CredenzaTitle +} from "@app/components/Credenza"; +import TwoFactorSetupForm from "./TwoFactorSetupForm"; +import { useTranslations } from "next-intl"; +import { useUserContext } from "@app/hooks/useUserContext"; + +type Enable2FaDialogProps = { + open: boolean; + setOpen: (val: boolean) => void; +}; + +export default function Enable2FaDialog({ open, setOpen }: Enable2FaDialogProps) { + const t = useTranslations(); + const [currentStep, setCurrentStep] = useState(1); + const [loading, setLoading] = useState(false); + const formRef = useRef<{ handleSubmit: () => void }>(null); + const { user, updateUser } = useUserContext(); + + function reset() { + setCurrentStep(1); + setLoading(false); + } + + const handleSubmit = () => { + if (formRef.current) { + formRef.current.handleSubmit(); + } + }; + + return ( + { + setOpen(val); + reset(); + }} + > + + + + {t('otpSetup')} + + + {t('otpSetupDescription')} + + + + {setOpen(false); updateUser({ twoFactorEnabled: true });}} + onStepChange={setCurrentStep} + onLoadingChange={setLoading} + /> + + + + + + {(currentStep === 1 || currentStep === 2) && ( + + )} + + + + ); +} \ No newline at end of file diff --git a/src/components/Enable2FaForm.tsx b/src/components/Enable2FaForm.tsx index 47cfb8e8..acc00400 100644 --- a/src/components/Enable2FaForm.tsx +++ b/src/components/Enable2FaForm.tsx @@ -1,46 +1,7 @@ "use client"; -import { useState } from "react"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { AlertCircle, CheckCircle2 } from "lucide-react"; -import { createApiClient } from "@app/lib/api"; -import { useEnvContext } from "@app/hooks/useEnvContext"; -import { AxiosResponse } from "axios"; -import { - RequestTotpSecretBody, - RequestTotpSecretResponse, - VerifyTotpBody, - VerifyTotpResponse -} from "@server/routers/auth"; -import { z } from "zod"; -import { useForm } from "react-hook-form"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage -} from "@app/components/ui/form"; -import { - Credenza, - CredenzaBody, - CredenzaClose, - CredenzaContent, - CredenzaDescription, - CredenzaFooter, - CredenzaHeader, - CredenzaTitle -} from "@app/components/Credenza"; -import { toast } from "@app/hooks/useToast"; -import { formatAxiosError } from "@app/lib/api"; -import CopyTextBox from "@app/components/CopyTextBox"; -import { QRCodeCanvas, QRCodeSVG } from "qrcode.react"; import { useUserContext } from "@app/hooks/useUserContext"; -import { useTranslations } from "next-intl"; +import Enable2FaDialog from "./Enable2FaDialog"; type Enable2FaProps = { open: boolean; @@ -48,261 +9,5 @@ type Enable2FaProps = { }; export default function Enable2FaForm({ open, setOpen }: Enable2FaProps) { - const [step, setStep] = useState(1); - const [secretKey, setSecretKey] = useState(""); - const [secretUri, setSecretUri] = useState(""); - const [verificationCode, setVerificationCode] = useState(""); - const [error, setError] = useState(""); - const [success, setSuccess] = useState(false); - const [loading, setLoading] = useState(false); - const [backupCodes, setBackupCodes] = useState([]); - - const { user, updateUser } = useUserContext(); - - const api = createApiClient(useEnvContext()); - const t = useTranslations(); - - const enableSchema = z.object({ - password: z.string().min(1, { message: t('passwordRequired') }) - }); - - const confirmSchema = z.object({ - code: z.string().length(6, { message: t('pincodeInvalid') }) - }); - - const enableForm = useForm>({ - resolver: zodResolver(enableSchema), - defaultValues: { - password: "" - } - }); - - const confirmForm = useForm>({ - resolver: zodResolver(confirmSchema), - defaultValues: { - code: "" - } - }); - - const request2fa = async (values: z.infer) => { - setLoading(true); - - const res = await api - .post>( - `/auth/2fa/request`, - { - password: values.password - } as RequestTotpSecretBody - ) - .catch((e) => { - toast({ - title: t('otpErrorEnable'), - description: formatAxiosError( - e, - t('otpErrorEnableDescription') - ), - variant: "destructive" - }); - }); - - if (res && res.data.data.secret) { - setSecretKey(res.data.data.secret); - setSecretUri(res.data.data.uri); - setStep(2); - } - - setLoading(false); - }; - - const confirm2fa = async (values: z.infer) => { - setLoading(true); - - const res = await api - .post>(`/auth/2fa/enable`, { - code: values.code - } as VerifyTotpBody) - .catch((e) => { - toast({ - title: t('otpErrorEnable'), - description: formatAxiosError( - e, - t('otpErrorEnableDescription') - ), - variant: "destructive" - }); - }); - - if (res && res.data.data.valid) { - setBackupCodes(res.data.data.backupCodes || []); - updateUser({ twoFactorEnabled: true }); - setStep(3); - } - - setLoading(false); - }; - - const handleVerify = () => { - if (verificationCode.length !== 6) { - setError(t('otpSetupCheckCode')); - return; - } - if (verificationCode === "123456") { - setSuccess(true); - setStep(3); - } else { - setError(t('otpSetupCheckCodeRetry')); - } - }; - - function reset() { - setLoading(false); - setStep(1); - setSecretKey(""); - setSecretUri(""); - setVerificationCode(""); - setError(""); - setSuccess(false); - setBackupCodes([]); - enableForm.reset(); - confirmForm.reset(); - } - - return ( - { - setOpen(val); - reset(); - }} - > - - - - {t('otpSetup')} - - - {t('otpSetupDescription')} - - - - {step === 1 && ( -
- -
- ( - - {t('password')} - - - - - - )} - /> -
-
- - )} - - {step === 2 && ( -
-

- {t('otpSetupScanQr')} -

-
- -
-
- -
- -
- -
- ( - - - {t('otpSetupSecretCode')} - - - - - - - )} - /> -
-
- -
- )} - - {step === 3 && ( -
- -

- {t('otpSetupSuccess')} -

-

- {t('otpSetupSuccessStoreBackupCodes')} -

- -
- -
-
- )} -
- - - - - {(step === 1 || step === 2) && ( - - )} - -
-
- ); + return ; } diff --git a/src/components/PermissionsSelectBox.tsx b/src/components/PermissionsSelectBox.tsx index 3f225dee..6f11d98e 100644 --- a/src/components/PermissionsSelectBox.tsx +++ b/src/components/PermissionsSelectBox.tsx @@ -23,7 +23,10 @@ function getActionsCategories(root: boolean) { [t('actionGetOrg')]: "getOrg", [t('actionUpdateOrg')]: "updateOrg", [t('actionGetOrgUser')]: "getOrgUser", - [t('actionListOrgDomains')]: "listOrgDomains", + [t('actionInviteUser')]: "inviteUser", + [t('actionRemoveUser')]: "removeUser", + [t('actionListUsers')]: "listUsers", + [t('actionListOrgDomains')]: "listOrgDomains" }, Site: { @@ -65,16 +68,9 @@ function getActionsCategories(root: boolean) { [t('actionGetRole')]: "getRole", [t('actionListRole')]: "listRoles", [t('actionUpdateRole')]: "updateRole", - [t('actionListAllowedRoleResources')]: "listRoleResources" - }, - - User: { - [t('actionInviteUser')]: "inviteUser", - [t('actionRemoveUser')]: "removeUser", - [t('actionListUsers')]: "listUsers", + [t('actionListAllowedRoleResources')]: "listRoleResources", [t('actionAddUserRole')]: "addUserRole" }, - "Access Token": { [t('actionGenerateAccessToken')]: "generateAccessToken", [t('actionDeleteAccessToken')]: "deleteAcessToken", @@ -114,6 +110,11 @@ function getActionsCategories(root: boolean) { [t('actionListIdpOrgs')]: "listIdpOrgs", [t('actionUpdateIdpOrg')]: "updateIdpOrg" }; + + actionsByCategory["User"] = { + [t('actionUpdateUser')]: "updateUser", + [t('actionGetUser')]: "getUser" + }; } return actionsByCategory; diff --git a/src/components/ProfileIcon.tsx b/src/components/ProfileIcon.tsx index eab2f51d..f76f9b20 100644 --- a/src/components/ProfileIcon.tsx +++ b/src/components/ProfileIcon.tsx @@ -20,7 +20,7 @@ import { useRouter } from "next/navigation"; import { useState } from "react"; import { useUserContext } from "@app/hooks/useUserContext"; import Disable2FaForm from "./Disable2FaForm"; -import Enable2FaForm from "./Enable2FaForm"; +import Enable2FaDialog from "./Enable2FaDialog"; import SupporterStatus from "./SupporterStatus"; import { UserType } from "@server/types/UserTypes"; import LocaleSwitcher from '@app/components/LocaleSwitcher'; @@ -71,7 +71,7 @@ export default function ProfileIcon() { return ( <> - +
diff --git a/src/components/SwitchInput.tsx b/src/components/SwitchInput.tsx index 571a1ab4..fd312115 100644 --- a/src/components/SwitchInput.tsx +++ b/src/components/SwitchInput.tsx @@ -6,6 +6,7 @@ interface SwitchComponentProps { id: string; label: string; description?: string; + checked?: boolean; defaultChecked?: boolean; disabled?: boolean; onCheckedChange: (checked: boolean) => void; @@ -16,6 +17,7 @@ export function SwitchInput({ label, description, disabled, + checked, defaultChecked = false, onCheckedChange }: SwitchComponentProps) { @@ -24,6 +26,7 @@ export function SwitchInput({
void; + onCancel?: () => void; + isDialog?: boolean; + email?: string; + password?: string; + submitButtonText?: string; + cancelButtonText?: string; + showCancelButton?: boolean; + onStepChange?: (step: number) => void; + onLoadingChange?: (loading: boolean) => void; +}; + +const TwoFactorSetupForm = forwardRef< + { handleSubmit: () => void }, + TwoFactorSetupFormProps +>( + ( + { + onComplete, + onCancel, + isDialog = false, + email, + password: initialPassword, + submitButtonText, + cancelButtonText, + showCancelButton = false, + onStepChange, + onLoadingChange + }, + ref + ) => { + const [step, setStep] = useState(1); + const [secretKey, setSecretKey] = useState(""); + const [secretUri, setSecretUri] = useState(""); + const [loading, setLoading] = useState(false); + const [backupCodes, setBackupCodes] = useState([]); + + const api = createApiClient(useEnvContext()); + const t = useTranslations(); + + // Notify parent of step and loading changes + useEffect(() => { + onStepChange?.(step); + }, [step, onStepChange]); + + useEffect(() => { + onLoadingChange?.(loading); + }, [loading, onLoadingChange]); + + const enableSchema = z.object({ + password: z.string().min(1, { message: t("passwordRequired") }) + }); + + const confirmSchema = z.object({ + code: z.string().length(6, { message: t("pincodeInvalid") }) + }); + + const enableForm = useForm>({ + resolver: zodResolver(enableSchema), + defaultValues: { + password: initialPassword || "" + } + }); + + const confirmForm = useForm>({ + resolver: zodResolver(confirmSchema), + defaultValues: { + code: "" + } + }); + + const request2fa = async (values: z.infer) => { + setLoading(true); + + const endpoint = `/auth/2fa/request`; + const payload = { email, password: values.password }; + + const res = await api + .post< + AxiosResponse + >(endpoint, payload) + .catch((e) => { + toast({ + title: t("otpErrorEnable"), + description: formatAxiosError( + e, + t("otpErrorEnableDescription") + ), + variant: "destructive" + }); + }); + + if (res && res.data.data.secret) { + setSecretKey(res.data.data.secret); + setSecretUri(res.data.data.uri); + setStep(2); + } + + setLoading(false); + }; + + const confirm2fa = async (values: z.infer) => { + setLoading(true); + + const endpoint = `/auth/2fa/enable`; + const payload = { + email, + password: enableForm.getValues().password, + code: values.code + }; + + const res = await api + .post>(endpoint, payload) + .catch((e) => { + toast({ + title: t("otpErrorEnable"), + description: formatAxiosError( + e, + t("otpErrorEnableDescription") + ), + variant: "destructive" + }); + }); + + if (res && res.data.data.valid) { + setBackupCodes(res.data.data.backupCodes || []); + await api + .post>("/auth/login", { + email, + password: enableForm.getValues().password, + code: values.code + }) + .catch((e) => { + console.error(e); + }); + setStep(3); + } + + setLoading(false); + }; + + const handleSubmit = () => { + if (step === 1) { + enableForm.handleSubmit(request2fa)(); + } else if (step === 2) { + confirmForm.handleSubmit(confirm2fa)(); + } + }; + + const handleComplete = (email: string, password: string) => { + if (onComplete) { + onComplete(email, password); + } + }; + + useImperativeHandle(ref, () => ({ + handleSubmit + })); + + return ( +
+ {step === 1 && ( +
+ +
+ ( + + + {t("password")} + + + + + + + )} + /> +
+
+ + )} + + {step === 2 && ( +
+

{t("otpSetupScanQr")}

+
+ +
+
+ +
+ +
+ + ( + + + {t("otpSetupSecretCode")} + + + + + + + )} + /> + + +
+ )} + + {step === 3 && ( +
+ +

+ {t("otpSetupSuccess")} +

+

{t("otpSetupSuccessStoreBackupCodes")}

+ + {backupCodes.length > 0 && ( +
+ +
+ )} +
+ )} + + {/* Action buttons - only show when not in dialog */} + {!isDialog && ( +
+ {showCancelButton && onCancel && ( + + )} + {(step === 1 || step === 2) && ( + + )} + {step === 3 && ( + + )} +
+ )} +
+ ); + } +); + +export default TwoFactorSetupForm;