diff --git a/seeds/generate_template.ts b/seeds/generate_template.ts index e71afead..40a805e8 100644 --- a/seeds/generate_template.ts +++ b/seeds/generate_template.ts @@ -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 { @@ -58,4 +59,18 @@ export async function seed(knex: Knex): Promise { 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, + }); + } } diff --git a/src/lib/global-constants.ts b/src/lib/global-constants.ts index eac429fd..9020cb76 100644 --- a/src/lib/global-constants.ts +++ b/src/lib/global-constants.ts @@ -69,6 +69,7 @@ export default { STATUS: "STATUS", LATENCY: "LATENCY", UPTIME: "UPTIME", + DOCS_URL: "https://kener.ing/docs", } as const; const AnalyticsProviders = { diff --git a/src/lib/server/controllers/emailController.ts b/src/lib/server/controllers/emailController.ts index 18dbc7b7..0ff8125a 100644 --- a/src/lib/server/controllers/emailController.ts +++ b/src/lib/server/controllers/emailController.ts @@ -15,7 +15,7 @@ export const IsResendSetup = () => { }; export const IsEmailSetup = () => { - return !!GetSMTPFromENV || IsResendSetup(); + return !!GetSMTPFromENV() || IsResendSetup(); }; export const SendEmailWithTemplate = async ( template: string, diff --git a/src/lib/server/controllers/userController.ts b/src/lib/server/controllers/userController.ts index f0e09b26..bc4a72eb 100644 --- a/src/lib/server/controllers/userController.ts +++ b/src/lib/server/controllers/userController.ts @@ -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, + ]); +}; diff --git a/src/lib/server/notification/email_notification.ts b/src/lib/server/notification/email_notification.ts index 8c98b3fb..b952040d 100644 --- a/src/lib/server/notification/email_notification.ts +++ b/src/lib/server/notification/email_notification.ts @@ -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; } } diff --git a/src/lib/server/templates/general/verify_email_template.ts b/src/lib/server/templates/general/verify_email_template.ts new file mode 100644 index 00000000..6bb8666b --- /dev/null +++ b/src/lib/server/templates/general/verify_email_template.ts @@ -0,0 +1,204 @@ +const emailTemplate = ` + + + + + + + + +
+ Verify your email for {{site_name}} +
+ + + + + + +
+ + + + + + +
+ {{site_name}} +
+ + + + + + +
+

+ Verify Your Email +

+

+ Please confirm your email address for {{site_name}} by clicking the button below: +

+ + + + + + +
+ + Verify Email + +
+

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

+

+ {{verification_link}} +

+

+ If you didn't request this, you can safely ignore this email. +

+
+
+ + +`; + +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.`, +}; diff --git a/src/lib/version.ts b/src/lib/version.ts index e66c844a..5198a88c 100644 --- a/src/lib/version.ts +++ b/src/lib/version.ts @@ -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"; } } diff --git a/src/routes/(account)/account/verify/+page.server.ts b/src/routes/(account)/account/verify/+page.server.ts new file mode 100644 index 00000000..afc70191 --- /dev/null +++ b/src/routes/(account)/account/verify/+page.server.ts @@ -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")); +}; diff --git a/src/routes/(account)/account/verify/+page.svelte b/src/routes/(account)/account/verify/+page.svelte new file mode 100644 index 00000000..daf527ba --- /dev/null +++ b/src/routes/(account)/account/verify/+page.svelte @@ -0,0 +1,33 @@ + + + + Email Verification + + +
+ + +
+ +
+ Email Verification Failed + {error} +
+ + + +
+
diff --git a/src/routes/(manage)/+layout.server.ts b/src/routes/(manage)/+layout.server.ts index ded2034e..31f07d25 100644 --- a/src/routes/(manage)/+layout.server.ts +++ b/src/routes/(manage)/+layout.server.ts @@ -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, diff --git a/src/routes/(manage)/manage/api/+server.ts b/src/routes/(manage)/manage/api/+server.ts index a4bba2fb..1aca3cd1 100644 --- a/src/routes/(manage)/manage/api/+server.ts +++ b/src/routes/(manage)/manage/api/+server.ts @@ -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; diff --git a/src/routes/(manage)/manage/app-sidebar.svelte b/src/routes/(manage)/manage/app-sidebar.svelte index ea9b9110..423b9c85 100644 --- a/src/routes/(manage)/manage/app-sidebar.svelte +++ b/src/routes/(manage)/manage/app-sidebar.svelte @@ -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 = $props(); + const appVersion = version(); @@ -17,9 +19,14 @@ {#snippet child({ props })} - + Kener Logo Kener + v{appVersion} {/snippet} diff --git a/src/routes/(manage)/manage/app/users/+page.server.ts b/src/routes/(manage)/manage/app/users/+page.server.ts deleted file mode 100644 index f3ca22d1..00000000 --- a/src/routes/(manage)/manage/app/users/+page.server.ts +++ /dev/null @@ -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, - }; -}; diff --git a/src/routes/(manage)/manage/app/users/+page.svelte b/src/routes/(manage)/manage/app/users/+page.svelte index 40e6bcf1..906d06a4 100644 --- a/src/routes/(manage)/manage/app/users/+page.svelte +++ b/src/routes/(manage)/manage/app/users/+page.svelte @@ -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(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(); }); @@ -284,9 +297,18 @@ {#if loading} {/if} - {#if currentUser.role === "admin"} - {/if} @@ -321,7 +343,7 @@ No users found. {:else} - {#each users as user} + {#each users as user (user.id)} {user.name} {user.email} @@ -349,6 +371,18 @@ + {:else if currentUser.id === user.id && !!!currentUser.is_verified} + {/if} @@ -370,7 +404,7 @@
- {#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)}
- {#if !toEditUser.has_password && canSendEmail} + {#if !toEditUser.has_password} - +

- 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}.

+ {#if !canSendEmail} + + Email service not configured. Cannot resend invitation email. + + {/if}