diff --git a/seeds/generate_template.ts b/seeds/generate_template.ts index 328a0074..e71afead 100644 --- a/seeds/generate_template.ts +++ b/seeds/generate_template.ts @@ -1,6 +1,7 @@ import subscriptionAccountCodeTemplate from "../src/lib/server/templates/general/subscription_account_code_template.ts"; import subscriptionUpdateTemplate from "../src/lib/server/templates/general/subscription_update_template.ts"; import forgotPasswordTemplate from "../src/lib/server/templates/general/forgot_password_template.ts"; +import inviteUserTemplate from "../src/lib/server/templates/general/invite_user_template.ts"; import type { Knex } from "knex"; export async function seed(knex: Knex): Promise { @@ -44,4 +45,17 @@ export async function seed(knex: Knex): Promise { template_text_body: forgotPasswordTemplate.template_text_body, }); } + count = await knex("general_email_templates") + .where({ template_id: inviteUserTemplate.template_id }) + .count("template_id as CNT") + .first(); + + if (count && count.CNT == 0) { + await knex("general_email_templates").insert({ + template_id: inviteUserTemplate.template_id, + template_subject: inviteUserTemplate.template_subject, + template_html_body: inviteUserTemplate.template_html_body, + template_text_body: inviteUserTemplate.template_text_body, + }); + } } diff --git a/src/lib/server/controllers/controller.ts b/src/lib/server/controllers/controller.ts index 1eee79ce..892488a5 100644 --- a/src/lib/server/controllers/controller.ts +++ b/src/lib/server/controllers/controller.ts @@ -2,7 +2,6 @@ export * from "./apiController.js"; export * from "./commonController.js"; export * from "./emailController.js"; export * from "./incidentController.js"; -export * from "./invitationController.js"; export * from "./monitorsController.js"; export * from "./siteDataController.js"; export * from "./siteDataKeys.js"; diff --git a/src/lib/server/controllers/invitationController.ts b/src/lib/server/controllers/invitationController.ts deleted file mode 100644 index 7fb53cc0..00000000 --- a/src/lib/server/controllers/invitationController.ts +++ /dev/null @@ -1,56 +0,0 @@ -import db from "../db/db.js"; -import crypto from "crypto"; -import { MaskString, CreateHash } from "./commonController.js"; -import type { InvitationRecord, InvitationRecordInsert } from "../types/db.js"; - -interface InvitationInput { - invitation_type: string; - invited_user_id: number; - invited_by_user_id: number; - invitation_meta?: string; - invitation_expiry: Date; -} - -export const CreateNewInvitation = async (data: InvitationInput): Promise<{ invitation_token: string }> => { - //create a token - let token = crypto.randomBytes(32).toString("hex"); - let hashedToken = CreateHash(token); - let invitation_token = data.invitation_type.toLowerCase() + "_" + hashedToken; - - let invite: InvitationRecordInsert = { - invitation_token: invitation_token, - invitation_type: data.invitation_type, - invited_user_id: data.invited_user_id, - invited_by_user_id: data.invited_by_user_id, - invitation_meta: data.invitation_meta, - invitation_expiry: data.invitation_expiry, - invitation_status: "PENDING", - }; - - //update old invitations to VOID - if (invite.invited_user_id) { - await db.updateInvitationStatusToVoid(invite.invited_user_id, invite.invitation_type); - } - - let res = await db.insertInvitation(invite); - return { - invitation_token, - }; -}; - -//check if there is a row for given invited_user_id,invitation_type and invitation_status = PENDING -export const CheckInvitationExists = async (invited_user_id: number, invitation_type: string): Promise => { - let invitation = await db.invitationExists(invited_user_id, invitation_type); - return !!invitation; -}; - -//getInvitationByToken -export const GetActiveInvitationByToken = async (invitation_token: string): Promise => { - let invitation = await db.getActiveInvitationByToken(invitation_token); - return invitation; -}; - -//updateInvitationStatusToAccepted -export const UpdateInvitationStatusToAccepted = async (invitation_token: string): Promise => { - return await db.updateInvitationStatusToAccepted(invitation_token); -}; diff --git a/src/lib/server/controllers/userController.ts b/src/lib/server/controllers/userController.ts index 4631d8bc..40e5f6c7 100644 --- a/src/lib/server/controllers/userController.ts +++ b/src/lib/server/controllers/userController.ts @@ -1,8 +1,12 @@ import db from "../db/db.js"; import type { PaginationInput } from "$lib/types/common"; -import { HashPassword, ValidatePassword, VerifyToken } from "./commonController.js"; +import { GenerateToken, HashPassword, ValidatePassword, VerifyToken } from "./commonController.js"; import type { Cookies } from "@sveltejs/kit"; -import type { UserRecordPublic } from "../types/db.js"; +import type { UserRecordPublic, UserRecordDashboard } from "../types/db.js"; +import { GetAllSiteData } from "./controller.js"; +import { siteDataToVariables } from "../notification/notification_utils.js"; +import sendEmail from "../notification/email_notification.js"; +import { GetGeneralEmailTemplateById } from "./generalTemplateController.js"; export interface UserUpdateInput { userID: number; @@ -35,6 +39,21 @@ export const GetAllUsersPaginated = async (data: PaginationInput): Promise => { + const users = await db.getUsersPaginated(data.page, data.limit); + if (users.length === 0) return []; + + // Batch fetch password statuses for all users + const userIds = users.map((u) => u.id); + const passwordData = await db.getUserPasswordHashesByIds(userIds); + const passwordMap = new Map(passwordData.map((p: { id: number; password_hash: string }) => [p.id, p.password_hash])); + + return users.map((u) => ({ + ...u, + has_password: !!(passwordMap.get(u.id) && passwordMap.get(u.id) !== ""), + })); +}; + export const GetAllUsers = async () => { return await db.getAllUsers(); }; @@ -52,6 +71,18 @@ export const GetUserByID = async (userID: number): Promise => { + const user = await db.getUserById(userID); + if (!user) return undefined; + + const passwordData = await db.getUserPasswordHashById(userID); + return { + ...user, + has_password: !!(passwordData && passwordData.password_hash !== ""), + }; +}; + //getUserByEmail export const GetUserByEmail = async (email: string): Promise => { return await db.getUserByEmail(email); @@ -256,3 +287,120 @@ export const GetTotalUserPages = async (limit: number): Promise => { let totalPages = Math.ceil(Number(totalUsers.count) / limit); return totalPages; }; + +//send invitation email to user for account creation +export const SendInvitationEmail = async (email: string, role: string, name: string, currentUserRole: string) => { + let acceptedRoles = ["member", "editor"]; + if (!acceptedRoles.includes(role)) { + throw new Error("Invalid role"); + } + + if (currentUserRole === "member") { + throw new Error("Only admins and editors can create new users"); + } + + //if data.email empty, throw error + if (!!!email) { + throw new Error("Email cannot be empty"); + } + + //if data.name empty, throw error + if (!!!name) { + throw new Error("Name cannot be empty"); + } + + // Check if user with this email already exists + const existingUser = await db.getUserByEmail(email); + if (existingUser) { + throw new Error(`A user with email ${email} already exists`); + } + + //create user with empty password and is_active = 0 + try { + await db.insertUser({ + email, + password_hash: "", + name, + role, + is_active: 0, + }); + } catch (error: unknown) { + // Handle database constraint errors + const errorMessage = error instanceof Error ? error.message : String(error); + if (errorMessage.includes("UNIQUE constraint failed") || errorMessage.includes("duplicate")) { + throw new Error(`A user with email ${email} already exists`); + } + throw error; + } + + const token = await GenerateToken({ + email, + validTill: Date.now() + 7 * 24 * 60 * 60 * 1000, //7 days + }); + + const siteData = await GetAllSiteData(); + const siteVars = siteDataToVariables(siteData); + const siteUrl = siteVars.site_url || ""; + let link = `${siteUrl}/account/invitation?view=confirm_token&token=${token}`; + + const emailVars = { + ...siteVars, + invitation_link: link, + }; + + const template = await GetGeneralEmailTemplateById("invite_user"); + if (template) { + await sendEmail( + template.template_html_body || "", + template.template_subject || "Your Invitation to Join", + emailVars, + [email], + ); + } +}; + +//resend invitation email to existing user with blank password +export const ResendInvitationEmail = async (email: string, currentUserRole: string) => { + if (currentUserRole === "member") { + throw new Error("Only admins and editors can resend invitations"); + } + + if (!email) { + throw new Error("Email cannot be empty"); + } + + const user = await db.getUserByEmail(email); + if (!user) { + throw new Error("User not found"); + } + + const passwordData = await db.getUserPasswordHashById(user.id); + if (passwordData && passwordData.password_hash !== "") { + throw new Error("User has already set their password"); + } + + const token = await GenerateToken({ + email, + validTill: Date.now() + 7 * 24 * 60 * 60 * 1000, //7 days + }); + + const siteData = await GetAllSiteData(); + const siteVars = siteDataToVariables(siteData); + const siteUrl = siteVars.site_url || ""; + let link = `${siteUrl}/account/invitation?view=confirm_token&token=${token}`; + + const emailVars = { + ...siteVars, + invitation_link: link, + }; + + const template = await GetGeneralEmailTemplateById("invite_user"); + if (template) { + await sendEmail( + template.template_html_body || "", + template.template_subject || "Your Invitation to Join", + emailVars, + [email], + ); + } +}; diff --git a/src/lib/server/db/dbimpl.ts b/src/lib/server/db/dbimpl.ts index a1a3308c..706157d1 100644 --- a/src/lib/server/db/dbimpl.ts +++ b/src/lib/server/db/dbimpl.ts @@ -108,6 +108,7 @@ class DbImpl { getUsersCount!: UsersRepository["getUsersCount"]; getUserByEmail!: UsersRepository["getUserByEmail"]; getUserPasswordHashById!: UsersRepository["getUserPasswordHashById"]; + getUserPasswordHashesByIds!: UsersRepository["getUserPasswordHashesByIds"]; getUserById!: UsersRepository["getUserById"]; insertUser!: UsersRepository["insertUser"]; updateUserPassword!: UsersRepository["updateUserPassword"]; @@ -126,13 +127,6 @@ class DbImpl { getApiKeyByHashedKey!: UsersRepository["getApiKeyByHashedKey"]; getAllApiKeys!: UsersRepository["getAllApiKeys"]; - // ============ Invitations ============ - insertInvitation!: UsersRepository["insertInvitation"]; - updateInvitationStatusToVoid!: UsersRepository["updateInvitationStatusToVoid"]; - invitationExists!: UsersRepository["invitationExists"]; - updateInvitationStatusToAccepted!: UsersRepository["updateInvitationStatusToAccepted"]; - getActiveInvitationByToken!: UsersRepository["getActiveInvitationByToken"]; - // ============ Site Data ============ insertOrUpdateSiteData!: SiteDataRepository["insertOrUpdateSiteData"]; getAllSiteData!: SiteDataRepository["getAllSiteData"]; @@ -454,6 +448,7 @@ class DbImpl { this.getUsersCount = this.users.getUsersCount.bind(this.users); this.getUserByEmail = this.users.getUserByEmail.bind(this.users); this.getUserPasswordHashById = this.users.getUserPasswordHashById.bind(this.users); + this.getUserPasswordHashesByIds = this.users.getUserPasswordHashesByIds.bind(this.users); this.getUserById = this.users.getUserById.bind(this.users); this.insertUser = this.users.insertUser.bind(this.users); this.updateUserPassword = this.users.updateUserPassword.bind(this.users); @@ -469,11 +464,6 @@ class DbImpl { this.updateApiKeyStatus = this.users.updateApiKeyStatus.bind(this.users); this.getApiKeyByHashedKey = this.users.getApiKeyByHashedKey.bind(this.users); this.getAllApiKeys = this.users.getAllApiKeys.bind(this.users); - this.insertInvitation = this.users.insertInvitation.bind(this.users); - this.updateInvitationStatusToVoid = this.users.updateInvitationStatusToVoid.bind(this.users); - this.invitationExists = this.users.invitationExists.bind(this.users); - this.updateInvitationStatusToAccepted = this.users.updateInvitationStatusToAccepted.bind(this.users); - this.getActiveInvitationByToken = this.users.getActiveInvitationByToken.bind(this.users); } private bindSiteDataMethods(): void { diff --git a/src/lib/server/db/repositories/users.ts b/src/lib/server/db/repositories/users.ts index 7ee91e13..519ddeff 100644 --- a/src/lib/server/db/repositories/users.ts +++ b/src/lib/server/db/repositories/users.ts @@ -1,15 +1,8 @@ import { BaseRepository, type CountResult } from "./base.js"; -import type { - UserRecordInsert, - UserRecordPublic, - ApiKeyRecord, - ApiKeyRecordInsert, - InvitationRecord, - InvitationRecordInsert, -} from "../../types/db.js"; +import type { UserRecordInsert, UserRecordPublic, ApiKeyRecord, ApiKeyRecordInsert } from "../../types/db.js"; /** - * Repository for users, API keys, and invitations operations + * Repository for users, API keys operations */ export class UsersRepository extends BaseRepository { // ============ Users ============ @@ -29,6 +22,11 @@ export class UsersRepository extends BaseRepository { return await this.knex("users").select("password_hash").where("id", id).first(); } + async getUserPasswordHashesByIds(ids: number[]): Promise<{ id: number; password_hash: string }[]> { + if (ids.length === 0) return []; + return await this.knex("users").select("id", "password_hash").whereIn("id", ids); + } + async getUserById(id: number): Promise { return await this.knex("users") .select("id", "email", "name", "is_active", "is_verified", "role", "created_at", "updated_at") @@ -135,52 +133,4 @@ export class UsersRepository extends BaseRepository { } // ============ Invitations ============ - - async insertInvitation(data: InvitationRecordInsert): Promise { - return await this.knex("invitations").insert({ - invitation_token: data.invitation_token, - invitation_type: data.invitation_type, - invited_user_id: data.invited_user_id, - invited_by_user_id: data.invited_by_user_id, - invitation_meta: data.invitation_meta, - invitation_expiry: data.invitation_expiry, - invitation_status: data.invitation_status, - created_at: this.knex.fn.now(), - updated_at: this.knex.fn.now(), - }); - } - - async updateInvitationStatusToVoid(invited_user_id: number, invitation_type: string): Promise { - return await this.knex("invitations").where({ invited_user_id, invitation_type }).update({ - invitation_status: "VOID", - updated_at: this.knex.fn.now(), - }); - } - - async invitationExists(invited_user_id: number, invitation_type: string): Promise { - const result = await this.knex("invitations") - .count("* as count") - .where({ - invited_user_id, - invitation_type, - invitation_status: "PENDING", - }) - .first(); - return Number(result?.count) > 0; - } - - async updateInvitationStatusToAccepted(invitation_token: string): Promise { - return await this.knex("invitations").where({ invitation_token }).update({ - invitation_status: "ACCEPTED", - updated_at: this.knex.fn.now(), - }); - } - - async getActiveInvitationByToken(invitation_token: string): Promise { - return await this.knex("invitations") - .where("invitation_token", invitation_token) - .andWhere("invitation_status", "PENDING") - .andWhere("invitation_expiry", ">", this.knex.fn.now()) - .first(); - } } diff --git a/src/lib/server/templates/general/invite_user_template.ts b/src/lib/server/templates/general/invite_user_template.ts new file mode 100644 index 00000000..3404deff --- /dev/null +++ b/src/lib/server/templates/general/invite_user_template.ts @@ -0,0 +1,204 @@ +const emailTemplate = ` + + + + + + + + +
+ You've been invited to join {{site_name}} +
+ + + + + + +
+ + + + + + +
+ {{site_name}} +
+ + + + + + +
+

+ You're Invited! +

+

+ You've been invited to join {{site_name}}. Click the button below to accept your invitation and set up your account: +

+ + + + + + +
+ + Accept Invitation + +
+

+ Or copy and paste this URL into your browser: +

+

+ {{invitation_link}} +

+

+ This invitation link will expire in 7 days. If you didn't expect this invitation, you can safely ignore this email. +

+
+
+ + +`; + +export default { + template_id: "invite_user", + template_subject: "{{site_name}} - You're Invited!", + template_html_body: emailTemplate, + template_text_body: `You've been invited to join {{site_name}}\n\nYou've been invited to join {{site_name}}. Click the link below to accept your invitation and set up your account:\n\n{{invitation_link}}\n\nThis invitation link will expire in 7 days. If you didn't expect this invitation, you can safely ignore this email.`, +}; diff --git a/src/lib/server/types/db.ts b/src/lib/server/types/db.ts index 8a94fa3e..28896d9c 100644 --- a/src/lib/server/types/db.ts +++ b/src/lib/server/types/db.ts @@ -244,6 +244,9 @@ export interface UserRecordPublic { created_at: Date; updated_at: Date; } +export interface UserRecordDashboard extends UserRecordPublic { + has_password: boolean; +} // ============ api_keys table ============ export interface ApiKeyRecord { @@ -341,30 +344,6 @@ export interface IncidentCommentRecordInsert { state?: string; } -// ============ invitations table ============ -export interface InvitationRecord { - id: number; - invitation_token: string; - invitation_type: string; - invited_user_id: number | null; - invited_by_user_id: number; - invitation_meta: string | null; - invitation_expiry: Date; - invitation_status: string; - created_at: Date; - updated_at: Date; -} - -export interface InvitationRecordInsert { - invitation_token: string; - invitation_type: string; - invited_user_id?: number | null; - invited_by_user_id: number; - invitation_meta?: string | null; - invitation_expiry: Date; - invitation_status?: string; -} - // ============ Filter types ============ export interface IncidentFilter { status?: string; diff --git a/src/routes/(account)/account/forgot/api/password-reset/+server.ts b/src/routes/(account)/account/forgot/api/password-reset/+server.ts index abf8bd23..c755386e 100644 --- a/src/routes/(account)/account/forgot/api/password-reset/+server.ts +++ b/src/routes/(account)/account/forgot/api/password-reset/+server.ts @@ -1,4 +1,10 @@ -import { HashPassword, GenerateToken, VerifyToken, GetAllSiteData } from "$lib/server/controllers/controller.js"; +import { + HashPassword, + GenerateToken, + VerifyToken, + GetAllSiteData, + ValidatePassword, +} from "$lib/server/controllers/controller.js"; import { json } from "@sveltejs/kit"; import type { RequestHandler } from "./$types"; import db from "$lib/server/db/db.js"; @@ -36,10 +42,21 @@ export const POST: RequestHandler = async ({ request }) => { let errorMessage = "User does not exist"; return json({ error: errorMessage }, { status: 401 }); } + // Validate password strength + if (!ValidatePassword(newPassword)) { + return json( + { + error: "Password must contain at least 8 characters, one uppercase letter, one lowercase letter and one number", + }, + { status: 400 }, + ); + } let password_hash = await HashPassword(newPassword); await db.updateUserPassword({ id: userDB.id, password_hash: password_hash, }); + //also update updateIsVerified + await db.updateIsVerified(userDB.id, 1); return json({ success: true }); }; diff --git a/src/routes/(account)/account/invitation/+page.server.ts b/src/routes/(account)/account/invitation/+page.server.ts new file mode 100644 index 00000000..5e5de1a1 --- /dev/null +++ b/src/routes/(account)/account/invitation/+page.server.ts @@ -0,0 +1,77 @@ +import type { PageServerLoad } from "./$types"; +import { VerifyToken } from "$lib/server/controllers/commonController.js"; +import db from "$lib/server/db/db.js"; +import { GetUserPasswordHashById } from "$lib/server/controllers/userController.js"; + +export const load: PageServerLoad = async ({ url, cookies }) => { + // Clear any existing session + cookies.delete("kener-user", { path: "/" }); + + const view = url.searchParams.get("view") || ""; + const token = url.searchParams.get("token") || ""; + + // If no token or not confirm_token view, show error + if (view !== "confirm_token" || !token) { + return { + valid: false, + error: "Invalid or missing invitation link.", + token: "", + }; + } + + // Verify the token + const tokenData = await VerifyToken(token); + if (!tokenData) { + return { + valid: false, + error: "Invalid or expired invitation link.", + token: "", + }; + } + + const email = tokenData.email; + if (!email) { + return { + valid: false, + error: "Invalid invitation link.", + token: "", + }; + } + + // Check if token has expired (validTill) + const validTill = tokenData.validTill; + if (!validTill || Date.now() > validTill) { + return { + valid: false, + error: "This invitation link has expired. Please ask your administrator to send a new one.", + token: "", + }; + } + + // Check if user exists with empty password (invited but not yet activated) + const user = await db.getUserByEmail(email); + if (!user) { + return { + valid: false, + error: "No invitation found for this email address.", + token: "", + }; + } + + const passwordData = await GetUserPasswordHashById(user.id); + if (passwordData && passwordData.password_hash !== "") { + return { + valid: false, + error: "This invitation has already been accepted. Please sign in instead.", + token: "", + }; + } + + return { + valid: true, + error: "", + token, + email: user.email, + name: user.name, + }; +}; diff --git a/src/routes/(account)/account/invitation/+page.svelte b/src/routes/(account)/account/invitation/+page.svelte new file mode 100644 index 00000000..6c4f7193 --- /dev/null +++ b/src/routes/(account)/account/invitation/+page.svelte @@ -0,0 +1,212 @@ + + + + Accept Invitation + + +
+ + {#if !valid} + + +
+ +
+ Invalid Invitation + {error} +
+ + + + {:else if accountActivated} + + +
+ +
+ Account Activated + + Your account has been set up successfully. You can now sign in with your new password. + +
+ + + + {:else} + + + Welcome, {name}! + + You've been invited to join as {email}. Create a password to activate your account and get + started. + + + +
+ + + Password + + + + + + + (showPassword = !showPassword)} + > + {#if showPassword} + + {:else} + + {/if} + + + + + Password must contain at least 8 characters, one uppercase, one lowercase, and one number. + + + + + Confirm Password + + + + + + + (showConfirmPassword = !showConfirmPassword)} + > + {#if showConfirmPassword} + + {:else} + + {/if} + + + + + + +
+ +
+ +
+ +
+
+
+ {/if} +
+
diff --git a/src/routes/(account)/account/invitation/api/accept-invitation/+server.ts b/src/routes/(account)/account/invitation/api/accept-invitation/+server.ts new file mode 100644 index 00000000..6a85d521 --- /dev/null +++ b/src/routes/(account)/account/invitation/api/accept-invitation/+server.ts @@ -0,0 +1,69 @@ +import { json } from "@sveltejs/kit"; +import type { RequestHandler } from "./$types"; +import db from "$lib/server/db/db.js"; +import { HashPassword, ValidatePassword, VerifyToken } from "$lib/server/controllers/commonController.js"; +import { GetUserPasswordHashById } from "$lib/server/controllers/userController.js"; + +export const POST: RequestHandler = async ({ request }) => { + const body = await request.json(); + const { receivedToken, newPassword } = body; + + if (!receivedToken) { + return json({ error: "Token is required" }, { status: 400 }); + } + + if (!newPassword) { + return json({ error: "Password is required" }, { status: 400 }); + } + + // Verify token + const tokenData = await VerifyToken(receivedToken); + if (!tokenData) { + return json({ error: "Invalid or expired invitation link" }, { status: 400 }); + } + + const email = tokenData.email; + if (!email) { + return json({ error: "Invalid token data" }, { status: 400 }); + } + + // Check token expiry + const validTill = tokenData.validTill; + if (!validTill || Date.now() > validTill) { + return json({ error: "This invitation link has expired" }, { status: 400 }); + } + + // Check user exists with empty password + const user = await db.getUserByEmail(email); + if (!user) { + return json({ error: "User does not exist" }, { status: 401 }); + } + + const passwordData = await GetUserPasswordHashById(user.id); + if (passwordData && passwordData.password_hash !== "") { + return json({ error: "This invitation has already been accepted" }, { status: 400 }); + } + + // Validate password strength + if (!ValidatePassword(newPassword)) { + return json( + { + error: "Password must contain at least 8 characters, one uppercase letter, one lowercase letter and one number", + }, + { status: 400 }, + ); + } + + // Hash and set password + const passwordHash = await HashPassword(newPassword); + await db.updateUserPassword({ + id: user.id, + password_hash: passwordHash, + }); + + // Activate user and mark as verified + await db.updateUserIsActive(user.id, 1); + await db.updateIsVerified(user.id, 1); + + return json({ success: true }); +}; diff --git a/src/routes/(manage)/+layout.server.ts b/src/routes/(manage)/+layout.server.ts index e868afd8..d4ac8ad7 100644 --- a/src/routes/(manage)/+layout.server.ts +++ b/src/routes/(manage)/+layout.server.ts @@ -2,7 +2,7 @@ import i18n from "$lib/i18n/server"; import { redirect } from "@sveltejs/kit"; import MobileDetect from "mobile-detect"; import type { LayoutServerLoad } from "./$types"; -import { IsEmailSetup, CheckInvitationExists } from "$lib/server/controllers/controller.js"; +import { IsEmailSetup } from "$lib/server/controllers/controller.js"; import GC from "$lib/global-constants"; import { resolve } from "$app/paths"; @@ -52,6 +52,5 @@ export const load: LayoutServerLoad = async ({ cookies, request, url }) => { userDb: isLoggedIn.user, siteStatusColors, canSendEmail: IsEmailSetup(), - activeInvitationExists: await CheckInvitationExists(isLoggedIn.user.id, GC.INVITE_VERIFY_EMAIL), }; }; diff --git a/src/routes/(manage)/manage/api/+server.ts b/src/routes/(manage)/manage/api/+server.ts index fbd5f5e3..3b66fac4 100644 --- a/src/routes/(manage)/manage/api/+server.ts +++ b/src/routes/(manage)/manage/api/+server.ts @@ -34,11 +34,14 @@ import { GetTriggerByID, IsLoggedInSession, UpdateUserData, - CreateNewInvitation, SendEmailWithTemplate, GetSiteLogoURL, UpdatePassword, - CreateNewUser, + SendInvitationEmail, + ResendInvitationEmail, + GetUserPasswordHashById, + GetAllUsersPaginatedDashboard, + GetUserByIDDashboard, GetAllUsers, GetAllUsersPaginated, GetUsersCount, @@ -167,66 +170,24 @@ export async function POST({ request, cookies }) { resp = await GetAllSiteData(); } else if (action == "manualUpdate") { await ManualUpdateUserData(userDB, data.id, data); - resp = await GetUserByID(data.id); + resp = await GetUserByIDDashboard(data.id); } else if (action == "updatePassword") { data.userID = userDB.id; resp = await UpdatePassword(data); } else if (action == "createNewUser") { - await CreateNewUser(userDB, data); + await SendInvitationEmail(data.email, data.role, data.name, userDB.role); resp = await GetUserByEmail(data.email); + } else if (action == "resendInvitation") { + AdminEditorCan(userDB.role); + await ResendInvitationEmail(data.email, userDB.role); + resp = { success: true }; } else if (action == "getUsers") { const page = parseInt(String(data.page)) || 1; const limit = parseInt(String(data.limit)) || 10; - const users = await GetAllUsersPaginated({ page, limit }); + const users = await GetAllUsersPaginatedDashboard({ page, limit }); const totalResult = await GetUsersCount(); const total = totalResult ? Number(totalResult.count) : 0; resp = { users, total }; - } else if (action == "sendVerificationEmail") { - data.invitation_type = GC.INVITE_VERIFY_EMAIL; - - let toEmail = userDB.email; - let toId = userDB.id; - - if (!!data.toId) { - toId = data.toId; - let user = await GetUserByID(toId); - if (!!!user) { - throw new Error("User not found"); - } - toEmail = user.email; - } - - data.invited_user_id = toId; - data.invited_by_user_id = userDB.id; - - data.invitation_meta = JSON.stringify({ - header: "Email Verified Successfully", - message: "Thanks for verifying your email", - }); - //create timestamp with 1 hour expiry - const expiryTimestamp = GetNowTimestampUTC() + 3600; - const expiryDate = new Date(expiryTimestamp * 1000); - data.invitation_expiry = format(expiryDate, "yyyy-MM-dd HH:mm:ss"); - - resp = await CreateNewInvitation(data); - let token = resp.invitation_token; - let siteData = await GetAllSiteData(); - let emailData = { - brand_name: siteData.siteName || "Kener", - logo_url: "", - verification_url: siteData.siteURL + "/manage/invitation?token=" + token, - }; - if (siteData.logo) { - emailData.logo_url = await GetSiteLogoURL(siteData.siteURL, siteData.logo, "/"); - } - - resp = await SendEmailWithTemplate( - verifyEmailTemplate, - emailData, - toEmail, - `[Important] Verify email for ${emailData.brand_name}`, - `go to ${emailData.verification_url} to verify email`, - ); } else if (action === "storeSiteData") { AdminEditorCan(userDB.role); resp = await storeSiteData(data); diff --git a/src/routes/(manage)/manage/app/users/+page.svelte b/src/routes/(manage)/manage/app/users/+page.svelte index 0bedcf3e..ac783df9 100644 --- a/src/routes/(manage)/manage/app/users/+page.svelte +++ b/src/routes/(manage)/manage/app/users/+page.svelte @@ -18,35 +18,24 @@ import MailWarningIcon from "@lucide/svelte/icons/mail-warning"; import ChevronLeftIcon from "@lucide/svelte/icons/chevron-left"; import ChevronRightIcon from "@lucide/svelte/icons/chevron-right"; + import EyeClosedIcon from "@lucide/svelte/icons/eye-closed"; + import EyeOpenIcon from "@lucide/svelte/icons/eye"; + import * as InputGroup from "$lib/components/ui/input-group/index.js"; import { toast } from "svelte-sonner"; import { format } from "date-fns"; + import type { UserRecordDashboard } from "$lib/server/types/db.js"; // Types - interface User { - id: number; - email: string; - name: string; - is_active: number; - is_verified: number; - role: string; - created_at: string; - updated_at: string; - } - interface NewUser { name: string; email: string; - password: string; - plainPassword: string; role: string; } - interface EditUser extends User { - password: string; - passwordPlain: string; + interface EditUser extends UserRecordDashboard { actions: { sendingVerificationEmail: boolean; - updatingPassword: boolean; + resendingInvitation: boolean; updatingRole: boolean; deactivatingUser: boolean; activatingUser: boolean; @@ -54,7 +43,7 @@ } interface PageData { - user: User; + user: UserRecordDashboard; canSendEmail: boolean; } @@ -66,7 +55,7 @@ // State let loading = $state(true); - let users = $state([]); + let users = $state([]); let page = $state(1); let limit = $state(10); let total = $state(0); @@ -79,8 +68,7 @@ let newUser = $state({ name: "", email: "", - password: "", - plainPassword: "", + role: "member" }); @@ -118,11 +106,6 @@ // Create new user async function createNewUser() { - if (newUser.password !== newUser.plainPassword) { - creatingUserError = "Passwords do not match"; - return; - } - creatingUser = true; creatingUserError = ""; @@ -143,12 +126,7 @@ users = [...users, result]; showAddUserDialog = false; resetNewUser(); - toast.success("User created successfully"); - - // Send verification email - if (canSendEmail) { - await sendVerificationEmail(result.id); - } + toast.success("User invited successfully"); } } catch (error) { creatingUserError = "Error while creating user"; @@ -161,21 +139,19 @@ newUser = { name: "", email: "", - password: "", - plainPassword: "", + role: "member" }; } // Open settings for a user - function openSettingsSheet(user: User) { + function openSettingsSheet(user: UserRecordDashboard) { toEditUser = { ...JSON.parse(JSON.stringify(user)), - password: "", - passwordPlain: "", + actions: { sendingVerificationEmail: false, - updatingPassword: false, + resendingInvitation: false, updatingRole: false, deactivatingUser: false, activatingUser: false @@ -186,6 +162,23 @@ showSettingsSheet = true; } + // Resend invitation email + async function resendInvitationEmail(email: string) { + try { + await fetch("/manage/api", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + action: "resendInvitation", + data: { email } + }) + }); + toast.success("Invitation email resent"); + } catch (error) { + toast.error("Failed to resend invitation email"); + } + } + // Send verification email async function sendVerificationEmail(id: number) { try { @@ -230,8 +223,7 @@ // Update toEditUser with the result toEditUser = { ...result, - password: "", - passwordPlain: "", + actions: toEditUser.actions }; } @@ -247,7 +239,10 @@ } // Format date - function formatDate(dateStr: string): string { + function formatDate(dateStr: string | Date): string { + if (dateStr instanceof Date) { + return format(dateStr, "MMM dd, yyyy HH:mm"); + } try { return format(new Date(dateStr), "MMM dd, yyyy HH:mm"); } catch { @@ -353,11 +348,6 @@ {/if} - {#if currentUser.id === user.id} - - {/if} {/each} @@ -419,26 +409,7 @@ -
- - -

- Set a dummy password. Ask the user to reset the password once they log in. -

-
-
- - - {#if newUser.plainPassword && newUser.password !== newUser.plainPassword} -

Passwords do not match

- {/if} -
+
v && (newUser.role = v)}> @@ -475,201 +446,153 @@ Settings - {toEditUser?.name} Manage user settings and permissions - - {#if toEditUser} -
- -
-

- Created At: - {formatDate(toEditUser.created_at)} -

-

- Updated At: - {formatDate(toEditUser.updated_at)} -

-

- Name: - {toEditUser.name} -

-
- - - {#if !toEditUser.is_verified && canSendEmail} - - -

- The email is not verified. Send a verification email to the user at {toEditUser.email}. -

- -
-
- {/if} - - - - -

Update password for the user

-
{ - e.preventDefault(); - toEditUser!.actions.updatingPassword = true; - manualUpdateData("password").then(() => { - toEditUser!.actions.updatingPassword = false; - }); - }} - > -
-
- - -
-
- - -
-
- -
-
-
- - - - -

- Change the role of the user. The user will have different permissions based on the role. +

+ {#if toEditUser} +
+ +
+

+ Created At: + {formatDate(toEditUser.created_at)}

-
- v && (toEditUser!.role = v)} - disabled={toEditUser.actions.updatingRole} - > - - {toEditUser.role.toUpperCase()} - - - EDITOR - MEMBER - - - -
- - +

+ Updated At: + {formatDate(toEditUser.updated_at)} +

+

+ Name: + {toEditUser.name} +

+
+ + {#if !toEditUser.has_password && canSendEmail} + + +

+ This user ha2sn't set their password yet. Resend the invitation email to {toEditUser.email}. +

+ +
+
+ {/if} - - {#if toEditUser.is_active} - - -

- Deactivate User. The user will not be able to login. Existing session will get invalidated. -

- -
-
- {:else} + -

Activate User. The user will be able to login.

- +

+ Change the role of the user. The user will have different permissions based on the role. +

+
+ v && (toEditUser!.role = v)} + disabled={toEditUser.actions.updatingRole} + > + + {toEditUser.role.toUpperCase()} + + + EDITOR + MEMBER + + + +
- {/if} - - {#if manualUpdateError} - - {manualUpdateError} - - {/if} - {#if manualSuccess} - - {manualSuccess} - - {/if} -
- {/if} + + {#if toEditUser.is_active} + + +

+ Deactivate User. The user will not be able to login. Existing session will get invalidated. +

+ +
+
+ {:else} + + +

Activate User. The user will be able to login.

+ +
+
+ {/if} + + + {#if manualUpdateError} + + {manualUpdateError} + + {/if} + {#if manualSuccess} + + {manualSuccess} + + {/if} +
+ {/if} +
diff --git a/src/routes/layout.css b/src/routes/layout.css index 7fc9b6b9..b7841969 100644 --- a/src/routes/layout.css +++ b/src/routes/layout.css @@ -4,6 +4,8 @@ @plugin "@tailwindcss/typography"; +@source not "../node_modules/svelte-codemirror-editor"; + @custom-variant dark (&:is(.dark *)); :root {