Merge branch 'dev' into feat/internal-user-passkey-support

This commit is contained in:
Milo Schwartz 2025-07-14 17:43:01 -04:00 committed by GitHub
commit 9075ecb007
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 1071 additions and 1123 deletions

View file

@ -958,6 +958,8 @@
"licenseTierProfessionalRequiredDescription": "This feature is only available in the Professional Edition.", "licenseTierProfessionalRequiredDescription": "This feature is only available in the Professional Edition.",
"actionGetOrg": "Get Organization", "actionGetOrg": "Get Organization",
"actionUpdateOrg": "Update Organization", "actionUpdateOrg": "Update Organization",
"actionUpdateUser": "Update User",
"actionGetUser": "Get User",
"actionGetOrgUser": "Get Organization User", "actionGetOrgUser": "Get Organization User",
"actionListOrgDomains": "List Organization Domains", "actionListOrgDomains": "List Organization Domains",
"actionCreateSite": "Create Site", "actionCreateSite": "Create Site",
@ -1158,5 +1160,8 @@
"securityKeyRemovedTooQuickly": "Please keep your security key connected until the sign-in process completes.", "securityKeyRemovedTooQuickly": "Please keep your security key connected until the sign-in process completes.",
"securityKeyNotSupported": "Your security key may not be compatible. Please try a different security key.", "securityKeyNotSupported": "Your security key may not be compatible. Please try a different security key.",
"securityKeyUnknownError": "There was a problem using your security key. Please try again.", "securityKeyUnknownError": "There was a problem using your security key. Please try again.",
"twoFactorRequired": "Two-factor authentication is required to register a security key." "twoFactorRequired": "Two-factor authentication is required to register a security key.",
"twoFactor": "Two-Factor Authentication",
"adminEnabled2FaOnYourAccount": "Your administrator has enabled two-factor authentication for {email}. Please complete the setup process to continue.",
"continueToApplication": "Continue to Application"
} }

View file

@ -56,6 +56,8 @@ export enum ActionsEnum {
// removeUserAction = "removeUserAction", // removeUserAction = "removeUserAction",
// removeUserSite = "removeUserSite", // removeUserSite = "removeUserSite",
getOrgUser = "getOrgUser", getOrgUser = "getOrgUser",
updateUser = "updateUser",
getUser = "getUser",
setResourcePassword = "setResourcePassword", setResourcePassword = "setResourcePassword",
setResourcePincode = "setResourcePincode", setResourcePincode = "setResourcePincode",
setResourceWhitelist = "setResourceWhitelist", setResourceWhitelist = "setResourceWhitelist",

View file

@ -121,6 +121,7 @@ export const users = pgTable("user", {
}), }),
passwordHash: varchar("passwordHash"), passwordHash: varchar("passwordHash"),
twoFactorEnabled: boolean("twoFactorEnabled").notNull().default(false), twoFactorEnabled: boolean("twoFactorEnabled").notNull().default(false),
twoFactorSetupRequested: boolean("twoFactorSetupRequested").default(false),
twoFactorSecret: varchar("twoFactorSecret"), twoFactorSecret: varchar("twoFactorSecret"),
emailVerified: boolean("emailVerified").notNull().default(false), emailVerified: boolean("emailVerified").notNull().default(false),
dateCreated: varchar("dateCreated").notNull(), dateCreated: varchar("dateCreated").notNull(),

View file

@ -125,6 +125,9 @@ export const users = sqliteTable("user", {
twoFactorEnabled: integer("twoFactorEnabled", { mode: "boolean" }) twoFactorEnabled: integer("twoFactorEnabled", { mode: "boolean" })
.notNull() .notNull()
.default(false), .default(false),
twoFactorSetupRequested: integer("twoFactorSetupRequested", {
mode: "boolean"
}).default(false),
twoFactorSecret: text("twoFactorSecret"), twoFactorSecret: text("twoFactorSecret"),
emailVerified: integer("emailVerified", { mode: "boolean" }) emailVerified: integer("emailVerified", { mode: "boolean" })
.notNull() .notNull()

10
server/lib/totp.ts Normal file
View file

@ -0,0 +1,10 @@
import { alphabet, generateRandomString } from "oslo/crypto";
export async function generateBackupCodes(): Promise<string[]> {
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;
}

View file

@ -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<typeof completeTotpSetupBody>;
export type CompleteTotpSetupResponse = {
valid: boolean;
backupCodes?: string[];
};
export async function completeTotpSetup(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
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<CompleteTotpSetupResponse>(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<string[]> {
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;
}

View file

@ -3,8 +3,6 @@ export * from "./signup";
export * from "./logout"; export * from "./logout";
export * from "./verifyTotp"; export * from "./verifyTotp";
export * from "./requestTotpSecret"; export * from "./requestTotpSecret";
export * from "./setupTotpSecret";
export * from "./completeTotpSetup";
export * from "./disable2fa"; export * from "./disable2fa";
export * from "./verifyEmail"; export * from "./verifyEmail";
export * from "./requestEmailVerificationCode"; export * from "./requestEmailVerificationCode";

View file

@ -21,10 +21,7 @@ import { UserType } from "@server/types/UserTypes";
export const loginBodySchema = z export const loginBodySchema = z
.object({ .object({
email: z email: z.string().toLowerCase().email(),
.string()
.toLowerCase()
.email(),
password: z.string(), password: z.string(),
code: z.string().optional() code: z.string().optional()
}) })
@ -39,8 +36,6 @@ export type LoginResponse = {
twoFactorSetupRequired?: boolean; twoFactorSetupRequired?: boolean;
}; };
export const dynamic = "force-dynamic";
export async function login( export async function login(
req: Request, req: Request,
res: Response, res: Response,
@ -127,18 +122,20 @@ export async function login(
}); });
} }
if (existingUser.twoFactorEnabled) { if (
// If 2FA is enabled but no secret exists, force setup existingUser.twoFactorSetupRequested &&
if (!existingUser.twoFactorSecret) { !existingUser.twoFactorEnabled
return response<LoginResponse>(res, { ) {
data: { twoFactorSetupRequired: true }, return response<LoginResponse>(res, {
success: true, data: { twoFactorSetupRequired: true },
error: false, success: true,
message: "Two-factor authentication setup required", error: false,
status: HttpCode.ACCEPTED message: "Two-factor authentication setup required",
}); status: HttpCode.ACCEPTED
} });
}
if (existingUser.twoFactorEnabled) {
if (!code) { if (!code) {
return response<{ codeRequested: boolean }>(res, { return response<{ codeRequested: boolean }>(res, {
data: { codeRequested: true }, data: { codeRequested: true },
@ -151,7 +148,7 @@ export async function login(
const validOTP = await verifyTotpCode( const validOTP = await verifyTotpCode(
code, code,
existingUser.twoFactorSecret, existingUser.twoFactorSecret!,
existingUser.userId existingUser.userId
); );

View file

@ -7,16 +7,19 @@ import HttpCode from "@server/types/HttpCode";
import { response } from "@server/lib"; import { response } from "@server/lib";
import { db } from "@server/db"; import { db } from "@server/db";
import { User, users } 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 { createTOTPKeyURI } from "oslo/otp";
import logger from "@server/logger"; import logger from "@server/logger";
import { verifyPassword } from "@server/auth/password"; import { verifyPassword } from "@server/auth/password";
import { unauthorized } from "@server/auth/unauthorizedResponse"; import { unauthorized } from "@server/auth/unauthorizedResponse";
import { UserType } from "@server/types/UserTypes"; import { UserType } from "@server/types/UserTypes";
import { verifySession } from "@server/auth/sessions/verifySession";
import config from "@server/lib/config";
export const requestTotpSecretBody = z export const requestTotpSecretBody = z
.object({ .object({
password: z.string() password: z.string(),
email: z.string().email().optional()
}) })
.strict(); .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) { if (user.type !== UserType.Internal) {
return next( return next(
@ -57,7 +93,10 @@ export async function requestTotpSecret(
} }
try { try {
const validPassword = await verifyPassword(password, user.passwordHash!); const validPassword = await verifyPassword(
password,
user.passwordHash!
);
if (!validPassword) { if (!validPassword) {
return next(unauthorized()); return next(unauthorized());
} }

View file

@ -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<typeof setupTotpSecretBody>;
export type SetupTotpSecretResponse = {
secret: string;
uri: string;
};
export async function setupTotpSecret(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
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<SetupTotpSecretResponse>(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"
)
);
}
}

View file

@ -6,18 +6,22 @@ import HttpCode from "@server/types/HttpCode";
import { response } from "@server/lib"; import { response } from "@server/lib";
import { db } from "@server/db"; import { db } from "@server/db";
import { twoFactorBackupCodes, User, users } from "@server/db"; import { twoFactorBackupCodes, User, users } from "@server/db";
import { eq } from "drizzle-orm"; import { eq, and } from "drizzle-orm";
import { alphabet, generateRandomString } from "oslo/crypto"; import { hashPassword, verifyPassword } from "@server/auth/password";
import { hashPassword } from "@server/auth/password";
import { verifyTotpCode } from "@server/auth/totp"; import { verifyTotpCode } from "@server/auth/totp";
import logger from "@server/logger"; import logger from "@server/logger";
import { sendEmail } from "@server/emails"; import { sendEmail } from "@server/emails";
import TwoFactorAuthNotification from "@server/emails/templates/TwoFactorAuthNotification"; import TwoFactorAuthNotification from "@server/emails/templates/TwoFactorAuthNotification";
import config from "@server/lib/config"; import config from "@server/lib/config";
import { UserType } from "@server/types/UserTypes"; 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 export const verifyTotpBody = z
.object({ .object({
email: z.string().email().optional(),
password: z.string().optional(),
code: z.string() code: z.string()
}) })
.strict(); .strict();
@ -45,38 +49,83 @@ export async function verifyTotp(
); );
} }
const { code } = parsedBody.data; const { code, email, password } = 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"
)
);
}
try { 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( const valid = await verifyTotpCode(
code, code,
user.twoFactorSecret, user.twoFactorSecret,
@ -89,7 +138,9 @@ export async function verifyTotp(
await db.transaction(async (trx) => { await db.transaction(async (trx) => {
await trx await trx
.update(users) .update(users)
.set({ twoFactorEnabled: true }) .set({
twoFactorEnabled: true
})
.where(eq(users.userId, user.userId)); .where(eq(users.userId, user.userId));
const backupCodes = await generateBackupCodes(); const backupCodes = await generateBackupCodes();
@ -153,12 +204,3 @@ export async function verifyTotp(
); );
} }
} }
async function generateBackupCodes(): Promise<string[]> {
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;
}

View file

@ -476,6 +476,7 @@ unauthenticated.get("/resource/:resourceId/auth", resource.getResourceAuthInfo);
unauthenticated.get("/user", verifySessionMiddleware, user.getUser); unauthenticated.get("/user", verifySessionMiddleware, user.getUser);
authenticated.get("/users", verifyUserIsServerAdmin, user.adminListUsers); authenticated.get("/users", verifyUserIsServerAdmin, user.adminListUsers);
authenticated.get("/user/:userId", verifyUserIsServerAdmin, user.adminGetUser);
authenticated.delete( authenticated.delete(
"/user/:userId", "/user/:userId",
verifyUserIsServerAdmin, verifyUserIsServerAdmin,
@ -490,11 +491,10 @@ authenticated.put(
); );
authenticated.get("/org/:orgId/user/:userId", verifyOrgAccess, user.getOrgUser); authenticated.get("/org/:orgId/user/:userId", verifyOrgAccess, user.getOrgUser);
authenticated.patch(
"/org/:orgId/user/:userId/2fa", authenticated.post(
verifyOrgAccess, "/user/:userId/2fa",
verifyUserAccess, verifyUserIsServerAdmin,
verifyUserHasAction(ActionsEnum.getOrgUser),
user.updateUser2FA user.updateUser2FA
); );
@ -719,14 +719,8 @@ authRouter.post("/login", auth.login);
authRouter.post("/logout", auth.logout); authRouter.post("/logout", auth.logout);
authRouter.post("/newt/get-token", getToken); authRouter.post("/newt/get-token", getToken);
authRouter.post("/2fa/enable", verifySessionUserMiddleware, auth.verifyTotp); authRouter.post("/2fa/enable", auth.verifyTotp);
authRouter.post( authRouter.post("/2fa/request", auth.requestTotpSecret);
"/2fa/request",
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("/2fa/disable", verifySessionUserMiddleware, auth.disable2fa);
authRouter.post("/verify-email", verifySessionMiddleware, auth.verifyEmail); authRouter.post("/verify-email", verifySessionMiddleware, auth.verifyEmail);

View file

@ -381,6 +381,20 @@ authenticated.get(
user.getOrgUser 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( authenticated.get(
"/org/:orgId/users", "/org/:orgId/users",
verifyApiKeyOrgAccess, verifyApiKeyOrgAccess,

View file

@ -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<ReturnType<typeof queryUser>>
>;
export async function adminGetUser(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
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<AdminGetUserResponse>(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")
);
}
}

View file

@ -37,7 +37,9 @@ async function queryUsers(limit: number, offset: number) {
serverAdmin: users.serverAdmin, serverAdmin: users.serverAdmin,
type: users.type, type: users.type,
idpName: idp.name, idpName: idp.name,
idpId: users.idpId idpId: users.idpId,
twoFactorEnabled: users.twoFactorEnabled,
twoFactorSetupRequested: users.twoFactorSetupRequested
}) })
.from(users) .from(users)
.leftJoin(idp, eq(users.idpId, idp.idpId)) .leftJoin(idp, eq(users.idpId, idp.idpId))

View file

@ -8,32 +8,30 @@ import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import logger from "@server/logger"; import logger from "@server/logger";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
const updateUser2FAParamsSchema = z const updateUser2FAParamsSchema = z
.object({ .object({
userId: z.string(), userId: z.string()
orgId: z.string()
}) })
.strict(); .strict();
const updateUser2FABodySchema = z const updateUser2FABodySchema = z
.object({ .object({
twoFactorEnabled: z.boolean() twoFactorSetupRequested: z.boolean()
}) })
.strict(); .strict();
export type UpdateUser2FAResponse = { export type UpdateUser2FAResponse = {
userId: string; userId: string;
twoFactorEnabled: boolean; twoFactorRequested: boolean;
}; };
registry.registerPath({ registry.registerPath({
method: "patch", method: "post",
path: "/org/{orgId}/user/{userId}/2fa", path: "/user/{userId}/2fa",
description: "Update a user's 2FA status within an organization.", description: "Update a user's 2FA status.",
tags: [OpenAPITags.Org, OpenAPITags.User], tags: [OpenAPITags.User],
request: { request: {
params: updateUser2FAParamsSchema, params: updateUser2FAParamsSchema,
body: { body: {
@ -73,73 +71,57 @@ export async function updateUser2FA(
); );
} }
const { userId, orgId } = parsedParams.data; const { userId } = parsedParams.data;
const { twoFactorEnabled } = parsedBody.data; const { twoFactorSetupRequested } = 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 // Verify the user exists in the organization
const existingUser = await db const existingUser = await db
.select() .select()
.from(userOrgs) .from(users)
.where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId))) .where(eq(users.userId, userId))
.limit(1); .limit(1);
if (existingUser.length === 0) { if (existingUser.length === 0) {
return next(createHttpError(HttpCode.NOT_FOUND, "User not found"));
}
if (existingUser[0].type !== "internal") {
return next( return next(
createHttpError( createHttpError(
HttpCode.NOT_FOUND, HttpCode.BAD_REQUEST,
"User not found or does not belong to the specified organization" "Two-factor authentication is not supported for external users"
) )
); );
} }
// Update the user's 2FA status logger.debug(`Updating 2FA for user ${userId} to ${twoFactorSetupRequested}`);
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) { if (twoFactorSetupRequested) {
return next( await db
createHttpError( .update(users)
HttpCode.NOT_FOUND, .set({
"User not found" 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<UpdateUser2FAResponse>(res, { return response<UpdateUser2FAResponse>(res, {
data: updatedUser[0], data: {
userId: existingUser[0].userId,
twoFactorRequested: twoFactorSetupRequested
},
success: true, success: true,
error: false, error: false,
message: `2FA ${twoFactorEnabled ? 'enabled' : 'disabled'} for user successfully`, message: `2FA ${twoFactorSetupRequested ? "enabled" : "disabled"} for user successfully`,
status: HttpCode.OK status: HttpCode.OK
}); });
} catch (error) { } catch (error) {
@ -148,4 +130,4 @@ export async function updateUser2FA(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
); );
} }
} }

View file

@ -7,7 +7,9 @@ export * from "./acceptInvite";
export * from "./getOrgUser"; export * from "./getOrgUser";
export * from "./adminListUsers"; export * from "./adminListUsers";
export * from "./adminRemoveUser"; export * from "./adminRemoveUser";
export * from "./adminGetUser";
export * from "./listInvitations"; export * from "./listInvitations";
export * from "./removeInvitation"; export * from "./removeInvitation";
export * from "./createOrgUser"; export * from "./createOrgUser";
export * from "./updateUser2FA"; export * from "./adminUpdateUser2FA";
export * from "./adminGetUser";

View file

@ -34,7 +34,6 @@ export type UserRow = {
status: string; status: string;
role: string; role: string;
isOwner: boolean; isOwner: boolean;
isTwoFactorEnabled: boolean;
}; };
type UsersTableProps = { type UsersTableProps = {
@ -171,39 +170,6 @@ export default function UsersTable({ users: u }: UsersTableProps) {
); );
} }
}, },
{
accessorKey: "isTwoFactorEnabled",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
2FA Enabled
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const userRow = row.original;
return (
<div className="flex flex-row items-center gap-2">
<span>{userRow.isTwoFactorEnabled && (
<span className="text-green-500">
{t('enabled')}
</span>
) || (
<span className="text-white/50">
{t('disabled')}
</span>
)}</span>
</div>
);
}
},
{ {
id: "actions", id: "actions",
cell: ({ row }) => { cell: ({ row }) => {

View file

@ -27,7 +27,6 @@ import { ListRolesResponse } from "@server/routers/role";
import { userOrgUserContext } from "@app/hooks/useOrgUserContext"; import { userOrgUserContext } from "@app/hooks/useOrgUserContext";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { Button } from "@app/components/ui/button"; import { Button } from "@app/components/ui/button";
import { Checkbox } from "@app/components/ui/checkbox";
import { import {
SettingsContainer, SettingsContainer,
SettingsSection, SettingsSection,
@ -44,14 +43,14 @@ import { useEnvContext } from "@app/hooks/useEnvContext";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
export default function AccessControlsPage() { export default function AccessControlsPage() {
const { orgUser: user, updateOrgUser } = userOrgUserContext(); const { orgUser: user } = userOrgUserContext();
const api = createApiClient(useEnvContext()); const api = createApiClient(useEnvContext());
const { orgId } = useParams(); const { orgId } = useParams();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [roles, setRoles] = useState<{ roleId: number; name: string }[]>([]); const [roles, setRoles] = useState<{ roleId: number; name: string }[]>([]);
const [enable2FA, setEnable2FA] = useState(user.twoFactorEnabled || false);
const t = useTranslations(); const t = useTranslations();
@ -97,8 +96,7 @@ export default function AccessControlsPage() {
async function onSubmit(values: z.infer<typeof formSchema>) { async function onSubmit(values: z.infer<typeof formSchema>) {
setLoading(true); setLoading(true);
// Update user role const res = await api
const roleRes = await api
.post< .post<
AxiosResponse<InviteUserResponse> AxiosResponse<InviteUserResponse>
>(`/role/${values.roleId}/add/${user.userId}`) >(`/role/${values.roleId}/add/${user.userId}`)
@ -111,34 +109,9 @@ export default function AccessControlsPage() {
t('accessRoleErrorAddDescription') t('accessRoleErrorAddDescription')
) )
}); });
return null;
}); });
// Update 2FA status if it changed if (res && res.status === 200) {
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({ toast({
variant: "default", variant: "default",
title: t('userSaved'), title: t('userSaved'),
@ -197,36 +170,6 @@ export default function AccessControlsPage() {
</FormItem> </FormItem>
)} )}
/> />
<div className="space-y-2">
<div className="flex items-center space-x-2">
<Checkbox
id="enable-2fa"
checked={enable2FA}
onCheckedChange={(
e
) =>
setEnable2FA(
e as boolean
)
}
/>
<label
htmlFor="enable-2fa"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Enable 2FA for this user
</label>
</div>
<p className="text-xs text-muted-foreground ml-6">
When enabled, the user will be required to set up their authenticator app on their next login.
{user.twoFactorEnabled && (
<span className="text-primary"> This user currently has 2FA enabled.</span>
)}
</p>
</div>
</form> </form>
</Form> </Form>
</SettingsSectionForm> </SettingsSectionForm>
@ -243,8 +186,6 @@ export default function AccessControlsPage() {
</Button> </Button>
</SettingsSectionFooter> </SettingsSectionFooter>
</SettingsSection> </SettingsSection>
</SettingsContainer> </SettingsContainer>
); );
} }

View file

@ -81,8 +81,7 @@ export default async function UsersPage(props: UsersPageProps) {
idpName: user.idpName || t('idpNameInternal'), idpName: user.idpName || t('idpNameInternal'),
status: t('userConfirmed'), status: t('userConfirmed'),
role: user.isOwner ? t('accessRoleOwner') : user.roleName || t('accessRoleMember'), role: user.isOwner ? t('accessRoleOwner') : user.roleName || t('accessRoleMember'),
isOwner: user.isOwner || false, isOwner: user.isOwner || false
isTwoFactorEnabled: user.twoFactorEnabled || false,
}; };
}); });

View file

@ -3,7 +3,7 @@
import { ColumnDef } from "@tanstack/react-table"; import { ColumnDef } from "@tanstack/react-table";
import { UsersDataTable } from "./AdminUsersDataTable"; import { UsersDataTable } from "./AdminUsersDataTable";
import { Button } from "@app/components/ui/button"; 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 { useRouter } from "next/navigation";
import { useState } from "react"; import { useState } from "react";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
@ -12,6 +12,12 @@ import { formatAxiosError } from "@app/lib/api";
import { createApiClient } from "@app/lib/api"; import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext"; import { useEnvContext } from "@app/hooks/useEnvContext";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import {
DropdownMenu,
DropdownMenuItem,
DropdownMenuContent,
DropdownMenuTrigger
} from "@app/components/ui/dropdown-menu";
export type GlobalUserRow = { export type GlobalUserRow = {
id: string; id: string;
@ -22,6 +28,8 @@ export type GlobalUserRow = {
idpId: number | null; idpId: number | null;
idpName: string; idpName: string;
dateCreated: string; dateCreated: string;
twoFactorEnabled: boolean | null;
twoFactorSetupRequested: boolean | null;
}; };
type Props = { type Props = {
@ -41,11 +49,11 @@ export default function UsersTable({ users }: Props) {
const deleteUser = (id: string) => { const deleteUser = (id: string) => {
api.delete(`/user/${id}`) api.delete(`/user/${id}`)
.catch((e) => { .catch((e) => {
console.error(t('userErrorDelete'), e); console.error(t("userErrorDelete"), e);
toast({ toast({
variant: "destructive", variant: "destructive",
title: t('userErrorDelete'), title: t("userErrorDelete"),
description: formatAxiosError(e, t('userErrorDelete')) description: formatAxiosError(e, t("userErrorDelete"))
}); });
}) })
.then(() => { .then(() => {
@ -84,7 +92,7 @@ export default function UsersTable({ users }: Props) {
column.toggleSorting(column.getIsSorted() === "asc") column.toggleSorting(column.getIsSorted() === "asc")
} }
> >
{t('username')} {t("username")}
<ArrowUpDown className="ml-2 h-4 w-4" /> <ArrowUpDown className="ml-2 h-4 w-4" />
</Button> </Button>
); );
@ -100,7 +108,7 @@ export default function UsersTable({ users }: Props) {
column.toggleSorting(column.getIsSorted() === "asc") column.toggleSorting(column.getIsSorted() === "asc")
} }
> >
{t('email')} {t("email")}
<ArrowUpDown className="ml-2 h-4 w-4" /> <ArrowUpDown className="ml-2 h-4 w-4" />
</Button> </Button>
); );
@ -116,7 +124,7 @@ export default function UsersTable({ users }: Props) {
column.toggleSorting(column.getIsSorted() === "asc") column.toggleSorting(column.getIsSorted() === "asc")
} }
> >
{t('name')} {t("name")}
<ArrowUpDown className="ml-2 h-4 w-4" /> <ArrowUpDown className="ml-2 h-4 w-4" />
</Button> </Button>
); );
@ -132,28 +140,85 @@ export default function UsersTable({ users }: Props) {
column.toggleSorting(column.getIsSorted() === "asc") column.toggleSorting(column.getIsSorted() === "asc")
} }
> >
{t('identityProvider')} {t("identityProvider")}
<ArrowUpDown className="ml-2 h-4 w-4" /> <ArrowUpDown className="ml-2 h-4 w-4" />
</Button> </Button>
); );
} }
}, },
{
accessorKey: "twoFactorEnabled",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("twoFactor")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const userRow = row.original;
return (
<div className="flex flex-row items-center gap-2">
<span>
{userRow.twoFactorEnabled ||
userRow.twoFactorSetupRequested ? (
<span className="text-green-500">
{t("enabled")}
</span>
) : (
<span>{t("disabled")}</span>
)}
</span>
</div>
);
}
},
{ {
id: "actions", id: "actions",
cell: ({ row }) => { cell: ({ row }) => {
const r = row.original; const r = row.original;
return ( return (
<> <>
<div className="flex items-center justify-end"> <div className="flex items-center justify-end gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="h-8 w-8 p-0"
>
<span className="sr-only">
Open menu
</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => {
setSelected(r);
setIsDeleteModalOpen(true);
}}
>
{t("delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button <Button
variant={"outlinePrimary"} variant={"secondary"}
className="ml-2" size="sm"
onClick={() => { onClick={() => {
setSelected(r); router.push(`/admin/users/${r.id}`);
setIsDeleteModalOpen(true);
}} }}
> >
{t('delete')} {t("edit")}
<ArrowRight className="ml-2 w-4 h-4" />
</Button> </Button>
</div> </div>
</> </>
@ -174,26 +239,27 @@ export default function UsersTable({ users }: Props) {
dialog={ dialog={
<div className="space-y-4"> <div className="space-y-4">
<p> <p>
{t('userQuestionRemove', {selectedUser: selected?.email || selected?.name || selected?.username})} {t("userQuestionRemove", {
selectedUser:
selected?.email ||
selected?.name ||
selected?.username
})}
</p> </p>
<p> <p>
<b> <b>{t("userMessageRemove")}</b>
{t('userMessageRemove')}
</b>
</p> </p>
<p> <p>{t("userMessageConfirm")}</p>
{t('userMessageConfirm')}
</p>
</div> </div>
} }
buttonText={t('userDeleteConfirm')} buttonText={t("userDeleteConfirm")}
onConfirm={async () => deleteUser(selected!.id)} onConfirm={async () => deleteUser(selected!.id)}
string={ string={
selected.email || selected.name || selected.username selected.email || selected.name || selected.username
} }
title={t('userDeleteServer')} title={t("userDeleteServer")}
/> />
)} )}

View file

@ -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<UserType | null>(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 (
<>
<SettingsContainer>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("general")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("userDescription2")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<div className="space-y-6">
<SwitchInput
id="two-factor-auth"
label={t("otpAuth")}
checked={twoFactorEnabled}
disabled={userType !== UserType.Internal}
onCheckedChange={handleTwoFactorToggle}
/>
</div>
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>
</SettingsContainer>
<div className="flex justify-end mt-6">
<Button
type="button"
loading={loading}
disabled={loading}
onClick={handleSaveSettings}
>
{t("targetTlsSubmit")}
</Button>
</div>
</>
);
}

View file

@ -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<AxiosResponse<AdminGetUserResponse>>(
`/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 (
<>
<SettingsSectionTitle
title={`${user?.email || user?.name || user?.username}`}
description={t('userDescription2')}
/>
<HorizontalTabs items={navItems}>
{children}
</HorizontalTabs>
</>
);
}

View file

@ -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`);
}

View file

@ -38,7 +38,9 @@ export default async function UsersPage(props: PageProps) {
idpId: row.idpId, idpId: row.idpId,
idpName: row.idpName || t('idpNameInternal'), idpName: row.idpName || t('idpNameInternal'),
dateCreated: row.dateCreated, dateCreated: row.dateCreated,
serverAdmin: row.serverAdmin serverAdmin: row.serverAdmin,
twoFactorEnabled: row.twoFactorEnabled,
twoFactorSetupRequested: row.twoFactorSetupRequested
}; };
}); });

View file

@ -1,29 +1,7 @@
"use client"; "use client";
import { useState, useEffect } from "react"; import { useEffect } from "react";
import { useRouter, useSearchParams } from "next/navigation"; 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 { import {
Card, Card,
CardContent, CardContent,
@ -31,124 +9,31 @@ import {
CardHeader, CardHeader,
CardTitle CardTitle
} from "@/components/ui/card"; } from "@/components/ui/card";
import { toast } from "@app/hooks/useToast"; import TwoFactorSetupForm from "@app/components/TwoFactorSetupForm";
import { formatAxiosError } from "@app/lib/api";
import CopyTextBox from "@app/components/CopyTextBox";
import { QRCodeCanvas } from "qrcode.react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { cleanRedirect } from "@app/lib/cleanRedirect";
export default function Setup2FAPage() { export default function Setup2FAPage() {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const redirect = searchParams?.get("redirect"); const redirect = searchParams?.get("redirect");
const email = searchParams?.get("email"); 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<string[]>([]);
const api = createApiClient(useEnvContext());
const t = useTranslations(); const t = useTranslations();
// Redirect to login if no email is provided // Redirect to login if no email is provided
useEffect(() => { useEffect(() => {
if (!email) { if (!email) {
router.push('/auth/login'); router.push("/auth/login");
} }
}, [email, router]); }, [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<z.infer<typeof enableSchema>>({
resolver: zodResolver(enableSchema),
defaultValues: {
password: ""
}
});
const confirmForm = useForm<z.infer<typeof confirmSchema>>({
resolver: zodResolver(confirmSchema),
defaultValues: {
code: ""
}
});
const request2fa = async (values: z.infer<typeof enableSchema>) => {
if (!email) return;
setLoading(true);
const res = await api
.post<AxiosResponse<RequestTotpSecretResponse>>(
`/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<typeof confirmSchema>) => {
if (!email) return;
setLoading(true);
const { password } = enableForm.getValues();
const res = await api
.post<AxiosResponse<VerifyTotpResponse>>(`/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 = () => { const handleComplete = () => {
console.log("2FA setup complete", redirect, email);
if (redirect) { if (redirect) {
router.push(redirect); const cleanUrl = cleanRedirect(redirect);
console.log("Redirecting to:", cleanUrl);
router.push(cleanUrl);
} else { } else {
router.push("/"); router.push("/");
} }
@ -158,132 +43,20 @@ export default function Setup2FAPage() {
<div className="min-h-screen flex items-center justify-center bg-background"> <div className="min-h-screen flex items-center justify-center bg-background">
<Card className="w-full max-w-md"> <Card className="w-full max-w-md">
<CardHeader> <CardHeader>
<CardTitle>{t('otpSetup')}</CardTitle> <CardTitle>{t("otpSetup")}</CardTitle>
<CardDescription> <CardDescription>
Your administrator has enabled two-factor authentication for <strong>{email}</strong>. {t("adminEnabled2FaOnYourAccount", { email: email || "your account" })}
Please complete the setup process to continue.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{step === 1 && ( <TwoFactorSetupForm
<Form {...enableForm}> email={email || undefined}
<form onComplete={handleComplete}
onSubmit={enableForm.handleSubmit(request2fa)} submitButtonText="Continue"
className="space-y-4" showCancelButton={false}
> />
<div className="space-y-4">
<FormField
control={enableForm.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>{t('password')}</FormLabel>
<FormControl>
<Input
type="password"
placeholder="Enter your current password"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<Button
type="submit"
className="w-full"
loading={loading}
disabled={loading}
>
Continue
</Button>
</form>
</Form>
)}
{step === 2 && (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
{t('otpSetupScanQr')}
</p>
<div className="flex justify-center">
<QRCodeCanvas value={secretUri} size={200} />
</div>
<div>
<Label className="text-xs text-muted-foreground">Manual entry key:</Label>
<CopyTextBox
text={secretKey}
wrapText={false}
/>
</div>
<Form {...confirmForm}>
<form
onSubmit={confirmForm.handleSubmit(confirm2fa)}
className="space-y-4"
>
<FormField
control={confirmForm.control}
name="code"
render={({ field }) => (
<FormItem>
<FormLabel>
{t('otpSetupSecretCode')}
</FormLabel>
<FormControl>
<Input
placeholder="Enter 6-digit code"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="submit"
className="w-full"
loading={loading}
disabled={loading}
>
Verify and Complete Setup
</Button>
</form>
</Form>
</div>
)}
{step === 3 && (
<div className="space-y-4 text-center">
<CheckCircle2
className="mx-auto text-green-500"
size={48}
/>
<p className="font-semibold text-lg">
{t('otpSetupSuccess')}
</p>
<p className="text-sm text-muted-foreground">
{t('otpSetupSuccessStoreBackupCodes')}
</p>
{backupCodes.length > 0 && (
<div>
<Label className="text-xs text-muted-foreground">Backup codes:</Label>
<CopyTextBox text={backupCodes.join("\n")} />
</div>
)}
<Button
onClick={handleComplete}
className="w-full"
>
Continue to Application
</Button>
</div>
)}
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
); );
} }

View file

@ -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 (
<Credenza
open={open}
onOpenChange={(val) => {
setOpen(val);
reset();
}}
>
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>
{t('otpSetup')}
</CredenzaTitle>
<CredenzaDescription>
{t('otpSetupDescription')}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<TwoFactorSetupForm
ref={formRef}
isDialog={true}
submitButtonText={t('submit')}
cancelButtonText="Close"
showCancelButton={false}
onComplete={() => {setOpen(false); updateUser({ twoFactorEnabled: true });}}
onStepChange={setCurrentStep}
onLoadingChange={setLoading}
/>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
</CredenzaClose>
{(currentStep === 1 || currentStep === 2) && (
<Button
type="button"
loading={loading}
disabled={loading}
onClick={handleSubmit}
>
{t('submit')}
</Button>
)}
</CredenzaFooter>
</CredenzaContent>
</Credenza>
);
}

View file

@ -1,46 +1,7 @@
"use client"; "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 { useUserContext } from "@app/hooks/useUserContext";
import { useTranslations } from "next-intl"; import Enable2FaDialog from "./Enable2FaDialog";
type Enable2FaProps = { type Enable2FaProps = {
open: boolean; open: boolean;
@ -48,261 +9,5 @@ type Enable2FaProps = {
}; };
export default function Enable2FaForm({ open, setOpen }: Enable2FaProps) { export default function Enable2FaForm({ open, setOpen }: Enable2FaProps) {
const [step, setStep] = useState(1); return <Enable2FaDialog open={open} setOpen={setOpen} />;
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<string[]>([]);
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<z.infer<typeof enableSchema>>({
resolver: zodResolver(enableSchema),
defaultValues: {
password: ""
}
});
const confirmForm = useForm<z.infer<typeof confirmSchema>>({
resolver: zodResolver(confirmSchema),
defaultValues: {
code: ""
}
});
const request2fa = async (values: z.infer<typeof enableSchema>) => {
setLoading(true);
const res = await api
.post<AxiosResponse<RequestTotpSecretResponse>>(
`/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<typeof confirmSchema>) => {
setLoading(true);
const res = await api
.post<AxiosResponse<VerifyTotpResponse>>(`/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 (
<Credenza
open={open}
onOpenChange={(val) => {
setOpen(val);
reset();
}}
>
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>
{t('otpSetup')}
</CredenzaTitle>
<CredenzaDescription>
{t('otpSetupDescription')}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
{step === 1 && (
<Form {...enableForm}>
<form
onSubmit={enableForm.handleSubmit(request2fa)}
className="space-y-4"
id="form"
>
<div className="space-y-4">
<FormField
control={enableForm.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>{t('password')}</FormLabel>
<FormControl>
<Input
type="password"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</form>
</Form>
)}
{step === 2 && (
<div className="space-y-4">
<p>
{t('otpSetupScanQr')}
</p>
<div className="h-[250px] mx-auto flex items-center justify-center">
<QRCodeCanvas value={secretUri} size={200} />
</div>
<div className="max-w-md mx-auto">
<CopyTextBox
text={secretUri}
wrapText={false}
/>
</div>
<Form {...confirmForm}>
<form
onSubmit={confirmForm.handleSubmit(
confirm2fa
)}
className="space-y-4"
id="form"
>
<div className="space-y-4">
<FormField
control={confirmForm.control}
name="code"
render={({ field }) => (
<FormItem>
<FormLabel>
{t('otpSetupSecretCode')}
</FormLabel>
<FormControl>
<Input
type="code"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</form>
</Form>
</div>
)}
{step === 3 && (
<div className="space-y-4 text-center">
<CheckCircle2
className="mx-auto text-green-500"
size={48}
/>
<p className="font-semibold text-lg">
{t('otpSetupSuccess')}
</p>
<p>
{t('otpSetupSuccessStoreBackupCodes')}
</p>
<div className="max-w-md mx-auto">
<CopyTextBox text={backupCodes.join("\n")} />
</div>
</div>
)}
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
</CredenzaClose>
{(step === 1 || step === 2) && (
<Button
type="button"
loading={loading}
disabled={loading}
onClick={() => {
if (step === 1) {
enableForm.handleSubmit(request2fa)();
} else {
confirmForm.handleSubmit(confirm2fa)();
}
}}
>
{t('submit')}
</Button>
)}
</CredenzaFooter>
</CredenzaContent>
</Credenza>
);
} }

View file

@ -23,7 +23,10 @@ function getActionsCategories(root: boolean) {
[t('actionGetOrg')]: "getOrg", [t('actionGetOrg')]: "getOrg",
[t('actionUpdateOrg')]: "updateOrg", [t('actionUpdateOrg')]: "updateOrg",
[t('actionGetOrgUser')]: "getOrgUser", [t('actionGetOrgUser')]: "getOrgUser",
[t('actionListOrgDomains')]: "listOrgDomains", [t('actionInviteUser')]: "inviteUser",
[t('actionRemoveUser')]: "removeUser",
[t('actionListUsers')]: "listUsers",
[t('actionListOrgDomains')]: "listOrgDomains"
}, },
Site: { Site: {
@ -65,16 +68,9 @@ function getActionsCategories(root: boolean) {
[t('actionGetRole')]: "getRole", [t('actionGetRole')]: "getRole",
[t('actionListRole')]: "listRoles", [t('actionListRole')]: "listRoles",
[t('actionUpdateRole')]: "updateRole", [t('actionUpdateRole')]: "updateRole",
[t('actionListAllowedRoleResources')]: "listRoleResources" [t('actionListAllowedRoleResources')]: "listRoleResources",
},
User: {
[t('actionInviteUser')]: "inviteUser",
[t('actionRemoveUser')]: "removeUser",
[t('actionListUsers')]: "listUsers",
[t('actionAddUserRole')]: "addUserRole" [t('actionAddUserRole')]: "addUserRole"
}, },
"Access Token": { "Access Token": {
[t('actionGenerateAccessToken')]: "generateAccessToken", [t('actionGenerateAccessToken')]: "generateAccessToken",
[t('actionDeleteAccessToken')]: "deleteAcessToken", [t('actionDeleteAccessToken')]: "deleteAcessToken",
@ -114,6 +110,11 @@ function getActionsCategories(root: boolean) {
[t('actionListIdpOrgs')]: "listIdpOrgs", [t('actionListIdpOrgs')]: "listIdpOrgs",
[t('actionUpdateIdpOrg')]: "updateIdpOrg" [t('actionUpdateIdpOrg')]: "updateIdpOrg"
}; };
actionsByCategory["User"] = {
[t('actionUpdateUser')]: "updateUser",
[t('actionGetUser')]: "getUser"
};
} }
return actionsByCategory; return actionsByCategory;

View file

@ -20,8 +20,8 @@ import { useRouter } from "next/navigation";
import { useState } from "react"; import { useState } from "react";
import { useUserContext } from "@app/hooks/useUserContext"; import { useUserContext } from "@app/hooks/useUserContext";
import Disable2FaForm from "./Disable2FaForm"; import Disable2FaForm from "./Disable2FaForm";
import Enable2FaForm from "./Enable2FaForm";
import SecurityKeyForm from "./SecurityKeyForm"; import SecurityKeyForm from "./SecurityKeyForm";
import Enable2FaDialog from "./Enable2FaDialog";
import SupporterStatus from "./SupporterStatus"; import SupporterStatus from "./SupporterStatus";
import { UserType } from "@server/types/UserTypes"; import { UserType } from "@server/types/UserTypes";
import LocaleSwitcher from '@app/components/LocaleSwitcher'; import LocaleSwitcher from '@app/components/LocaleSwitcher';
@ -72,7 +72,7 @@ export default function ProfileIcon() {
return ( return (
<> <>
<Enable2FaForm open={openEnable2fa} setOpen={setOpenEnable2fa} /> <Enable2FaDialog open={openEnable2fa} setOpen={setOpenEnable2fa} />
<Disable2FaForm open={openDisable2fa} setOpen={setOpenDisable2fa} /> <Disable2FaForm open={openDisable2fa} setOpen={setOpenDisable2fa} />
<SecurityKeyForm open={openSecurityKey} setOpen={setOpenSecurityKey} /> <SecurityKeyForm open={openSecurityKey} setOpen={setOpenSecurityKey} />

View file

@ -6,6 +6,7 @@ interface SwitchComponentProps {
id: string; id: string;
label: string; label: string;
description?: string; description?: string;
checked?: boolean;
defaultChecked?: boolean; defaultChecked?: boolean;
disabled?: boolean; disabled?: boolean;
onCheckedChange: (checked: boolean) => void; onCheckedChange: (checked: boolean) => void;
@ -16,6 +17,7 @@ export function SwitchInput({
label, label,
description, description,
disabled, disabled,
checked,
defaultChecked = false, defaultChecked = false,
onCheckedChange onCheckedChange
}: SwitchComponentProps) { }: SwitchComponentProps) {
@ -24,6 +26,7 @@ export function SwitchInput({
<div className="flex items-center space-x-2 mb-2"> <div className="flex items-center space-x-2 mb-2">
<Switch <Switch
id={id} id={id}
checked={checked}
defaultChecked={defaultChecked} defaultChecked={defaultChecked}
onCheckedChange={onCheckedChange} onCheckedChange={onCheckedChange}
disabled={disabled} disabled={disabled}

View file

@ -0,0 +1,327 @@
"use client";
import { useState, forwardRef, useImperativeHandle, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { CheckCircle2 } from "lucide-react";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { AxiosResponse } from "axios";
import {
LoginResponse,
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 { toast } from "@app/hooks/useToast";
import { formatAxiosError } from "@app/lib/api";
import CopyTextBox from "@app/components/CopyTextBox";
import { QRCodeCanvas } from "qrcode.react";
import { useUserContext } from "@app/hooks/useUserContext";
import { useTranslations } from "next-intl";
type TwoFactorSetupFormProps = {
onComplete?: (email: string, password: string) => 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<string[]>([]);
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<z.infer<typeof enableSchema>>({
resolver: zodResolver(enableSchema),
defaultValues: {
password: initialPassword || ""
}
});
const confirmForm = useForm<z.infer<typeof confirmSchema>>({
resolver: zodResolver(confirmSchema),
defaultValues: {
code: ""
}
});
const request2fa = async (values: z.infer<typeof enableSchema>) => {
setLoading(true);
const endpoint = `/auth/2fa/request`;
const payload = { email, password: values.password };
const res = await api
.post<
AxiosResponse<RequestTotpSecretResponse>
>(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<typeof confirmSchema>) => {
setLoading(true);
const endpoint = `/auth/2fa/enable`;
const payload = {
email,
password: enableForm.getValues().password,
code: values.code
};
const res = await api
.post<AxiosResponse<VerifyTotpResponse>>(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<AxiosResponse<LoginResponse>>("/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 (
<div className="space-y-4">
{step === 1 && (
<Form {...enableForm}>
<form
onSubmit={enableForm.handleSubmit(request2fa)}
className="space-y-4"
id="form"
>
<div className="space-y-4">
<FormField
control={enableForm.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("password")}
</FormLabel>
<FormControl>
<Input
type="password"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</form>
</Form>
)}
{step === 2 && (
<div className="space-y-4">
<p>{t("otpSetupScanQr")}</p>
<div className="h-[250px] mx-auto flex items-center justify-center">
<QRCodeCanvas value={secretUri} size={200} />
</div>
<div className="max-w-md mx-auto">
<CopyTextBox text={secretUri} wrapText={false} />
</div>
<Form {...confirmForm}>
<form
onSubmit={confirmForm.handleSubmit(confirm2fa)}
className="space-y-4"
id="form"
>
<FormField
control={confirmForm.control}
name="code"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("otpSetupSecretCode")}
</FormLabel>
<FormControl>
<Input type="code" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</div>
)}
{step === 3 && (
<div className="space-y-4 text-center">
<CheckCircle2
className="mx-auto text-green-500"
size={48}
/>
<p className="font-semibold text-lg">
{t("otpSetupSuccess")}
</p>
<p>{t("otpSetupSuccessStoreBackupCodes")}</p>
{backupCodes.length > 0 && (
<div className="max-w-md mx-auto">
<CopyTextBox text={backupCodes.join("\n")} />
</div>
)}
</div>
)}
{/* Action buttons - only show when not in dialog */}
{!isDialog && (
<div className="flex gap-2 justify-end">
{showCancelButton && onCancel && (
<Button
variant="outline"
onClick={onCancel}
disabled={loading}
>
{cancelButtonText || "Cancel"}
</Button>
)}
{(step === 1 || step === 2) && (
<Button
type="button"
loading={loading}
disabled={loading}
onClick={handleSubmit}
className="w-full"
>
{submitButtonText || t("submit")}
</Button>
)}
{step === 3 && (
<Button
onClick={() =>
handleComplete(
email!,
enableForm.getValues().password
)
}
className="w-full"
>
{t("continueToApplication")}
</Button>
)}
</div>
)}
</div>
);
}
);
export default TwoFactorSetupForm;