Merge branch 'user-management-and-resources' of github.com:adrianeastles/pangolin into adrianeastles-user-management-and-resources

This commit is contained in:
Owen 2025-07-07 09:44:45 -07:00
commit 7601e0d2d9
No known key found for this signature in database
GPG key ID: 8271FDFFD9E0CCBD
32 changed files with 1929 additions and 68 deletions

View file

@ -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"
}

View file

@ -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", {

View file

@ -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", {

View file

@ -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()

View file

@ -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,

View file

@ -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";

View 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"
)
);
}
}

View 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;
}>;
};
};

View file

@ -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";

View 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"
)
);
}
}

View file

@ -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";

View 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")
);
}
}

View file

@ -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;

View file

@ -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;

View 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`);
}

View 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`);
}

View 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>
);
}

View file

@ -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>
);
}

View file

@ -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) {
/>
</>
);
}
}

View 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>
);
}

View file

@ -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`
}
];

View file

@ -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`);
}

View file

@ -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

View file

@ -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,

View file

@ -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`,
},
];

View file

@ -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");
}
}

View 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>
);
}

View file

@ -0,0 +1 @@

View file

@ -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);

View file

@ -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"

View file

@ -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}"
}
]
}
];

View file

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