mirror of
https://github.com/fosrl/pangolin.git
synced 2025-08-22 18:29:19 +02:00
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.
This commit is contained in:
parent
2ead5f4506
commit
fd933e3dec
32 changed files with 1930 additions and 68 deletions
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -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", {
|
||||
|
|
|
@ -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", {
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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";
|
||||
|
|
119
server/routers/org/updateOrgSecurity.ts
Normal file
119
server/routers/org/updateOrgSecurity.ts
Normal file
|
@ -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<typeof updateOrgSecurityBodySchema>;
|
||||
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<any> {
|
||||
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<UpdateOrgSecurityResponse>(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"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
160
server/routers/resource/getUserResources.ts
Normal file
160
server/routers/resource/getUserResources.ts
Normal file
|
@ -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<any> {
|
||||
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;
|
||||
}>;
|
||||
};
|
||||
};
|
|
@ -21,4 +21,5 @@ export * from "./getExchangeToken";
|
|||
export * from "./createResourceRule";
|
||||
export * from "./deleteResourceRule";
|
||||
export * from "./listResourceRules";
|
||||
export * from "./updateResourceRule";
|
||||
export * from "./updateResourceRule";
|
||||
export * from "./getUserResources";
|
234
server/routers/user/adminResetUserPassword.ts
Normal file
234
server/routers/user/adminResetUserPassword.ts
Normal file
|
@ -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<typeof adminResetUserPasswordBodySchema>;
|
||||
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<any> {
|
||||
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<AdminResetUserPasswordResponse>(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"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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";
|
||||
|
|
159
server/routers/user/updateUser.ts
Normal file
159
server/routers/user/updateUser.ts
Normal file
|
@ -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<any> {
|
||||
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<UpdateUserResponse>(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")
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
21
server/setup/scriptsPg/1.7.0.ts
Normal file
21
server/setup/scriptsPg/1.7.0.ts
Normal file
|
@ -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`);
|
||||
}
|
30
server/setup/scriptsSqlite/1.7.0.ts
Normal file
30
server/setup/scriptsSqlite/1.7.0.ts
Normal file
|
@ -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`);
|
||||
}
|
543
src/app/[orgId]/MemberResourcesPortal.tsx
Normal file
543
src/app/[orgId]/MemberResourcesPortal.tsx
Normal file
|
@ -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 <Globe className="h-4 w-4 text-muted-foreground flex-shrink-0" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative h-4 w-4 flex-shrink-0">
|
||||
{!faviconLoaded && (
|
||||
<div className="absolute inset-0 bg-muted animate-pulse rounded-sm"></div>
|
||||
)}
|
||||
<img
|
||||
src={faviconUrl}
|
||||
alt={`${cleanDomain} favicon`}
|
||||
className={`h-4 w-4 rounded-sm transition-opacity ${faviconLoaded ? 'opacity-100' : 'opacity-0'}`}
|
||||
onLoad={handleFaviconLoad}
|
||||
onError={handleFaviconError}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Enhanced status badge component
|
||||
const StatusBadge = ({ enabled, protected: isProtected }: { enabled: boolean; protected: boolean }) => {
|
||||
if (!enabled) {
|
||||
return (
|
||||
<Badge variant="secondary" className="gap-1.5 bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 border-gray-200 dark:border-gray-700">
|
||||
<div className="h-2 w-2 bg-gray-400 dark:bg-gray-500 rounded-full"></div>
|
||||
Disabled
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
if (isProtected) {
|
||||
return (
|
||||
<Badge variant="secondary" className="gap-1.5 bg-green-50 dark:bg-green-950 text-green-700 dark:text-green-300 border-green-200 dark:border-green-800">
|
||||
<ShieldCheck className="h-3 w-3" />
|
||||
Protected
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge variant="secondary" className="gap-1.5 bg-orange-50 dark:bg-orange-950 text-orange-700 dark:text-orange-300 border-orange-200 dark:border-orange-800">
|
||||
<ShieldOff className="h-3 w-3" />
|
||||
Unprotected
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
// 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 (
|
||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-4 mt-8">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Showing {startItem}-{endItem} of {totalItems} resources
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onPageChange(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
className="gap-1"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
Previous
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{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 (
|
||||
<span key={page} className="px-2 text-muted-foreground">
|
||||
...
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={page}
|
||||
variant={currentPage === page ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => onPageChange(page)}
|
||||
className="w-8 h-8 p-0"
|
||||
>
|
||||
{page}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onPageChange(currentPage + 1)}
|
||||
disabled={currentPage === totalPages}
|
||||
className="gap-1"
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Loading skeleton component
|
||||
const ResourceCardSkeleton = () => (
|
||||
<Card className="rounded-lg bg-card text-card-foreground border-2 flex flex-col w-full animate-pulse">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="h-6 bg-muted rounded w-3/4"></div>
|
||||
<div className="h-5 bg-muted rounded w-16"></div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="px-6 pb-6 flex-1 flex flex-col justify-between">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="h-4 w-4 bg-muted rounded"></div>
|
||||
<div className="h-4 bg-muted rounded w-1/2"></div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="h-4 w-4 bg-muted rounded"></div>
|
||||
<div className="h-4 bg-muted rounded w-1/3"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<div className="h-8 bg-muted rounded w-full"></div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
export default function MemberResourcesPortal({ orgId }: MemberResourcesPortalProps) {
|
||||
const t = useTranslations();
|
||||
const { env } = useEnvContext();
|
||||
const api = createApiClient({ env });
|
||||
|
||||
const [resources, setResources] = useState<Resource[]>([]);
|
||||
const [filteredResources, setFilteredResources] = useState<Resource[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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<GetUserResourcesResponse>(
|
||||
`/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 (
|
||||
<div className="container mx-auto max-w-12xl">
|
||||
<SettingsSectionTitle
|
||||
title="Resources"
|
||||
description="Resources you have access to in this organization"
|
||||
/>
|
||||
|
||||
{/* Search and Sort Controls - Skeleton */}
|
||||
<div className="mb-6 flex flex-col sm:flex-row gap-4 justify-start">
|
||||
<div className="relative w-full sm:w-80">
|
||||
<div className="h-10 bg-muted rounded animate-pulse"></div>
|
||||
</div>
|
||||
<div className="w-full sm:w-36">
|
||||
<div className="h-10 bg-muted rounded animate-pulse"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Loading Skeletons */}
|
||||
<div className="grid gap-4 sm:gap-6 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 auto-cols-fr">
|
||||
{Array.from({ length: 12 }).map((_, index) => (
|
||||
<ResourceCardSkeleton key={index} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="container mx-auto max-w-12xl">
|
||||
<SettingsSectionTitle
|
||||
title="Resources"
|
||||
description="Resources you have access to in this organization"
|
||||
/>
|
||||
<Card className="border-destructive/50 bg-destructive/5 dark:bg-destructive/10">
|
||||
<CardContent className="flex flex-col items-center justify-center py-20 text-center">
|
||||
<div className="mb-6">
|
||||
<AlertCircle className="h-16 w-16 text-destructive/60" />
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-foreground mb-3">
|
||||
Unable to Load Resources
|
||||
</h3>
|
||||
<p className="text-muted-foreground max-w-lg text-base mb-6">
|
||||
{error}
|
||||
</p>
|
||||
<Button
|
||||
onClick={handleRetry}
|
||||
variant="outline"
|
||||
className="gap-2"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
Try Again
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto max-w-12xl">
|
||||
<SettingsSectionTitle
|
||||
title="Resources"
|
||||
description="Resources you have access to in this organization"
|
||||
/>
|
||||
|
||||
{/* Search and Sort Controls with Refresh */}
|
||||
<div className="mb-6 flex flex-col sm:flex-row gap-4 justify-between items-start">
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-start flex-1">
|
||||
{/* Search */}
|
||||
<div className="relative w-full sm:w-80">
|
||||
<Input
|
||||
placeholder="Search resources..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-8"
|
||||
/>
|
||||
<Search className="h-4 w-4 absolute left-2 top-1/2 transform -translate-y-1/2 text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
{/* Sort */}
|
||||
<div className="w-full sm:w-36">
|
||||
<Select value={sortBy} onValueChange={setSortBy}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Sort by..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="name-asc">Name A-Z</SelectItem>
|
||||
<SelectItem value="name-desc">Name Z-A</SelectItem>
|
||||
<SelectItem value="domain-asc">Domain A-Z</SelectItem>
|
||||
<SelectItem value="domain-desc">Domain Z-A</SelectItem>
|
||||
<SelectItem value="status-enabled">Enabled First</SelectItem>
|
||||
<SelectItem value="status-disabled">Disabled First</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Refresh Button */}
|
||||
<Button
|
||||
onClick={handleRefresh}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={refreshing}
|
||||
className="gap-2 shrink-0"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Resources Content */}
|
||||
{filteredResources.length === 0 ? (
|
||||
/* Enhanced Empty State */
|
||||
<Card className="border-muted/50 bg-muted/5 dark:bg-muted/10">
|
||||
<CardContent className="flex flex-col items-center justify-center py-20 text-center">
|
||||
<div className="mb-8 p-4 rounded-full bg-muted/20 dark:bg-muted/30">
|
||||
{searchQuery ? (
|
||||
<Search className="h-12 w-12 text-muted-foreground/70" />
|
||||
) : (
|
||||
<Globe className="h-12 w-12 text-muted-foreground/70" />
|
||||
)}
|
||||
</div>
|
||||
<h3 className="text-2xl font-semibold text-foreground mb-3">
|
||||
{searchQuery ? "No Resources Found" : "No Resources Available"}
|
||||
</h3>
|
||||
<p className="text-muted-foreground max-w-lg text-base mb-6">
|
||||
{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."
|
||||
}
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
{searchQuery ? (
|
||||
<Button
|
||||
onClick={() => setSearchQuery("")}
|
||||
variant="outline"
|
||||
className="gap-2"
|
||||
>
|
||||
Clear Search
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={handleRefresh}
|
||||
variant="outline"
|
||||
disabled={refreshing}
|
||||
className="gap-2"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} />
|
||||
Refresh Resources
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
{/* Resources Grid */}
|
||||
<div className="grid gap-4 sm:gap-6 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 auto-cols-fr">
|
||||
{paginatedResources.map((resource) => (
|
||||
<Card key={resource.resourceId} className="rounded-lg bg-card text-card-foreground hover:shadow-lg transition-all duration-200 border-2 hover:border-primary/20 dark:hover:border-primary/30 flex flex-col w-full group">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<CardTitle className="text-lg font-bold text-foreground truncate mr-2 group-hover:text-primary transition-colors">
|
||||
{resource.name}
|
||||
</CardTitle>
|
||||
<Badge variant="secondary" className="text-xs shrink-0 bg-muted/60 dark:bg-muted/80 text-muted-foreground">
|
||||
Your Site
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="px-6 pb-6 flex-1 flex flex-col justify-between">
|
||||
<div className="space-y-4">
|
||||
{/* Resource URL with Favicon */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<ResourceFavicon domain={resource.domain} enabled={resource.enabled} />
|
||||
<button
|
||||
onClick={() => handleOpenResource(resource)}
|
||||
className="text-sm text-blue-500 dark:text-blue-400 font-medium hover:underline text-left truncate transition-colors hover:text-blue-600 dark:hover:text-blue-300"
|
||||
disabled={!resource.enabled}
|
||||
>
|
||||
{resource.domain.replace(/^https?:\/\//, '')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Enhanced Status Badge */}
|
||||
<div className="flex items-center">
|
||||
<StatusBadge enabled={resource.enabled} protected={resource.protected} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Open Resource Button */}
|
||||
<div className="mt-4">
|
||||
<Button
|
||||
onClick={() => handleOpenResource(resource)}
|
||||
className="w-full h-8 transition-all group-hover:shadow-sm"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!resource.enabled}
|
||||
>
|
||||
<ExternalLink className="h-3 w-3 mr-2" />
|
||||
Open Resource
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Pagination Controls */}
|
||||
<PaginationControls
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={handlePageChange}
|
||||
totalItems={filteredResources.length}
|
||||
itemsPerPage={itemsPerPage}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -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<AxiosResponse<GetOrgOverviewResponse>>(
|
||||
|
@ -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<AxiosResponse<ListUserOrgsResponse>>(
|
||||
`/user/${user.userId}/orgs`,
|
||||
await authCookieHeader()
|
||||
)
|
||||
);
|
||||
const res = await getOrgs();
|
||||
if (res && res.data.data.orgs) {
|
||||
orgs = res.data.data.orgs;
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
return (
|
||||
<UserProvider user={user}>
|
||||
<EnvProvider env={env}>
|
||||
<Layout orgId={orgId} navItems={orgLangingNavItems} orgs={orgs}>
|
||||
{overview && (
|
||||
<div className="w-full max-w-4xl mx-auto md:mt-32 mt-4">
|
||||
<OrganizationLandingCard
|
||||
overview={{
|
||||
orgId: overview.orgId,
|
||||
orgName: overview.orgName,
|
||||
stats: {
|
||||
users: overview.numUsers,
|
||||
sites: overview.numSites,
|
||||
resources: overview.numResources
|
||||
},
|
||||
isAdmin: overview.isAdmin,
|
||||
isOwner: overview.isOwner,
|
||||
userRole: overview.userRoleName
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Layout>
|
||||
</EnvProvider>
|
||||
</UserProvider>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<UserProvider user={user}>
|
||||
<Layout orgId={orgId} navItems={orgLangingNavItems} orgs={orgs}>
|
||||
{overview && (
|
||||
<div className="w-full max-w-4xl mx-auto md:mt-32 mt-4">
|
||||
<OrganizationLandingCard
|
||||
overview={{
|
||||
orgId: overview.orgId,
|
||||
orgName: overview.orgName,
|
||||
stats: {
|
||||
users: overview.numUsers,
|
||||
sites: overview.numSites,
|
||||
resources: overview.numResources
|
||||
},
|
||||
isAdmin: overview.isAdmin,
|
||||
isOwner: overview.isOwner,
|
||||
userRole: overview.userRoleName
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Layout>
|
||||
<EnvProvider env={env}>
|
||||
<Layout orgId={orgId} navItems={orgLangingNavItems} orgs={orgs}>
|
||||
<MemberResourcesPortal orgId={orgId} />
|
||||
</Layout>
|
||||
</EnvProvider>
|
||||
</UserProvider>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -110,6 +110,28 @@ export default function UsersTable({ users: u }: UsersTableProps) {
|
|||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
{t('name')}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const userRow = row.original;
|
||||
return (
|
||||
<span>{userRow.name || "-"}</span>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "displayUsername",
|
||||
header: ({ column }) => {
|
||||
|
@ -120,7 +142,7 @@ export default function UsersTable({ users: u }: UsersTableProps) {
|
|||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
{t('username')}
|
||||
{t('email')}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
|
@ -276,4 +298,4 @@ export default function UsersTable({ users: u }: UsersTableProps) {
|
|||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
308
src/app/[orgId]/settings/access/users/[userId]/details/page.tsx
Normal file
308
src/app/[orgId]/settings/access/users/[userId]/details/page.tsx
Normal file
|
@ -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<string | null>(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<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
name: user?.name || "",
|
||||
email: user?.email || ""
|
||||
}
|
||||
});
|
||||
|
||||
async function onSubmit(values: z.infer<typeof formSchema>) {
|
||||
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 (
|
||||
<SettingsContainer>
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t('userDetailsTitle')}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t('userDetailsDescription')}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('name')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t('namePlaceholder')}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('email')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="email"
|
||||
placeholder={t('emailPlaceholder')}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
<SettingsSectionFooter>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
onClick={form.handleSubmit(onSubmit)}
|
||||
>
|
||||
{loading ? t('saving') : t('saveChanges')}
|
||||
</Button>
|
||||
</SettingsSectionFooter>
|
||||
</SettingsSection>
|
||||
|
||||
<Separator className="my-6" />
|
||||
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t('passwordReset')}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t('passwordResetAdminDescription')}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
{!env.email.emailEnabled && (
|
||||
<Alert variant="neutral" className="mb-4">
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
<AlertTitle className="font-semibold">
|
||||
{t('otpEmailSmtpRequired')}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
When SMTP is not configured, you'll receive a manual reset link to share with the user.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{isExternalUser ? (
|
||||
<Alert>
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertTitle>{t('passwordResetUnavailable')}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t('passwordResetExternalUserDescription')}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{env.email.emailEnabled
|
||||
? t('passwordResetAdminInstructions')
|
||||
: "Click the button below to generate a password reset link that you can manually share with the user."
|
||||
}
|
||||
</p>
|
||||
|
||||
{resetLink && (
|
||||
<Alert className="border-green-200 bg-green-50">
|
||||
<Link className="h-4 w-4 text-green-600" />
|
||||
<AlertTitle className="text-green-800">Reset Link Generated</AlertTitle>
|
||||
<AlertDescription className="text-green-700">
|
||||
<div className="mt-2 space-y-2">
|
||||
<p className="text-sm">Share this link with the user to reset their password:</p>
|
||||
<div className="flex items-center gap-2 p-2 bg-white border rounded border-green-200">
|
||||
<code className="flex-1 text-xs break-all">{resetLink}</code>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => copyToClipboard(resetLink)}
|
||||
className="shrink-0"
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-green-600">
|
||||
This link will expire in 1 hour.
|
||||
</p>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</SettingsSectionBody>
|
||||
<SettingsSectionFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={resetLoading || isExternalUser}
|
||||
onClick={onResetPassword}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
{env.email.emailEnabled ? (
|
||||
<Mail className="h-4 w-4" />
|
||||
) : (
|
||||
<Link className="h-4 w-4" />
|
||||
)}
|
||||
{resetLoading
|
||||
? t('passwordResetSending')
|
||||
: env.email.emailEnabled
|
||||
? t('passwordResetSendEmail')
|
||||
: "Generate Reset Link"
|
||||
}
|
||||
</Button>
|
||||
</SettingsSectionFooter>
|
||||
</SettingsSection>
|
||||
</SettingsContainer>
|
||||
);
|
||||
}
|
|
@ -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`
|
||||
}
|
||||
];
|
||||
|
||||
|
|
|
@ -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`);
|
||||
}
|
||||
|
|
|
@ -639,11 +639,11 @@ export default function Page() {
|
|||
control={
|
||||
externalForm.control
|
||||
}
|
||||
name="email"
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t('emailOptional')}
|
||||
{t('nameOptional')}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
|
@ -659,11 +659,11 @@ export default function Page() {
|
|||
control={
|
||||
externalForm.control
|
||||
}
|
||||
name="name"
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t('nameOptional')}
|
||||
{t('emailOptional')}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
|
|
|
@ -73,7 +73,7 @@ export default async function UsersPage(props: UsersPageProps) {
|
|||
return {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
displayUsername: user.email || user.name || user.username,
|
||||
displayUsername: user.email || user.username,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
type: user.type,
|
||||
|
|
|
@ -63,7 +63,11 @@ export default async function GeneralSettingsPage({
|
|||
const navItems = [
|
||||
{
|
||||
title: t('general'),
|
||||
href: `/{orgId}/settings/general`,
|
||||
href: `/${orgId}/settings/general`,
|
||||
},
|
||||
{
|
||||
title: t('security'),
|
||||
href: `/${orgId}/settings/general/security`,
|
||||
},
|
||||
];
|
||||
|
||||
|
|
|
@ -22,17 +22,6 @@ import { zodResolver } from "@hookform/resolvers/zod";
|
|||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { formatAxiosError } from "@app/lib/api";
|
||||
import { AlertTriangle, Trash2 } from "lucide-react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle
|
||||
} from "@/components/ui/card";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { DeleteOrgResponse, ListUserOrgsResponse } from "@server/routers/org";
|
||||
import { redirect, useRouter } from "next/navigation";
|
||||
import {
|
||||
SettingsContainer,
|
||||
SettingsSection,
|
||||
|
@ -45,6 +34,9 @@ import {
|
|||
} from "@app/components/Settings";
|
||||
import { useUserContext } from "@app/hooks/useUserContext";
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { AxiosResponse } from "axios";
|
||||
import { DeleteOrgResponse, ListUserOrgsResponse } from "@server/routers/org";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
const GeneralFormSchema = z.object({
|
||||
name: z.string()
|
||||
|
@ -109,10 +101,8 @@ export default function GeneralPage() {
|
|||
if (res.status === 200) {
|
||||
if (res.data.data.orgs.length > 0) {
|
||||
const orgId = res.data.data.orgs[0].orgId;
|
||||
// go to `/${orgId}/settings`);
|
||||
router.push(`/${orgId}/settings`);
|
||||
} else {
|
||||
// go to `/setup`
|
||||
router.push("/setup");
|
||||
}
|
||||
}
|
||||
|
|
141
src/app/[orgId]/settings/general/security/page.tsx
Normal file
141
src/app/[orgId]/settings/general/security/page.tsx
Normal file
|
@ -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<typeof SecurityFormSchema>;
|
||||
|
||||
export default function SecuritySettingsPage() {
|
||||
const { org } = useOrgContext();
|
||||
const t = useTranslations();
|
||||
const api = createApiClient(useEnvContext());
|
||||
const [loadingSave, setLoadingSave] = useState(false);
|
||||
|
||||
const form = useForm<SecurityFormValues>({
|
||||
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 (
|
||||
<SettingsContainer>
|
||||
{/* Security Settings Content */}
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>{t('tokenExpiration')}</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t('tokenExpirationDescription')}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="space-y-4"
|
||||
id="security-settings-form"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="passwordResetTokenExpiryHours"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('passwordResetExpireLimit')}</FormLabel>
|
||||
<FormControl>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
max="24"
|
||||
className="w-24"
|
||||
{...field}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t('hours')}
|
||||
</span>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
{t('passwordResetExpireLimitDescription')}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
|
||||
<SettingsSectionFooter>
|
||||
<Button
|
||||
type="submit"
|
||||
form="security-settings-form"
|
||||
loading={loadingSave}
|
||||
disabled={loadingSave}
|
||||
>
|
||||
{t('saveSecuritySettings')}
|
||||
</Button>
|
||||
</SettingsSectionFooter>
|
||||
</SettingsSection>
|
||||
</SettingsContainer>
|
||||
);
|
||||
}
|
1
src/app/admin/settings/general/page.tsx
Normal file
1
src/app/admin/settings/general/page.tsx
Normal file
|
@ -0,0 +1 @@
|
|||
|
|
@ -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);
|
||||
|
|
|
@ -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<z.infer<typeof formSchema>>({
|
||||
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<typeof formSchema>) {
|
||||
const { email, password } = values;
|
||||
const { name, email, password } = values;
|
||||
|
||||
setLoading(true);
|
||||
const res = await api
|
||||
.put<AxiosResponse<SignUpResponse>>("/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"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('name')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
|
|
|
@ -8,14 +8,22 @@ import {
|
|||
Combine,
|
||||
Fingerprint,
|
||||
KeyRound,
|
||||
TicketCheck
|
||||
TicketCheck,
|
||||
User
|
||||
} from "lucide-react";
|
||||
|
||||
export const orgLangingNavItems: SidebarNavItem[] = [
|
||||
{
|
||||
title: "sidebarOverview",
|
||||
title: "sidebarAccount",
|
||||
href: "/{orgId}",
|
||||
icon: <Home className="h-4 w-4" />
|
||||
icon: <User className="h-4 w-4" />,
|
||||
autoExpand: true,
|
||||
children: [
|
||||
{
|
||||
title: "sidebarResources",
|
||||
href: "/{orgId}"
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
|
|
|
@ -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() {
|
|||
|
||||
<div className="flex items-center md:gap-2 grow min-w-0 gap-2 md:gap-0">
|
||||
<span className="truncate max-w-full font-medium min-w-0">
|
||||
{user.email || user.name || user.username}
|
||||
{user.name || user.email || user.username}
|
||||
</span>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
|
@ -100,7 +100,7 @@ export default function ProfileIcon() {
|
|||
{t('signingAs')}
|
||||
</p>
|
||||
<p className="text-xs leading-none text-muted-foreground">
|
||||
{user.email || user.name || user.username}
|
||||
{user.name || user.email || user.username}
|
||||
</p>
|
||||
</div>
|
||||
{user.serverAdmin ? (
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue