diff --git a/package-lock.json b/package-lock.json index 292874b0..0316d12b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -121,8 +121,7 @@ "tsc-alias": "1.8.16", "tsx": "4.20.3", "typescript": "^5", - "typescript-eslint": "^8.35.0", - "yargs": "18.0.0" + "typescript-eslint": "^8.35.0" } }, "node_modules/@alloc/quick-lru": { @@ -6174,7 +6173,6 @@ "version": "9.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", - "dev": true, "license": "ISC", "dependencies": { "string-width": "^7.2.0", @@ -6189,7 +6187,6 @@ "version": "6.2.1", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -6202,14 +6199,12 @@ "version": "10.4.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", - "dev": true, "license": "MIT" }, "node_modules/cliui/node_modules/string-width": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", - "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^10.3.0", @@ -6227,7 +6222,6 @@ "version": "9.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.0.tgz", "integrity": "sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==", - "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^6.2.1", @@ -7397,7 +7391,6 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -8342,7 +8335,6 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, "license": "ISC", "engines": { "node": "6.* || 8.* || >= 10.*" @@ -8352,7 +8344,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.3.0.tgz", "integrity": "sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -16479,7 +16470,6 @@ "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "dev": true, "license": "ISC", "engines": { "node": ">=10" @@ -16511,7 +16501,6 @@ "version": "18.0.0", "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", - "dev": true, "license": "MIT", "dependencies": { "cliui": "^9.0.1", @@ -16529,7 +16518,6 @@ "version": "22.0.0", "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", - "dev": true, "license": "ISC", "engines": { "node": "^20.19.0 || ^22.12.0 || >=23" @@ -16539,14 +16527,12 @@ "version": "10.4.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", - "dev": true, "license": "MIT" }, "node_modules/yargs/node_modules/string-width": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", - "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^10.3.0", diff --git a/server/routers/auth/completeTotpSetup.ts b/server/routers/auth/completeTotpSetup.ts new file mode 100644 index 00000000..397f356b --- /dev/null +++ b/server/routers/auth/completeTotpSetup.ts @@ -0,0 +1,179 @@ +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 6955e16c..2b45a140 100644 --- a/server/routers/auth/index.ts +++ b/server/routers/auth/index.ts @@ -3,6 +3,8 @@ 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 f5f7ff77..5080846a 100644 --- a/server/routers/auth/login.ts +++ b/server/routers/auth/login.ts @@ -35,6 +35,7 @@ export type LoginBody = z.infer; export type LoginResponse = { codeRequested?: boolean; emailVerificationRequired?: boolean; + twoFactorSetupRequired?: boolean; }; export const dynamic = "force-dynamic"; @@ -110,6 +111,17 @@ 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 (!code) { return response<{ codeRequested: boolean }>(res, { data: { codeRequested: true }, @@ -122,7 +134,7 @@ export async function login( const validOTP = await verifyTotpCode( code, - existingUser.twoFactorSecret!, + existingUser.twoFactorSecret, existingUser.userId ); diff --git a/server/routers/auth/setupTotpSecret.ts b/server/routers/auth/setupTotpSecret.ts new file mode 100644 index 00000000..89807e8e --- /dev/null +++ b/server/routers/auth/setupTotpSecret.ts @@ -0,0 +1,127 @@ +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/external.ts b/server/routers/external.ts index 8cb3a19d..c764fa6d 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -490,6 +490,13 @@ authenticated.put( ); authenticated.get("/org/:orgId/user/:userId", verifyOrgAccess, user.getOrgUser); +authenticated.patch( + "/org/:orgId/user/:userId/2fa", + verifyOrgAccess, + verifyUserAccess, + verifyUserHasAction(ActionsEnum.getOrgUser), + user.updateUser2FA +); authenticated.get( "/org/:orgId/users", @@ -718,6 +725,8 @@ authRouter.post( verifySessionUserMiddleware, auth.requestTotpSecret ); +authRouter.post("/2fa/setup", auth.setupTotpSecret); +authRouter.post("/2fa/complete-setup", auth.completeTotpSetup); authRouter.post("/2fa/disable", verifySessionUserMiddleware, auth.disable2fa); authRouter.post("/verify-email", verifySessionMiddleware, auth.verifyEmail); diff --git a/server/routers/user/getOrgUser.ts b/server/routers/user/getOrgUser.ts index 562ef34e..05e231c9 100644 --- a/server/routers/user/getOrgUser.ts +++ b/server/routers/user/getOrgUser.ts @@ -23,7 +23,8 @@ async function queryUser(orgId: string, userId: string) { roleId: userOrgs.roleId, roleName: roles.name, isOwner: userOrgs.isOwner, - isAdmin: roles.isAdmin + isAdmin: roles.isAdmin, + twoFactorEnabled: users.twoFactorEnabled, }) .from(userOrgs) .leftJoin(roles, eq(userOrgs.roleId, roles.roleId)) diff --git a/server/routers/user/index.ts b/server/routers/user/index.ts index 49278c14..ed8a1769 100644 --- a/server/routers/user/index.ts +++ b/server/routers/user/index.ts @@ -10,3 +10,4 @@ export * from "./adminRemoveUser"; export * from "./listInvitations"; export * from "./removeInvitation"; export * from "./createOrgUser"; +export * from "./updateUser2FA"; diff --git a/server/routers/user/updateUser2FA.ts b/server/routers/user/updateUser2FA.ts new file mode 100644 index 00000000..845eaa0c --- /dev/null +++ b/server/routers/user/updateUser2FA.ts @@ -0,0 +1,151 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { users, userOrgs } from "@server/db"; +import { eq, and } 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 { 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() + }) + .strict(); + +const updateUser2FABodySchema = z + .object({ + twoFactorEnabled: z.boolean() + }) + .strict(); + +export type UpdateUser2FAResponse = { + userId: string; + twoFactorEnabled: 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], + request: { + params: updateUser2FAParamsSchema, + body: { + content: { + "application/json": { + schema: updateUser2FABodySchema + } + } + } + }, + responses: {} +}); + +export async function updateUser2FA( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = updateUser2FAParamsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const parsedBody = updateUser2FABodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + 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" + ) + ); + } + + // Verify the user exists in the organization + const existingUser = await db + .select() + .from(userOrgs) + .where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId))) + .limit(1); + + if (existingUser.length === 0) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "User not found or does not belong to the specified organization" + ) + ); + } + + // 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 }); + + if (updatedUser.length === 0) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "User not found" + ) + ); + } + + return response(res, { + data: updatedUser[0], + success: true, + error: false, + message: `2FA ${twoFactorEnabled ? 'enabled' : 'disabled'} for user successfully`, + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} \ No newline at end of file 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 82999ad2..191fac77 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,6 +27,7 @@ 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, @@ -43,7 +44,9 @@ import { useEnvContext } from "@app/hooks/useEnvContext"; import { useTranslations } from "next-intl"; export default function AccessControlsPage() { - const { orgUser: user } = userOrgUserContext(); + const { orgUser: user, updateOrgUser } = userOrgUserContext(); + + console.log("User:", user); const api = createApiClient(useEnvContext()); @@ -51,6 +54,7 @@ export default function AccessControlsPage() { const [loading, setLoading] = useState(false); const [roles, setRoles] = useState<{ roleId: number; name: string }[]>([]); + const [enable2FA, setEnable2FA] = useState(user.twoFactorEnabled || false); const t = useTranslations(); @@ -96,7 +100,8 @@ export default function AccessControlsPage() { async function onSubmit(values: z.infer) { setLoading(true); - const res = await api + // Update user role + const roleRes = await api .post< AxiosResponse >(`/role/${values.roleId}/add/${user.userId}`) @@ -109,9 +114,34 @@ export default function AccessControlsPage() { t('accessRoleErrorAddDescription') ) }); + return null; }); - if (res && res.status === 200) { + // 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) { toast({ variant: "default", title: t('userSaved'), @@ -170,6 +200,36 @@ 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. + )} +

+
+ + diff --git a/src/app/auth/2fa/setup/page.tsx b/src/app/auth/2fa/setup/page.tsx new file mode 100644 index 00000000..2f77aaa5 --- /dev/null +++ b/src/app/auth/2fa/setup/page.tsx @@ -0,0 +1,289 @@ +"use client"; + +import { useState, 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, + CardDescription, + 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 { useTranslations } from "next-intl"; + +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'); + } + }, [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 = () => { + if (redirect) { + router.push(redirect); + } else { + router.push("/"); + } + }; + + return ( +
+ + + {t('otpSetup')} + + Your administrator has enabled two-factor authentication for {email}. + Please complete the setup process to continue. + + + + {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/LoginForm.tsx b/src/components/LoginForm.tsx index 14189c37..a1480d30 100644 --- a/src/components/LoginForm.tsx +++ b/src/components/LoginForm.tsx @@ -134,6 +134,12 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) { return; } + if (data?.twoFactorSetupRequired) { + const setupUrl = `/auth/2fa/setup?email=${encodeURIComponent(email)}${redirect ? `&redirect=${encodeURIComponent(redirect)}` : ''}`; + router.push(setupUrl); + return; + } + if (onLogin) { await onLogin(); }