mirror of
https://github.com/fosrl/pangolin.git
synced 2025-07-22 19:55:37 +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", {
|
||||
userId: text("userId")
|
||||
.notNull()
|
||||
.references(() => users.userId),
|
||||
.references(() => users.userId, { onDelete: "cascade" }),
|
||||
orgId: text("orgId")
|
||||
.references(() => orgs.orgId, {
|
||||
onDelete: "cascade"
|
||||
|
@ -395,4 +395,4 @@ export type ResourcePassword = InferSelectModel<typeof resourcePassword>;
|
|||
export type ResourceOtp = InferSelectModel<typeof resourceOtp>;
|
||||
export type ResourceAccessToken = InferSelectModel<typeof resourceAccessToken>;
|
||||
export type ResourceWhitelist = InferSelectModel<typeof resourceWhitelist>;
|
||||
export type VersionMigration = InferSelectModel<typeof versionMigrations>;
|
||||
export type VersionMigration = InferSelectModel<typeof versionMigrations>;
|
||||
|
|
|
@ -6,6 +6,7 @@ import logger from "@server/logger";
|
|||
export async function sendEmail(
|
||||
template: ReactElement,
|
||||
opts: {
|
||||
name: string | undefined;
|
||||
from: string | undefined;
|
||||
to: string | undefined;
|
||||
subject: string;
|
||||
|
@ -23,14 +24,15 @@ export async function sendEmail(
|
|||
|
||||
const emailHtml = await render(template);
|
||||
|
||||
const options = {
|
||||
from: opts.from,
|
||||
await emailClient.sendMail({
|
||||
from: {
|
||||
name: opts.name || "Pangolin Proxy",
|
||||
address: opts.from,
|
||||
},
|
||||
to: opts.to,
|
||||
subject: opts.subject,
|
||||
html: emailHtml,
|
||||
};
|
||||
|
||||
await emailClient.sendMail(options);
|
||||
});
|
||||
}
|
||||
|
||||
export default sendEmail;
|
||||
|
|
|
@ -10,6 +10,7 @@ import {
|
|||
Tailwind
|
||||
} from "@react-email/components";
|
||||
import * as React from "react";
|
||||
import LetterHead from "./components/LetterHead";
|
||||
|
||||
interface Props {
|
||||
email: string;
|
||||
|
@ -35,15 +36,7 @@ export const ConfirmPasswordReset = ({ email }: Props) => {
|
|||
>
|
||||
<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">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm font-bold text-orange-500">
|
||||
Pangolin
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-gray-500">
|
||||
{new Date().toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
<LetterHead />
|
||||
|
||||
<Heading className="text-2xl font-semibold text-gray-800 text-center">
|
||||
Password Reset Confirmation
|
||||
|
@ -56,10 +49,6 @@ export const ConfirmPasswordReset = ({ email }: Props) => {
|
|||
reset. If you made this change, no further action is
|
||||
required.
|
||||
</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">
|
||||
Thank you for keeping your account secure.
|
||||
</Text>
|
||||
|
|
|
@ -10,6 +10,7 @@ import {
|
|||
Tailwind
|
||||
} from "@react-email/components";
|
||||
import * as React from "react";
|
||||
import LetterHead from "./components/LetterHead";
|
||||
|
||||
interface Props {
|
||||
email: string;
|
||||
|
@ -18,7 +19,7 @@ interface Props {
|
|||
}
|
||||
|
||||
export const ResetPasswordCode = ({ email, code, link }: Props) => {
|
||||
const previewText = `Reset your password, ${email}`;
|
||||
const previewText = `Your password reset code is ${code}`;
|
||||
|
||||
return (
|
||||
<Html>
|
||||
|
@ -37,15 +38,7 @@ export const ResetPasswordCode = ({ email, code, link }: Props) => {
|
|||
>
|
||||
<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">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm font-bold text-orange-500">
|
||||
Pangolin
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-gray-500">
|
||||
{new Date().toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
<LetterHead />
|
||||
|
||||
<Heading className="text-2xl font-semibold text-gray-800 text-center">
|
||||
Password Reset Request
|
||||
|
|
|
@ -10,6 +10,7 @@ import {
|
|||
Tailwind
|
||||
} from "@react-email/components";
|
||||
import * as React from "react";
|
||||
import LetterHead from "./components/LetterHead";
|
||||
|
||||
interface ResourceOTPCodeProps {
|
||||
email?: string;
|
||||
|
@ -24,7 +25,7 @@ export const ResourceOTPCode = ({
|
|||
orgName: organizationName,
|
||||
otp
|
||||
}: ResourceOTPCodeProps) => {
|
||||
const previewText = `Your one-time password for ${resourceName} is ready!`;
|
||||
const previewText = `Your one-time password for ${resourceName} is ${otp}`;
|
||||
|
||||
return (
|
||||
<Html>
|
||||
|
@ -43,27 +44,18 @@ export const ResourceOTPCode = ({
|
|||
>
|
||||
<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">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm font-bold text-orange-500">
|
||||
Pangolin
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-gray-500">
|
||||
{new Date().toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
<LetterHead />
|
||||
|
||||
<Heading className="text-2xl font-semibold text-gray-800 text-center">
|
||||
Your One-Time Password
|
||||
Your One-Time Password for {resourceName}
|
||||
</Heading>
|
||||
<Text className="text-base text-gray-700 mt-4">
|
||||
Hi {email || "there"},
|
||||
</Text>
|
||||
<Text className="text-base text-gray-700 mt-2">
|
||||
You’ve requested a one-time password (OTP) to
|
||||
authenticate with the resource{" "}
|
||||
You’ve requested a one-time password to access{" "}
|
||||
<strong>{resourceName}</strong> in{" "}
|
||||
<strong>{organizationName}</strong>. Use the OTP
|
||||
<strong>{organizationName}</strong>. Use the code
|
||||
below to complete your authentication:
|
||||
</Text>
|
||||
<Section className="text-center">
|
||||
|
|
|
@ -11,6 +11,7 @@ import {
|
|||
Button
|
||||
} from "@react-email/components";
|
||||
import * as React from "react";
|
||||
import LetterHead from "./components/LetterHead";
|
||||
|
||||
interface SendInviteLinkProps {
|
||||
email: string;
|
||||
|
@ -27,7 +28,7 @@ export const SendInviteLink = ({
|
|||
inviterName,
|
||||
expiresInDays
|
||||
}: SendInviteLinkProps) => {
|
||||
const previewText = `${inviterName} invited to join ${orgName}`;
|
||||
const previewText = `${inviterName} invited you to join ${orgName}`;
|
||||
|
||||
return (
|
||||
<Html>
|
||||
|
@ -46,18 +47,10 @@ export const SendInviteLink = ({
|
|||
>
|
||||
<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">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm font-bold text-orange-500">
|
||||
Pangolin
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-gray-500">
|
||||
{new Date().toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
<LetterHead />
|
||||
|
||||
<Heading className="text-2xl font-semibold text-gray-800 text-center">
|
||||
You're Invite to Join {orgName}
|
||||
Invited to Join {orgName}
|
||||
</Heading>
|
||||
<Text className="text-base text-gray-700 mt-4">
|
||||
Hi {email || "there"},
|
||||
|
|
|
@ -10,6 +10,7 @@ import {
|
|||
Tailwind
|
||||
} from "@react-email/components";
|
||||
import * as React from "react";
|
||||
import LetterHead from "./components/LetterHead";
|
||||
|
||||
interface Props {
|
||||
email: string;
|
||||
|
@ -36,15 +37,7 @@ export const TwoFactorAuthNotification = ({ email, enabled }: Props) => {
|
|||
>
|
||||
<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">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm font-bold text-orange-500">
|
||||
Pangolin
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-gray-500">
|
||||
{new Date().toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
<LetterHead />
|
||||
|
||||
<Heading className="text-2xl font-semibold text-gray-800 text-center">
|
||||
Two-Factor Authentication{" "}
|
||||
|
@ -71,10 +64,6 @@ export const TwoFactorAuthNotification = ({ email, enabled }: Props) => {
|
|||
enabling it to protect your account.
|
||||
</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">
|
||||
Best regards,
|
||||
<br />
|
||||
|
|
|
@ -10,6 +10,7 @@ import {
|
|||
Tailwind
|
||||
} from "@react-email/components";
|
||||
import * as React from "react";
|
||||
import LetterHead from "./components/LetterHead";
|
||||
|
||||
interface VerifyEmailProps {
|
||||
username?: string;
|
||||
|
@ -22,7 +23,7 @@ export const VerifyEmail = ({
|
|||
verificationCode,
|
||||
verifyLink
|
||||
}: VerifyEmailProps) => {
|
||||
const previewText = `Verify your email, ${username}`;
|
||||
const previewText = `Your verification code is ${verificationCode}`;
|
||||
|
||||
return (
|
||||
<Html>
|
||||
|
@ -41,15 +42,7 @@ export const VerifyEmail = ({
|
|||
>
|
||||
<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">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm font-bold text-orange-500">
|
||||
Pangolin
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-gray-500">
|
||||
{new Date().toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
<LetterHead />
|
||||
|
||||
<Heading className="text-2xl font-semibold text-gray-800 text-center">
|
||||
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")
|
||||
);
|
||||
}
|
||||
|
||||
if (existingInvite.email !== email) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Invite is not for this user"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
|
|
|
@ -137,7 +137,7 @@ authenticated.post(
|
|||
verifyUserHasAction(ActionsEnum.inviteUser),
|
||||
user.inviteUser
|
||||
); // 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(
|
||||
"/resource/:resourceId/roles",
|
||||
|
|
|
@ -12,6 +12,7 @@ import { fromError } from "zod-validation-error";
|
|||
import { isWithinExpirationDate } from "oslo";
|
||||
import { verifyPassword } from "@server/auth/password";
|
||||
import { checkValidInvite } from "@server/auth/checkValidInvite";
|
||||
import { verifySession } from "@server/auth";
|
||||
|
||||
const acceptInviteBodySchema = z
|
||||
.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(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
|
|
|
@ -304,8 +304,8 @@ export default function ReverseProxyTargets(props: {
|
|||
{row.original.method}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="http">http</SelectItem>
|
||||
<SelectItem value="https">https</SelectItem>
|
||||
<SelectItem value="http">HTTP</SelectItem>
|
||||
<SelectItem value="https">HTTPS</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
|
|
|
@ -5,19 +5,34 @@ import {
|
|||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardTitle
|
||||
} from "@/components/ui/card";
|
||||
import { createApiClient } from "@app/api";
|
||||
import LoginForm from "@app/components/LoginForm";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
|
||||
type DashboardLoginFormProps = {
|
||||
redirect?: string;
|
||||
};
|
||||
|
||||
export default function DashboardLoginForm({
|
||||
redirect,
|
||||
redirect
|
||||
}: DashboardLoginFormProps) {
|
||||
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 (
|
||||
<Card className="w-full max-w-md">
|
||||
|
|
|
@ -20,10 +20,6 @@ export default async function InvitePage(props: {
|
|||
|
||||
const user = await verifySession();
|
||||
|
||||
if (!user) {
|
||||
redirect(`/auth/signup?redirect=/invite?token=${params.token}`);
|
||||
}
|
||||
|
||||
const parts = tokenParam.split("-");
|
||||
if (parts.length !== 2) {
|
||||
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 (
|
||||
<>
|
||||
<InviteStatusCard type={cardType()} token={tokenParam} />
|
||||
<InviteStatusCard type={type} token={tokenParam} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue