mirror of
https://github.com/fosrl/pangolin.git
synced 2025-07-23 12:15:36 +02:00
improve email formatting and invite flow for new users
This commit is contained in:
parent
d244d6003b
commit
d447de9e8a
15 changed files with 107 additions and 89 deletions
|
@ -131,7 +131,7 @@ export const newtSessions = sqliteTable("newtSession", {
|
||||||
export const userOrgs = sqliteTable("userOrgs", {
|
export const userOrgs = sqliteTable("userOrgs", {
|
||||||
userId: text("userId")
|
userId: text("userId")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => users.userId),
|
.references(() => users.userId, { onDelete: "cascade" }),
|
||||||
orgId: text("orgId")
|
orgId: text("orgId")
|
||||||
.references(() => orgs.orgId, {
|
.references(() => orgs.orgId, {
|
||||||
onDelete: "cascade"
|
onDelete: "cascade"
|
||||||
|
|
|
@ -6,6 +6,7 @@ import logger from "@server/logger";
|
||||||
export async function sendEmail(
|
export async function sendEmail(
|
||||||
template: ReactElement,
|
template: ReactElement,
|
||||||
opts: {
|
opts: {
|
||||||
|
name: string | undefined;
|
||||||
from: string | undefined;
|
from: string | undefined;
|
||||||
to: string | undefined;
|
to: string | undefined;
|
||||||
subject: string;
|
subject: string;
|
||||||
|
@ -23,14 +24,15 @@ export async function sendEmail(
|
||||||
|
|
||||||
const emailHtml = await render(template);
|
const emailHtml = await render(template);
|
||||||
|
|
||||||
const options = {
|
await emailClient.sendMail({
|
||||||
from: opts.from,
|
from: {
|
||||||
|
name: opts.name || "Pangolin Proxy",
|
||||||
|
address: opts.from,
|
||||||
|
},
|
||||||
to: opts.to,
|
to: opts.to,
|
||||||
subject: opts.subject,
|
subject: opts.subject,
|
||||||
html: emailHtml,
|
html: emailHtml,
|
||||||
};
|
});
|
||||||
|
|
||||||
await emailClient.sendMail(options);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default sendEmail;
|
export default sendEmail;
|
||||||
|
|
|
@ -10,6 +10,7 @@ import {
|
||||||
Tailwind
|
Tailwind
|
||||||
} from "@react-email/components";
|
} from "@react-email/components";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
import LetterHead from "./components/LetterHead";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
email: string;
|
email: string;
|
||||||
|
@ -35,15 +36,7 @@ export const ConfirmPasswordReset = ({ email }: Props) => {
|
||||||
>
|
>
|
||||||
<Body className="font-sans relative">
|
<Body className="font-sans relative">
|
||||||
<Container className="bg-white border border-solid border-gray-200 p-6 max-w-lg mx-auto my-8 rounded-lg">
|
<Container className="bg-white border border-solid border-gray-200 p-6 max-w-lg mx-auto my-8 rounded-lg">
|
||||||
<div className="flex items-center justify-between">
|
<LetterHead />
|
||||||
<div className="text-sm font-bold text-orange-500">
|
|
||||||
Pangolin
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-sm text-gray-500">
|
|
||||||
{new Date().toLocaleDateString()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Heading className="text-2xl font-semibold text-gray-800 text-center">
|
<Heading className="text-2xl font-semibold text-gray-800 text-center">
|
||||||
Password Reset Confirmation
|
Password Reset Confirmation
|
||||||
|
@ -56,10 +49,6 @@ export const ConfirmPasswordReset = ({ email }: Props) => {
|
||||||
reset. If you made this change, no further action is
|
reset. If you made this change, no further action is
|
||||||
required.
|
required.
|
||||||
</Text>
|
</Text>
|
||||||
<Text className="text-base text-gray-700">
|
|
||||||
If you did not request this change, please contact
|
|
||||||
our support team immediately.
|
|
||||||
</Text>
|
|
||||||
<Text className="text-base text-gray-700 mt-2">
|
<Text className="text-base text-gray-700 mt-2">
|
||||||
Thank you for keeping your account secure.
|
Thank you for keeping your account secure.
|
||||||
</Text>
|
</Text>
|
||||||
|
|
|
@ -10,6 +10,7 @@ import {
|
||||||
Tailwind
|
Tailwind
|
||||||
} from "@react-email/components";
|
} from "@react-email/components";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
import LetterHead from "./components/LetterHead";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
email: string;
|
email: string;
|
||||||
|
@ -18,7 +19,7 @@ interface Props {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ResetPasswordCode = ({ email, code, link }: Props) => {
|
export const ResetPasswordCode = ({ email, code, link }: Props) => {
|
||||||
const previewText = `Reset your password, ${email}`;
|
const previewText = `Your password reset code is ${code}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Html>
|
<Html>
|
||||||
|
@ -37,15 +38,7 @@ export const ResetPasswordCode = ({ email, code, link }: Props) => {
|
||||||
>
|
>
|
||||||
<Body className="font-sans">
|
<Body className="font-sans">
|
||||||
<Container className="bg-white border border-solid border-gray-200 p-6 max-w-lg mx-auto my-8 rounded-lg">
|
<Container className="bg-white border border-solid border-gray-200 p-6 max-w-lg mx-auto my-8 rounded-lg">
|
||||||
<div className="flex items-center justify-between">
|
<LetterHead />
|
||||||
<div className="text-sm font-bold text-orange-500">
|
|
||||||
Pangolin
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-sm text-gray-500">
|
|
||||||
{new Date().toLocaleDateString()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Heading className="text-2xl font-semibold text-gray-800 text-center">
|
<Heading className="text-2xl font-semibold text-gray-800 text-center">
|
||||||
Password Reset Request
|
Password Reset Request
|
||||||
|
|
|
@ -10,6 +10,7 @@ import {
|
||||||
Tailwind
|
Tailwind
|
||||||
} from "@react-email/components";
|
} from "@react-email/components";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
import LetterHead from "./components/LetterHead";
|
||||||
|
|
||||||
interface ResourceOTPCodeProps {
|
interface ResourceOTPCodeProps {
|
||||||
email?: string;
|
email?: string;
|
||||||
|
@ -24,7 +25,7 @@ export const ResourceOTPCode = ({
|
||||||
orgName: organizationName,
|
orgName: organizationName,
|
||||||
otp
|
otp
|
||||||
}: ResourceOTPCodeProps) => {
|
}: ResourceOTPCodeProps) => {
|
||||||
const previewText = `Your one-time password for ${resourceName} is ready!`;
|
const previewText = `Your one-time password for ${resourceName} is ${otp}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Html>
|
<Html>
|
||||||
|
@ -43,27 +44,18 @@ export const ResourceOTPCode = ({
|
||||||
>
|
>
|
||||||
<Body className="font-sans">
|
<Body className="font-sans">
|
||||||
<Container className="bg-white border border-solid border-gray-200 p-6 max-w-lg mx-auto my-8 rounded-lg">
|
<Container className="bg-white border border-solid border-gray-200 p-6 max-w-lg mx-auto my-8 rounded-lg">
|
||||||
<div className="flex items-center justify-between">
|
<LetterHead />
|
||||||
<div className="text-sm font-bold text-orange-500">
|
|
||||||
Pangolin
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-sm text-gray-500">
|
|
||||||
{new Date().toLocaleDateString()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Heading className="text-2xl font-semibold text-gray-800 text-center">
|
<Heading className="text-2xl font-semibold text-gray-800 text-center">
|
||||||
Your One-Time Password
|
Your One-Time Password for {resourceName}
|
||||||
</Heading>
|
</Heading>
|
||||||
<Text className="text-base text-gray-700 mt-4">
|
<Text className="text-base text-gray-700 mt-4">
|
||||||
Hi {email || "there"},
|
Hi {email || "there"},
|
||||||
</Text>
|
</Text>
|
||||||
<Text className="text-base text-gray-700 mt-2">
|
<Text className="text-base text-gray-700 mt-2">
|
||||||
You’ve requested a one-time password (OTP) to
|
You’ve requested a one-time password to access{" "}
|
||||||
authenticate with the resource{" "}
|
|
||||||
<strong>{resourceName}</strong> in{" "}
|
<strong>{resourceName}</strong> in{" "}
|
||||||
<strong>{organizationName}</strong>. Use the OTP
|
<strong>{organizationName}</strong>. Use the code
|
||||||
below to complete your authentication:
|
below to complete your authentication:
|
||||||
</Text>
|
</Text>
|
||||||
<Section className="text-center">
|
<Section className="text-center">
|
||||||
|
|
|
@ -11,6 +11,7 @@ import {
|
||||||
Button
|
Button
|
||||||
} from "@react-email/components";
|
} from "@react-email/components";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
import LetterHead from "./components/LetterHead";
|
||||||
|
|
||||||
interface SendInviteLinkProps {
|
interface SendInviteLinkProps {
|
||||||
email: string;
|
email: string;
|
||||||
|
@ -27,7 +28,7 @@ export const SendInviteLink = ({
|
||||||
inviterName,
|
inviterName,
|
||||||
expiresInDays
|
expiresInDays
|
||||||
}: SendInviteLinkProps) => {
|
}: SendInviteLinkProps) => {
|
||||||
const previewText = `${inviterName} invited to join ${orgName}`;
|
const previewText = `${inviterName} invited you to join ${orgName}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Html>
|
<Html>
|
||||||
|
@ -46,18 +47,10 @@ export const SendInviteLink = ({
|
||||||
>
|
>
|
||||||
<Body className="font-sans">
|
<Body className="font-sans">
|
||||||
<Container className="bg-white border border-solid border-gray-200 p-6 max-w-lg mx-auto my-8 rounded-lg">
|
<Container className="bg-white border border-solid border-gray-200 p-6 max-w-lg mx-auto my-8 rounded-lg">
|
||||||
<div className="flex items-center justify-between">
|
<LetterHead />
|
||||||
<div className="text-sm font-bold text-orange-500">
|
|
||||||
Pangolin
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-sm text-gray-500">
|
|
||||||
{new Date().toLocaleDateString()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Heading className="text-2xl font-semibold text-gray-800 text-center">
|
<Heading className="text-2xl font-semibold text-gray-800 text-center">
|
||||||
You're Invite to Join {orgName}
|
Invited to Join {orgName}
|
||||||
</Heading>
|
</Heading>
|
||||||
<Text className="text-base text-gray-700 mt-4">
|
<Text className="text-base text-gray-700 mt-4">
|
||||||
Hi {email || "there"},
|
Hi {email || "there"},
|
||||||
|
|
|
@ -10,6 +10,7 @@ import {
|
||||||
Tailwind
|
Tailwind
|
||||||
} from "@react-email/components";
|
} from "@react-email/components";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
import LetterHead from "./components/LetterHead";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
email: string;
|
email: string;
|
||||||
|
@ -36,15 +37,7 @@ export const TwoFactorAuthNotification = ({ email, enabled }: Props) => {
|
||||||
>
|
>
|
||||||
<Body className="font-sans">
|
<Body className="font-sans">
|
||||||
<Container className="bg-white border border-solid border-gray-200 p-6 max-w-lg mx-auto my-8 rounded-lg">
|
<Container className="bg-white border border-solid border-gray-200 p-6 max-w-lg mx-auto my-8 rounded-lg">
|
||||||
<div className="flex items-center justify-between">
|
<LetterHead />
|
||||||
<div className="text-sm font-bold text-orange-500">
|
|
||||||
Pangolin
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-sm text-gray-500">
|
|
||||||
{new Date().toLocaleDateString()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Heading className="text-2xl font-semibold text-gray-800 text-center">
|
<Heading className="text-2xl font-semibold text-gray-800 text-center">
|
||||||
Two-Factor Authentication{" "}
|
Two-Factor Authentication{" "}
|
||||||
|
@ -71,10 +64,6 @@ export const TwoFactorAuthNotification = ({ email, enabled }: Props) => {
|
||||||
enabling it to protect your account.
|
enabling it to protect your account.
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
<Text className="text-base text-gray-700 mt-2">
|
|
||||||
If you did not make this change, please contact our
|
|
||||||
support team immediately.
|
|
||||||
</Text>
|
|
||||||
<Text className="text-sm text-gray-500 mt-6">
|
<Text className="text-sm text-gray-500 mt-6">
|
||||||
Best regards,
|
Best regards,
|
||||||
<br />
|
<br />
|
||||||
|
|
|
@ -10,6 +10,7 @@ import {
|
||||||
Tailwind
|
Tailwind
|
||||||
} from "@react-email/components";
|
} from "@react-email/components";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
import LetterHead from "./components/LetterHead";
|
||||||
|
|
||||||
interface VerifyEmailProps {
|
interface VerifyEmailProps {
|
||||||
username?: string;
|
username?: string;
|
||||||
|
@ -22,7 +23,7 @@ export const VerifyEmail = ({
|
||||||
verificationCode,
|
verificationCode,
|
||||||
verifyLink
|
verifyLink
|
||||||
}: VerifyEmailProps) => {
|
}: VerifyEmailProps) => {
|
||||||
const previewText = `Verify your email, ${username}`;
|
const previewText = `Your verification code is ${verificationCode}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Html>
|
<Html>
|
||||||
|
@ -41,15 +42,7 @@ export const VerifyEmail = ({
|
||||||
>
|
>
|
||||||
<Body className="font-sans">
|
<Body className="font-sans">
|
||||||
<Container className="bg-white border border-solid border-gray-200 p-6 max-w-lg mx-auto my-8 rounded-lg">
|
<Container className="bg-white border border-solid border-gray-200 p-6 max-w-lg mx-auto my-8 rounded-lg">
|
||||||
<div className="flex items-center justify-between">
|
<LetterHead />
|
||||||
<div className="text-sm font-bold text-orange-500">
|
|
||||||
Pangolin
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-sm text-gray-500">
|
|
||||||
{new Date().toLocaleDateString()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Heading className="text-2xl font-semibold text-gray-800 text-center">
|
<Heading className="text-2xl font-semibold text-gray-800 text-center">
|
||||||
Please Verify Your Email
|
Please Verify Your Email
|
||||||
|
|
36
server/emails/templates/components/LetterHead.tsx
Normal file
36
server/emails/templates/components/LetterHead.tsx
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
export function LetterHead() {
|
||||||
|
return (
|
||||||
|
<table
|
||||||
|
role="presentation"
|
||||||
|
width="100%"
|
||||||
|
style={{
|
||||||
|
marginBottom: "24px"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
style={{
|
||||||
|
fontSize: "14px",
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: "#F97317"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Pangolin
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
style={{
|
||||||
|
fontSize: "14px",
|
||||||
|
textAlign: "right",
|
||||||
|
color: "#6B7280"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{new Date().getFullYear()}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LetterHead;
|
|
@ -84,6 +84,15 @@ export async function signup(
|
||||||
createHttpError(HttpCode.BAD_REQUEST, "Invite does not exist")
|
createHttpError(HttpCode.BAD_REQUEST, "Invite does not exist")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (existingInvite.email !== email) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"Invite is not for this user"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
|
@ -137,7 +137,7 @@ authenticated.post(
|
||||||
verifyUserHasAction(ActionsEnum.inviteUser),
|
verifyUserHasAction(ActionsEnum.inviteUser),
|
||||||
user.inviteUser
|
user.inviteUser
|
||||||
); // maybe make this /invite/create instead
|
); // maybe make this /invite/create instead
|
||||||
authenticated.post("/invite/accept", user.acceptInvite);
|
unauthenticated.post("/invite/accept", user.acceptInvite); // this is supposed to be unauthenticated
|
||||||
|
|
||||||
authenticated.get(
|
authenticated.get(
|
||||||
"/resource/:resourceId/roles",
|
"/resource/:resourceId/roles",
|
||||||
|
|
|
@ -12,6 +12,7 @@ import { fromError } from "zod-validation-error";
|
||||||
import { isWithinExpirationDate } from "oslo";
|
import { isWithinExpirationDate } from "oslo";
|
||||||
import { verifyPassword } from "@server/auth/password";
|
import { verifyPassword } from "@server/auth/password";
|
||||||
import { checkValidInvite } from "@server/auth/checkValidInvite";
|
import { checkValidInvite } from "@server/auth/checkValidInvite";
|
||||||
|
import { verifySession } from "@server/auth";
|
||||||
|
|
||||||
const acceptInviteBodySchema = z
|
const acceptInviteBodySchema = z
|
||||||
.object({
|
.object({
|
||||||
|
@ -72,7 +73,9 @@ export async function acceptInvite(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.user && req.user.email !== existingInvite.email) {
|
const { user, session } = await verifySession(req);
|
||||||
|
|
||||||
|
if (user && user.email !== existingInvite.email) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.BAD_REQUEST,
|
HttpCode.BAD_REQUEST,
|
||||||
|
|
|
@ -304,8 +304,8 @@ export default function ReverseProxyTargets(props: {
|
||||||
{row.original.method}
|
{row.original.method}
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="http">http</SelectItem>
|
<SelectItem value="http">HTTP</SelectItem>
|
||||||
<SelectItem value="https">https</SelectItem>
|
<SelectItem value="https">HTTPS</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
)
|
)
|
||||||
|
|
|
@ -5,19 +5,34 @@ import {
|
||||||
CardContent,
|
CardContent,
|
||||||
CardDescription,
|
CardDescription,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
|
import { createApiClient } from "@app/api";
|
||||||
import LoginForm from "@app/components/LoginForm";
|
import LoginForm from "@app/components/LoginForm";
|
||||||
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
type DashboardLoginFormProps = {
|
type DashboardLoginFormProps = {
|
||||||
redirect?: string;
|
redirect?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function DashboardLoginForm({
|
export default function DashboardLoginForm({
|
||||||
redirect,
|
redirect
|
||||||
}: DashboardLoginFormProps) {
|
}: DashboardLoginFormProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
// const api = createApiClient(useEnvContext());
|
||||||
|
//
|
||||||
|
// useEffect(() => {
|
||||||
|
// const logout = async () => {
|
||||||
|
// try {
|
||||||
|
// await api.post("/auth/logout");
|
||||||
|
// console.log("user logged out");
|
||||||
|
// } catch (e) {}
|
||||||
|
// };
|
||||||
|
//
|
||||||
|
// logout();
|
||||||
|
// });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="w-full max-w-md">
|
<Card className="w-full max-w-md">
|
||||||
|
|
|
@ -20,10 +20,6 @@ export default async function InvitePage(props: {
|
||||||
|
|
||||||
const user = await verifySession();
|
const user = await verifySession();
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
redirect(`/auth/signup?redirect=/invite?token=${params.token}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const parts = tokenParam.split("-");
|
const parts = tokenParam.split("-");
|
||||||
if (parts.length !== 2) {
|
if (parts.length !== 2) {
|
||||||
return (
|
return (
|
||||||
|
@ -70,9 +66,17 @@ export default async function InvitePage(props: {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const type = cardType();
|
||||||
|
|
||||||
|
console.log("card type is", type, error)
|
||||||
|
|
||||||
|
if (!user && type === "user_does_not_exist") {
|
||||||
|
redirect(`/auth/signup?redirect=/invite?token=${params.token}`);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<InviteStatusCard type={cardType()} token={tokenParam} />
|
<InviteStatusCard type={type} token={tokenParam} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue