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 ? (