mirror of
https://github.com/sourcebot-dev/sourcebot.git
synced 2025-12-13 04:45:19 +00:00
switch magic link to invite code (#222)
* wip magic link codes * pipe email to email provider properly * remove magic link data cookie after sign in * clean up unused imports * dont remove cookie before we use it * rm package-lock.json * revert yarn files to v3 state * switch email passing from cookie to search param * add comment for settings dropdown auth update
This commit is contained in:
parent
51186fe87d
commit
ff350566b0
11 changed files with 310 additions and 68 deletions
|
|
@ -109,6 +109,7 @@
|
||||||
"fuse.js": "^7.0.0",
|
"fuse.js": "^7.0.0",
|
||||||
"graphql": "^16.9.0",
|
"graphql": "^16.9.0",
|
||||||
"http-status-codes": "^2.3.0",
|
"http-status-codes": "^2.3.0",
|
||||||
|
"input-otp": "^1.4.2",
|
||||||
"lucide-react": "^0.435.0",
|
"lucide-react": "^0.435.0",
|
||||||
"next": "14.2.21",
|
"next": "14.2.21",
|
||||||
"next-auth": "^5.0.0-beta.25",
|
"next-auth": "^5.0.0-beta.25",
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ import { gitlabSchema } from "@sourcebot/schemas/v3/gitlab.schema";
|
||||||
import { giteaSchema } from "@sourcebot/schemas/v3/gitea.schema";
|
import { giteaSchema } from "@sourcebot/schemas/v3/gitea.schema";
|
||||||
import { gerritSchema } from "@sourcebot/schemas/v3/gerrit.schema";
|
import { gerritSchema } from "@sourcebot/schemas/v3/gerrit.schema";
|
||||||
import { GithubConnectionConfig, GitlabConnectionConfig, GiteaConnectionConfig, GerritConnectionConfig, ConnectionConfig } from "@sourcebot/schemas/v3/connection.type";
|
import { GithubConnectionConfig, GitlabConnectionConfig, GiteaConnectionConfig, GerritConnectionConfig, ConnectionConfig } from "@sourcebot/schemas/v3/connection.type";
|
||||||
import { encrypt } from "@sourcebot/crypto"
|
import { decrypt, encrypt } from "@sourcebot/crypto"
|
||||||
import { getConnection } from "./data/connection";
|
import { getConnection } from "./data/connection";
|
||||||
import { ConnectionSyncStatus, Prisma, OrgRole, RepoIndexingStatus, StripeSubscriptionStatus } from "@sourcebot/db";
|
import { ConnectionSyncStatus, Prisma, OrgRole, RepoIndexingStatus, StripeSubscriptionStatus } from "@sourcebot/db";
|
||||||
import { cookies, headers } from "next/headers"
|
import { cookies, headers } from "next/headers"
|
||||||
|
|
@ -1408,3 +1408,11 @@ const parseConnectionConfig = (connectionType: string, config: string) => {
|
||||||
|
|
||||||
return parsedConfig;
|
return parsedConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const encryptValue = async (value: string) => {
|
||||||
|
return encrypt(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const decryptValue = async (iv: string, encryptedValue: string) => {
|
||||||
|
return decrypt(iv, encryptedValue);
|
||||||
|
}
|
||||||
|
|
@ -24,7 +24,7 @@ import {
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu"
|
} from "@/components/ui/dropdown-menu"
|
||||||
import { useTheme } from "next-themes"
|
import { useTheme } from "next-themes"
|
||||||
import { useMemo } from "react"
|
import { useMemo, useState } from "react"
|
||||||
import { KeymapType } from "@/lib/types"
|
import { KeymapType } from "@/lib/types"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { useKeymapType } from "@/hooks/useKeymapType"
|
import { useKeymapType } from "@/hooks/useKeymapType"
|
||||||
|
|
@ -44,7 +44,7 @@ export const SettingsDropdown = ({
|
||||||
|
|
||||||
const { theme: _theme, setTheme } = useTheme();
|
const { theme: _theme, setTheme } = useTheme();
|
||||||
const [keymapType, setKeymapType] = useKeymapType();
|
const [keymapType, setKeymapType] = useKeymapType();
|
||||||
const { data: session } = useSession();
|
const { data: session, update } = useSession();
|
||||||
|
|
||||||
const theme = useMemo(() => {
|
const theme = useMemo(() => {
|
||||||
return _theme ?? "light";
|
return _theme ?? "light";
|
||||||
|
|
@ -64,7 +64,14 @@ export const SettingsDropdown = ({
|
||||||
}, [theme]);
|
}, [theme]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
// Was hitting a bug with invite code login where the first time the user signs in, the settingsDropdown doesn't have a valid session. To fix this
|
||||||
|
// we can simply update the session everytime the settingsDropdown is opened. This isn't a super frequent operation and updating the session is low cost,
|
||||||
|
// so this is a simple solution to the problem.
|
||||||
|
<DropdownMenu onOpenChange={(isOpen) => {
|
||||||
|
if (isOpen) {
|
||||||
|
update();
|
||||||
|
}
|
||||||
|
}}>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="outline" size="icon" className={cn(menuButtonClassName)}>
|
<Button variant="outline" size="icon" className={cn(menuButtonClassName)}>
|
||||||
<Settings className="h-4 w-4" />
|
<Settings className="h-4 w-4" />
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import { signIn } from "next-auth/react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
import useCaptureEvent from "@/hooks/useCaptureEvent";
|
import useCaptureEvent from "@/hooks/useCaptureEvent";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
const magicLinkSchema = z.object({
|
const magicLinkSchema = z.object({
|
||||||
email: z.string().email(),
|
email: z.string().email(),
|
||||||
|
|
@ -22,6 +23,8 @@ interface MagicLinkFormProps {
|
||||||
export const MagicLinkForm = ({ callbackUrl }: MagicLinkFormProps) => {
|
export const MagicLinkForm = ({ callbackUrl }: MagicLinkFormProps) => {
|
||||||
const captureEvent = useCaptureEvent();
|
const captureEvent = useCaptureEvent();
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
const magicLinkForm = useForm<z.infer<typeof magicLinkSchema>>({
|
const magicLinkForm = useForm<z.infer<typeof magicLinkSchema>>({
|
||||||
resolver: zodResolver(magicLinkSchema),
|
resolver: zodResolver(magicLinkSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
|
|
@ -29,11 +32,18 @@ export const MagicLinkForm = ({ callbackUrl }: MagicLinkFormProps) => {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const onSignIn = (values: z.infer<typeof magicLinkSchema>) => {
|
const onSignIn = async (values: z.infer<typeof magicLinkSchema>) => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
captureEvent("wa_login_with_magic_link", {});
|
captureEvent("wa_login_with_magic_link", {});
|
||||||
signIn("nodemailer", { email: values.email, redirectTo: callbackUrl ?? "/" })
|
|
||||||
.finally(() => {
|
signIn("nodemailer", { email: values.email, redirect: false, redirectTo: callbackUrl ?? "/" })
|
||||||
|
.then(() => {
|
||||||
|
setIsLoading(false);
|
||||||
|
|
||||||
|
router.push("/login/verify?email=" + encodeURIComponent(values.email));
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("Error signing in", error);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -66,7 +76,7 @@ export const MagicLinkForm = ({ callbackUrl }: MagicLinkFormProps) => {
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
>
|
>
|
||||||
{isLoading ? <Loader2 className="animate-spin mr-2" /> : ""}
|
{isLoading ? <Loader2 className="animate-spin mr-2" /> : ""}
|
||||||
Sign in with magic link
|
Sign in with login code
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,99 @@
|
||||||
import { SourcebotLogo } from "@/app/components/sourcebotLogo";
|
"use client"
|
||||||
|
|
||||||
|
import { InputOTPSeparator } from "@/components/ui/input-otp"
|
||||||
|
import { InputOTPGroup } from "@/components/ui/input-otp"
|
||||||
|
import { InputOTPSlot } from "@/components/ui/input-otp"
|
||||||
|
import { InputOTP } from "@/components/ui/input-otp"
|
||||||
|
import { Card, CardHeader, CardDescription, CardTitle, CardContent, CardFooter } from "@/components/ui/card"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { ArrowLeft } from "lucide-react"
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation"
|
||||||
|
import { useCallback, useState } from "react"
|
||||||
|
import VerificationFailed from "./verificationFailed"
|
||||||
|
import { SourcebotLogo } from "@/app/components/sourcebotLogo"
|
||||||
|
import useCaptureEvent from "@/hooks/useCaptureEvent"
|
||||||
|
|
||||||
export default function VerifyPage() {
|
export default function VerifyPage() {
|
||||||
|
const [value, setValue] = useState("")
|
||||||
|
const searchParams = useSearchParams()
|
||||||
|
const email = searchParams.get("email")
|
||||||
|
const router = useRouter()
|
||||||
|
const captureEvent = useCaptureEvent();
|
||||||
|
|
||||||
|
if (!email) {
|
||||||
|
captureEvent("wa_login_verify_page_no_email", {})
|
||||||
|
return <VerificationFailed />
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(async () => {
|
||||||
|
const url = new URL("/api/auth/callback/nodemailer", window.location.origin)
|
||||||
|
url.searchParams.set("token", value)
|
||||||
|
url.searchParams.set("email", email)
|
||||||
|
router.push(url.toString())
|
||||||
|
}, [value])
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (e.key === 'Enter' && value.length === 6) {
|
||||||
|
handleSubmit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center p-12 h-screen">
|
<div className="min-h-screen flex flex-col items-center justify-center p-4 bg-gradient-to-b from-background to-muted/30">
|
||||||
<SourcebotLogo
|
<div className="w-full max-w-md">
|
||||||
className="mb-2 h-16"
|
<div className="flex justify-center mb-6">
|
||||||
size="small"
|
<SourcebotLogo className="h-16" size="large" />
|
||||||
/>
|
</div>
|
||||||
<h1 className="text-2xl font-bold mb-2">Verify your email</h1>
|
<Card className="w-full shadow-lg border-muted/40">
|
||||||
<p className="text-sm text-muted-foreground">
|
<CardHeader className="space-y-1">
|
||||||
{`We've sent a magic link to your email. Please check your inbox.`}
|
<CardTitle className="text-2xl font-bold text-center">Verify your email</CardTitle>
|
||||||
|
<CardDescription className="text-center">
|
||||||
|
Enter the 6-digit code we sent to <span className="font-semibold text-primary">{email}</span>
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (value.length === 6) {
|
||||||
|
handleSubmit()
|
||||||
|
}
|
||||||
|
}} className="space-y-6">
|
||||||
|
<div className="flex justify-center py-4">
|
||||||
|
<InputOTP maxLength={6} value={value} onChange={setValue} onKeyDown={handleKeyDown} className="gap-2">
|
||||||
|
<InputOTPGroup>
|
||||||
|
<InputOTPSlot index={0} className="rounded-md border-input" />
|
||||||
|
<InputOTPSlot index={1} className="rounded-md border-input" />
|
||||||
|
<InputOTPSlot index={2} className="rounded-md border-input" />
|
||||||
|
</InputOTPGroup>
|
||||||
|
<InputOTPSeparator />
|
||||||
|
<InputOTPGroup>
|
||||||
|
<InputOTPSlot index={3} className="rounded-md border-input" />
|
||||||
|
<InputOTPSlot index={4} className="rounded-md border-input" />
|
||||||
|
<InputOTPSlot index={5} className="rounded-md border-input" />
|
||||||
|
</InputOTPGroup>
|
||||||
|
</InputOTP>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
|
||||||
|
<CardFooter className="flex flex-col space-y-4 pt-0">
|
||||||
|
<Button variant="ghost" className="w-full text-sm" size="sm" onClick={() => window.history.back()}>
|
||||||
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
|
Back to login
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
<div className="mt-8 text-center text-sm text-muted-foreground">
|
||||||
|
<p>
|
||||||
|
Having trouble?{" "}
|
||||||
|
<a href="mailto:team@sourcebot.dev" className="text-primary hover:underline">
|
||||||
|
Contact support
|
||||||
|
</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
43
packages/web/src/app/login/verify/verificationFailed.tsx
Normal file
43
packages/web/src/app/login/verify/verificationFailed.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { AlertCircle } from "lucide-react"
|
||||||
|
import { SourcebotLogo } from "@/app/components/sourcebotLogo"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
|
||||||
|
export default function VerificationFailed() {
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen flex-col items-center justify-center bg-[#111318] text-white">
|
||||||
|
<div className="w-full max-w-md rounded-lg bg-[#1A1D24] p-8 shadow-lg">
|
||||||
|
<div className="mb-6 flex justify-center">
|
||||||
|
<SourcebotLogo />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-6 text-center">
|
||||||
|
<div className="mb-4 flex justify-center">
|
||||||
|
<AlertCircle className="h-10 w-10 text-red-500" />
|
||||||
|
</div>
|
||||||
|
<p className="mb-2 text-center text-lg font-medium">Login verification failed</p>
|
||||||
|
<p className="text-center text-sm text-gray-400">
|
||||||
|
Something went wrong when trying to verify your login. Please try again.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button onClick={() => router.push("/login")} className="w-full bg-purple-600 hover:bg-purple-700">
|
||||||
|
Return to login
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-8 flex gap-6 text-sm text-gray-500">
|
||||||
|
<a href="https://www.sourcebot.dev" className="hover:text-gray-300">
|
||||||
|
About
|
||||||
|
</a>
|
||||||
|
<a href="mailto:team@sourcebot.dev" className="hover:text-gray-300">
|
||||||
|
Contact Us
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -63,15 +63,19 @@ export const getProviders = () => {
|
||||||
server: SMTP_CONNECTION_URL,
|
server: SMTP_CONNECTION_URL,
|
||||||
from: EMAIL_FROM,
|
from: EMAIL_FROM,
|
||||||
maxAge: 60 * 10,
|
maxAge: 60 * 10,
|
||||||
sendVerificationRequest: async ({ identifier, url, provider }) => {
|
generateVerificationToken: async () => {
|
||||||
|
const token = String(Math.floor(100000 + Math.random() * 900000));
|
||||||
|
return token;
|
||||||
|
},
|
||||||
|
sendVerificationRequest: async ({ identifier, provider, token }) => {
|
||||||
const transport = createTransport(provider.server);
|
const transport = createTransport(provider.server);
|
||||||
const html = await render(MagicLinkEmail({ magicLink: url, baseUrl: AUTH_URL }));
|
const html = await render(MagicLinkEmail({ baseUrl: AUTH_URL, token: token }));
|
||||||
const result = await transport.sendMail({
|
const result = await transport.sendMail({
|
||||||
to: identifier,
|
to: identifier,
|
||||||
from: provider.from,
|
from: provider.from,
|
||||||
subject: 'Log in to Sourcebot',
|
subject: 'Log in to Sourcebot',
|
||||||
html,
|
html,
|
||||||
text: `Log in to Sourcebot by clicking here: ${url}`
|
text: `Log in to Sourcebot using this code: ${token}`
|
||||||
});
|
});
|
||||||
|
|
||||||
const failed = result.rejected.concat(result.pending).filter(Boolean);
|
const failed = result.rejected.concat(result.pending).filter(Boolean);
|
||||||
|
|
@ -186,6 +190,7 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
|
||||||
providers: getProviders(),
|
providers: getProviders(),
|
||||||
pages: {
|
pages: {
|
||||||
signIn: "/login",
|
signIn: "/login",
|
||||||
verifyRequest: "/login/verify",
|
// We set redirect to false in signInOptions so we can pass the email is as a param
|
||||||
|
// verifyRequest: "/login/verify",
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
71
packages/web/src/components/ui/input-otp.tsx
Normal file
71
packages/web/src/components/ui/input-otp.tsx
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { OTPInput, OTPInputContext } from "input-otp"
|
||||||
|
import { Dot } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const InputOTP = React.forwardRef<
|
||||||
|
React.ElementRef<typeof OTPInput>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof OTPInput>
|
||||||
|
>(({ className, containerClassName, ...props }, ref) => (
|
||||||
|
<OTPInput
|
||||||
|
ref={ref}
|
||||||
|
containerClassName={cn(
|
||||||
|
"flex items-center gap-2 has-[:disabled]:opacity-50",
|
||||||
|
containerClassName
|
||||||
|
)}
|
||||||
|
className={cn("disabled:cursor-not-allowed", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
InputOTP.displayName = "InputOTP"
|
||||||
|
|
||||||
|
const InputOTPGroup = React.forwardRef<
|
||||||
|
React.ElementRef<"div">,
|
||||||
|
React.ComponentPropsWithoutRef<"div">
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn("flex items-center", className)} {...props} />
|
||||||
|
))
|
||||||
|
InputOTPGroup.displayName = "InputOTPGroup"
|
||||||
|
|
||||||
|
const InputOTPSlot = React.forwardRef<
|
||||||
|
React.ElementRef<"div">,
|
||||||
|
React.ComponentPropsWithoutRef<"div"> & { index: number }
|
||||||
|
>(({ index, className, ...props }, ref) => {
|
||||||
|
const inputOTPContext = React.useContext(OTPInputContext)
|
||||||
|
const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex h-10 w-10 items-center justify-center border-y border-r border-input text-sm transition-all first:rounded-l-md first:border-l last:rounded-r-md",
|
||||||
|
isActive && "z-10 ring-2 ring-ring ring-offset-background",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{char}
|
||||||
|
{hasFakeCaret && (
|
||||||
|
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||||
|
<div className="h-4 w-px animate-caret-blink bg-foreground duration-1000" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
InputOTPSlot.displayName = "InputOTPSlot"
|
||||||
|
|
||||||
|
const InputOTPSeparator = React.forwardRef<
|
||||||
|
React.ElementRef<"div">,
|
||||||
|
React.ComponentPropsWithoutRef<"div">
|
||||||
|
>(({ ...props }, ref) => (
|
||||||
|
<div ref={ref} role="separator" {...props}>
|
||||||
|
<Dot />
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
InputOTPSeparator.displayName = "InputOTPSeparator"
|
||||||
|
|
||||||
|
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }
|
||||||
|
|
@ -1,65 +1,66 @@
|
||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Container,
|
Container,
|
||||||
|
Head,
|
||||||
|
Html,
|
||||||
Img,
|
Img,
|
||||||
Link,
|
|
||||||
Preview,
|
Preview,
|
||||||
Section,
|
Section,
|
||||||
Tailwind,
|
Tailwind,
|
||||||
Text,
|
Text,
|
||||||
} from '@react-email/components';
|
} from '@react-email/components';
|
||||||
import { EmailFooter } from './emailFooter';
|
|
||||||
|
|
||||||
interface MagicLinkEmailProps {
|
interface MagicLinkEmailProps {
|
||||||
magicLink: string,
|
|
||||||
baseUrl: string,
|
baseUrl: string,
|
||||||
|
token: string,
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MagicLinkEmail = ({
|
export const MagicLinkEmail = ({
|
||||||
magicLink: url,
|
baseUrl,
|
||||||
baseUrl: baseUrl,
|
token,
|
||||||
}: MagicLinkEmailProps) => (
|
}: MagicLinkEmailProps) => (
|
||||||
|
<Html>
|
||||||
|
<Head />
|
||||||
|
<Preview>Use this code {token} to log in to Sourcebot</Preview>
|
||||||
<Tailwind>
|
<Tailwind>
|
||||||
<Preview>Log in to Sourcebot</Preview>
|
<Body className="bg-white font-sans m-0 p-0">
|
||||||
<Body className="bg-white my-auto mx-auto font-sans px-2">
|
<Container className="mx-auto max-w-[600px] p-6">
|
||||||
<Container className="border border-solid border-[#eaeaea] rounded my-[40px] mx-auto p-[20px] max-w-[465px]">
|
<Section className="mb-4">
|
||||||
<Section className="mt-[32px]">
|
|
||||||
<Img
|
<Img
|
||||||
src={`${baseUrl}/sb_logo_light_large.png`}
|
src={`${baseUrl}/sb_logo_light_large.png`}
|
||||||
height="60"
|
|
||||||
width="auto"
|
|
||||||
alt="Sourcebot Logo"
|
alt="Sourcebot Logo"
|
||||||
className="my-0 mx-auto"
|
width="auto"
|
||||||
|
height="40"
|
||||||
|
className="mx-0"
|
||||||
/>
|
/>
|
||||||
</Section>
|
</Section>
|
||||||
<Text className="text-black text-[14px] leading-[24px]">
|
|
||||||
Hello,
|
<Section className="mb-4">
|
||||||
|
<Text className="text-base text-black">
|
||||||
|
Use the code below to log in to Sourcebot.
|
||||||
</Text>
|
</Text>
|
||||||
<Text className="text-black text-[14px] leading-[24px]">
|
</Section>
|
||||||
You can log in to your Sourcebot account by clicking the link below.
|
|
||||||
|
<Section className="bg-[#f4f7fa] py-4 px-2 rounded mb-4 text-center">
|
||||||
|
<Text className="text-xl font-bold text-black tracking-[0.5em]">
|
||||||
|
{token}
|
||||||
</Text>
|
</Text>
|
||||||
<Link
|
</Section>
|
||||||
href={url}
|
|
||||||
className="text-blue-600 no-underline"
|
<Section>
|
||||||
target="_blank"
|
<Text className="text-sm text-gray-600 leading-6">
|
||||||
style={{
|
This code is only valid for the next 10 minutes. If you didn't try to log in,
|
||||||
display: 'block',
|
you can safely ignore this email.
|
||||||
marginBottom: '16px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Click here to log in
|
|
||||||
</Link>
|
|
||||||
<Text className="text-black text-[14px] leading-[24px]">
|
|
||||||
If you didn't try to login, you can safely ignore this email.
|
|
||||||
</Text>
|
</Text>
|
||||||
<EmailFooter />
|
</Section>
|
||||||
</Container>
|
</Container>
|
||||||
</Body>
|
</Body>
|
||||||
</Tailwind>
|
</Tailwind>
|
||||||
)
|
</Html>
|
||||||
|
);
|
||||||
|
|
||||||
MagicLinkEmail.PreviewProps = {
|
MagicLinkEmail.PreviewProps = {
|
||||||
magicLink: 'https://example.com/login',
|
token: '123456',
|
||||||
baseUrl: 'http://localhost:3000',
|
baseUrl: 'http://localhost:3000',
|
||||||
} as MagicLinkEmailProps;
|
} as MagicLinkEmailProps;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -222,6 +222,8 @@ export type PosthogEventMap = {
|
||||||
wa_mobile_unsupported_splash_screen_dismissed: {},
|
wa_mobile_unsupported_splash_screen_dismissed: {},
|
||||||
wa_mobile_unsupported_splash_screen_displayed: {},
|
wa_mobile_unsupported_splash_screen_displayed: {},
|
||||||
//////////////////////////////////////////////////////////////////
|
//////////////////////////////////////////////////////////////////
|
||||||
|
wa_login_verify_page_no_email: {},
|
||||||
|
//////////////////////////////////////////////////////////////////
|
||||||
wa_org_name_updated_success: {},
|
wa_org_name_updated_success: {},
|
||||||
wa_org_name_updated_fail: {
|
wa_org_name_updated_fail: {
|
||||||
error: string,
|
error: string,
|
||||||
|
|
|
||||||
14
yarn.lock
14
yarn.lock
|
|
@ -6022,6 +6022,11 @@ inherits@2, inherits@2.0.4, inherits@^2.0.3, inherits@^2.0.4:
|
||||||
resolved "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz"
|
resolved "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz"
|
||||||
integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
|
integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
|
||||||
|
|
||||||
|
input-otp@^1.4.2:
|
||||||
|
version "1.4.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/input-otp/-/input-otp-1.4.2.tgz#f4d3d587d0f641729e55029b3b8c4870847f4f07"
|
||||||
|
integrity sha512-l3jWwYNvrEa6NTCt7BECfCm48GvwuZzkoeG3gBL2w4CHeOXW3eKFmf9UNYkNfYc3mxMrthMnxjIE07MT0zLBQA==
|
||||||
|
|
||||||
internal-slot@^1.0.4, internal-slot@^1.0.7:
|
internal-slot@^1.0.4, internal-slot@^1.0.7:
|
||||||
version "1.0.7"
|
version "1.0.7"
|
||||||
resolved "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.7.tgz"
|
resolved "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.7.tgz"
|
||||||
|
|
@ -8623,7 +8628,14 @@ stringify-entities@^4.0.0:
|
||||||
character-entities-html4 "^2.0.0"
|
character-entities-html4 "^2.0.0"
|
||||||
character-entities-legacy "^3.0.0"
|
character-entities-legacy "^3.0.0"
|
||||||
|
|
||||||
"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1:
|
"strip-ansi-cjs@npm:strip-ansi@^6.0.1":
|
||||||
|
version "6.0.1"
|
||||||
|
resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz"
|
||||||
|
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
|
||||||
|
dependencies:
|
||||||
|
ansi-regex "^5.0.1"
|
||||||
|
|
||||||
|
strip-ansi@^6.0.0, strip-ansi@^6.0.1:
|
||||||
version "6.0.1"
|
version "6.0.1"
|
||||||
resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz"
|
resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz"
|
||||||
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
|
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue