From fd933e3dece0c187bcbfe71b04c86a9d81051510 Mon Sep 17 00:00:00 2001 From: Adrian Astles Date: Fri, 27 Jun 2025 18:55:04 +0800 Subject: [PATCH] Implemented a resouce landing page for members and Implemented basic user details (full name) and password reset via that is sent via SMTP or if SMTP is disabled will be shown to the admin to copy. --- messages/en-US.json | 42 +- server/db/pg/schema.ts | 3 +- server/db/sqlite/schema.ts | 3 +- server/routers/auth/signup.ts | 6 +- server/routers/external.ts | 26 + server/routers/org/index.ts | 1 + server/routers/org/updateOrgSecurity.ts | 119 ++++ server/routers/resource/getUserResources.ts | 160 ++++++ server/routers/resource/index.ts | 3 +- server/routers/user/adminResetUserPassword.ts | 234 ++++++++ server/routers/user/index.ts | 3 + server/routers/user/updateUser.ts | 159 +++++ server/setup/migrationsPg.ts | 4 +- server/setup/migrationsSqlite.ts | 4 +- server/setup/scriptsPg/1.7.0.ts | 21 + server/setup/scriptsSqlite/1.7.0.ts | 30 + src/app/[orgId]/MemberResourcesPortal.tsx | 543 ++++++++++++++++++ src/app/[orgId]/page.tsx | 79 ++- .../settings/access/users/UsersTable.tsx | 26 +- .../access/users/[userId]/details/page.tsx | 308 ++++++++++ .../settings/access/users/[userId]/layout.tsx | 6 +- .../settings/access/users/[userId]/page.tsx | 2 +- .../settings/access/users/create/page.tsx | 8 +- .../[orgId]/settings/access/users/page.tsx | 2 +- src/app/[orgId]/settings/general/layout.tsx | 6 +- src/app/[orgId]/settings/general/page.tsx | 16 +- .../settings/general/security/page.tsx | 141 +++++ src/app/admin/settings/general/page.tsx | 1 + .../auth/reset-password/ResetPasswordForm.tsx | 2 +- src/app/auth/signup/SignupForm.tsx | 20 +- src/app/navigation.tsx | 14 +- src/components/ProfileIcon.tsx | 6 +- 32 files changed, 1930 insertions(+), 68 deletions(-) create mode 100644 server/routers/org/updateOrgSecurity.ts create mode 100644 server/routers/resource/getUserResources.ts create mode 100644 server/routers/user/adminResetUserPassword.ts create mode 100644 server/routers/user/updateUser.ts create mode 100644 server/setup/scriptsPg/1.7.0.ts create mode 100644 server/setup/scriptsSqlite/1.7.0.ts create mode 100644 src/app/[orgId]/MemberResourcesPortal.tsx create mode 100644 src/app/[orgId]/settings/access/users/[userId]/details/page.tsx create mode 100644 src/app/[orgId]/settings/general/security/page.tsx create mode 100644 src/app/admin/settings/general/page.tsx diff --git a/messages/en-US.json b/messages/en-US.json index 4990774b..a9399c6e 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -444,13 +444,25 @@ "emailOptional": "Email (Optional)", "nameOptional": "Name (Optional)", "accessControls": "Access Controls", + "accessControlsSubmit": "Save Access Controls", "userDescription2": "Manage the settings on this user", "accessRoleErrorAdd": "Failed to add user to role", "accessRoleErrorAddDescription": "An error occurred while adding user to the role.", "userSaved": "User saved", "userSavedDescription": "The user has been updated.", + "userDetails": "Details", + "userDetailsTitle": "User Details", + "userDetailsDescription": "Edit the user's name and email address", + "userUpdated": "User updated", + "userUpdatedDescription": "The user's details have been updated successfully.", + "userErrorUpdate": "Failed to update user", + "userErrorUpdateDescription": "An error occurred while updating the user's details.", + "nameRequired": "Name is required", + "namePlaceholder": "Enter full name", + "emailPlaceholder": "Enter email address", + "saving": "Saving...", + "saveChanges": "Save Changes", "accessControlsDescription": "Manage what this user can access and do in the organization", - "accessControlsSubmit": "Save Access Controls", "roles": "Roles", "accessUsersRoles": "Manage Users & Roles", "accessUsersRolesDescription": "Invite users and add them to roles to manage access to your organization", @@ -1077,6 +1089,7 @@ "orgErrorNoProvided": "No org provided", "apiKeysErrorNoUpdate": "No API key to update", "sidebarOverview": "Overview", + "sidebarAccount": "Account", "sidebarHome": "Home", "sidebarSites": "Sites", "sidebarResources": "Resources", @@ -1132,5 +1145,30 @@ "initialSetupTitle": "Initial Server Setup", "initialSetupDescription": "Create the intial server admin account. Only one server admin can exist. You can always change these credentials later.", "createAdminAccount": "Create Admin Account", - "setupErrorCreateAdmin": "An error occurred while creating the server admin account." + "setupErrorCreateAdmin": "An error occurred while creating the server admin account.", + "passwordReset": "Password Reset", + "passwordResetAdminDescription": "Generate a password reset link for this user.", + "passwordResetAdminInstructions": "Clicking the button below will send a password reset email to the user. They will be able to set a new password using the link provided.", + "passwordResetSent": "Password Reset Sent", + "passwordResetSentDescription": "A password reset email has been sent to {email}.", + "passwordResetError": "Failed to Send Reset", + "passwordResetErrorDescription": "Unable to send password reset email. Please try again.", + "passwordResetSending": "Sending...", + "passwordResetSendEmail": "Send Reset Email", + "passwordResetUnavailable": "Password Reset Unavailable", + "passwordResetExternalUserDescription": "Password reset is only available for internal users. External users authenticate through their identity provider.", + "adminSettings": "Admin Settings", + "adminSettingsDescription": "Configure server-wide administration settings", + "security": "Security", + "tokenExpiration": "Token Expiration", + "tokenExpirationDescription": "Configure how long password reset tokens remain valid before expiring", + "passwordResetExpireLimit": "Password Reset Token Expiry", + "passwordResetExpireLimitDescription": "Set how many hours password reset tokens remain valid. Default is 1 hour.", + "hours": "hours", + "securitySettingsSaved": "Security Settings Saved", + "securitySettingsSavedDescription": "Your security settings have been updated successfully", + "securitySettingsUpdated": "Security Settings Updated", + "securitySettingsUpdatedDescription": "Your security settings have been updated successfully", + "securitySettingsError": "Security Settings Error", + "saveSecuritySettings": "Save Security Settings" } diff --git a/server/db/pg/schema.ts b/server/db/pg/schema.ts index cb641974..7702eced 100644 --- a/server/db/pg/schema.ts +++ b/server/db/pg/schema.ts @@ -17,7 +17,8 @@ export const domains = pgTable("domains", { export const orgs = pgTable("orgs", { orgId: varchar("orgId").primaryKey(), - name: varchar("name").notNull() + name: varchar("name").notNull(), + passwordResetTokenExpiryHours: integer("passwordResetTokenExpiryHours").notNull().default(1) }); export const orgDomains = pgTable("orgDomains", { diff --git a/server/db/sqlite/schema.ts b/server/db/sqlite/schema.ts index b587d1c7..4d2b80c6 100644 --- a/server/db/sqlite/schema.ts +++ b/server/db/sqlite/schema.ts @@ -11,7 +11,8 @@ export const domains = sqliteTable("domains", { export const orgs = sqliteTable("orgs", { orgId: text("orgId").primaryKey(), - name: text("name").notNull() + name: text("name").notNull(), + passwordResetTokenExpiryHours: integer("passwordResetTokenExpiryHours").notNull().default(1) }); export const orgDomains = sqliteTable("orgDomains", { diff --git a/server/routers/auth/signup.ts b/server/routers/auth/signup.ts index 0c7e926e..a248ff07 100644 --- a/server/routers/auth/signup.ts +++ b/server/routers/auth/signup.ts @@ -24,6 +24,7 @@ import { passwordSchema } from "@server/auth/passwordSchema"; import { UserType } from "@server/types/UserTypes"; export const signupBodySchema = z.object({ + name: z.string().optional(), email: z .string() .toLowerCase() @@ -55,9 +56,9 @@ export async function signup( ); } - const { email, password, inviteToken, inviteId } = parsedBody.data; + const { name, email, password, inviteToken, inviteId } = parsedBody.data; - logger.debug("signup", { email, password, inviteToken, inviteId }); + logger.debug("signup", { name, email, password, inviteToken, inviteId }); const passwordHash = await hashPassword(password); const userId = generateId(15); @@ -162,6 +163,7 @@ export async function signup( userId: userId, type: UserType.Internal, username: email, + name: name, email: email, passwordHash, dateCreated: moment().toISOString() diff --git a/server/routers/external.ts b/server/routers/external.ts index 8cb3a19d..c2b64bf8 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -67,6 +67,12 @@ authenticated.post( verifyUserHasAction(ActionsEnum.updateOrg), org.updateOrg ); +authenticated.put( + "/org/:orgId/security", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.updateOrg), + org.updateOrgSecurity +); authenticated.delete( "/org/:orgId", verifyOrgAccess, @@ -175,6 +181,12 @@ authenticated.get( resource.listResources ); +authenticated.get( + "/org/:orgId/user-resources", + verifyOrgAccess, + resource.getUserResources +); + authenticated.get( "/org/:orgId/domains", verifyOrgAccess, @@ -491,6 +503,20 @@ authenticated.put( authenticated.get("/org/:orgId/user/:userId", verifyOrgAccess, user.getOrgUser); +authenticated.post( + "/org/:orgId/user/:userId", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.getOrgUser), + user.updateUser +); + +authenticated.post( + "/org/:orgId/user/:userId/reset-password", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.getOrgUser), + user.adminResetUserPassword +); + authenticated.get( "/org/:orgId/users", verifyOrgAccess, diff --git a/server/routers/org/index.ts b/server/routers/org/index.ts index 5623823d..772f0cec 100644 --- a/server/routers/org/index.ts +++ b/server/routers/org/index.ts @@ -2,6 +2,7 @@ export * from "./getOrg"; export * from "./createOrg"; export * from "./deleteOrg"; export * from "./updateOrg"; +export * from "./updateOrgSecurity"; export * from "./listUserOrgs"; export * from "./checkId"; export * from "./getOrgOverview"; diff --git a/server/routers/org/updateOrgSecurity.ts b/server/routers/org/updateOrgSecurity.ts new file mode 100644 index 00000000..188b733e --- /dev/null +++ b/server/routers/org/updateOrgSecurity.ts @@ -0,0 +1,119 @@ +import { NextFunction, Request, Response } from "express"; +import { z } from "zod"; +import { fromError } from "zod-validation-error"; +import createHttpError from "http-errors"; +import { db } from "@server/db"; +import { orgs } from "@server/db"; +import { eq } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import { OpenAPITags, registry } from "@server/openApi"; +import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions"; +import logger from "@server/logger"; + +const updateOrgSecurityParamsSchema = z + .object({ + orgId: z.string() + }) + .strict(); + +const updateOrgSecurityBodySchema = z + .object({ + passwordResetTokenExpiryHours: z.number().min(1).max(24) + }) + .strict(); + +export type UpdateOrgSecurityBody = z.infer; +export type UpdateOrgSecurityResponse = { + success: boolean; +}; + +registry.registerPath({ + method: "put", + path: "/org/{orgId}/security", + description: "Update organization security settings", + tags: [OpenAPITags.Org], + request: { + params: updateOrgSecurityParamsSchema, + body: { + content: { + "application/json": { + schema: updateOrgSecurityBodySchema + } + } + } + }, + responses: {} +}); + +export async function updateOrgSecurity( + req: Request, + res: Response, + next: NextFunction +): Promise { + const parsedParams = updateOrgSecurityParamsSchema.safeParse(req.params); + + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const parsedBody = updateOrgSecurityBodySchema.safeParse(req.body); + + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { orgId } = parsedParams.data; + const { passwordResetTokenExpiryHours } = parsedBody.data; + + // Check if the requesting user has permission to update the org + const hasPermission = await checkUserActionPermission(ActionsEnum.updateOrg, req); + + if (!hasPermission) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Insufficient permissions to update organization security settings" + ) + ); + } + + try { + // Update the organization + await db + .update(orgs) + .set({ passwordResetTokenExpiryHours }) + .where(eq(orgs.orgId, orgId)); + + logger.info( + `Organization ${orgId} security settings updated by user ${req.user!.userId}. Password reset token expiry set to ${passwordResetTokenExpiryHours} hours.` + ); + + return response(res, { + data: { success: true }, + success: true, + error: false, + message: "Organization security settings updated successfully", + status: HttpCode.OK + }); + + } catch (e) { + logger.error("Failed to update organization security settings", e); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to update organization security settings" + ) + ); + } +} \ No newline at end of file diff --git a/server/routers/resource/getUserResources.ts b/server/routers/resource/getUserResources.ts new file mode 100644 index 00000000..87892f67 --- /dev/null +++ b/server/routers/resource/getUserResources.ts @@ -0,0 +1,160 @@ +import { Request, Response, NextFunction } from "express"; +import { db } from "@server/db"; +import { and, eq, or, inArray } from "drizzle-orm"; +import { + resources, + userResources, + roleResources, + userOrgs, + roles, + resourcePassword, + resourcePincode, + resourceWhitelist +} from "@server/db"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; +import { response } from "@server/lib/response"; + +export async function getUserResources( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const { orgId } = req.params; + const userId = req.user?.userId; + + if (!userId) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated") + ); + } + + // First get the user's role in the organization + const userOrgResult = await db + .select({ + roleId: userOrgs.roleId + }) + .from(userOrgs) + .where( + and( + eq(userOrgs.userId, userId), + eq(userOrgs.orgId, orgId) + ) + ) + .limit(1); + + if (userOrgResult.length === 0) { + return next( + createHttpError(HttpCode.FORBIDDEN, "User not in organization") + ); + } + + const userRoleId = userOrgResult[0].roleId; + + // Get resources accessible through direct assignment or role assignment + const directResourcesQuery = db + .select({ resourceId: userResources.resourceId }) + .from(userResources) + .where(eq(userResources.userId, userId)); + + const roleResourcesQuery = db + .select({ resourceId: roleResources.resourceId }) + .from(roleResources) + .where(eq(roleResources.roleId, userRoleId)); + + const [directResources, roleResourceResults] = await Promise.all([ + directResourcesQuery, + roleResourcesQuery + ]); + + // Combine all accessible resource IDs + const accessibleResourceIds = [ + ...directResources.map(r => r.resourceId), + ...roleResourceResults.map(r => r.resourceId) + ]; + + if (accessibleResourceIds.length === 0) { + return response(res, { + data: { resources: [] }, + success: true, + error: false, + message: "No resources found", + status: HttpCode.OK + }); + } + + // Get resource details for accessible resources + const resourcesData = await db + .select({ + resourceId: resources.resourceId, + name: resources.name, + fullDomain: resources.fullDomain, + ssl: resources.ssl, + enabled: resources.enabled, + sso: resources.sso, + protocol: resources.protocol, + emailWhitelistEnabled: resources.emailWhitelistEnabled + }) + .from(resources) + .where( + and( + inArray(resources.resourceId, accessibleResourceIds), + eq(resources.orgId, orgId), + eq(resources.enabled, true) + ) + ); + + // Check for password, pincode, and whitelist protection for each resource + const resourcesWithAuth = await Promise.all( + resourcesData.map(async (resource) => { + const [passwordCheck, pincodeCheck, whitelistCheck] = await Promise.all([ + db.select().from(resourcePassword).where(eq(resourcePassword.resourceId, resource.resourceId)).limit(1), + db.select().from(resourcePincode).where(eq(resourcePincode.resourceId, resource.resourceId)).limit(1), + db.select().from(resourceWhitelist).where(eq(resourceWhitelist.resourceId, resource.resourceId)).limit(1) + ]); + + const hasPassword = passwordCheck.length > 0; + const hasPincode = pincodeCheck.length > 0; + const hasWhitelist = whitelistCheck.length > 0 || resource.emailWhitelistEnabled; + + return { + resourceId: resource.resourceId, + name: resource.name, + domain: `${resource.ssl ? "https://" : "http://"}${resource.fullDomain}`, + enabled: resource.enabled, + protected: !!(resource.sso || hasPassword || hasPincode || hasWhitelist), + protocol: resource.protocol + }; + }) + ); + + return response(res, { + data: { resources: resourcesWithAuth }, + success: true, + error: false, + message: "User resources retrieved successfully", + status: HttpCode.OK + }); + + } catch (error) { + console.error("Error fetching user resources:", error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "Internal server error") + ); + } +} + +export type GetUserResourcesResponse = { + success: boolean; + data: { + resources: Array<{ + resourceId: number; + name: string; + domain: string; + enabled: boolean; + protected: boolean; + protocol: string; + }>; + }; +}; \ No newline at end of file diff --git a/server/routers/resource/index.ts b/server/routers/resource/index.ts index 03c9ffbe..f97fcdf4 100644 --- a/server/routers/resource/index.ts +++ b/server/routers/resource/index.ts @@ -21,4 +21,5 @@ export * from "./getExchangeToken"; export * from "./createResourceRule"; export * from "./deleteResourceRule"; export * from "./listResourceRules"; -export * from "./updateResourceRule"; \ No newline at end of file +export * from "./updateResourceRule"; +export * from "./getUserResources"; \ No newline at end of file diff --git a/server/routers/user/adminResetUserPassword.ts b/server/routers/user/adminResetUserPassword.ts new file mode 100644 index 00000000..72bd352b --- /dev/null +++ b/server/routers/user/adminResetUserPassword.ts @@ -0,0 +1,234 @@ +import { NextFunction, Request, Response } from "express"; +import { z } from "zod"; +import { fromError } from "zod-validation-error"; +import createHttpError from "http-errors"; +import { db } from "@server/db"; +import { passwordResetTokens, users, orgs } from "@server/db"; +import { eq, and } from "drizzle-orm"; +import { alphabet, generateRandomString } from "oslo/crypto"; +import { createDate, TimeSpan } from "oslo"; +import { hashPassword } from "@server/auth/password"; +import { sendEmail } from "@server/emails"; +import ResetPasswordCode from "@server/emails/templates/ResetPasswordCode"; +import config from "@server/lib/config"; +import logger from "@server/logger"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import { OpenAPITags, registry } from "@server/openApi"; +import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions"; +import { UserType } from "@server/types/UserTypes"; + +const adminResetUserPasswordParamsSchema = z + .object({ + orgId: z.string(), + userId: z.string() + }) + .strict(); + +const adminResetUserPasswordBodySchema = z + .object({ + sendEmail: z.boolean().optional().default(true) + }) + .strict(); + +export type AdminResetUserPasswordBody = z.infer; +export type AdminResetUserPasswordResponse = { + resetLink?: string; + emailSent: boolean; +}; + +registry.registerPath({ + method: "post", + path: "/org/{orgId}/user/{userId}/reset-password", + description: "Generate a password reset link for a user (admin only).", + tags: [OpenAPITags.User], + request: { + params: adminResetUserPasswordParamsSchema, + body: { + content: { + "application/json": { + schema: adminResetUserPasswordBodySchema + } + } + } + }, + responses: {} +}); + +export async function adminResetUserPassword( + req: Request, + res: Response, + next: NextFunction +): Promise { + const parsedParams = adminResetUserPasswordParamsSchema.safeParse(req.params); + + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const parsedBody = adminResetUserPasswordBodySchema.safeParse(req.body); + + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { orgId, userId } = parsedParams.data; + const { sendEmail: shouldSendEmail } = parsedBody.data; + + // Check if the requesting user has permission to manage users in this org + const hasPermission = await checkUserActionPermission(ActionsEnum.getOrgUser, req); + + if (!hasPermission) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Insufficient permissions to reset user passwords" + ) + ); + } + + try { + // Get the organization settings + const orgResult = await db + .select() + .from(orgs) + .where(eq(orgs.orgId, orgId)) + .limit(1); + + if (!orgResult || !orgResult.length) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "Organization not found" + ) + ); + } + + const org = orgResult[0]; + + // Get the target user + const targetUser = await db + .select() + .from(users) + .where(eq(users.userId, userId)); + + if (!targetUser || !targetUser.length) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "User not found" + ) + ); + } + + const user = targetUser[0]; + + // Only allow resetting passwords for internal users + if (user.type !== UserType.Internal) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Password reset is only available for internal users" + ) + ); + } + + if (!user.email) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "User does not have an email address" + ) + ); + } + + // Generate reset token + const token = generateRandomString(16, alphabet("0-9", "A-Z", "a-z")); + const tokenHash = await hashPassword(token); + + // Use organization's password reset token expiry setting + const expiryHours = org.passwordResetTokenExpiryHours || 1; + + // Store reset token in database + await db.transaction(async (trx) => { + // Delete any existing reset tokens for this user + await trx + .delete(passwordResetTokens) + .where(eq(passwordResetTokens.userId, userId)); + + // Insert new reset token + await trx.insert(passwordResetTokens).values({ + userId: userId, + email: user.email!, + tokenHash, + expiresAt: createDate(new TimeSpan(expiryHours, "h")).getTime() + }); + }); + + const resetUrl = `${config.getRawConfig().app.dashboard_url}/auth/reset-password?email=${encodeURIComponent(user.email!)}&token=${token}`; + + let emailSent = false; + + // Send email if requested + if (shouldSendEmail) { + try { + await sendEmail( + ResetPasswordCode({ + email: user.email!, + code: token, + link: resetUrl + }), + { + from: config.getNoReplyEmail(), + to: user.email!, + subject: "Password Reset - Initiated by Administrator" + } + ); + emailSent = true; + + logger.info( + `Admin ${req.user!.userId} initiated password reset for user ${userId} in org ${orgId}. Email sent to ${user.email}. Token expires in ${expiryHours} hours.` + ); + } catch (e) { + logger.error("Failed to send admin-initiated password reset email", e); + // Don't fail the request if email fails, just log it + } + } else { + logger.info( + `Admin ${req.user!.userId} generated password reset link for user ${userId} in org ${orgId}. No email sent. Token expires in ${expiryHours} hours.` + ); + } + + return response(res, { + data: { + resetLink: resetUrl, + emailSent + }, + success: true, + error: false, + message: emailSent + ? `Password reset email sent to ${user.email}` + : "Password reset link generated successfully", + status: HttpCode.OK + }); + + } catch (e) { + logger.error("Failed to generate admin password reset", e); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to generate password reset" + ) + ); + } +} \ No newline at end of file diff --git a/server/routers/user/index.ts b/server/routers/user/index.ts index 49278c14..d0d5242f 100644 --- a/server/routers/user/index.ts +++ b/server/routers/user/index.ts @@ -10,3 +10,6 @@ export * from "./adminRemoveUser"; export * from "./listInvitations"; export * from "./removeInvitation"; export * from "./createOrgUser"; +export { updateUser } from "./updateUser"; +export { adminResetUserPassword } from "./adminResetUserPassword"; +export type { AdminResetUserPasswordBody, AdminResetUserPasswordResponse } from "./adminResetUserPassword"; diff --git a/server/routers/user/updateUser.ts b/server/routers/user/updateUser.ts new file mode 100644 index 00000000..09a98c33 --- /dev/null +++ b/server/routers/user/updateUser.ts @@ -0,0 +1,159 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { 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 { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions"; + +const updateUserParamsSchema = z + .object({ + orgId: z.string(), + userId: z.string() + }) + .strict(); + +const updateUserBodySchema = z + .object({ + name: z.string().min(1).max(255).optional(), + email: z.string().email().optional() + }) + .strict() + .refine((data) => Object.keys(data).length > 0, { + message: "At least one field must be provided for update" + }); + +export type UpdateUserResponse = { + userId: string; + name: string | null; + email: string | null; + username: string; +}; + +registry.registerPath({ + method: "post", + path: "/org/{orgId}/user/{userId}", + description: "Update a user's details (name, email).", + tags: [OpenAPITags.User], + request: { + params: updateUserParamsSchema, + body: { + content: { + "application/json": { + schema: updateUserBodySchema + } + } + } + }, + responses: {} +}); + +export async function updateUser( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = updateUserParamsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const parsedBody = updateUserBodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { orgId, userId } = parsedParams.data; + const updateData = parsedBody.data; + + // Check if user has permission to update users + const hasPermission = await checkUserActionPermission( + ActionsEnum.getOrgUser, + req + ); + if (!hasPermission) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "User does not have permission to update user details" + ) + ); + } + + // Check if the user exists + const existingUser = await db + .select() + .from(users) + .where(eq(users.userId, userId)) + .limit(1); + + if (existingUser.length === 0) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `User with ID ${userId} not found` + ) + ); + } + + // Prevent updating server admin details unless requester is also server admin + if (existingUser[0].serverAdmin && !req.user?.serverAdmin) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Cannot update server admin details" + ) + ); + } + + // Update the user + const updatedUser = await db + .update(users) + .set(updateData) + .where(eq(users.userId, userId)) + .returning({ + userId: users.userId, + name: users.name, + email: users.email, + username: users.username + }); + + if (updatedUser.length === 0) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `User with ID ${userId} not found` + ) + ); + } + + return response(res, { + data: updatedUser[0], + success: true, + error: false, + message: "User updated successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} \ No newline at end of file diff --git a/server/setup/migrationsPg.ts b/server/setup/migrationsPg.ts index 1f88baa8..4a140e52 100644 --- a/server/setup/migrationsPg.ts +++ b/server/setup/migrationsPg.ts @@ -5,13 +5,15 @@ import { versionMigrations } from "../db/pg"; import { __DIRNAME, APP_VERSION } from "@server/lib/consts"; import path from "path"; import m1 from "./scriptsSqlite/1.6.0"; +import m2 from "./scriptsPg/1.7.0"; // THIS CANNOT IMPORT ANYTHING FROM THE SERVER // EXCEPT FOR THE DATABASE AND THE SCHEMA // Define the migration list with versions and their corresponding functions const migrations = [ - { version: "1.6.0", run: m1 } + { version: "1.6.0", run: m1 }, + { version: "1.7.0", run: m2 } // Add new migrations here as they are created ] as { version: string; diff --git a/server/setup/migrationsSqlite.ts b/server/setup/migrationsSqlite.ts index 1e279bae..bf2f1230 100644 --- a/server/setup/migrationsSqlite.ts +++ b/server/setup/migrationsSqlite.ts @@ -22,6 +22,7 @@ import m18 from "./scriptsSqlite/1.2.0"; import m19 from "./scriptsSqlite/1.3.0"; import m20 from "./scriptsSqlite/1.5.0"; import m21 from "./scriptsSqlite/1.6.0"; +import m22 from "./scriptsSqlite/1.7.0"; // THIS CANNOT IMPORT ANYTHING FROM THE SERVER // EXCEPT FOR THE DATABASE AND THE SCHEMA @@ -43,7 +44,8 @@ const migrations = [ { version: "1.2.0", run: m18 }, { version: "1.3.0", run: m19 }, { version: "1.5.0", run: m20 }, - { version: "1.6.0", run: m21 } + { version: "1.6.0", run: m21 }, + { version: "1.7.0", run: m22 } // Add new migrations here as they are created ] as const; diff --git a/server/setup/scriptsPg/1.7.0.ts b/server/setup/scriptsPg/1.7.0.ts new file mode 100644 index 00000000..432ab1c1 --- /dev/null +++ b/server/setup/scriptsPg/1.7.0.ts @@ -0,0 +1,21 @@ +import { db } from "@server/db/pg"; + +const version = "1.7.0"; + +export default async function migration() { + console.log(`Running PostgreSQL setup script ${version}...`); + + try { + // Add passwordResetTokenExpiryHours column to orgs table with default value of 1 + await db.execute(` + ALTER TABLE orgs ADD COLUMN passwordResetTokenExpiryHours INTEGER NOT NULL DEFAULT 1; + `); + console.log(`Added passwordResetTokenExpiryHours column to orgs table`); + } catch (e) { + console.log("Error adding passwordResetTokenExpiryHours column to orgs table:"); + console.log(e); + throw e; + } + + console.log(`${version} PostgreSQL migration complete`); +} \ No newline at end of file diff --git a/server/setup/scriptsSqlite/1.7.0.ts b/server/setup/scriptsSqlite/1.7.0.ts new file mode 100644 index 00000000..0f86e191 --- /dev/null +++ b/server/setup/scriptsSqlite/1.7.0.ts @@ -0,0 +1,30 @@ +import { APP_PATH } from "@server/lib/consts"; +import Database from "better-sqlite3"; +import path from "path"; + +const version = "1.7.0"; + +export default async function migration() { + console.log(`Running setup script ${version}...`); + + const location = path.join(APP_PATH, "db", "db.sqlite"); + const db = new Database(location); + + try { + db.pragma("foreign_keys = OFF"); + db.transaction(() => { + // Add passwordResetTokenExpiryHours column to orgs table with default value of 1 + db.exec(` + ALTER TABLE orgs ADD COLUMN passwordResetTokenExpiryHours INTEGER NOT NULL DEFAULT 1; + `); + })(); // <-- executes the transaction immediately + db.pragma("foreign_keys = ON"); + console.log(`Added passwordResetTokenExpiryHours column to orgs table`); + } catch (e) { + console.log("Error adding passwordResetTokenExpiryHours column to orgs table:"); + console.log(e); + throw e; + } + + console.log(`${version} migration complete`); +} \ No newline at end of file diff --git a/src/app/[orgId]/MemberResourcesPortal.tsx b/src/app/[orgId]/MemberResourcesPortal.tsx new file mode 100644 index 00000000..3c525e81 --- /dev/null +++ b/src/app/[orgId]/MemberResourcesPortal.tsx @@ -0,0 +1,543 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { useTranslations } from "next-intl"; +import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Input } from "@/components/ui/input"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { ExternalLink, Globe, ShieldCheck, Search, RefreshCw, AlertCircle, Plus, Shield, ShieldOff, ChevronLeft, ChevronRight } from "lucide-react"; +import { createApiClient } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { GetUserResourcesResponse } from "@server/routers/resource/getUserResources"; +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; + +type Resource = { + resourceId: number; + name: string; + domain: string; + enabled: boolean; + protected: boolean; + protocol: string; +}; + +type MemberResourcesPortalProps = { + orgId: string; +}; + +// Favicon component with fallback +const ResourceFavicon = ({ domain, enabled }: { domain: string; enabled: boolean }) => { + const [faviconError, setFaviconError] = useState(false); + const [faviconLoaded, setFaviconLoaded] = useState(false); + + // Extract domain for favicon URL + const cleanDomain = domain.replace(/^https?:\/\//, '').split('/')[0]; + const faviconUrl = `https://www.google.com/s2/favicons?domain=${cleanDomain}&sz=32`; + + const handleFaviconLoad = () => { + setFaviconLoaded(true); + setFaviconError(false); + }; + + const handleFaviconError = () => { + setFaviconError(true); + setFaviconLoaded(false); + }; + + if (faviconError || !enabled) { + return ; + } + + return ( +
+ {!faviconLoaded && ( +
+ )} + {`${cleanDomain} +
+ ); +}; + +// Enhanced status badge component +const StatusBadge = ({ enabled, protected: isProtected }: { enabled: boolean; protected: boolean }) => { + if (!enabled) { + return ( + +
+ Disabled +
+ ); + } + + if (isProtected) { + return ( + + + Protected + + ); + } + + return ( + + + Unprotected + + ); +}; + +// Pagination component +const PaginationControls = ({ + currentPage, + totalPages, + onPageChange, + totalItems, + itemsPerPage +}: { + currentPage: number; + totalPages: number; + onPageChange: (page: number) => void; + totalItems: number; + itemsPerPage: number; +}) => { + const startItem = (currentPage - 1) * itemsPerPage + 1; + const endItem = Math.min(currentPage * itemsPerPage, totalItems); + + if (totalPages <= 1) return null; + + return ( +
+
+ Showing {startItem}-{endItem} of {totalItems} resources +
+ +
+ + +
+ {Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => { + // Show first page, last page, current page, and 2 pages around current + const showPage = + page === 1 || + page === totalPages || + Math.abs(page - currentPage) <= 1; + + const showEllipsis = + (page === 2 && currentPage > 4) || + (page === totalPages - 1 && currentPage < totalPages - 3); + + if (!showPage && !showEllipsis) return null; + + if (showEllipsis) { + return ( + + ... + + ); + } + + return ( + + ); + })} +
+ + +
+
+ ); +}; + +// Loading skeleton component +const ResourceCardSkeleton = () => ( + + +
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+); + +export default function MemberResourcesPortal({ orgId }: MemberResourcesPortalProps) { + const t = useTranslations(); + const { env } = useEnvContext(); + const api = createApiClient({ env }); + + const [resources, setResources] = useState([]); + const [filteredResources, setFilteredResources] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [searchQuery, setSearchQuery] = useState(""); + const [sortBy, setSortBy] = useState("name-asc"); + const [refreshing, setRefreshing] = useState(false); + + // Pagination state + const [currentPage, setCurrentPage] = useState(1); + const itemsPerPage = 12; // 3x4 grid on desktop + + const fetchUserResources = async (isRefresh = false) => { + try { + if (isRefresh) { + setRefreshing(true); + } else { + setLoading(true); + } + setError(null); + + const response = await api.get( + `/org/${orgId}/user-resources` + ); + + if (response.data.success) { + setResources(response.data.data.resources); + setFilteredResources(response.data.data.resources); + } else { + setError("Failed to load resources"); + } + } catch (err) { + console.error("Error fetching user resources:", err); + setError("Failed to load resources. Please check your connection and try again."); + } finally { + setLoading(false); + setRefreshing(false); + } + }; + + useEffect(() => { + fetchUserResources(); + }, [orgId, api]); + + // Filter and sort resources + useEffect(() => { + let filtered = resources.filter(resource => + resource.name.toLowerCase().includes(searchQuery.toLowerCase()) || + resource.domain.toLowerCase().includes(searchQuery.toLowerCase()) + ); + + // Sort resources + filtered.sort((a, b) => { + switch (sortBy) { + case "name-asc": + return a.name.localeCompare(b.name); + case "name-desc": + return b.name.localeCompare(a.name); + case "domain-asc": + return a.domain.localeCompare(b.domain); + case "domain-desc": + return b.domain.localeCompare(a.domain); + case "status-enabled": + // Enabled first, then protected vs unprotected + if (a.enabled !== b.enabled) return b.enabled ? 1 : -1; + return b.protected ? 1 : -1; + case "status-disabled": + // Disabled first, then unprotected vs protected + if (a.enabled !== b.enabled) return a.enabled ? 1 : -1; + return a.protected ? 1 : -1; + default: + return a.name.localeCompare(b.name); + } + }); + + setFilteredResources(filtered); + + // Reset to first page when search/sort changes + setCurrentPage(1); + }, [resources, searchQuery, sortBy]); + + // Calculate pagination + const totalPages = Math.ceil(filteredResources.length / itemsPerPage); + const startIndex = (currentPage - 1) * itemsPerPage; + const paginatedResources = filteredResources.slice(startIndex, startIndex + itemsPerPage); + + const handleOpenResource = (resource: Resource) => { + // Open the resource in a new tab + window.open(resource.domain, '_blank'); + }; + + const handleRefresh = () => { + fetchUserResources(true); + }; + + const handleRetry = () => { + fetchUserResources(); + }; + + const handlePageChange = (page: number) => { + setCurrentPage(page); + // Scroll to top when page changes + window.scrollTo({ top: 0, behavior: 'smooth' }); + }; + + if (loading) { + return ( +
+ + + {/* Search and Sort Controls - Skeleton */} +
+
+
+
+
+
+
+
+ + {/* Loading Skeletons */} +
+ {Array.from({ length: 12 }).map((_, index) => ( + + ))} +
+
+ ); + } + + if (error) { + return ( +
+ + + +
+ +
+

+ Unable to Load Resources +

+

+ {error} +

+ +
+
+
+ ); + } + + return ( +
+ + + {/* Search and Sort Controls with Refresh */} +
+
+ {/* Search */} +
+ setSearchQuery(e.target.value)} + className="w-full pl-8" + /> + +
+ + {/* Sort */} +
+ +
+
+ + {/* Refresh Button */} + +
+ + {/* Resources Content */} + {filteredResources.length === 0 ? ( + /* Enhanced Empty State */ + + +
+ {searchQuery ? ( + + ) : ( + + )} +
+

+ {searchQuery ? "No Resources Found" : "No Resources Available"} +

+

+ {searchQuery + ? `No resources match "${searchQuery}". Try adjusting your search terms or clearing the search to see all resources.` + : "You don't have access to any resources yet. Contact your administrator to get access to resources you need." + } +

+
+ {searchQuery ? ( + + ) : ( + + )} +
+
+
+ ) : ( + <> + {/* Resources Grid */} +
+ {paginatedResources.map((resource) => ( + + +
+ + {resource.name} + + + Your Site + +
+
+ +
+ {/* Resource URL with Favicon */} +
+ + +
+ + {/* Enhanced Status Badge */} +
+ +
+
+ + {/* Open Resource Button */} +
+ +
+
+
+ ))} +
+ + {/* Pagination Controls */} + + + )} +
+ ); +} \ No newline at end of file diff --git a/src/app/[orgId]/page.tsx b/src/app/[orgId]/page.tsx index 5f91fb62..d3e60bd9 100644 --- a/src/app/[orgId]/page.tsx +++ b/src/app/[orgId]/page.tsx @@ -2,6 +2,7 @@ import { verifySession } from "@app/lib/auth/verifySession"; import UserProvider from "@app/providers/UserProvider"; import { cache } from "react"; import OrganizationLandingCard from "./OrganizationLandingCard"; +import MemberResourcesPortal from "./MemberResourcesPortal"; import { GetOrgOverviewResponse } from "@server/routers/org/getOrgOverview"; import { internal } from "@app/lib/api"; import { AxiosResponse } from "axios"; @@ -10,6 +11,8 @@ import { redirect } from "next/navigation"; import { Layout } from "@app/components/Layout"; import { orgLangingNavItems, orgNavItems, rootNavItems } from "../navigation"; import { ListUserOrgsResponse } from "@server/routers/org"; +import { pullEnv } from "@app/lib/pullEnv"; +import EnvProvider from "@app/providers/EnvProvider"; type OrgPageProps = { params: Promise<{ orgId: string }>; @@ -18,6 +21,7 @@ type OrgPageProps = { export default async function OrgPage(props: OrgPageProps) { const params = await props.params; const orgId = params.orgId; + const env = pullEnv(); const getUser = cache(verifySession); const user = await getUser(); @@ -26,7 +30,6 @@ export default async function OrgPage(props: OrgPageProps) { redirect("/"); } - let redirectToSettings = false; let overview: GetOrgOverviewResponse | undefined; try { const res = await internal.get>( @@ -34,16 +37,53 @@ export default async function OrgPage(props: OrgPageProps) { await authCookieHeader() ); overview = res.data.data; - - if (overview.isAdmin || overview.isOwner) { - redirectToSettings = true; - } } catch (e) {} - if (redirectToSettings) { - redirect(`/${orgId}/settings`); + // If user is admin or owner, show the admin landing card and potentially redirect + if (overview?.isAdmin || overview?.isOwner) { + let orgs: ListUserOrgsResponse["orgs"] = []; + try { + const getOrgs = cache(async () => + internal.get>( + `/user/${user.userId}/orgs`, + await authCookieHeader() + ) + ); + const res = await getOrgs(); + if (res && res.data.data.orgs) { + orgs = res.data.data.orgs; + } + } catch (e) {} + + return ( + + + + {overview && ( +
+ +
+ )} +
+
+
+ ); } + // For non-admin users, show the member resources portal let orgs: ListUserOrgsResponse["orgs"] = []; try { const getOrgs = cache(async () => @@ -60,26 +100,11 @@ export default async function OrgPage(props: OrgPageProps) { return ( - - {overview && ( -
- -
- )} -
+ + + + +
); } diff --git a/src/app/[orgId]/settings/access/users/UsersTable.tsx b/src/app/[orgId]/settings/access/users/UsersTable.tsx index d3ee404e..948b1749 100644 --- a/src/app/[orgId]/settings/access/users/UsersTable.tsx +++ b/src/app/[orgId]/settings/access/users/UsersTable.tsx @@ -110,6 +110,28 @@ export default function UsersTable({ users: u }: UsersTableProps) { ); } }, + { + accessorKey: "name", + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + const userRow = row.original; + return ( + {userRow.name || "-"} + ); + } + }, { accessorKey: "displayUsername", header: ({ column }) => { @@ -120,7 +142,7 @@ export default function UsersTable({ users: u }: UsersTableProps) { column.toggleSorting(column.getIsSorted() === "asc") } > - {t('username')} + {t('email')} ); @@ -276,4 +298,4 @@ export default function UsersTable({ users: u }: UsersTableProps) { /> ); -} +} \ No newline at end of file diff --git a/src/app/[orgId]/settings/access/users/[userId]/details/page.tsx b/src/app/[orgId]/settings/access/users/[userId]/details/page.tsx new file mode 100644 index 00000000..5bbf8053 --- /dev/null +++ b/src/app/[orgId]/settings/access/users/[userId]/details/page.tsx @@ -0,0 +1,308 @@ +"use client"; + +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage +} from "@app/components/ui/form"; +import { Input } from "@app/components/ui/input"; +import { toast } from "@app/hooks/useToast"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import { Button } from "@app/components/ui/button"; +import { + SettingsContainer, + SettingsSection, + SettingsSectionHeader, + SettingsSectionTitle, + SettingsSectionDescription, + SettingsSectionBody, + SettingsSectionForm, + SettingsSectionFooter +} from "@app/components/Settings"; +import { formatAxiosError } from "@app/lib/api"; +import { createApiClient } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { useTranslations } from "next-intl"; +import { userOrgUserContext } from "@app/hooks/useOrgUserContext"; +import { useParams, useRouter } from "next/navigation"; +import { AlertTriangle, Mail, InfoIcon, Copy, Link } from "lucide-react"; +import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; +import { Separator } from "@app/components/ui/separator"; + +export default function UserDetailsPage() { + const { orgUser: user } = userOrgUserContext(); + const api = createApiClient(useEnvContext()); + const { env } = useEnvContext(); + const { orgId } = useParams(); + const router = useRouter(); + const [loading, setLoading] = useState(false); + const [resetLoading, setResetLoading] = useState(false); + const [resetLink, setResetLink] = useState(null); + const t = useTranslations(); + + const formSchema = z.object({ + name: z.string().min(1, { message: t('nameRequired') }).max(255), + email: z.string().email({ message: t('emailInvalid') }) + }); + + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + name: user?.name || "", + email: user?.email || "" + } + }); + + async function onSubmit(values: z.infer) { + setLoading(true); + + try { + const res = await api.post(`/org/${orgId}/user/${user?.userId}`, { + name: values.name, + email: values.email + }); + + if (res.status === 200) { + toast({ + variant: "default", + title: t('userUpdated'), + description: t('userUpdatedDescription') + }); + router.refresh(); + } + } catch (e) { + toast({ + variant: "destructive", + title: t('userErrorUpdate'), + description: formatAxiosError( + e, + t('userErrorUpdateDescription') + ) + }); + } finally { + setLoading(false); + } + } + + async function onResetPassword() { + setResetLoading(true); + + try { + const res = await api.post(`/org/${orgId}/user/${user?.userId}/reset-password`, { + sendEmail: env.email.emailEnabled + }); + + if (res.status === 200) { + const responseData = res.data.data; + + if (env.email.emailEnabled) { + toast({ + variant: "default", + title: t('passwordResetSent'), + description: t('passwordResetSentDescription', { email: user?.email || "" }) + }); + setResetLink(null); + } else { + // Show the manual reset link when SMTP is not configured + setResetLink(responseData.resetLink); + toast({ + variant: "default", + title: t('passwordReset'), + description: "Password reset link generated successfully" + }); + } + } + } catch (e) { + toast({ + variant: "destructive", + title: t('passwordResetError'), + description: formatAxiosError( + e, + t('passwordResetErrorDescription') + ) + }); + } finally { + setResetLoading(false); + } + } + + async function copyToClipboard(text: string) { + try { + await navigator.clipboard.writeText(text); + toast({ + variant: "default", + title: "Copied!", + description: "Reset link copied to clipboard" + }); + } catch (e) { + toast({ + variant: "destructive", + title: "Copy failed", + description: "Failed to copy to clipboard. Please copy manually." + }); + } + } + + const isExternalUser = user?.type !== "internal"; + + return ( + + + + + {t('userDetailsTitle')} + + + {t('userDetailsDescription')} + + + + +
+ + ( + + {t('name')} + + + + + + )} + /> + ( + + {t('email')} + + + + + + )} + /> + + +
+
+ + + +
+ + + + + + + {t('passwordReset')} + + + {t('passwordResetAdminDescription')} + + + + {!env.email.emailEnabled && ( + + + + {t('otpEmailSmtpRequired')} + + + When SMTP is not configured, you'll receive a manual reset link to share with the user. + + + )} + {isExternalUser ? ( + + + {t('passwordResetUnavailable')} + + {t('passwordResetExternalUserDescription')} + + + ) : ( +
+

+ {env.email.emailEnabled + ? t('passwordResetAdminInstructions') + : "Click the button below to generate a password reset link that you can manually share with the user." + } +

+ + {resetLink && ( + + + Reset Link Generated + +
+

Share this link with the user to reset their password:

+
+ {resetLink} + +
+

+ This link will expire in 1 hour. +

+
+
+
+ )} +
+ )} +
+ + + +
+
+ ); +} \ No newline at end of file diff --git a/src/app/[orgId]/settings/access/users/[userId]/layout.tsx b/src/app/[orgId]/settings/access/users/[userId]/layout.tsx index 82fbba86..6b341c41 100644 --- a/src/app/[orgId]/settings/access/users/[userId]/layout.tsx +++ b/src/app/[orgId]/settings/access/users/[userId]/layout.tsx @@ -44,9 +44,13 @@ export default async function UserLayoutProps(props: UserLayoutProps) { } const navItems = [ + { + title: t('userDetails'), + href: `/${params.orgId}/settings/access/users/${params.userId}/details` + }, { title: t('accessControls'), - href: "/{orgId}/settings/access/users/{userId}/access-controls" + href: `/${params.orgId}/settings/access/users/${params.userId}/access-controls` } ]; diff --git a/src/app/[orgId]/settings/access/users/[userId]/page.tsx b/src/app/[orgId]/settings/access/users/[userId]/page.tsx index 04153728..79b3ab12 100644 --- a/src/app/[orgId]/settings/access/users/[userId]/page.tsx +++ b/src/app/[orgId]/settings/access/users/[userId]/page.tsx @@ -4,5 +4,5 @@ export default async function UserPage(props: { params: Promise<{ orgId: string; userId: string }>; }) { const { orgId, userId } = await props.params; - redirect(`/${orgId}/settings/access/users/${userId}/access-controls`); + redirect(`/${orgId}/settings/access/users/${userId}/details`); } diff --git a/src/app/[orgId]/settings/access/users/create/page.tsx b/src/app/[orgId]/settings/access/users/create/page.tsx index e4ea99fe..0804b22e 100644 --- a/src/app/[orgId]/settings/access/users/create/page.tsx +++ b/src/app/[orgId]/settings/access/users/create/page.tsx @@ -639,11 +639,11 @@ export default function Page() { control={ externalForm.control } - name="email" + name="name" render={({ field }) => ( - {t('emailOptional')} + {t('nameOptional')} ( - {t('nameOptional')} + {t('emailOptional')} 0) { const orgId = res.data.data.orgs[0].orgId; - // go to `/${orgId}/settings`); router.push(`/${orgId}/settings`); } else { - // go to `/setup` router.push("/setup"); } } diff --git a/src/app/[orgId]/settings/general/security/page.tsx b/src/app/[orgId]/settings/general/security/page.tsx new file mode 100644 index 00000000..43b0f932 --- /dev/null +++ b/src/app/[orgId]/settings/general/security/page.tsx @@ -0,0 +1,141 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { Button } from "@app/components/ui/button"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage +} from "@app/components/ui/form"; +import { Input } from "@app/components/ui/input"; +import { useOrgContext } from "@app/hooks/useOrgContext"; +import { useForm } from "react-hook-form"; +import { toast } from "@app/hooks/useToast"; +import { useState } from "react"; +import { + SettingsContainer, + SettingsSection, + SettingsSectionHeader, + SettingsSectionTitle, + SettingsSectionDescription, + SettingsSectionBody, + SettingsSectionForm, + SettingsSectionFooter +} from "@app/components/Settings"; +import { formatAxiosError } from "@app/lib/api"; +import { createApiClient } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { useTranslations } from 'next-intl'; + +const SecurityFormSchema = z.object({ + passwordResetTokenExpiryHours: z.coerce.number().min(1).max(24) +}); + +type SecurityFormValues = z.infer; + +export default function SecuritySettingsPage() { + const { org } = useOrgContext(); + const t = useTranslations(); + const api = createApiClient(useEnvContext()); + const [loadingSave, setLoadingSave] = useState(false); + + const form = useForm({ + resolver: zodResolver(SecurityFormSchema), + defaultValues: { + passwordResetTokenExpiryHours: org?.org.passwordResetTokenExpiryHours || 1 + }, + mode: "onChange" + }); + + async function onSubmit(data: SecurityFormValues) { + setLoadingSave(true); + try { + await api.put(`/org/${org?.org.orgId}/security`, { + passwordResetTokenExpiryHours: data.passwordResetTokenExpiryHours + }); + + toast({ + title: t('securitySettingsUpdated'), + description: t('securitySettingsUpdatedDescription') + }); + } catch (err) { + toast({ + variant: "destructive", + title: t('securitySettingsError'), + description: formatAxiosError(err, t('securitySettingsErrorMessage')) + }); + } finally { + setLoadingSave(false); + } + } + + return ( + + {/* Security Settings Content */} + + + {t('tokenExpiration')} + + {t('tokenExpirationDescription')} + + + + + +
+ + ( + + {t('passwordResetExpireLimit')} + +
+ + + {t('hours')} + +
+
+ + + {t('passwordResetExpireLimitDescription')} + +
+ )} + /> + + +
+
+ + + + +
+
+ ); +} \ No newline at end of file diff --git a/src/app/admin/settings/general/page.tsx b/src/app/admin/settings/general/page.tsx new file mode 100644 index 00000000..0519ecba --- /dev/null +++ b/src/app/admin/settings/general/page.tsx @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/app/auth/reset-password/ResetPasswordForm.tsx b/src/app/auth/reset-password/ResetPasswordForm.tsx index 8262c738..4869cb5b 100644 --- a/src/app/auth/reset-password/ResetPasswordForm.tsx +++ b/src/app/auth/reset-password/ResetPasswordForm.tsx @@ -191,7 +191,7 @@ export default function ResetPasswordForm({ const safe = cleanRedirect(redirect); router.push(safe); } else { - router.push("/login"); + router.push("/auth/login"); } setIsSubmitting(false); }, 1500); diff --git a/src/app/auth/signup/SignupForm.tsx b/src/app/auth/signup/SignupForm.tsx index bd693180..9ae8450e 100644 --- a/src/app/auth/signup/SignupForm.tsx +++ b/src/app/auth/signup/SignupForm.tsx @@ -41,6 +41,7 @@ type SignupFormProps = { const formSchema = z .object({ + name: z.string().optional(), email: z.string().email({ message: "Invalid email address" }), password: passwordSchema, confirmPassword: passwordSchema @@ -65,6 +66,7 @@ export default function SignupForm({ const form = useForm>({ resolver: zodResolver(formSchema), defaultValues: { + name: "", email: "", password: "", confirmPassword: "" @@ -74,11 +76,12 @@ export default function SignupForm({ const t = useTranslations(); async function onSubmit(values: z.infer) { - const { email, password } = values; + const { name, email, password } = values; setLoading(true); const res = await api .put>("/auth/signup", { + name, email, password, inviteId, @@ -108,7 +111,7 @@ export default function SignupForm({ const safe = cleanRedirect(redirect); router.push(safe); } else { - router.push("/"); + router.push("/setup"); } } @@ -141,6 +144,19 @@ export default function SignupForm({ onSubmit={form.handleSubmit(onSubmit)} className="space-y-4" > + ( + + {t('name')} + + + + + + )} + /> + icon: , + autoExpand: true, + children: [ + { + title: "sidebarResources", + href: "/{orgId}" + } + ] } ]; diff --git a/src/components/ProfileIcon.tsx b/src/components/ProfileIcon.tsx index eab2f51d..e2871fc7 100644 --- a/src/components/ProfileIcon.tsx +++ b/src/components/ProfileIcon.tsx @@ -44,7 +44,7 @@ export default function ProfileIcon() { const t = useTranslations(); function getInitials() { - return (user.email || user.name || user.username) + return (user.name || user.email || user.username) .substring(0, 1) .toUpperCase(); } @@ -76,7 +76,7 @@ export default function ProfileIcon() {
- {user.email || user.name || user.username} + {user.name || user.email || user.username} @@ -100,7 +100,7 @@ export default function ProfileIcon() { {t('signingAs')}

- {user.email || user.name || user.username} + {user.name || user.email || user.username}

{user.serverAdmin ? (