This commit is contained in:
Raj Nandan Sharma
2026-02-13 16:48:47 +05:30
parent 552bdc09ad
commit 275c9d7ff4
14 changed files with 437 additions and 47 deletions
+15
View File
@@ -2,6 +2,7 @@ import subscriptionAccountCodeTemplate from "../src/lib/server/templates/general
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 verifyEmailTemplate from "../src/lib/server/templates/general/verify_email_template.ts";
import type { Knex } from "knex";
export async function seed(knex: Knex): Promise<void> {
@@ -58,4 +59,18 @@ export async function seed(knex: Knex): Promise<void> {
template_text_body: inviteUserTemplate.template_text_body,
});
}
count = await knex("general_email_templates")
.where({ template_id: verifyEmailTemplate.template_id })
.count("template_id as CNT")
.first();
if (count && count.CNT == 0) {
await knex("general_email_templates").insert({
template_id: verifyEmailTemplate.template_id,
template_subject: verifyEmailTemplate.template_subject,
template_html_body: verifyEmailTemplate.template_html_body,
template_text_body: verifyEmailTemplate.template_text_body,
});
}
}
+1
View File
@@ -69,6 +69,7 @@ export default {
STATUS: "STATUS",
LATENCY: "LATENCY",
UPTIME: "UPTIME",
DOCS_URL: "https://kener.ing/docs",
} as const;
const AnalyticsProviders = {
@@ -15,7 +15,7 @@ export const IsResendSetup = () => {
};
export const IsEmailSetup = () => {
return !!GetSMTPFromENV || IsResendSetup();
return !!GetSMTPFromENV() || IsResendSetup();
};
export const SendEmailWithTemplate = async (
template: string,
@@ -377,3 +377,48 @@ export const ResendInvitationEmail = async (email: string, currentUserRole: stri
);
}
};
// send verification email with verification link
export const SendVerificationEmail = async (toUserId: number, currentUser: { id: number; role: string }) => {
if (!toUserId) {
throw new Error("User ID is required");
}
// Only admins/editors can send verification to other users.
// Members can only send verification email to themselves.
if (currentUser.role === "member" && currentUser.id !== toUserId) {
throw new Error("You do not have permission to send verification email for this user");
}
const user = await db.getUserById(toUserId);
if (!user) {
throw new Error("User not found");
}
if (user.is_verified) {
throw new Error("User email is already verified");
}
const token = await GenerateToken({
email: user.email,
validTill: Date.now() + 24 * 60 * 60 * 1000, //24 hours
});
const siteData = await GetAllSiteData();
const siteVars = siteDataToVariables(siteData);
const siteUrl = siteVars.site_url || "";
const verificationLink = `${siteUrl}/account/verify?view=confirm_token&token=${token}`;
const emailVars = {
...siteVars,
verification_link: verificationLink,
};
const template = await GetGeneralEmailTemplateById("verify_email");
if (!template) {
throw new Error("Verify email template not found");
}
await sendEmail(template.template_html_body || "", template.template_subject || "Verify Your Email", emailVars, [
user.email,
]);
};
@@ -47,7 +47,11 @@ export default async function send(
html: htmlBody,
text: textBody,
};
return await resend.emails.send(emailBody);
let resp = await resend.emails.send(emailBody);
if (!!resp.error) {
throw new Error(`Resend API error: ${resp.error.message}`);
}
return resp;
} else if (mySMTPData) {
// SMTP Configuration
const transport = getSMTPTransport(mySMTPData as SMTPConfiguration);
@@ -59,9 +63,11 @@ export default async function send(
html: htmlBody, // HTML body (if any)
};
return await transport.sendMail(mailOptions);
} else {
throw new Error("No valid email configuration found. Please check your SMTP or Resend settings.");
}
} catch (error) {
console.error("Error sending email", error);
return error;
throw error;
}
}
@@ -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;
"
>
Verify your email for {{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;
"
>
Verify Your Email
</h1>
<p
style="
font-size: 16px;
color: rgb(75, 85, 99);
margin-bottom: 24px;
line-height: 24px;
margin-top: 16px;
"
>
Please confirm your email address for {{site_name}} by clicking the button below:
</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="{{verification_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"
>
Verify Email
</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;
"
>
{{verification_link}}
</p>
<p
style="
font-size: 16px;
color: rgb(75, 85, 99);
margin-bottom: 24px;
line-height: 24px;
margin-top: 16px;
"
>
If you didn&#x27;t request this, you can safely ignore this email.
</p>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<!--7--><!--/$-->
</body>
</html>`;
export default {
template_id: "verify_email",
template_subject: "{{site_name}} - Verify Your Email",
template_html_body: emailTemplate,
template_text_body: `Verify your email for {{site_name}}\n\nPlease confirm your email address by clicking the link below:\n\n{{verification_link}}\n\nIf you didn't request this, you can safely ignore this email.`,
};
+4 -12
View File
@@ -1,7 +1,3 @@
import { fileURLToPath } from "url";
import { dirname, resolve } from "path";
import fs from "fs";
function getVersionUsingVite() {
try {
if (!!import.meta.env.PACKAGE_VERSION) {
@@ -18,14 +14,10 @@ export default function version() {
if (!!v) {
return v;
} else {
try {
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const packagePath = resolve(__dirname, "../../package.json");
const packageJson = JSON.parse(fs.readFileSync(packagePath, "utf8"));
return packageJson.version;
} catch (e) {
return "0.0.0";
// Browser-safe fallback (avoid importing Node modules in client bundle)
if (typeof process !== "undefined" && process.env?.npm_package_version) {
return process.env.npm_package_version;
}
return "0.0.0";
}
}
@@ -0,0 +1,55 @@
import { redirect } from "@sveltejs/kit";
import type { PageServerLoad } from "./$types";
import { VerifyToken } from "$lib/server/controllers/commonController.js";
import db from "$lib/server/db/db.js";
import serverResolve from "$lib/server/resolver.js";
export const load: PageServerLoad = async ({ url }) => {
const view = url.searchParams.get("view") || "";
const token = url.searchParams.get("token") || "";
if (view !== "confirm_token" || !token) {
return {
valid: false,
error: "Invalid or missing verification link.",
};
}
const tokenData = await VerifyToken(token);
if (!tokenData) {
return {
valid: false,
error: "Invalid or expired verification link.",
};
}
const email = tokenData.email;
if (!email) {
return {
valid: false,
error: "Invalid verification link.",
};
}
const validTill = tokenData.validTill;
if (!validTill || Date.now() > validTill) {
return {
valid: false,
error: "This verification link has expired. Please request a new one.",
};
}
const user = await db.getUserByEmail(email);
if (!user) {
return {
valid: false,
error: "No user found for this verification link.",
};
}
if (!user.is_verified) {
await db.updateIsVerified(user.id, 1);
}
throw redirect(302, serverResolve("/manage/app/users"));
};
@@ -0,0 +1,33 @@
<script lang="ts">
import { Button } from "$lib/components/ui/button/index.js";
import * as Card from "$lib/components/ui/card/index.js";
import AlertCircleIcon from "@lucide/svelte/icons/alert-circle";
import ArrowLeftIcon from "@lucide/svelte/icons/arrow-left";
import { resolve } from "$app/paths";
import clientResolver from "$lib/client/resolver.js";
const { data } = $props();
const error: string = $derived(data.error || "Invalid verification link.");
</script>
<svelte:head>
<title>Email Verification</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">
<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>Email Verification Failed</Card.Title>
<Card.Description>{error}</Card.Description>
</Card.Header>
<Card.Content>
<Button href={clientResolver(resolve, "/account/signin")} class="w-full">
<ArrowLeftIcon class="mr-2 h-4 w-4" />
Go to Sign In
</Button>
</Card.Content>
</Card.Root>
</div>
-2
View File
@@ -28,11 +28,9 @@ export const load: LayoutServerLoad = async ({ cookies }) => {
const siteData = await GetAllSiteData();
let selectedLang = GetLocaleFromCookie(siteData, cookies);
const siteStatusColors = siteData.colors;
const siteStatusColorsDark = siteData.colorsDark || siteStatusColors;
const font = siteData.font || { cssSrc: "", family: "" };
// const emailSubscriptionTrigger = await GetSubscriptionTriggerByEmail();
return {
userDb: loggedInUser,
@@ -37,6 +37,7 @@ import {
GetSiteLogoURL,
UpdatePassword,
SendInvitationEmail,
SendVerificationEmail,
ResendInvitationEmail,
GetUserPasswordHashById,
GetAllUsersPaginatedDashboard,
@@ -180,6 +181,13 @@ export async function POST({ request, cookies }) {
AdminEditorCan(userDB.role);
await ResendInvitationEmail(data.email, userDB.role);
resp = { success: true };
} else if (action == "sendVerificationEmail") {
const toId = parseInt(String(data.toId));
if (!toId) {
throw new Error("User ID is required");
}
await SendVerificationEmail(toId, { id: userDB.id, role: userDB.role });
resp = { success: true };
} else if (action == "getUsers") {
const page = parseInt(String(data.page)) || 1;
const limit = parseInt(String(data.limit)) || 10;
@@ -3,12 +3,14 @@
import NavMain from "./nav-main.svelte";
import NavUser from "./nav-user.svelte";
import * as Sidebar from "$lib/components/ui/sidebar/index.js";
import version from "$lib/version";
import type { Component, ComponentProps } from "svelte";
import { resolve } from "$app/paths";
import clientResolver from "$lib/client/resolver.js";
type NavItem = { title: string; url: string; icon: Component };
let { navItems, ...restProps }: { navItems: NavItem[] } & ComponentProps<typeof Sidebar.Root> = $props();
const appVersion = version();
</script>
<Sidebar.Root collapsible="offcanvas" {...restProps}>
@@ -17,9 +19,14 @@
<Sidebar.MenuItem>
<Sidebar.MenuButton class="data-[slot=sidebar-menu-button]:p-1.5!">
{#snippet child({ props })}
<a href={clientResolver(resolve, "/manage/app")} {...props}>
<a
href={clientResolver(resolve, "/manage/app")}
{...props}
class="justify-start-safe flex items-center gap-2"
>
<img src={clientResolver(resolve, "/logo96.png")} class="size-5!" alt="Kener Logo" />
<span class="text-base font-semibold"> Kener </span>
<span class="text-muted-foreground pt-0.5 text-xs font-medium"> v{appVersion} </span>
</a>
{/snippet}
</Sidebar.MenuButton>
@@ -1,13 +0,0 @@
import type { PageServerLoad } from "./$types";
import { IsEmailSetup } from "$lib/server/controllers/emailController.js";
import { GetLoggedInSession } from "$lib/server/controllers/userController.js";
export const load: PageServerLoad = async ({ cookies }) => {
const canSendEmail = IsEmailSetup();
const loggedInUser = await GetLoggedInSession(cookies);
return {
canSendEmail,
user: loggedInUser || null,
};
};
@@ -8,6 +8,9 @@
import { Button } from "$lib/components/ui/button/index.js";
import { Input } from "$lib/components/ui/input/index.js";
import { Label } from "$lib/components/ui/label/index.js";
import { buttonVariants } from "$lib/components/ui/button/index.js";
import GC from "$lib/global-constants";
import { Badge } from "$lib/components/ui/badge/index.js";
import { Spinner } from "$lib/components/ui/spinner/index.js";
import UsersIcon from "@lucide/svelte/icons/users";
@@ -20,10 +23,11 @@
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 * as Tooltip from "$lib/components/ui/tooltip/index.js";
import { toast } from "svelte-sonner";
import { format } from "date-fns";
import type { UserRecordDashboard } from "$lib/server/types/db.js";
import { onMount } from "svelte";
import type { UserRecordDashboard, UserRecordPublic } from "$lib/server/types/db.js";
import { resolve } from "$app/paths";
import clientResolver from "$lib/client/resolver.js";
@@ -45,14 +49,14 @@
}
interface PageData {
user: UserRecordDashboard;
userDb: UserRecordPublic;
canSendEmail: boolean;
}
let { data }: { data: PageData } = $props();
// Derived from data
let currentUser = $derived(data.user);
let currentUser = $derived(data.userDb);
let canSendEmail = $derived(data.canSendEmail);
// State
@@ -79,6 +83,7 @@
let toEditUser = $state<EditUser | null>(null);
let manualUpdateError = $state("");
let manualSuccess = $state("");
let sendingSelfVerification = $state(false);
// Fetch users
async function fetchUsers() {
@@ -183,8 +188,9 @@
// Send verification email
async function sendVerificationEmail(id: number) {
sendingSelfVerification = true;
try {
await fetch(clientResolver(resolve, "/manage/api"), {
const res = await fetch(clientResolver(resolve, "/manage/api"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -192,9 +198,16 @@
data: { toId: id }
})
});
const result = await res.json();
if (!res.ok || result.error) {
throw new Error(result.error || "Failed to send verification email");
}
toast.success("Verification email sent");
} catch (error) {
toast.error("Failed to send verification email");
const message = error instanceof Error ? error.message : "Failed to send verification email";
toast.error(message);
} finally {
sendingSelfVerification = false;
}
}
@@ -265,7 +278,7 @@
}
// Initial load
$effect(() => {
onMount(() => {
fetchUsers();
});
</script>
@@ -284,9 +297,18 @@
{#if loading}
<Spinner class="size-5" />
{/if}
{#if currentUser.role === "admin"}
<Button onclick={() => (showAddUserDialog = true)}>
<PlusIcon class="mr-2 h-4 w-4" />
{#if currentUser.role === "admin" || currentUser.role === "editor"}
{#if !canSendEmail}
<p class="text-muted-foreground max-w-xs text-xs">
Email service not configured. Cannot invite new users. Please go to
<a href={`${GC.DOCS_URL}/setup/email-setup`} target="_blank" class="text-blue-500 underline">
setup email
</a>
for more info.
</p>
{/if}
<Button onclick={() => (showAddUserDialog = true)} disabled={!canSendEmail}>
<PlusIcon class="h-4 w-4" />
Add User
</Button>
{/if}
@@ -321,7 +343,7 @@
<Table.Cell colspan={6} class="text-muted-foreground py-8 text-center">No users found.</Table.Cell>
</Table.Row>
{:else}
{#each users as user}
{#each users as user (user.id)}
<Table.Row>
<Table.Cell class="font-medium">{user.name}</Table.Cell>
<Table.Cell>{user.email}</Table.Cell>
@@ -349,6 +371,18 @@
<Button variant="ghost" size="icon" class="h-8 w-8" onclick={() => openSettingsSheet(user)}>
<SettingsIcon class="h-4 w-4" />
</Button>
{:else if currentUser.id === user.id && !!!currentUser.is_verified}
<Button
variant="outline"
size="sm"
disabled={sendingSelfVerification}
onclick={() => sendVerificationEmail(user.id)}
>
{#if sendingSelfVerification}
<Spinner class="mr-2 size-4" />
{/if}
Verify Email
</Button>
{/if}
</Table.Cell>
</Table.Row>
@@ -370,7 +404,7 @@
<ChevronLeftIcon class="size-4" />
</Button>
<div class="flex items-center gap-1">
{#each Array.from({ length: totalPages }, (_, i) => i + 1) as pageNum}
{#each Array.from({ length: totalPages }, (_, i) => i + 1) as pageNum (pageNum)}
{#if pageNum === 1 || pageNum === totalPages || (pageNum >= page - 1 && pageNum <= page + 1)}
<Button variant={pageNum === page ? "default" : "ghost"} size="sm" onclick={() => goToPage(pageNum)}>
{pageNum}
@@ -467,15 +501,20 @@
</p>
</div>
<!-- Resend Invitation -->
{#if !toEditUser.has_password && canSendEmail}
{#if !toEditUser.has_password}
<Card.Root>
<Card.Content class="p-4">
<Card.Content class="">
<p class="mb-3 text-sm">
This user ha2sn't set their password yet. Resend the invitation email to {toEditUser.email}.
This user hasn't set their password yet. Resend the invitation email to {toEditUser.email}.
</p>
{#if !canSendEmail}
<Alert.Root variant="destructive" class="mb-4">
<Alert.Description>Email service not configured. Cannot resend invitation email.</Alert.Description>
</Alert.Root>
{/if}
<Button
variant="secondary"
disabled={toEditUser.actions.resendingInvitation}
disabled={toEditUser.actions.resendingInvitation || !canSendEmail}
onclick={async () => {
toEditUser!.actions.resendingInvitation = true;
manualSuccess = "";