mirror of
https://github.com/fosrl/pangolin.git
synced 2025-09-01 15:25:17 +02:00
Merge branch 'user-management-and-resources' of github.com:adrianeastles/pangolin into adrianeastles-user-management-and-resources
This commit is contained in:
commit
7601e0d2d9
32 changed files with 1929 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")
|
||||
);
|
||||
}
|
||||
}
|
|
@ -11,7 +11,8 @@ import m1 from "./scriptsPg/1.6.0";
|
|||
|
||||
// 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