mirror of
https://github.com/rajnandan1/kener.git
synced 2026-05-12 22:28:32 -05:00
Implement user invitation system with password setup and validation
- Create UserRecordDashboard interface to include password status. - Update password reset API to validate password strength and set user as verified. - Refactor invitation handling in the manage route to streamline user management. - Introduce invitation acceptance flow with password creation and validation. - Create email template for user invitations. - Implement invitation verification logic to ensure token validity and user existence. - Enhance user management UI to support invitation resending and account activation.
This commit is contained in:
@@ -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<void> {
|
||||
@@ -44,4 +45,17 @@ export async function seed(knex: Knex): Promise<void> {
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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<boolean> => {
|
||||
let invitation = await db.invitationExists(invited_user_id, invitation_type);
|
||||
return !!invitation;
|
||||
};
|
||||
|
||||
//getInvitationByToken
|
||||
export const GetActiveInvitationByToken = async (invitation_token: string): Promise<InvitationRecord | undefined> => {
|
||||
let invitation = await db.getActiveInvitationByToken(invitation_token);
|
||||
return invitation;
|
||||
};
|
||||
|
||||
//updateInvitationStatusToAccepted
|
||||
export const UpdateInvitationStatusToAccepted = async (invitation_token: string): Promise<number> => {
|
||||
return await db.updateInvitationStatusToAccepted(invitation_token);
|
||||
};
|
||||
@@ -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<UserR
|
||||
return await db.getUsersPaginated(data.page, data.limit);
|
||||
};
|
||||
|
||||
export const GetAllUsersPaginatedDashboard = async (data: PaginationInput): Promise<UserRecordDashboard[]> => {
|
||||
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<UserRecordPublic | un
|
||||
return await db.getUserById(userID);
|
||||
};
|
||||
|
||||
//getUserById with has_password for dashboard
|
||||
export const GetUserByIDDashboard = async (userID: number): Promise<UserRecordDashboard | undefined> => {
|
||||
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<UserRecordPublic | undefined> => {
|
||||
return await db.getUserByEmail(email);
|
||||
@@ -256,3 +287,120 @@ export const GetTotalUserPages = async (limit: number): Promise<number> => {
|
||||
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],
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<UserRecordPublic | undefined> {
|
||||
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<number[]> {
|
||||
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<number> {
|
||||
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<boolean> {
|
||||
const result = await this.knex("invitations")
|
||||
.count("* as count")
|
||||
.where({
|
||||
invited_user_id,
|
||||
invitation_type,
|
||||
invitation_status: "PENDING",
|
||||
})
|
||||
.first<CountResult>();
|
||||
return Number(result?.count) > 0;
|
||||
}
|
||||
|
||||
async updateInvitationStatusToAccepted(invitation_token: string): Promise<number> {
|
||||
return await this.knex("invitations").where({ invitation_token }).update({
|
||||
invitation_status: "ACCEPTED",
|
||||
updated_at: this.knex.fn.now(),
|
||||
});
|
||||
}
|
||||
|
||||
async getActiveInvitationByToken(invitation_token: string): Promise<InvitationRecord | undefined> {
|
||||
return await this.knex("invitations")
|
||||
.where("invitation_token", invitation_token)
|
||||
.andWhere("invitation_status", "PENDING")
|
||||
.andWhere("invitation_expiry", ">", this.knex.fn.now())
|
||||
.first();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
const emailTemplate = `<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||
<html dir="ltr" lang="en">
|
||||
<head>
|
||||
<link rel="preload" as="image" href="{{site_logo_url}}" />
|
||||
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type" />
|
||||
<meta name="x-apple-disable-message-reformatting" />
|
||||
</head>
|
||||
<body
|
||||
style="
|
||||
background-color: rgb(243, 244, 246);
|
||||
font-family:
|
||||
ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji",
|
||||
"Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
||||
padding-top: 40px;
|
||||
padding-bottom: 40px;
|
||||
"
|
||||
>
|
||||
<!--$-->
|
||||
<div
|
||||
style="
|
||||
display: none;
|
||||
overflow: hidden;
|
||||
line-height: 1px;
|
||||
opacity: 0;
|
||||
max-height: 0;
|
||||
max-width: 0;
|
||||
"
|
||||
>
|
||||
You've been invited to join {{site_name}}
|
||||
</div>
|
||||
<table
|
||||
align="center"
|
||||
width="100%"
|
||||
border="0"
|
||||
cellpadding="0"
|
||||
cellspacing="0"
|
||||
role="presentation"
|
||||
style="
|
||||
background-color: rgb(255, 255, 255);
|
||||
border-radius: 8px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
margin-top: 0px;
|
||||
margin-bottom: 0px;
|
||||
padding: 24px;
|
||||
max-width: 600px;
|
||||
"
|
||||
>
|
||||
<tbody>
|
||||
<tr style="width: 100%">
|
||||
<td>
|
||||
<table
|
||||
align="center"
|
||||
width="100%"
|
||||
border="0"
|
||||
cellpadding="0"
|
||||
cellspacing="0"
|
||||
role="presentation"
|
||||
style="margin-top: 8px; margin-bottom: 32px; text-align: center"
|
||||
>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<img
|
||||
alt="{{site_name}}"
|
||||
height="40"
|
||||
src="{{site_logo_url}}"
|
||||
style="
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
display: block;
|
||||
outline: none;
|
||||
border: none;
|
||||
text-decoration: none;
|
||||
"
|
||||
width="120"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<table
|
||||
align="center"
|
||||
width="100%"
|
||||
border="0"
|
||||
cellpadding="0"
|
||||
cellspacing="0"
|
||||
role="presentation"
|
||||
>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<h1
|
||||
style="
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: rgb(31, 41, 55);
|
||||
margin-bottom: 16px;
|
||||
text-align: center;
|
||||
"
|
||||
>
|
||||
You're Invited!
|
||||
</h1>
|
||||
<p
|
||||
style="
|
||||
font-size: 16px;
|
||||
color: rgb(75, 85, 99);
|
||||
margin-bottom: 24px;
|
||||
line-height: 24px;
|
||||
margin-top: 16px;
|
||||
"
|
||||
>
|
||||
You've been invited to join {{site_name}}. Click the button below to accept your invitation and set up your account:
|
||||
</p>
|
||||
<table
|
||||
align="center"
|
||||
width="100%"
|
||||
border="0"
|
||||
cellpadding="0"
|
||||
cellspacing="0"
|
||||
role="presentation"
|
||||
style="
|
||||
margin-bottom: 24px;
|
||||
text-align: center;
|
||||
"
|
||||
>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<a
|
||||
href="{{invitation_link}}"
|
||||
style="
|
||||
display: inline-block;
|
||||
background-color: rgb(59, 130, 246);
|
||||
color: rgb(255, 255, 255);
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
text-align: center;
|
||||
padding: 12px 32px;
|
||||
border-radius: 8px;
|
||||
line-height: 24px;
|
||||
"
|
||||
target="_blank"
|
||||
>
|
||||
Accept Invitation
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p
|
||||
style="
|
||||
font-size: 14px;
|
||||
color: rgb(107, 114, 128);
|
||||
margin-bottom: 16px;
|
||||
line-height: 20px;
|
||||
margin-top: 16px;
|
||||
text-align: center;
|
||||
"
|
||||
>
|
||||
Or copy and paste this URL into your browser:
|
||||
</p>
|
||||
<p
|
||||
style="
|
||||
font-size: 14px;
|
||||
color: rgb(59, 130, 246);
|
||||
margin-bottom: 24px;
|
||||
line-height: 20px;
|
||||
word-break: break-all;
|
||||
text-align: center;
|
||||
"
|
||||
>
|
||||
{{invitation_link}}
|
||||
</p>
|
||||
<p
|
||||
style="
|
||||
font-size: 16px;
|
||||
color: rgb(75, 85, 99);
|
||||
margin-bottom: 24px;
|
||||
line-height: 24px;
|
||||
margin-top: 16px;
|
||||
"
|
||||
>
|
||||
This invitation link will expire in 7 days. If you didn't expect this invitation, you can safely ignore this email.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<!--7--><!--/$-->
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
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.`,
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,212 @@
|
||||
<script lang="ts">
|
||||
import { toast } from "svelte-sonner";
|
||||
import { Button } from "$lib/components/ui/button/index.js";
|
||||
import * as Card from "$lib/components/ui/card/index.js";
|
||||
import * as Field from "$lib/components/ui/field/index.js";
|
||||
import * as InputGroup from "$lib/components/ui/input-group/index.js";
|
||||
import LockIcon from "@lucide/svelte/icons/lock";
|
||||
import CheckCircleIcon from "@lucide/svelte/icons/check-circle";
|
||||
import AlertCircleIcon from "@lucide/svelte/icons/alert-circle";
|
||||
import EyeClosedIcon from "@lucide/svelte/icons/eye-closed";
|
||||
import EyeOpenIcon from "@lucide/svelte/icons/eye";
|
||||
import ArrowLeftIcon from "@lucide/svelte/icons/arrow-left";
|
||||
|
||||
const { data } = $props();
|
||||
|
||||
const valid: boolean = $derived(data.valid);
|
||||
const error: string = $derived(data.error);
|
||||
const token: string = $derived(data.token);
|
||||
const email: string = $derived(data.email || "");
|
||||
const name: string = $derived(data.name || "");
|
||||
|
||||
let loading = $state(false);
|
||||
let showPassword = $state(false);
|
||||
let showConfirmPassword = $state(false);
|
||||
let accountActivated = $state(false);
|
||||
|
||||
let newPassword = $state("");
|
||||
let confirmPassword = $state("");
|
||||
|
||||
async function handleAcceptInvitation() {
|
||||
if (!newPassword || !confirmPassword) {
|
||||
toast.error("Please fill in all fields");
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
toast.error("Passwords do not match");
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword.length < 8) {
|
||||
toast.error("Password must be at least 8 characters");
|
||||
return;
|
||||
}
|
||||
|
||||
loading = true;
|
||||
try {
|
||||
const response = await fetch("/account/invitation/api/accept-invitation", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ receivedToken: token, newPassword })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
toast.error(data.error || "Failed to set password");
|
||||
return;
|
||||
}
|
||||
|
||||
accountActivated = true;
|
||||
toast.success("Account activated successfully!");
|
||||
} catch (e) {
|
||||
toast.error("An error occurred. Please try again.");
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleSubmit(e: Event) {
|
||||
e.preventDefault();
|
||||
handleAcceptInvitation();
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Accept Invitation</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="flex min-h-screen items-center justify-center p-4">
|
||||
<Card.Root class="kener-card w-full max-w-md">
|
||||
{#if !valid}
|
||||
<!-- Error View -->
|
||||
<Card.Header class="text-center">
|
||||
<div class="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-red-100">
|
||||
<AlertCircleIcon class="h-8 w-8 text-red-600" />
|
||||
</div>
|
||||
<Card.Title>Invalid Invitation</Card.Title>
|
||||
<Card.Description>{error}</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<Button href="/account/signin" class="w-full">
|
||||
<ArrowLeftIcon class="mr-2 h-4 w-4" />
|
||||
Go to Sign In
|
||||
</Button>
|
||||
</Card.Content>
|
||||
{:else if accountActivated}
|
||||
<!-- Success View -->
|
||||
<Card.Header class="text-center">
|
||||
<div class="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-green-100">
|
||||
<CheckCircleIcon class="h-8 w-8 text-green-600" />
|
||||
</div>
|
||||
<Card.Title>Account Activated</Card.Title>
|
||||
<Card.Description>
|
||||
Your account has been set up successfully. You can now sign in with your new password.
|
||||
</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<Button href="/account/signin" class="w-full">
|
||||
<ArrowLeftIcon class="mr-2 h-4 w-4" />
|
||||
Go to Sign In
|
||||
</Button>
|
||||
</Card.Content>
|
||||
{:else}
|
||||
<!-- Set Password View -->
|
||||
<Card.Header>
|
||||
<Card.Title>Welcome, {name}!</Card.Title>
|
||||
<Card.Description>
|
||||
You've been invited to join as <strong>{email}</strong>. Create a password to activate your account and get
|
||||
started.
|
||||
</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<form onsubmit={handleSubmit}>
|
||||
<Field.Group>
|
||||
<Field.Field class="relative flex flex-col gap-1">
|
||||
<Field.Label for="newPassword">Password</Field.Label>
|
||||
<InputGroup.Root>
|
||||
<InputGroup.Addon>
|
||||
<LockIcon />
|
||||
</InputGroup.Addon>
|
||||
<InputGroup.Input
|
||||
id="newPassword"
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder="••••••••"
|
||||
bind:value={newPassword}
|
||||
required
|
||||
/>
|
||||
<InputGroup.Addon align="inline-end">
|
||||
<InputGroup.Button
|
||||
type="button"
|
||||
aria-label={showPassword ? "Hide password" : "Show password"}
|
||||
title={showPassword ? "Hide password" : "Show password"}
|
||||
size="icon-xs"
|
||||
onclick={() => (showPassword = !showPassword)}
|
||||
>
|
||||
{#if showPassword}
|
||||
<EyeClosedIcon class="size-4" />
|
||||
{:else}
|
||||
<EyeOpenIcon class="size-4" />
|
||||
{/if}
|
||||
</InputGroup.Button>
|
||||
</InputGroup.Addon>
|
||||
</InputGroup.Root>
|
||||
<Field.Description>
|
||||
Password must contain at least 8 characters, one uppercase, one lowercase, and one number.
|
||||
</Field.Description>
|
||||
</Field.Field>
|
||||
|
||||
<Field.Field class="relative flex flex-col gap-1">
|
||||
<Field.Label for="confirmPassword">Confirm Password</Field.Label>
|
||||
<InputGroup.Root>
|
||||
<InputGroup.Addon>
|
||||
<LockIcon />
|
||||
</InputGroup.Addon>
|
||||
<InputGroup.Input
|
||||
id="confirmPassword"
|
||||
type={showConfirmPassword ? "text" : "password"}
|
||||
placeholder="••••••••"
|
||||
bind:value={confirmPassword}
|
||||
required
|
||||
/>
|
||||
<InputGroup.Addon align="inline-end">
|
||||
<InputGroup.Button
|
||||
type="button"
|
||||
aria-label={showConfirmPassword ? "Hide password" : "Show password"}
|
||||
title={showConfirmPassword ? "Hide password" : "Show password"}
|
||||
size="icon-xs"
|
||||
onclick={() => (showConfirmPassword = !showConfirmPassword)}
|
||||
>
|
||||
{#if showConfirmPassword}
|
||||
<EyeClosedIcon class="size-4" />
|
||||
{:else}
|
||||
<EyeOpenIcon class="size-4" />
|
||||
{/if}
|
||||
</InputGroup.Button>
|
||||
</InputGroup.Addon>
|
||||
</InputGroup.Root>
|
||||
</Field.Field>
|
||||
</Field.Group>
|
||||
|
||||
<div class="mt-6">
|
||||
<Button type="submit" class="w-full" disabled={loading}>
|
||||
{#if loading}
|
||||
Activating Account...
|
||||
{:else}
|
||||
Activate Account
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 text-center">
|
||||
<Button variant="link" href="/account/signin" class="text-sm">
|
||||
<ArrowLeftIcon class="mr-1 h-3 w-3" />
|
||||
Back to Sign In
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card.Content>
|
||||
{/if}
|
||||
</Card.Root>
|
||||
</div>
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -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),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<User[]>([]);
|
||||
let users = $state<UserRecordDashboard[]>([]);
|
||||
let page = $state(1);
|
||||
let limit = $state(10);
|
||||
let total = $state(0);
|
||||
@@ -79,8 +68,7 @@
|
||||
let newUser = $state<NewUser>({
|
||||
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 @@
|
||||
<SettingsIcon class="h-4 w-4" />
|
||||
</Button>
|
||||
{/if}
|
||||
{#if currentUser.id === user.id}
|
||||
<Button variant="ghost" size="icon" class="h-8 w-8" href="/manage/app/profile">
|
||||
<ArrowRightIcon class="h-4 w-4" />
|
||||
</Button>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
@@ -419,26 +409,7 @@
|
||||
<Label for="email">Email</Label>
|
||||
<Input id="email" type="email" placeholder="email@example.com" bind:value={newUser.email} required />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="password">Password</Label>
|
||||
<Input id="password" type="password" placeholder="********" bind:value={newUser.password} required />
|
||||
<p class="text-muted-foreground text-xs">
|
||||
Set a dummy password. Ask the user to reset the password once they log in.
|
||||
</p>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="plainPassword">Confirm Password</Label>
|
||||
<Input
|
||||
id="plainPassword"
|
||||
type="password"
|
||||
placeholder="********"
|
||||
bind:value={newUser.plainPassword}
|
||||
required
|
||||
/>
|
||||
{#if newUser.plainPassword && newUser.password !== newUser.plainPassword}
|
||||
<p class="text-destructive text-xs font-medium">Passwords do not match</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="role">Role</Label>
|
||||
<Select.Root type="single" value={newUser.role} onValueChange={(v) => v && (newUser.role = v)}>
|
||||
@@ -475,201 +446,153 @@
|
||||
<Sheet.Title>Settings - {toEditUser?.name}</Sheet.Title>
|
||||
<Sheet.Description>Manage user settings and permissions</Sheet.Description>
|
||||
</Sheet.Header>
|
||||
|
||||
{#if toEditUser}
|
||||
<div class="space-y-6 py-6">
|
||||
<!-- User Info -->
|
||||
<div class="space-y-2 text-sm">
|
||||
<p>
|
||||
<strong>Created At:</strong>
|
||||
{formatDate(toEditUser.created_at)}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Updated At:</strong>
|
||||
{formatDate(toEditUser.updated_at)}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Name:</strong>
|
||||
{toEditUser.name}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Send Verification Email -->
|
||||
{#if !toEditUser.is_verified && canSendEmail}
|
||||
<Card.Root>
|
||||
<Card.Content class="p-4">
|
||||
<p class="mb-3 text-sm">
|
||||
The email is not verified. Send a verification email to the user at {toEditUser.email}.
|
||||
</p>
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={toEditUser.actions.sendingVerificationEmail}
|
||||
onclick={async () => {
|
||||
toEditUser!.actions.sendingVerificationEmail = true;
|
||||
manualSuccess = "";
|
||||
await sendVerificationEmail(toEditUser!.id);
|
||||
toEditUser!.actions.sendingVerificationEmail = false;
|
||||
manualSuccess = "Verification email sent successfully";
|
||||
}}
|
||||
>
|
||||
{#if toEditUser.actions.sendingVerificationEmail}
|
||||
<Spinner class="mr-2 size-4" />
|
||||
{/if}
|
||||
Send Verification Email
|
||||
</Button>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
{/if}
|
||||
|
||||
<!-- Update Password -->
|
||||
<Card.Root>
|
||||
<Card.Content class="p-4">
|
||||
<p class="mb-3 text-sm">Update password for the user</p>
|
||||
<form
|
||||
class="space-y-3"
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
toEditUser!.actions.updatingPassword = true;
|
||||
manualUpdateData("password").then(() => {
|
||||
toEditUser!.actions.updatingPassword = false;
|
||||
});
|
||||
}}
|
||||
>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="space-y-2">
|
||||
<Label for="password2">Password</Label>
|
||||
<Input
|
||||
id="password2"
|
||||
type="password"
|
||||
placeholder="********"
|
||||
bind:value={toEditUser.password}
|
||||
disabled={toEditUser.actions.updatingPassword}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="passwordPlain2">Confirm Password</Label>
|
||||
<Input
|
||||
id="passwordPlain2"
|
||||
type="text"
|
||||
placeholder="********"
|
||||
bind:value={toEditUser.passwordPlain}
|
||||
disabled={toEditUser.actions.updatingPassword}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button type="submit" variant="secondary" disabled={toEditUser.actions.updatingPassword}>
|
||||
{#if toEditUser.actions.updatingPassword}
|
||||
<Spinner class="mr-2 size-4" />
|
||||
{/if}
|
||||
Update Password
|
||||
</Button>
|
||||
</form>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
<!-- Update Role -->
|
||||
<Card.Root>
|
||||
<Card.Content class="p-4">
|
||||
<p class="mb-3 text-sm">
|
||||
Change the role of the user. The user will have different permissions based on the role.
|
||||
<div class="px-4">
|
||||
{#if toEditUser}
|
||||
<div class="space-y-6 py-6">
|
||||
<!-- User Info -->
|
||||
<div class="space-y-2 text-sm">
|
||||
<p>
|
||||
<strong>Created At:</strong>
|
||||
{formatDate(toEditUser.created_at)}
|
||||
</p>
|
||||
<div class="flex items-center gap-3">
|
||||
<Select.Root
|
||||
type="single"
|
||||
value={toEditUser.role}
|
||||
onValueChange={(v) => v && (toEditUser!.role = v)}
|
||||
disabled={toEditUser.actions.updatingRole}
|
||||
>
|
||||
<Select.Trigger class="w-48">
|
||||
{toEditUser.role.toUpperCase()}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
<Select.Item value="editor">EDITOR</Select.Item>
|
||||
<Select.Item value="member">MEMBER</Select.Item>
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={toEditUser.actions.updatingRole}
|
||||
onclick={() => {
|
||||
toEditUser!.actions.updatingRole = true;
|
||||
manualUpdateData("role").then(() => {
|
||||
toEditUser!.actions.updatingRole = false;
|
||||
});
|
||||
}}
|
||||
>
|
||||
{#if toEditUser.actions.updatingRole}
|
||||
<Spinner class="mr-2 size-4" />
|
||||
{/if}
|
||||
Update Role
|
||||
</Button>
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
<p>
|
||||
<strong>Updated At:</strong>
|
||||
{formatDate(toEditUser.updated_at)}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Name:</strong>
|
||||
{toEditUser.name}
|
||||
</p>
|
||||
</div>
|
||||
<!-- Resend Invitation -->
|
||||
{#if !toEditUser.has_password && canSendEmail}
|
||||
<Card.Root>
|
||||
<Card.Content class="p-4">
|
||||
<p class="mb-3 text-sm">
|
||||
This user ha2sn't set their password yet. Resend the invitation email to {toEditUser.email}.
|
||||
</p>
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={toEditUser.actions.resendingInvitation}
|
||||
onclick={async () => {
|
||||
toEditUser!.actions.resendingInvitation = true;
|
||||
manualSuccess = "";
|
||||
await resendInvitationEmail(toEditUser!.email);
|
||||
toEditUser!.actions.resendingInvitation = false;
|
||||
manualSuccess = "Invitation email resent successfully";
|
||||
}}
|
||||
>
|
||||
{#if toEditUser.actions.resendingInvitation}
|
||||
<Spinner class="mr-2 size-4" />
|
||||
{/if}
|
||||
Resend Invitation
|
||||
</Button>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
{/if}
|
||||
|
||||
<!-- Activate/Deactivate User -->
|
||||
{#if toEditUser.is_active}
|
||||
<Card.Root class="border-destructive">
|
||||
<Card.Content class="p-4">
|
||||
<p class="mb-3 text-sm">
|
||||
Deactivate User. The user will not be able to login. Existing session will get invalidated.
|
||||
</p>
|
||||
<Button
|
||||
variant="destructive"
|
||||
disabled={toEditUser.actions.deactivatingUser}
|
||||
onclick={() => {
|
||||
toEditUser!.actions.deactivatingUser = true;
|
||||
toEditUser!.is_active = 0;
|
||||
manualUpdateData("is_active").then(() => {
|
||||
toEditUser!.actions.deactivatingUser = false;
|
||||
});
|
||||
}}
|
||||
>
|
||||
{#if toEditUser.actions.deactivatingUser}
|
||||
<Spinner class="mr-2 size-4" />
|
||||
{/if}
|
||||
Deactivate User
|
||||
</Button>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
{:else}
|
||||
<!-- Update Role -->
|
||||
<Card.Root>
|
||||
<Card.Content class="p-4">
|
||||
<p class="mb-3 text-sm">Activate User. The user will be able to login.</p>
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={toEditUser.actions.activatingUser}
|
||||
onclick={() => {
|
||||
toEditUser!.actions.activatingUser = true;
|
||||
toEditUser!.is_active = 1;
|
||||
manualUpdateData("is_active").then(() => {
|
||||
toEditUser!.actions.activatingUser = false;
|
||||
});
|
||||
}}
|
||||
>
|
||||
{#if toEditUser.actions.activatingUser}
|
||||
<Spinner class="mr-2 size-4" />
|
||||
{/if}
|
||||
Activate User
|
||||
</Button>
|
||||
<p class="mb-3 text-sm">
|
||||
Change the role of the user. The user will have different permissions based on the role.
|
||||
</p>
|
||||
<div class="flex items-center gap-3">
|
||||
<Select.Root
|
||||
type="single"
|
||||
value={toEditUser.role}
|
||||
onValueChange={(v) => v && (toEditUser!.role = v)}
|
||||
disabled={toEditUser.actions.updatingRole}
|
||||
>
|
||||
<Select.Trigger class="w-48">
|
||||
{toEditUser.role.toUpperCase()}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
<Select.Item value="editor">EDITOR</Select.Item>
|
||||
<Select.Item value="member">MEMBER</Select.Item>
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={toEditUser.actions.updatingRole}
|
||||
onclick={() => {
|
||||
toEditUser!.actions.updatingRole = true;
|
||||
manualUpdateData("role").then(() => {
|
||||
toEditUser!.actions.updatingRole = false;
|
||||
});
|
||||
}}
|
||||
>
|
||||
{#if toEditUser.actions.updatingRole}
|
||||
<Spinner class="mr-2 size-4" />
|
||||
{/if}
|
||||
Update Role
|
||||
</Button>
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
{/if}
|
||||
|
||||
<!-- Status Messages -->
|
||||
{#if manualUpdateError}
|
||||
<Alert.Root variant="destructive">
|
||||
<Alert.Description>{manualUpdateError}</Alert.Description>
|
||||
</Alert.Root>
|
||||
{/if}
|
||||
{#if manualSuccess}
|
||||
<Alert.Root class="border-green-500 text-green-500">
|
||||
<Alert.Description>{manualSuccess}</Alert.Description>
|
||||
</Alert.Root>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<!-- Activate/Deactivate User -->
|
||||
{#if toEditUser.is_active}
|
||||
<Card.Root class="border-destructive">
|
||||
<Card.Content class="p-4">
|
||||
<p class="mb-3 text-sm">
|
||||
Deactivate User. The user will not be able to login. Existing session will get invalidated.
|
||||
</p>
|
||||
<Button
|
||||
variant="destructive"
|
||||
disabled={toEditUser.actions.deactivatingUser}
|
||||
onclick={() => {
|
||||
toEditUser!.actions.deactivatingUser = true;
|
||||
toEditUser!.is_active = 0;
|
||||
manualUpdateData("is_active").then(() => {
|
||||
toEditUser!.actions.deactivatingUser = false;
|
||||
});
|
||||
}}
|
||||
>
|
||||
{#if toEditUser.actions.deactivatingUser}
|
||||
<Spinner class="mr-2 size-4" />
|
||||
{/if}
|
||||
Deactivate User
|
||||
</Button>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
{:else}
|
||||
<Card.Root>
|
||||
<Card.Content class="p-4">
|
||||
<p class="mb-3 text-sm">Activate User. The user will be able to login.</p>
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={toEditUser.actions.activatingUser}
|
||||
onclick={() => {
|
||||
toEditUser!.actions.activatingUser = true;
|
||||
toEditUser!.is_active = 1;
|
||||
manualUpdateData("is_active").then(() => {
|
||||
toEditUser!.actions.activatingUser = false;
|
||||
});
|
||||
}}
|
||||
>
|
||||
{#if toEditUser.actions.activatingUser}
|
||||
<Spinner class="mr-2 size-4" />
|
||||
{/if}
|
||||
Activate User
|
||||
</Button>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
{/if}
|
||||
|
||||
<!-- Status Messages -->
|
||||
{#if manualUpdateError}
|
||||
<Alert.Root variant="destructive">
|
||||
<Alert.Description>{manualUpdateError}</Alert.Description>
|
||||
</Alert.Root>
|
||||
{/if}
|
||||
{#if manualSuccess}
|
||||
<Alert.Root class="border-green-500 text-green-500">
|
||||
<Alert.Description>{manualSuccess}</Alert.Description>
|
||||
</Alert.Root>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</Sheet.Content>
|
||||
</Sheet.Root>
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
|
||||
@plugin "@tailwindcss/typography";
|
||||
|
||||
@source not "../node_modules/svelte-codemirror-editor";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
:root {
|
||||
|
||||
Reference in New Issue
Block a user