mirror of
https://github.com/fosrl/pangolin.git
synced 2025-07-27 22:25:58 +02:00
Merge branch 'dev' into feat/internal-user-passkey-support
This commit is contained in:
commit
9075ecb007
32 changed files with 1071 additions and 1123 deletions
|
@ -958,6 +958,8 @@
|
|||
"licenseTierProfessionalRequiredDescription": "This feature is only available in the Professional Edition.",
|
||||
"actionGetOrg": "Get Organization",
|
||||
"actionUpdateOrg": "Update Organization",
|
||||
"actionUpdateUser": "Update User",
|
||||
"actionGetUser": "Get User",
|
||||
"actionGetOrgUser": "Get Organization User",
|
||||
"actionListOrgDomains": "List Organization Domains",
|
||||
"actionCreateSite": "Create Site",
|
||||
|
@ -1158,5 +1160,8 @@
|
|||
"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.",
|
||||
"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"
|
||||
}
|
||||
|
|
|
@ -56,6 +56,8 @@ export enum ActionsEnum {
|
|||
// removeUserAction = "removeUserAction",
|
||||
// removeUserSite = "removeUserSite",
|
||||
getOrgUser = "getOrgUser",
|
||||
updateUser = "updateUser",
|
||||
getUser = "getUser",
|
||||
setResourcePassword = "setResourcePassword",
|
||||
setResourcePincode = "setResourcePincode",
|
||||
setResourceWhitelist = "setResourceWhitelist",
|
||||
|
|
|
@ -121,6 +121,7 @@ export const users = pgTable("user", {
|
|||
}),
|
||||
passwordHash: varchar("passwordHash"),
|
||||
twoFactorEnabled: boolean("twoFactorEnabled").notNull().default(false),
|
||||
twoFactorSetupRequested: boolean("twoFactorSetupRequested").default(false),
|
||||
twoFactorSecret: varchar("twoFactorSecret"),
|
||||
emailVerified: boolean("emailVerified").notNull().default(false),
|
||||
dateCreated: varchar("dateCreated").notNull(),
|
||||
|
|
|
@ -125,6 +125,9 @@ export const users = sqliteTable("user", {
|
|||
twoFactorEnabled: integer("twoFactorEnabled", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
twoFactorSetupRequested: integer("twoFactorSetupRequested", {
|
||||
mode: "boolean"
|
||||
}).default(false),
|
||||
twoFactorSecret: text("twoFactorSecret"),
|
||||
emailVerified: integer("emailVerified", { mode: "boolean" })
|
||||
.notNull()
|
||||
|
|
10
server/lib/totp.ts
Normal file
10
server/lib/totp.ts
Normal 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;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -3,8 +3,6 @@ export * from "./signup";
|
|||
export * from "./logout";
|
||||
export * from "./verifyTotp";
|
||||
export * from "./requestTotpSecret";
|
||||
export * from "./setupTotpSecret";
|
||||
export * from "./completeTotpSetup";
|
||||
export * from "./disable2fa";
|
||||
export * from "./verifyEmail";
|
||||
export * from "./requestEmailVerificationCode";
|
||||
|
|
|
@ -21,10 +21,7 @@ import { UserType } from "@server/types/UserTypes";
|
|||
|
||||
export const loginBodySchema = z
|
||||
.object({
|
||||
email: z
|
||||
.string()
|
||||
.toLowerCase()
|
||||
.email(),
|
||||
email: z.string().toLowerCase().email(),
|
||||
password: z.string(),
|
||||
code: z.string().optional()
|
||||
})
|
||||
|
@ -39,8 +36,6 @@ export type LoginResponse = {
|
|||
twoFactorSetupRequired?: boolean;
|
||||
};
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function login(
|
||||
req: Request,
|
||||
res: Response,
|
||||
|
@ -127,9 +122,10 @@ export async function login(
|
|||
});
|
||||
}
|
||||
|
||||
if (existingUser.twoFactorEnabled) {
|
||||
// If 2FA is enabled but no secret exists, force setup
|
||||
if (!existingUser.twoFactorSecret) {
|
||||
if (
|
||||
existingUser.twoFactorSetupRequested &&
|
||||
!existingUser.twoFactorEnabled
|
||||
) {
|
||||
return response<LoginResponse>(res, {
|
||||
data: { twoFactorSetupRequired: true },
|
||||
success: true,
|
||||
|
@ -139,6 +135,7 @@ export async function login(
|
|||
});
|
||||
}
|
||||
|
||||
if (existingUser.twoFactorEnabled) {
|
||||
if (!code) {
|
||||
return response<{ codeRequested: boolean }>(res, {
|
||||
data: { codeRequested: true },
|
||||
|
@ -151,7 +148,7 @@ export async function login(
|
|||
|
||||
const validOTP = await verifyTotpCode(
|
||||
code,
|
||||
existingUser.twoFactorSecret,
|
||||
existingUser.twoFactorSecret!,
|
||||
existingUser.userId
|
||||
);
|
||||
|
||||
|
|
|
@ -7,16 +7,19 @@ import HttpCode from "@server/types/HttpCode";
|
|||
import { response } from "@server/lib";
|
||||
import { db } from "@server/db";
|
||||
import { User, users } from "@server/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { createTOTPKeyURI } from "oslo/otp";
|
||||
import logger from "@server/logger";
|
||||
import { verifyPassword } from "@server/auth/password";
|
||||
import { unauthorized } from "@server/auth/unauthorizedResponse";
|
||||
import { UserType } from "@server/types/UserTypes";
|
||||
import { verifySession } from "@server/auth/sessions/verifySession";
|
||||
import config from "@server/lib/config";
|
||||
|
||||
export const requestTotpSecretBody = z
|
||||
.object({
|
||||
password: z.string()
|
||||
password: z.string(),
|
||||
email: z.string().email().optional()
|
||||
})
|
||||
.strict();
|
||||
|
||||
|
@ -43,9 +46,42 @@ export async function requestTotpSecret(
|
|||
);
|
||||
}
|
||||
|
||||
const { password } = parsedBody.data;
|
||||
const { password, email } = parsedBody.data;
|
||||
|
||||
const user = req.user as User;
|
||||
const { user: sessionUser, session: existingSession } = await verifySession(req);
|
||||
|
||||
let user: User | null = sessionUser;
|
||||
if (!existingSession) {
|
||||
if (!email) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Email is required for two-factor authentication setup"
|
||||
)
|
||||
);
|
||||
}
|
||||
const [res] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(
|
||||
and(eq(users.type, UserType.Internal), eq(users.email, email))
|
||||
);
|
||||
user = res;
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
if (config.getRawConfig().app.log_failed_attempts) {
|
||||
logger.info(
|
||||
`Username or password incorrect. Email: ${email}. IP: ${req.ip}.`
|
||||
);
|
||||
}
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.UNAUTHORIZED,
|
||||
"Username or password is incorrect"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (user.type !== UserType.Internal) {
|
||||
return next(
|
||||
|
@ -57,7 +93,10 @@ export async function requestTotpSecret(
|
|||
}
|
||||
|
||||
try {
|
||||
const validPassword = await verifyPassword(password, user.passwordHash!);
|
||||
const validPassword = await verifyPassword(
|
||||
password,
|
||||
user.passwordHash!
|
||||
);
|
||||
if (!validPassword) {
|
||||
return next(unauthorized());
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
|
@ -6,18 +6,22 @@ import HttpCode from "@server/types/HttpCode";
|
|||
import { response } from "@server/lib";
|
||||
import { db } from "@server/db";
|
||||
import { twoFactorBackupCodes, User, users } from "@server/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { alphabet, generateRandomString } from "oslo/crypto";
|
||||
import { hashPassword } from "@server/auth/password";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { hashPassword, verifyPassword } from "@server/auth/password";
|
||||
import { verifyTotpCode } from "@server/auth/totp";
|
||||
import logger from "@server/logger";
|
||||
import { sendEmail } from "@server/emails";
|
||||
import TwoFactorAuthNotification from "@server/emails/templates/TwoFactorAuthNotification";
|
||||
import config from "@server/lib/config";
|
||||
import { UserType } from "@server/types/UserTypes";
|
||||
import { generateBackupCodes } from "@server/lib/totp";
|
||||
import { verifySession } from "@server/auth/sessions/verifySession";
|
||||
import { unauthorized } from "@server/auth/unauthorizedResponse";
|
||||
|
||||
export const verifyTotpBody = z
|
||||
.object({
|
||||
email: z.string().email().optional(),
|
||||
password: z.string().optional(),
|
||||
code: z.string()
|
||||
})
|
||||
.strict();
|
||||
|
@ -45,9 +49,55 @@ export async function verifyTotp(
|
|||
);
|
||||
}
|
||||
|
||||
const { code } = parsedBody.data;
|
||||
const { code, email, password } = parsedBody.data;
|
||||
|
||||
const user = req.user as User;
|
||||
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(
|
||||
|
@ -76,7 +126,6 @@ export async function verifyTotp(
|
|||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const valid = await verifyTotpCode(
|
||||
code,
|
||||
user.twoFactorSecret,
|
||||
|
@ -89,7 +138,9 @@ export async function verifyTotp(
|
|||
await db.transaction(async (trx) => {
|
||||
await trx
|
||||
.update(users)
|
||||
.set({ twoFactorEnabled: true })
|
||||
.set({
|
||||
twoFactorEnabled: true
|
||||
})
|
||||
.where(eq(users.userId, user.userId));
|
||||
|
||||
const backupCodes = await generateBackupCodes();
|
||||
|
@ -153,12 +204,3 @@ export async function verifyTotp(
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function generateBackupCodes(): Promise<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;
|
||||
}
|
||||
|
|
|
@ -476,6 +476,7 @@ unauthenticated.get("/resource/:resourceId/auth", resource.getResourceAuthInfo);
|
|||
unauthenticated.get("/user", verifySessionMiddleware, user.getUser);
|
||||
|
||||
authenticated.get("/users", verifyUserIsServerAdmin, user.adminListUsers);
|
||||
authenticated.get("/user/:userId", verifyUserIsServerAdmin, user.adminGetUser);
|
||||
authenticated.delete(
|
||||
"/user/:userId",
|
||||
verifyUserIsServerAdmin,
|
||||
|
@ -490,11 +491,10 @@ authenticated.put(
|
|||
);
|
||||
|
||||
authenticated.get("/org/:orgId/user/:userId", verifyOrgAccess, user.getOrgUser);
|
||||
authenticated.patch(
|
||||
"/org/:orgId/user/:userId/2fa",
|
||||
verifyOrgAccess,
|
||||
verifyUserAccess,
|
||||
verifyUserHasAction(ActionsEnum.getOrgUser),
|
||||
|
||||
authenticated.post(
|
||||
"/user/:userId/2fa",
|
||||
verifyUserIsServerAdmin,
|
||||
user.updateUser2FA
|
||||
);
|
||||
|
||||
|
@ -719,14 +719,8 @@ authRouter.post("/login", auth.login);
|
|||
authRouter.post("/logout", auth.logout);
|
||||
authRouter.post("/newt/get-token", getToken);
|
||||
|
||||
authRouter.post("/2fa/enable", verifySessionUserMiddleware, auth.verifyTotp);
|
||||
authRouter.post(
|
||||
"/2fa/request",
|
||||
verifySessionUserMiddleware,
|
||||
auth.requestTotpSecret
|
||||
);
|
||||
authRouter.post("/2fa/setup", auth.setupTotpSecret);
|
||||
authRouter.post("/2fa/complete-setup", auth.completeTotpSetup);
|
||||
authRouter.post("/2fa/enable", auth.verifyTotp);
|
||||
authRouter.post("/2fa/request", auth.requestTotpSecret);
|
||||
authRouter.post("/2fa/disable", verifySessionUserMiddleware, auth.disable2fa);
|
||||
authRouter.post("/verify-email", verifySessionMiddleware, auth.verifyEmail);
|
||||
|
||||
|
|
|
@ -381,6 +381,20 @@ authenticated.get(
|
|||
user.getOrgUser
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
"/user/:userId/2fa",
|
||||
verifyApiKeyIsRoot,
|
||||
verifyApiKeyHasAction(ActionsEnum.updateUser),
|
||||
user.updateUser2FA
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/user/:userId",
|
||||
verifyApiKeyIsRoot,
|
||||
verifyApiKeyHasAction(ActionsEnum.getUser),
|
||||
user.adminGetUser
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/users",
|
||||
verifyApiKeyOrgAccess,
|
||||
|
|
94
server/routers/user/adminGetUser.ts
Normal file
94
server/routers/user/adminGetUser.ts
Normal 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")
|
||||
);
|
||||
}
|
||||
}
|
|
@ -37,7 +37,9 @@ async function queryUsers(limit: number, offset: number) {
|
|||
serverAdmin: users.serverAdmin,
|
||||
type: users.type,
|
||||
idpName: idp.name,
|
||||
idpId: users.idpId
|
||||
idpId: users.idpId,
|
||||
twoFactorEnabled: users.twoFactorEnabled,
|
||||
twoFactorSetupRequested: users.twoFactorSetupRequested
|
||||
})
|
||||
.from(users)
|
||||
.leftJoin(idp, eq(users.idpId, idp.idpId))
|
||||
|
|
|
@ -8,32 +8,30 @@ import HttpCode from "@server/types/HttpCode";
|
|||
import createHttpError from "http-errors";
|
||||
import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
|
||||
const updateUser2FAParamsSchema = z
|
||||
.object({
|
||||
userId: z.string(),
|
||||
orgId: z.string()
|
||||
userId: z.string()
|
||||
})
|
||||
.strict();
|
||||
|
||||
const updateUser2FABodySchema = z
|
||||
.object({
|
||||
twoFactorEnabled: z.boolean()
|
||||
twoFactorSetupRequested: z.boolean()
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type UpdateUser2FAResponse = {
|
||||
userId: string;
|
||||
twoFactorEnabled: boolean;
|
||||
twoFactorRequested: boolean;
|
||||
};
|
||||
|
||||
registry.registerPath({
|
||||
method: "patch",
|
||||
path: "/org/{orgId}/user/{userId}/2fa",
|
||||
description: "Update a user's 2FA status within an organization.",
|
||||
tags: [OpenAPITags.Org, OpenAPITags.User],
|
||||
method: "post",
|
||||
path: "/user/{userId}/2fa",
|
||||
description: "Update a user's 2FA status.",
|
||||
tags: [OpenAPITags.User],
|
||||
request: {
|
||||
params: updateUser2FAParamsSchema,
|
||||
body: {
|
||||
|
@ -73,73 +71,57 @@ export async function updateUser2FA(
|
|||
);
|
||||
}
|
||||
|
||||
const { userId, orgId } = parsedParams.data;
|
||||
const { twoFactorEnabled } = parsedBody.data;
|
||||
|
||||
if (!req.userOrg) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
"You do not have access to this organization"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Check if user has permission to update other users' 2FA
|
||||
const hasPermission = await checkUserActionPermission(
|
||||
ActionsEnum.getOrgUser,
|
||||
req
|
||||
);
|
||||
if (!hasPermission) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
"User does not have permission to update other users' 2FA settings"
|
||||
)
|
||||
);
|
||||
}
|
||||
const { userId } = parsedParams.data;
|
||||
const { twoFactorSetupRequested } = parsedBody.data;
|
||||
|
||||
// Verify the user exists in the organization
|
||||
const existingUser = await db
|
||||
.select()
|
||||
.from(userOrgs)
|
||||
.where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId)))
|
||||
.from(users)
|
||||
.where(eq(users.userId, userId))
|
||||
.limit(1);
|
||||
|
||||
if (existingUser.length === 0) {
|
||||
return next(createHttpError(HttpCode.NOT_FOUND, "User not found"));
|
||||
}
|
||||
|
||||
if (existingUser[0].type !== "internal") {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
"User not found or does not belong to the specified organization"
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Two-factor authentication is not supported for external users"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Update the user's 2FA status
|
||||
const updatedUser = await db
|
||||
logger.debug(`Updating 2FA for user ${userId} to ${twoFactorSetupRequested}`);
|
||||
|
||||
if (twoFactorSetupRequested) {
|
||||
await db
|
||||
.update(users)
|
||||
.set({
|
||||
twoFactorEnabled,
|
||||
// If disabling 2FA, also clear the secret
|
||||
twoFactorSecret: twoFactorEnabled ? undefined : null
|
||||
twoFactorSetupRequested: true,
|
||||
})
|
||||
.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"
|
||||
)
|
||||
);
|
||||
.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, {
|
||||
data: updatedUser[0],
|
||||
data: {
|
||||
userId: existingUser[0].userId,
|
||||
twoFactorRequested: twoFactorSetupRequested
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
message: `2FA ${twoFactorEnabled ? 'enabled' : 'disabled'} for user successfully`,
|
||||
message: `2FA ${twoFactorSetupRequested ? "enabled" : "disabled"} for user successfully`,
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
|
@ -7,7 +7,9 @@ export * from "./acceptInvite";
|
|||
export * from "./getOrgUser";
|
||||
export * from "./adminListUsers";
|
||||
export * from "./adminRemoveUser";
|
||||
export * from "./adminGetUser";
|
||||
export * from "./listInvitations";
|
||||
export * from "./removeInvitation";
|
||||
export * from "./createOrgUser";
|
||||
export * from "./updateUser2FA";
|
||||
export * from "./adminUpdateUser2FA";
|
||||
export * from "./adminGetUser";
|
||||
|
|
|
@ -34,7 +34,6 @@ export type UserRow = {
|
|||
status: string;
|
||||
role: string;
|
||||
isOwner: boolean;
|
||||
isTwoFactorEnabled: boolean;
|
||||
};
|
||||
|
||||
type UsersTableProps = {
|
||||
|
@ -171,39 +170,6 @@ export default function UsersTable({ users: u }: UsersTableProps) {
|
|||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "isTwoFactorEnabled",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<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",
|
||||
cell: ({ row }) => {
|
||||
|
|
|
@ -27,7 +27,6 @@ import { ListRolesResponse } from "@server/routers/role";
|
|||
import { userOrgUserContext } from "@app/hooks/useOrgUserContext";
|
||||
import { useParams } from "next/navigation";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { Checkbox } from "@app/components/ui/checkbox";
|
||||
import {
|
||||
SettingsContainer,
|
||||
SettingsSection,
|
||||
|
@ -44,14 +43,14 @@ import { useEnvContext } from "@app/hooks/useEnvContext";
|
|||
import { useTranslations } from "next-intl";
|
||||
|
||||
export default function AccessControlsPage() {
|
||||
const { orgUser: user, updateOrgUser } = userOrgUserContext();
|
||||
const { orgUser: user } = userOrgUserContext();
|
||||
|
||||
const api = createApiClient(useEnvContext());
|
||||
|
||||
const { orgId } = useParams();
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [roles, setRoles] = useState<{ roleId: number; name: string }[]>([]);
|
||||
const [enable2FA, setEnable2FA] = useState(user.twoFactorEnabled || false);
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
|
@ -97,8 +96,7 @@ export default function AccessControlsPage() {
|
|||
async function onSubmit(values: z.infer<typeof formSchema>) {
|
||||
setLoading(true);
|
||||
|
||||
// Update user role
|
||||
const roleRes = await api
|
||||
const res = await api
|
||||
.post<
|
||||
AxiosResponse<InviteUserResponse>
|
||||
>(`/role/${values.roleId}/add/${user.userId}`)
|
||||
|
@ -111,34 +109,9 @@ export default function AccessControlsPage() {
|
|||
t('accessRoleErrorAddDescription')
|
||||
)
|
||||
});
|
||||
return null;
|
||||
});
|
||||
|
||||
// Update 2FA status if it changed
|
||||
if (enable2FA !== user.twoFactorEnabled) {
|
||||
const twoFARes = await api
|
||||
.patch(`/org/${orgId}/user/${user.userId}/2fa`, {
|
||||
twoFactorEnabled: enable2FA
|
||||
})
|
||||
.catch((e) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error updating 2FA",
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
"Failed to update 2FA status"
|
||||
)
|
||||
});
|
||||
return null;
|
||||
});
|
||||
|
||||
if (twoFARes && twoFARes.status === 200) {
|
||||
// Update the user context with the new 2FA status
|
||||
updateOrgUser({ twoFactorEnabled: enable2FA });
|
||||
}
|
||||
}
|
||||
|
||||
if (roleRes && roleRes.status === 200) {
|
||||
if (res && res.status === 200) {
|
||||
toast({
|
||||
variant: "default",
|
||||
title: t('userSaved'),
|
||||
|
@ -197,36 +170,6 @@ export default function AccessControlsPage() {
|
|||
</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>
|
||||
</SettingsSectionForm>
|
||||
|
@ -243,8 +186,6 @@ export default function AccessControlsPage() {
|
|||
</Button>
|
||||
</SettingsSectionFooter>
|
||||
</SettingsSection>
|
||||
|
||||
|
||||
</SettingsContainer>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -81,8 +81,7 @@ export default async function UsersPage(props: UsersPageProps) {
|
|||
idpName: user.idpName || t('idpNameInternal'),
|
||||
status: t('userConfirmed'),
|
||||
role: user.isOwner ? t('accessRoleOwner') : user.roleName || t('accessRoleMember'),
|
||||
isOwner: user.isOwner || false,
|
||||
isTwoFactorEnabled: user.twoFactorEnabled || false,
|
||||
isOwner: user.isOwner || false
|
||||
};
|
||||
});
|
||||
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { UsersDataTable } from "./AdminUsersDataTable";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { ArrowRight, ArrowUpDown } from "lucide-react";
|
||||
import { ArrowRight, ArrowUpDown, MoreHorizontal } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||
|
@ -12,6 +12,12 @@ import { formatAxiosError } from "@app/lib/api";
|
|||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useTranslations } from "next-intl";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger
|
||||
} from "@app/components/ui/dropdown-menu";
|
||||
|
||||
export type GlobalUserRow = {
|
||||
id: string;
|
||||
|
@ -22,6 +28,8 @@ export type GlobalUserRow = {
|
|||
idpId: number | null;
|
||||
idpName: string;
|
||||
dateCreated: string;
|
||||
twoFactorEnabled: boolean | null;
|
||||
twoFactorSetupRequested: boolean | null;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
|
@ -41,11 +49,11 @@ export default function UsersTable({ users }: Props) {
|
|||
const deleteUser = (id: string) => {
|
||||
api.delete(`/user/${id}`)
|
||||
.catch((e) => {
|
||||
console.error(t('userErrorDelete'), e);
|
||||
console.error(t("userErrorDelete"), e);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t('userErrorDelete'),
|
||||
description: formatAxiosError(e, t('userErrorDelete'))
|
||||
title: t("userErrorDelete"),
|
||||
description: formatAxiosError(e, t("userErrorDelete"))
|
||||
});
|
||||
})
|
||||
.then(() => {
|
||||
|
@ -84,7 +92,7 @@ export default function UsersTable({ users }: Props) {
|
|||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
{t('username')}
|
||||
{t("username")}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
|
@ -100,7 +108,7 @@ export default function UsersTable({ users }: Props) {
|
|||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
{t('email')}
|
||||
{t("email")}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
|
@ -116,7 +124,7 @@ export default function UsersTable({ users }: Props) {
|
|||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
{t('name')}
|
||||
{t("name")}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
|
@ -132,28 +140,85 @@ export default function UsersTable({ users }: Props) {
|
|||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
{t('identityProvider')}
|
||||
{t("identityProvider")}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</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",
|
||||
cell: ({ row }) => {
|
||||
const r = row.original;
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-end">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant={"outlinePrimary"}
|
||||
className="ml-2"
|
||||
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')}
|
||||
{t("delete")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button
|
||||
variant={"secondary"}
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
router.push(`/admin/users/${r.id}`);
|
||||
}}
|
||||
>
|
||||
{t("edit")}
|
||||
<ArrowRight className="ml-2 w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
|
@ -174,26 +239,27 @@ export default function UsersTable({ users }: Props) {
|
|||
dialog={
|
||||
<div className="space-y-4">
|
||||
<p>
|
||||
{t('userQuestionRemove', {selectedUser: selected?.email || selected?.name || selected?.username})}
|
||||
{t("userQuestionRemove", {
|
||||
selectedUser:
|
||||
selected?.email ||
|
||||
selected?.name ||
|
||||
selected?.username
|
||||
})}
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<b>
|
||||
{t('userMessageRemove')}
|
||||
</b>
|
||||
<b>{t("userMessageRemove")}</b>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
{t('userMessageConfirm')}
|
||||
</p>
|
||||
<p>{t("userMessageConfirm")}</p>
|
||||
</div>
|
||||
}
|
||||
buttonText={t('userDeleteConfirm')}
|
||||
buttonText={t("userDeleteConfirm")}
|
||||
onConfirm={async () => deleteUser(selected!.id)}
|
||||
string={
|
||||
selected.email || selected.name || selected.username
|
||||
}
|
||||
title={t('userDeleteServer')}
|
||||
title={t("userDeleteServer")}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
134
src/app/admin/users/[userId]/general/page.tsx
Normal file
134
src/app/admin/users/[userId]/general/page.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
55
src/app/admin/users/[userId]/layout.tsx
Normal file
55
src/app/admin/users/[userId]/layout.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
8
src/app/admin/users/[userId]/page.tsx
Normal file
8
src/app/admin/users/[userId]/page.tsx
Normal 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`);
|
||||
}
|
|
@ -38,7 +38,9 @@ export default async function UsersPage(props: PageProps) {
|
|||
idpId: row.idpId,
|
||||
idpName: row.idpName || t('idpNameInternal'),
|
||||
dateCreated: row.dateCreated,
|
||||
serverAdmin: row.serverAdmin
|
||||
serverAdmin: row.serverAdmin,
|
||||
twoFactorEnabled: row.twoFactorEnabled,
|
||||
twoFactorSetupRequested: row.twoFactorSetupRequested
|
||||
};
|
||||
});
|
||||
|
||||
|
|
|
@ -1,29 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { AlertCircle, CheckCircle2 } from "lucide-react";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { AxiosResponse } from "axios";
|
||||
import {
|
||||
RequestTotpSecretResponse,
|
||||
VerifyTotpResponse
|
||||
} from "@server/routers/auth";
|
||||
import { z } from "zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
|
@ -31,11 +9,9 @@ import {
|
|||
CardHeader,
|
||||
CardTitle
|
||||
} from "@/components/ui/card";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { formatAxiosError } from "@app/lib/api";
|
||||
import CopyTextBox from "@app/components/CopyTextBox";
|
||||
import { QRCodeCanvas } from "qrcode.react";
|
||||
import TwoFactorSetupForm from "@app/components/TwoFactorSetupForm";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { cleanRedirect } from "@app/lib/cleanRedirect";
|
||||
|
||||
export default function Setup2FAPage() {
|
||||
const router = useRouter();
|
||||
|
@ -43,112 +19,21 @@ export default function Setup2FAPage() {
|
|||
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<string[]>([]);
|
||||
|
||||
const api = createApiClient(useEnvContext());
|
||||
const t = useTranslations();
|
||||
|
||||
// Redirect to login if no email is provided
|
||||
useEffect(() => {
|
||||
if (!email) {
|
||||
router.push('/auth/login');
|
||||
router.push("/auth/login");
|
||||
}
|
||||
}, [email, router]);
|
||||
|
||||
const enableSchema = z.object({
|
||||
password: z.string().min(1, { message: t('passwordRequired') })
|
||||
});
|
||||
|
||||
const confirmSchema = z.object({
|
||||
code: z.string().length(6, { message: t('pincodeInvalid') })
|
||||
});
|
||||
|
||||
const enableForm = useForm<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 = () => {
|
||||
console.log("2FA setup complete", redirect, email);
|
||||
if (redirect) {
|
||||
router.push(redirect);
|
||||
const cleanUrl = cleanRedirect(redirect);
|
||||
console.log("Redirecting to:", cleanUrl);
|
||||
router.push(cleanUrl);
|
||||
} else {
|
||||
router.push("/");
|
||||
}
|
||||
|
@ -158,130 +43,18 @@ export default function Setup2FAPage() {
|
|||
<div className="min-h-screen flex items-center justify-center bg-background">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle>{t('otpSetup')}</CardTitle>
|
||||
<CardTitle>{t("otpSetup")}</CardTitle>
|
||||
<CardDescription>
|
||||
Your administrator has enabled two-factor authentication for <strong>{email}</strong>.
|
||||
Please complete the setup process to continue.
|
||||
{t("adminEnabled2FaOnYourAccount", { email: email || "your account" })}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{step === 1 && (
|
||||
<Form {...enableForm}>
|
||||
<form
|
||||
onSubmit={enableForm.handleSubmit(request2fa)}
|
||||
className="space-y-4"
|
||||
>
|
||||
<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}
|
||||
<TwoFactorSetupForm
|
||||
email={email || undefined}
|
||||
onComplete={handleComplete}
|
||||
submitButtonText="Continue"
|
||||
showCancelButton={false}
|
||||
/>
|
||||
</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>
|
||||
</Card>
|
||||
</div>
|
||||
|
|
89
src/components/Enable2FaDialog.tsx
Normal file
89
src/components/Enable2FaDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -1,46 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { AlertCircle, CheckCircle2 } from "lucide-react";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { AxiosResponse } from "axios";
|
||||
import {
|
||||
RequestTotpSecretBody,
|
||||
RequestTotpSecretResponse,
|
||||
VerifyTotpBody,
|
||||
VerifyTotpResponse
|
||||
} from "@server/routers/auth";
|
||||
import { z } from "zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import {
|
||||
Credenza,
|
||||
CredenzaBody,
|
||||
CredenzaClose,
|
||||
CredenzaContent,
|
||||
CredenzaDescription,
|
||||
CredenzaFooter,
|
||||
CredenzaHeader,
|
||||
CredenzaTitle
|
||||
} from "@app/components/Credenza";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { formatAxiosError } from "@app/lib/api";
|
||||
import CopyTextBox from "@app/components/CopyTextBox";
|
||||
import { QRCodeCanvas, QRCodeSVG } from "qrcode.react";
|
||||
import { useUserContext } from "@app/hooks/useUserContext";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Enable2FaDialog from "./Enable2FaDialog";
|
||||
|
||||
type Enable2FaProps = {
|
||||
open: boolean;
|
||||
|
@ -48,261 +9,5 @@ type Enable2FaProps = {
|
|||
};
|
||||
|
||||
export default function Enable2FaForm({ open, setOpen }: Enable2FaProps) {
|
||||
const [step, setStep] = useState(1);
|
||||
const [secretKey, setSecretKey] = useState("");
|
||||
const [secretUri, setSecretUri] = useState("");
|
||||
const [verificationCode, setVerificationCode] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [backupCodes, setBackupCodes] = useState<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>
|
||||
);
|
||||
return <Enable2FaDialog open={open} setOpen={setOpen} />;
|
||||
}
|
||||
|
|
|
@ -23,7 +23,10 @@ function getActionsCategories(root: boolean) {
|
|||
[t('actionGetOrg')]: "getOrg",
|
||||
[t('actionUpdateOrg')]: "updateOrg",
|
||||
[t('actionGetOrgUser')]: "getOrgUser",
|
||||
[t('actionListOrgDomains')]: "listOrgDomains",
|
||||
[t('actionInviteUser')]: "inviteUser",
|
||||
[t('actionRemoveUser')]: "removeUser",
|
||||
[t('actionListUsers')]: "listUsers",
|
||||
[t('actionListOrgDomains')]: "listOrgDomains"
|
||||
},
|
||||
|
||||
Site: {
|
||||
|
@ -65,16 +68,9 @@ function getActionsCategories(root: boolean) {
|
|||
[t('actionGetRole')]: "getRole",
|
||||
[t('actionListRole')]: "listRoles",
|
||||
[t('actionUpdateRole')]: "updateRole",
|
||||
[t('actionListAllowedRoleResources')]: "listRoleResources"
|
||||
},
|
||||
|
||||
User: {
|
||||
[t('actionInviteUser')]: "inviteUser",
|
||||
[t('actionRemoveUser')]: "removeUser",
|
||||
[t('actionListUsers')]: "listUsers",
|
||||
[t('actionListAllowedRoleResources')]: "listRoleResources",
|
||||
[t('actionAddUserRole')]: "addUserRole"
|
||||
},
|
||||
|
||||
"Access Token": {
|
||||
[t('actionGenerateAccessToken')]: "generateAccessToken",
|
||||
[t('actionDeleteAccessToken')]: "deleteAcessToken",
|
||||
|
@ -114,6 +110,11 @@ function getActionsCategories(root: boolean) {
|
|||
[t('actionListIdpOrgs')]: "listIdpOrgs",
|
||||
[t('actionUpdateIdpOrg')]: "updateIdpOrg"
|
||||
};
|
||||
|
||||
actionsByCategory["User"] = {
|
||||
[t('actionUpdateUser')]: "updateUser",
|
||||
[t('actionGetUser')]: "getUser"
|
||||
};
|
||||
}
|
||||
|
||||
return actionsByCategory;
|
||||
|
|
|
@ -20,8 +20,8 @@ import { useRouter } from "next/navigation";
|
|||
import { useState } from "react";
|
||||
import { useUserContext } from "@app/hooks/useUserContext";
|
||||
import Disable2FaForm from "./Disable2FaForm";
|
||||
import Enable2FaForm from "./Enable2FaForm";
|
||||
import SecurityKeyForm from "./SecurityKeyForm";
|
||||
import Enable2FaDialog from "./Enable2FaDialog";
|
||||
import SupporterStatus from "./SupporterStatus";
|
||||
import { UserType } from "@server/types/UserTypes";
|
||||
import LocaleSwitcher from '@app/components/LocaleSwitcher';
|
||||
|
@ -72,7 +72,7 @@ export default function ProfileIcon() {
|
|||
|
||||
return (
|
||||
<>
|
||||
<Enable2FaForm open={openEnable2fa} setOpen={setOpenEnable2fa} />
|
||||
<Enable2FaDialog open={openEnable2fa} setOpen={setOpenEnable2fa} />
|
||||
<Disable2FaForm open={openDisable2fa} setOpen={setOpenDisable2fa} />
|
||||
<SecurityKeyForm open={openSecurityKey} setOpen={setOpenSecurityKey} />
|
||||
|
||||
|
|
|
@ -6,6 +6,7 @@ interface SwitchComponentProps {
|
|||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
checked?: boolean;
|
||||
defaultChecked?: boolean;
|
||||
disabled?: boolean;
|
||||
onCheckedChange: (checked: boolean) => void;
|
||||
|
@ -16,6 +17,7 @@ export function SwitchInput({
|
|||
label,
|
||||
description,
|
||||
disabled,
|
||||
checked,
|
||||
defaultChecked = false,
|
||||
onCheckedChange
|
||||
}: SwitchComponentProps) {
|
||||
|
@ -24,6 +26,7 @@ export function SwitchInput({
|
|||
<div className="flex items-center space-x-2 mb-2">
|
||||
<Switch
|
||||
id={id}
|
||||
checked={checked}
|
||||
defaultChecked={defaultChecked}
|
||||
onCheckedChange={onCheckedChange}
|
||||
disabled={disabled}
|
||||
|
|
327
src/components/TwoFactorSetupForm.tsx
Normal file
327
src/components/TwoFactorSetupForm.tsx
Normal 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;
|
Loading…
Add table
Add a link
Reference in a new issue