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:
Raj Nandan Sharma
2026-02-06 10:24:43 +05:30
parent ba41c6f2a7
commit 6ed95ed8dc
16 changed files with 949 additions and 461 deletions
+14
View File
@@ -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,
});
}
}
-1
View File
@@ -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);
};
+150 -2
View File
@@ -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],
);
}
};
+2 -12
View File
@@ -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 {
+7 -57
View File
@@ -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, &quot;Apple Color Emoji&quot;,
&quot;Segoe UI Emoji&quot;, &quot;Segoe UI Symbol&quot;, &quot;Noto Color Emoji&quot;;
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&#x27;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.`,
};
+3 -24
View File
@@ -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 });
};
+1 -2
View File
@@ -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),
};
};
+12 -51
View File
@@ -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);
+178 -255
View File
@@ -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>
+2
View File
@@ -4,6 +4,8 @@
@plugin "@tailwindcss/typography";
@source not "../node_modules/svelte-codemirror-editor";
@custom-variant dark (&:is(.dark *));
:root {