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

This commit is contained in:
Adrian Astles 2025-07-14 07:20:33 +08:00 committed by GitHub
commit c9f5ffae42
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 2522 additions and 367 deletions

View file

@ -33,3 +33,8 @@ updates:
minor-updates:
update-types:
- "minor"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"

View file

@ -12,13 +12,13 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v3
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
uses: docker/login-action@v2
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
@ -28,7 +28,7 @@ jobs:
run: echo "TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV
- name: Install Go
uses: actions/setup-go@v4
uses: actions/setup-go@v5
with:
go-version: 1.23.0

View file

@ -23,6 +23,9 @@ jobs:
- name: Install dependencies
run: npm ci
- name: Create database index.ts
run: echo 'export * from "./sqlite";' > server/db/index.ts
- name: Generate database migrations
run: npm run db:sqlite:generate
@ -45,5 +48,8 @@ jobs:
echo "App failed to start"
exit 1
- name: Build Docker image
- name: Build Docker image sqlite
run: make build
- name: Build Docker image pg
run: make build-pg

View file

@ -1,4 +1,4 @@
.PHONY: build build-release build-arm build-x86 test clean
.PHONY: build build-pg build-release build-arm build-x86 test clean
build-release:
@if [ -z "$(tag)" ]; then \
@ -17,7 +17,10 @@ build-x86:
docker buildx build --platform linux/amd64 -t fosrl/pangolin:latest .
build:
docker build -t fosrl/pangolin:latest .
docker build -t fosrl/pangolin:latest -f Dockerfile .
build-pg:
docker build -t fosrl/pangolin:postgresql-latest -f Dockerfile.pg .
test:
docker run -it -p 3000:3000 -p 3001:3001 -p 3002:3002 -v ./config:/app/config fosrl/pangolin:latest

View file

@ -12,7 +12,7 @@
"componentsErrorNoMember": "You are not currently a member of any organizations.",
"welcome": "Welcome to Pangolin",
"componentsCreateOrg": "Create an Organization",
"componentsMember": "You're a member of {count, plural, =0 {no organization} =1 {one organization} other {# organizations}}.",
"componentsMember": "You're a member of {count, plural, =0 {no organization} one {one organization} other {# organizations}}.",
"componentsInvalidKey": "Invalid or expired license keys detected. Follow license terms to continue using all features.",
"dismiss": "Dismiss",
"componentsLicenseViolation": "License Violation: This server is using {usedSites} sites which exceeds its licensed limit of {maxSites} sites. Follow license terms to continue using all features.",
@ -249,7 +249,7 @@
"weeks": "Weeks",
"months": "Months",
"years": "Years",
"day": "{count, plural, =1 {# day} other {# days}}",
"day": "{count, plural, one {# day} other {# days}}",
"apiKeysTitle": "API Key Information",
"apiKeysConfirmCopy2": "You must confirm that you have copied the API key.",
"apiKeysErrorCreate": "Error creating API key",
@ -347,7 +347,7 @@
"licensePurchase": "Purchase License",
"licensePurchaseSites": "Purchase Additional Sites",
"licenseSitesUsedMax": "{usedSites} of {maxSites} sites used",
"licenseSitesUsed": "{count, plural, =0 {# sites} =1 {# site} other {# sites}} in system.",
"licenseSitesUsed": "{count, plural, =0 {# sites} one {# site} other {# sites}} in system.",
"licensePurchaseDescription": "Choose how many sites you want to {selectedMode, select, license {purchase a license for. You can always add more sites later.} other {add to your existing license.}}",
"licenseFee": "License fee",
"licensePriceSite": "Price per site",
@ -436,7 +436,7 @@
"accessRoleSelect": "Select role",
"inviteEmailSentDescription": "An email has been sent to the user with the access link below. They must access the link to accept the invitation.",
"inviteSentDescription": "The user has been invited. They must access the link below to accept the invitation.",
"inviteExpiresIn": "The invite will expire in {days, plural, =1 {# day} other {# days}}.",
"inviteExpiresIn": "The invite will expire in {days, plural, one {# day} other {# days}}.",
"idpTitle": "Identity Provider",
"idpSelect": "Select the identity provider for the external user",
"idpNotConfigured": "No identity providers are configured. Please configure an identity provider before creating external users.",
@ -1102,7 +1102,7 @@
"containerNetworks": "Networks",
"containerHostnameIp": "Hostname/IP",
"containerLabels": "Labels",
"containerLabelsCount": "{count} label{s,plural,one{} other{s}}",
"containerLabelsCount": "{count, plural, one {# label} other {# labels}}",
"containerLabelsTitle": "Container Labels",
"containerLabelEmpty": "<empty>",
"containerPorts": "Ports",
@ -1114,7 +1114,7 @@
"showStoppedContainers": "Show stopped containers",
"noContainersFound": "No containers found. Make sure Docker containers are running.",
"searchContainersPlaceholder": "Search across {count} containers...",
"searchResultsCount": "{count} result{s,plural,one{} other{s}}",
"searchResultsCount": "{count, plural, one {# result} other {# results}}",
"filters": "Filters",
"filterOptions": "Filter Options",
"filterPorts": "Ports",

1136
messages/ko-KR.json Normal file

File diff suppressed because it is too large Load diff

785
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -49,11 +49,11 @@
"@radix-ui/react-switch": "1.2.5",
"@radix-ui/react-tabs": "1.1.12",
"@radix-ui/react-toast": "1.2.14",
"@react-email/components": "0.1.0",
"@react-email/components": "0.3.1",
"@react-email/render": "^1.1.2",
"@react-email/tailwind": "1.0.5",
"@simplewebauthn/browser": "^13.1.0",
"@simplewebauthn/server": "^9.0.3",
"@react-email/tailwind": "1.2.1",
"@tailwindcss/forms": "^0.5.10",
"@tanstack/react-table": "8.21.3",
"arctic": "^3.7.0",
@ -69,8 +69,8 @@
"cors": "2.8.5",
"crypto-js": "^4.2.0",
"drizzle-orm": "0.44.2",
"eslint": "9.29.0",
"eslint-config-next": "15.3.4",
"eslint": "9.31.0",
"eslint-config-next": "15.3.5",
"express": "4.21.2",
"express-rate-limit": "7.5.1",
"glob": "11.0.3",
@ -81,14 +81,14 @@
"jmespath": "^0.16.0",
"js-yaml": "4.1.0",
"jsonwebtoken": "^9.0.2",
"lucide-react": "0.522.0",
"lucide-react": "0.525.0",
"moment": "2.30.1",
"next": "15.3.4",
"next-intl": "^4.1.0",
"next": "15.3.5",
"next-intl": "^4.3.4",
"next-themes": "0.4.6",
"node-cache": "5.1.2",
"node-fetch": "3.3.2",
"nodemailer": "7.0.3",
"nodemailer": "7.0.5",
"npm": "^11.4.2",
"oslo": "1.2.1",
"pg": "^8.16.2",
@ -96,24 +96,24 @@
"react": "19.1.0",
"react-dom": "19.1.0",
"react-easy-sort": "^1.6.0",
"react-hook-form": "7.58.1",
"react-hook-form": "7.60.0",
"react-icons": "^5.5.0",
"rebuild": "0.1.2",
"semver": "^7.7.2",
"swagger-ui-express": "^5.0.1",
"tailwind-merge": "3.3.1",
"tw-animate-css": "^1.3.3",
"tw-animate-css": "^1.3.5",
"uuid": "^11.1.0",
"vaul": "1.1.2",
"winston": "3.17.0",
"winston-daily-rotate-file": "5.0.0",
"ws": "8.18.2",
"zod": "3.25.67",
"ws": "8.18.3",
"zod": "3.25.76",
"zod-validation-error": "3.5.2",
"yargs": "18.0.0"
},
"devDependencies": {
"@dotenvx/dotenvx": "1.45.1",
"@dotenvx/dotenvx": "1.47.3",
"@esbuild-plugins/tsconfig-paths": "0.1.2",
"@tailwindcss/postcss": "^4.1.10",
"@types/better-sqlite3": "7.6.12",
@ -133,16 +133,16 @@
"@types/swagger-ui-express": "^4.1.8",
"@types/ws": "8.18.1",
"@types/yargs": "17.0.33",
"drizzle-kit": "0.31.2",
"esbuild": "0.25.5",
"drizzle-kit": "0.31.4",
"esbuild": "0.25.6",
"esbuild-node-externals": "1.18.0",
"postcss": "^8",
"react-email": "4.0.16",
"react-email": "4.1.0",
"tailwindcss": "^4.1.4",
"tsc-alias": "1.8.16",
"tsx": "4.20.3",
"typescript": "^5",
"typescript-eslint": "^8.35.0"
"typescript-eslint": "^8.36.0"
},
"overrides": {
"emblor": {

View file

@ -0,0 +1,179 @@
import { Request, Response, NextFunction } from "express";
import createHttpError from "http-errors";
import { z } from "zod";
import { fromError } from "zod-validation-error";
import HttpCode from "@server/types/HttpCode";
import { response } from "@server/lib";
import { db } from "@server/db";
import { twoFactorBackupCodes, users } from "@server/db";
import { eq, and } from "drizzle-orm";
import { alphabet, generateRandomString } from "oslo/crypto";
import { hashPassword, verifyPassword } from "@server/auth/password";
import { verifyTotpCode } from "@server/auth/totp";
import logger from "@server/logger";
import { sendEmail } from "@server/emails";
import TwoFactorAuthNotification from "@server/emails/templates/TwoFactorAuthNotification";
import config from "@server/lib/config";
import { UserType } from "@server/types/UserTypes";
export const completeTotpSetupBody = z
.object({
email: z.string().email(),
password: z.string(),
code: z.string()
})
.strict();
export type CompleteTotpSetupBody = z.infer<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,6 +3,8 @@ export * from "./signup";
export * from "./logout";
export * from "./verifyTotp";
export * from "./requestTotpSecret";
export * from "./setupTotpSecret";
export * from "./completeTotpSetup";
export * from "./disable2fa";
export * from "./verifyEmail";
export * from "./requestEmailVerificationCode";

View file

@ -36,6 +36,7 @@ export type LoginResponse = {
codeRequested?: boolean;
emailVerificationRequired?: boolean;
useSecurityKey?: boolean;
twoFactorSetupRequired?: boolean;
};
export const dynamic = "force-dynamic";
@ -127,6 +128,17 @@ export async function login(
}
if (existingUser.twoFactorEnabled) {
// If 2FA is enabled but no secret exists, force setup
if (!existingUser.twoFactorSecret) {
return response<LoginResponse>(res, {
data: { twoFactorSetupRequired: true },
success: true,
error: false,
message: "Two-factor authentication setup required",
status: HttpCode.ACCEPTED
});
}
if (!code) {
return response<{ codeRequested: boolean }>(res, {
data: { codeRequested: true },
@ -139,7 +151,7 @@ export async function login(
const validOTP = await verifyTotpCode(
code,
existingUser.twoFactorSecret!,
existingUser.twoFactorSecret,
existingUser.userId
);

View file

@ -0,0 +1,127 @@
import { Request, Response, NextFunction } from "express";
import createHttpError from "http-errors";
import { z } from "zod";
import { fromError } from "zod-validation-error";
import { encodeHex } from "oslo/encoding";
import HttpCode from "@server/types/HttpCode";
import { response } from "@server/lib";
import { db } from "@server/db";
import { User, users } from "@server/db";
import { eq, and } from "drizzle-orm";
import { createTOTPKeyURI } from "oslo/otp";
import logger from "@server/logger";
import { verifyPassword } from "@server/auth/password";
import { UserType } from "@server/types/UserTypes";
export const setupTotpSecretBody = z
.object({
email: z.string().email(),
password: z.string()
})
.strict();
export type SetupTotpSecretBody = z.infer<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

@ -490,6 +490,13 @@ authenticated.put(
);
authenticated.get("/org/:orgId/user/:userId", verifyOrgAccess, user.getOrgUser);
authenticated.patch(
"/org/:orgId/user/:userId/2fa",
verifyOrgAccess,
verifyUserAccess,
verifyUserHasAction(ActionsEnum.getOrgUser),
user.updateUser2FA
);
authenticated.get(
"/org/:orgId/users",
@ -718,6 +725,8 @@ authRouter.post(
verifySessionUserMiddleware,
auth.requestTotpSecret
);
authRouter.post("/2fa/setup", auth.setupTotpSecret);
authRouter.post("/2fa/complete-setup", auth.completeTotpSetup);
authRouter.post("/2fa/disable", verifySessionUserMiddleware, auth.disable2fa);
authRouter.post("/verify-email", verifySessionMiddleware, auth.verifyEmail);

View file

@ -23,7 +23,8 @@ async function queryUser(orgId: string, userId: string) {
roleId: userOrgs.roleId,
roleName: roles.name,
isOwner: userOrgs.isOwner,
isAdmin: roles.isAdmin
isAdmin: roles.isAdmin,
twoFactorEnabled: users.twoFactorEnabled,
})
.from(userOrgs)
.leftJoin(roles, eq(userOrgs.roleId, roles.roleId))

View file

@ -10,3 +10,4 @@ export * from "./adminRemoveUser";
export * from "./listInvitations";
export * from "./removeInvitation";
export * from "./createOrgUser";
export * from "./updateUser2FA";

View file

@ -49,7 +49,8 @@ async function queryUsers(orgId: string, limit: number, offset: number) {
roleName: roles.name,
isOwner: userOrgs.isOwner,
idpName: idp.name,
idpId: users.idpId
idpId: users.idpId,
twoFactorEnabled: users.twoFactorEnabled,
})
.from(users)
.leftJoin(userOrgs, eq(users.userId, userOrgs.userId))

View file

@ -0,0 +1,151 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { users, userOrgs } from "@server/db";
import { eq, and } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions";
import { OpenAPITags, registry } from "@server/openApi";
const updateUser2FAParamsSchema = z
.object({
userId: z.string(),
orgId: z.string()
})
.strict();
const updateUser2FABodySchema = z
.object({
twoFactorEnabled: z.boolean()
})
.strict();
export type UpdateUser2FAResponse = {
userId: string;
twoFactorEnabled: boolean;
};
registry.registerPath({
method: "patch",
path: "/org/{orgId}/user/{userId}/2fa",
description: "Update a user's 2FA status within an organization.",
tags: [OpenAPITags.Org, OpenAPITags.User],
request: {
params: updateUser2FAParamsSchema,
body: {
content: {
"application/json": {
schema: updateUser2FABodySchema
}
}
}
},
responses: {}
});
export async function updateUser2FA(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = updateUser2FAParamsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const parsedBody = updateUser2FABodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { userId, orgId } = parsedParams.data;
const { twoFactorEnabled } = parsedBody.data;
if (!req.userOrg) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"You do not have access to this organization"
)
);
}
// Check if user has permission to update other users' 2FA
const hasPermission = await checkUserActionPermission(
ActionsEnum.getOrgUser,
req
);
if (!hasPermission) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"User does not have permission to update other users' 2FA settings"
)
);
}
// Verify the user exists in the organization
const existingUser = await db
.select()
.from(userOrgs)
.where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId)))
.limit(1);
if (existingUser.length === 0) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
"User not found or does not belong to the specified organization"
)
);
}
// Update the user's 2FA status
const updatedUser = await db
.update(users)
.set({
twoFactorEnabled,
// If disabling 2FA, also clear the secret
twoFactorSecret: twoFactorEnabled ? undefined : null
})
.where(eq(users.userId, userId))
.returning({ userId: users.userId, twoFactorEnabled: users.twoFactorEnabled });
if (updatedUser.length === 0) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
"User not found"
)
);
}
return response<UpdateUser2FAResponse>(res, {
data: updatedUser[0],
success: true,
error: false,
message: `2FA ${twoFactorEnabled ? 'enabled' : 'disabled'} for user successfully`,
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View file

@ -34,6 +34,7 @@ export type UserRow = {
status: string;
role: string;
isOwner: boolean;
isTwoFactorEnabled: boolean;
};
type UsersTableProps = {
@ -170,6 +171,39 @@ 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 }) => {

View file

@ -27,6 +27,7 @@ import { ListRolesResponse } from "@server/routers/role";
import { userOrgUserContext } from "@app/hooks/useOrgUserContext";
import { useParams } from "next/navigation";
import { Button } from "@app/components/ui/button";
import { Checkbox } from "@app/components/ui/checkbox";
import {
SettingsContainer,
SettingsSection,
@ -43,14 +44,14 @@ import { useEnvContext } from "@app/hooks/useEnvContext";
import { useTranslations } from "next-intl";
export default function AccessControlsPage() {
const { orgUser: user } = userOrgUserContext();
const { orgUser: user, updateOrgUser } = 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();
@ -96,7 +97,8 @@ export default function AccessControlsPage() {
async function onSubmit(values: z.infer<typeof formSchema>) {
setLoading(true);
const res = await api
// Update user role
const roleRes = await api
.post<
AxiosResponse<InviteUserResponse>
>(`/role/${values.roleId}/add/${user.userId}`)
@ -109,9 +111,34 @@ export default function AccessControlsPage() {
t('accessRoleErrorAddDescription')
)
});
return null;
});
if (res && res.status === 200) {
// Update 2FA status if it changed
if (enable2FA !== user.twoFactorEnabled) {
const twoFARes = await api
.patch(`/org/${orgId}/user/${user.userId}/2fa`, {
twoFactorEnabled: enable2FA
})
.catch((e) => {
toast({
variant: "destructive",
title: "Error updating 2FA",
description: formatAxiosError(
e,
"Failed to update 2FA status"
)
});
return null;
});
if (twoFARes && twoFARes.status === 200) {
// Update the user context with the new 2FA status
updateOrgUser({ twoFactorEnabled: enable2FA });
}
}
if (roleRes && roleRes.status === 200) {
toast({
variant: "default",
title: t('userSaved'),
@ -170,6 +197,36 @@ 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>
@ -186,6 +243,8 @@ export default function AccessControlsPage() {
</Button>
</SettingsSectionFooter>
</SettingsSection>
</SettingsContainer>
);
}

View file

@ -81,7 +81,8 @@ 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
isOwner: user.isOwner || false,
isTwoFactorEnabled: user.twoFactorEnabled || false,
};
});

View file

@ -0,0 +1,289 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { AlertCircle, CheckCircle2 } from "lucide-react";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { AxiosResponse } from "axios";
import {
RequestTotpSecretResponse,
VerifyTotpResponse
} from "@server/routers/auth";
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@app/components/ui/form";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from "@/components/ui/card";
import { toast } from "@app/hooks/useToast";
import { formatAxiosError } from "@app/lib/api";
import CopyTextBox from "@app/components/CopyTextBox";
import { QRCodeCanvas } from "qrcode.react";
import { useTranslations } from "next-intl";
export default function Setup2FAPage() {
const router = useRouter();
const searchParams = useSearchParams();
const redirect = searchParams?.get("redirect");
const email = searchParams?.get("email");
const [step, setStep] = useState(1);
const [secretKey, setSecretKey] = useState("");
const [secretUri, setSecretUri] = useState("");
const [loading, setLoading] = useState(false);
const [backupCodes, setBackupCodes] = useState<string[]>([]);
const api = createApiClient(useEnvContext());
const t = useTranslations();
// Redirect to login if no email is provided
useEffect(() => {
if (!email) {
router.push('/auth/login');
}
}, [email, router]);
const enableSchema = z.object({
password: z.string().min(1, { message: t('passwordRequired') })
});
const confirmSchema = z.object({
code: z.string().length(6, { message: t('pincodeInvalid') })
});
const enableForm = useForm<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 = () => {
if (redirect) {
router.push(redirect);
} else {
router.push("/");
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-background">
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>{t('otpSetup')}</CardTitle>
<CardDescription>
Your administrator has enabled two-factor authentication for <strong>{email}</strong>.
Please complete the setup process to continue.
</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}
/>
</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>
);
}

View file

@ -48,6 +48,10 @@ export default function LocaleSwitcher() {
{
value: 'zh-CN',
label: '简体中文'
},
{
value: 'ko-KR',
label: '한국어'
}
]}
/>

View file

@ -210,6 +210,12 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
return;
}
if (data?.twoFactorSetupRequired) {
const setupUrl = `/auth/2fa/setup?email=${encodeURIComponent(email)}${redirect ? `&redirect=${encodeURIComponent(redirect)}` : ''}`;
router.push(setupUrl);
return;
}
if (onLogin) {
await onLogin();
}

View file

@ -1,4 +1,4 @@
export type Locale = (typeof locales)[number];
export const locales = ['en-US', 'es-ES', 'fr-FR', 'de-DE', 'nl-NL', 'it-IT', 'pl-PL', 'pt-PT', 'tr-TR', 'zh-CN'] as const;
export const locales = ['en-US', 'es-ES', 'fr-FR', 'de-DE', 'nl-NL', 'it-IT', 'pl-PL', 'pt-PT', 'tr-TR', 'zh-CN', 'ko-KR'] as const;
export const defaultLocale: Locale = 'en-US';