Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Showing 27 changed files with 659 additions and 15 deletions.
| @@ -27,3 +27,6 @@ yarn-error.log* | ||
| 27 | 27 | # editors |
| 28 | 28 | .idea/ |
| 29 | 29 | *.iml |
| 30 | + | |
| 31 | + # example settings, safe to commit | |
| 32 | + !.env.example | |
| @@ -0,0 +1,13 @@ | ||
| 1 | + # Copy to .env (Next.js reads it on start). See docs/installation.md#configuration. | |
| 2 | + | |
| 3 | + # The address users reach Vectrum at; links in emails point here. | |
| 4 | + # PUBLIC_URL=https://vectrum.example.com | |
| 5 | + | |
| 6 | + # Email: address confirmation, password resets, invitations. Without SMTP_HOST, nothing is sent. | |
| 7 | + # SMTP_HOST=smtp.example.com | |
| 8 | + # SMTP_PORT=587 | |
| 9 | + # 1 for TLS from the start (the default for port 465); otherwise STARTTLS is used when the server offers it. | |
| 10 | + # SMTP_SECURE=0 | |
| 11 | + # SMTP_USER=vectrum@example.com | |
| 12 | + # SMTP_PASSWORD= | |
| 13 | + # MAIL_FROM="Vectrum <vectrum@example.com>" | |
| @@ -2,8 +2,10 @@ | ||
| 2 | 2 | |
| 3 | 3 | import { redirect } from "next/navigation"; |
| 4 | 4 | import { completeSignIn, endSession, safeNext, signIn } from "@/lib/auth"; |
| 5 | + import { requestPasswordReset, resetPassword, sendVerificationEmailLater } from "@/lib/emails"; | |
| 6 | + import { MAIL_ENABLED } from "@/lib/mail"; | |
| 5 | 7 | import { SIGNUPS_ENABLED } from "@/lib/signups"; |
| 6 | - import { createUser, UserError } from "@/lib/users"; | |
| 8 | + import { createUser, UserError, validatePassword } from "@/lib/users"; | |
| 7 | 9 | import type { FormState } from "./types"; |
| 8 | 10 | |
| 9 | 11 | export async function signupAction(_prev: FormState, formData: FormData): Promise<FormState> { |
| @@ -16,6 +18,7 @@ export async function signupAction(_prev: FormState, formData: FormData): Promis | ||
| 16 | 18 | password, |
| 17 | 19 | name: String(formData.get("name") ?? ""), |
| 18 | 20 | }); |
| 21 | + sendVerificationEmailLater(user); | |
| 19 | 22 | await signIn(user.email, password); |
| 20 | 23 | } catch (e) { |
| 21 | 24 | if (e instanceof UserError) return { error: e.message }; |
| @@ -41,6 +44,25 @@ export async function twoFactorLoginAction(_prev: FormState, formData: FormData) | ||
| 41 | 44 | redirect(next); |
| 42 | 45 | } |
| 43 | 46 | |
| 47 | + export async function forgotPasswordAction(_prev: FormState, formData: FormData): Promise<FormState> { | |
| 48 | + if (!MAIL_ENABLED) return { error: "This server can't send email. Ask the person who runs it to reset your password." }; | |
| 49 | + const login = String(formData.get("login") ?? "").trim(); | |
| 50 | + if (!login) return { error: "Enter your username or email address." }; | |
| 51 | + await requestPasswordReset(login); | |
| 52 | + return { success: "If that's an account here, we've sent a link to choose a new password to its email address. It works for one hour." }; | |
| 53 | + } | |
| 54 | + | |
| 55 | + export async function resetPasswordAction(_prev: FormState, formData: FormData): Promise<FormState> { | |
| 56 | + const password = String(formData.get("password") ?? ""); | |
| 57 | + if (password !== formData.get("confirm")) return { error: "The passwords don't match." }; | |
| 58 | + const problem = validatePassword(password); | |
| 59 | + if (problem) return { error: problem }; | |
| 60 | + if (!(await resetPassword(String(formData.get("token") ?? ""), password))) { | |
| 61 | + return { error: "This link has expired or was already used. Ask for a new one." }; | |
| 62 | + } | |
| 63 | + redirect("/login?reset=1"); | |
| 64 | + } | |
| 65 | + | |
| 44 | 66 | export async function logoutAction() { |
| 45 | 67 | await endSession(); |
| 46 | 68 | redirect("/"); |
| @@ -2,6 +2,7 @@ | ||
| 2 | 2 | |
| 3 | 3 | import { revalidatePath } from "next/cache"; |
| 4 | 4 | import { redirect } from "next/navigation"; |
| 5 | + import { notifyInvitation } from "@/lib/emails"; | |
| 5 | 6 | import { |
| 6 | 7 | answerInvitation, |
| 7 | 8 | cancelInvitation, |
| @@ -100,6 +101,7 @@ export async function inviteMemberAction(name: string, _prev: FormState, formDat | ||
| 100 | 101 | const { org, viewer } = await orgForAction(name, "owner"); |
| 101 | 102 | const role = roleOf(formData); |
| 102 | 103 | const user = await inviteMember(org, viewer, String(formData.get("username") ?? ""), role); |
| 104 | + notifyInvitation(user, org, viewer, role); | |
| 103 | 105 | revalidatePath(`/organizations/${org.username}/settings/members`); |
| 104 | 106 | return { success: `Invited ${user.username} as ${ROLE_INFO[role].title.toLowerCase()}. They’ll find the invitation on their dashboard.` }; |
| 105 | 107 | }); |
| @@ -16,6 +16,7 @@ import { | ||
| 16 | 16 | updateRepo, |
| 17 | 17 | type Visibility, |
| 18 | 18 | } from "@/lib/repos"; |
| 19 | + import { notifyCollaborator } from "@/lib/emails"; | |
| 19 | 20 | import { getOrg, mayCreateRepos } from "@/lib/orgs"; |
| 20 | 21 | import { ActionError, repoForAction, viewerOrThrow } from "./guard"; |
| 21 | 22 | import type { FormState } from "./types"; |
| @@ -134,9 +135,10 @@ export async function deleteRepoAction(owner: string, name: string, _prev: FormS | ||
| 134 | 135 | |
| 135 | 136 | export async function addCollaboratorAction(owner: string, name: string, _prev: FormState, formData: FormData) { |
| 136 | 137 | return attempt(async () => { |
| 137 | - const { repo } = await repoForAction(owner, name, "admin"); | |
| 138 | + const { repo, viewer } = await repoForAction(owner, name, "admin"); | |
| 138 | 139 | const username = String(formData.get("username") ?? "").trim(); |
| 139 | - await addCollaborator(repo, username); | |
| 140 | + const { user, added } = await addCollaborator(repo, username); | |
| 141 | + if (added) notifyCollaborator(user, repo, viewer); | |
| 140 | 142 | revalidatePath(`/${repo.owner}/${repo.name}/settings`); |
| 141 | 143 | return { success: `${username} can now push to this repository.` }; |
| 142 | 144 | }); |
| @@ -2,6 +2,7 @@ | ||
| 2 | 2 | |
| 3 | 3 | import { revalidatePath } from "next/cache"; |
| 4 | 4 | import { renewSession } from "@/lib/auth"; |
| 5 | + import { notifyEmailChanged, notifyPasswordChanged, sendVerificationEmail, sendVerificationEmailLater } from "@/lib/emails"; | |
| 5 | 6 | import { addSshKey, deleteSshKey, KeyError } from "@/lib/keys"; |
| 6 | 7 | import { createToken, deleteToken, TokenError, type TokenPermission } from "@/lib/tokens"; |
| 7 | 8 | import { |
| @@ -19,13 +20,18 @@ import type { FormState } from "./types"; | ||
| 19 | 20 | export async function updateProfileAction(_prev: FormState, formData: FormData): Promise<FormState> { |
| 20 | 21 | const viewer = await viewerOrThrow(); |
| 21 | 22 | try { |
| 22 | - const { emailChanged } = await updateProfile(viewer.id, { | |
| 23 | + const email = String(formData.get("email") ?? "").trim(); | |
| 24 | + const { emailChanged, newAddress } = await updateProfile(viewer.id, { | |
| 23 | 25 | name: String(formData.get("name") ?? ""), |
| 24 | - email: String(formData.get("email") ?? ""), | |
| 26 | + email, | |
| 25 | 27 | bio: String(formData.get("bio") ?? ""), |
| 26 | 28 | }); |
| 27 | 29 | // PocketBase revokes a user's tokens when their email changes; keep this browser signed in. |
| 28 | 30 | if (emailChanged) await renewSession(viewer.id); |
| 31 | + if (newAddress) { | |
| 32 | + notifyEmailChanged(viewer, email); | |
| 33 | + sendVerificationEmailLater({ ...viewer, email, verified: false }); | |
| 34 | + } | |
| 29 | 35 | } catch (e) { |
| 30 | 36 | if (e instanceof UserError) return { error: e.message }; |
| 31 | 37 | throw e; |
| @@ -34,6 +40,19 @@ export async function updateProfileAction(_prev: FormState, formData: FormData): | ||
| 34 | 40 | return { success: "Profile updated." }; |
| 35 | 41 | } |
| 36 | 42 | |
| 43 | + export async function resendVerificationAction(): Promise<FormState> { | |
| 44 | + const viewer = await viewerOrThrow(); | |
| 45 | + try { | |
| 46 | + const result = await sendVerificationEmail(viewer); | |
| 47 | + if (result === "wait") return { error: "We just sent one. Wait a minute before asking again." }; | |
| 48 | + if (result === "verified") return { success: "Your email address is already confirmed." }; | |
| 49 | + } catch (e) { | |
| 50 | + console.error("[mail] couldn't send the verification email:", e instanceof Error ? e.message : e); | |
| 51 | + return { error: "The email couldn't be sent. Try again later." }; | |
| 52 | + } | |
| 53 | + return { success: `Sent. Check ${viewer.email} for the link.` }; | |
| 54 | + } | |
| 55 | + | |
| 37 | 56 | export async function changePasswordAction(_prev: FormState, formData: FormData): Promise<FormState> { |
| 38 | 57 | const viewer = await viewerOrThrow(); |
| 39 | 58 | const current = String(formData.get("current") ?? ""); |
| @@ -45,6 +64,7 @@ export async function changePasswordAction(_prev: FormState, formData: FormData) | ||
| 45 | 64 | // This signs the user out everywhere, so sign this browser back in. |
| 46 | 65 | await setPassword(viewer.id, next); |
| 47 | 66 | await renewSession(viewer.id); |
| 67 | + notifyPasswordChanged(viewer); | |
| 48 | 68 | return { success: "Password changed." }; |
| 49 | 69 | } |
| 50 | 70 | |
| @@ -0,0 +1,35 @@ | ||
| 1 | + import type { Metadata } from "next"; | |
| 2 | + import NextLink from "next/link"; | |
| 3 | + import { notFound } from "next/navigation"; | |
| 4 | + import { Link } from "@unlocalhosted/metalui"; | |
| 5 | + import { ForgotPasswordForm } from "@/components/auth-forms"; | |
| 6 | + import { KeyIcon } from "@/components/icons"; | |
| 7 | + import { Box } from "@/components/ui"; | |
| 8 | + import { MAIL_ENABLED } from "@/lib/mail"; | |
| 9 | + | |
| 10 | + export const metadata: Metadata = { title: "Forgot your password?" }; | |
| 11 | + | |
| 12 | + export default function ForgotPasswordPage() { | |
| 13 | + if (!MAIL_ENABLED) notFound(); | |
| 14 | + return ( | |
| 15 | + <main className="relative flex flex-1 flex-col items-center justify-center overflow-hidden px-4 py-16"> | |
| 16 | + <div className="aurora pointer-events-none absolute inset-0" aria-hidden /> | |
| 17 | + <div className="dot-grid pointer-events-none absolute inset-0" aria-hidden /> | |
| 18 | + <div className="animate-fade-up relative w-full max-w-[380px]"> | |
| 19 | + <div className="text-center"> | |
| 20 | + <span className="mx-auto flex size-16 items-center justify-center rounded-hero material-cap [&_svg]:size-8"> | |
| 21 | + <KeyIcon /> | |
| 22 | + </span> | |
| 23 | + <h1 className="mt-6 text-[22px] leading-7 font-semibold tracking-[-0.025em] text-ink">Forgot your password?</h1> | |
| 24 | + <p className="mt-1 type-lead text-ink2">We’ll email you a link to choose a new one</p> | |
| 25 | + </div> | |
| 26 | + <Box className="mt-8 p-6"> | |
| 27 | + <ForgotPasswordForm /> | |
| 28 | + </Box> | |
| 29 | + <p className="mt-6 text-center type-body text-ink2"> | |
| 30 | + Remembered it? <Link render={<NextLink href="/login" />}>Sign in</Link> | |
| 31 | + </p> | |
| 32 | + </div> | |
| 33 | + </main> | |
| 34 | + ); | |
| 35 | + } | |
| @@ -6,6 +6,7 @@ import { LoginForm } from "@/components/auth-forms"; | ||
| 6 | 6 | import { BRAND, LogoMark } from "@/components/brand"; |
| 7 | 7 | import { Alert, Box } from "@/components/ui"; |
| 8 | 8 | import { getViewer, safeNext } from "@/lib/auth"; |
| 9 | + import { MAIL_ENABLED } from "@/lib/mail"; | |
| 9 | 10 | import { SIGNUPS_ENABLED } from "@/lib/signups"; |
| 10 | 11 | |
| 11 | 12 | export const metadata: Metadata = { title: "Sign in" }; |
| @@ -32,8 +33,13 @@ export default async function LoginPage({ searchParams }: PageProps<"/login">) { | ||
| 32 | 33 | <Alert kind="info">Your sign-in timed out. Enter your password again.</Alert> |
| 33 | 34 | </div> |
| 34 | 35 | )} |
| 36 | + {params.reset && ( | |
| 37 | + <div className="mt-8"> | |
| 38 | + <Alert kind="success">Your password was changed. Sign in with the new one.</Alert> | |
| 39 | + </div> | |
| 40 | + )} | |
| 35 | 41 | <Box className="mt-8 p-6"> |
| 36 | - <LoginForm next={next} /> | |
| 42 | + <LoginForm next={next} canReset={MAIL_ENABLED} /> | |
| 37 | 43 | </Box> |
| 38 | 44 | {SIGNUPS_ENABLED && ( |
| 39 | 45 | <p className="mt-6 text-center type-body text-ink2"> |
| @@ -0,0 +1,45 @@ | ||
| 1 | + import type { Metadata } from "next"; | |
| 2 | + import NextLink from "next/link"; | |
| 3 | + import { Link } from "@unlocalhosted/metalui"; | |
| 4 | + import { ResetPasswordForm } from "@/components/auth-forms"; | |
| 5 | + import { KeyIcon } from "@/components/icons"; | |
| 6 | + import { Alert, Box } from "@/components/ui"; | |
| 7 | + import { passwordResetUser } from "@/lib/emails"; | |
| 8 | + | |
| 9 | + export const metadata: Metadata = { title: "Choose a new password" }; | |
| 10 | + | |
| 11 | + /** Where the link in a password reset email (lib/emails.ts) leads. */ | |
| 12 | + export default async function ResetPasswordPage({ searchParams }: PageProps<"/login/reset-password">) { | |
| 13 | + const token = (await searchParams).token; | |
| 14 | + const user = typeof token === "string" ? await passwordResetUser(token) : null; | |
| 15 | + | |
| 16 | + return ( | |
| 17 | + <main className="relative flex flex-1 flex-col items-center justify-center overflow-hidden px-4 py-16"> | |
| 18 | + <div className="aurora pointer-events-none absolute inset-0" aria-hidden /> | |
| 19 | + <div className="dot-grid pointer-events-none absolute inset-0" aria-hidden /> | |
| 20 | + <div className="animate-fade-up relative w-full max-w-[380px]"> | |
| 21 | + <div className="text-center"> | |
| 22 | + <span className="mx-auto flex size-16 items-center justify-center rounded-hero material-cap [&_svg]:size-8"> | |
| 23 | + <KeyIcon /> | |
| 24 | + </span> | |
| 25 | + <h1 className="mt-6 text-[22px] leading-7 font-semibold tracking-[-0.025em] text-ink">Choose a new password</h1> | |
| 26 | + {user && <p className="mt-1 type-lead text-ink2">For @{user.username}</p>} | |
| 27 | + </div> | |
| 28 | + <Box className="mt-8 p-6"> | |
| 29 | + {user && typeof token === "string" ? ( | |
| 30 | + <ResetPasswordForm token={token} username={user.username} /> | |
| 31 | + ) : ( | |
| 32 | + <Alert>This link has expired or was already used.</Alert> | |
| 33 | + )} | |
| 34 | + </Box> | |
| 35 | + <p className="mt-6 text-center type-body text-ink2"> | |
| 36 | + {user ? ( | |
| 37 | + <Link render={<NextLink href="/login" />}>Back to sign in</Link> | |
| 38 | + ) : ( | |
| 39 | + <Link render={<NextLink href="/login/forgot-password" />}>Ask for a new link</Link> | |
| 40 | + )} | |
| 41 | + </p> | |
| 42 | + </div> | |
| 43 | + </main> | |
| 44 | + ); | |
| 45 | + } | |
| @@ -0,0 +1,48 @@ | ||
| 1 | + import type { Metadata } from "next"; | |
| 2 | + import NextLink from "next/link"; | |
| 3 | + import { Link } from "@unlocalhosted/metalui"; | |
| 4 | + import { MailIcon } from "@/components/icons"; | |
| 5 | + import { Alert, Box } from "@/components/ui"; | |
| 6 | + import { getViewer } from "@/lib/auth"; | |
| 7 | + import { confirmEmail } from "@/lib/emails"; | |
| 8 | + | |
| 9 | + export const metadata: Metadata = { title: "Confirm your email address" }; | |
| 10 | + | |
| 11 | + /** Where the link in a verification email (lib/emails.ts) leads. Opening it confirms the address. */ | |
| 12 | + export default async function VerifyEmailPage({ searchParams }: PageProps<"/login/verify-email">) { | |
| 13 | + const token = (await searchParams).token; | |
| 14 | + const [user, viewer] = await Promise.all([typeof token === "string" ? confirmEmail(token) : null, getViewer()]); | |
| 15 | + | |
| 16 | + return ( | |
| 17 | + <main className="relative flex flex-1 flex-col items-center justify-center overflow-hidden px-4 py-16"> | |
| 18 | + <div className="aurora pointer-events-none absolute inset-0" aria-hidden /> | |
| 19 | + <div className="dot-grid pointer-events-none absolute inset-0" aria-hidden /> | |
| 20 | + <div className="animate-fade-up relative w-full max-w-[380px]"> | |
| 21 | + <div className="text-center"> | |
| 22 | + <span className="mx-auto flex size-16 items-center justify-center rounded-hero material-cap [&_svg]:size-8"> | |
| 23 | + <MailIcon /> | |
| 24 | + </span> | |
| 25 | + <h1 className="mt-6 text-[22px] leading-7 font-semibold tracking-[-0.025em] text-ink">Confirm your email address</h1> | |
| 26 | + </div> | |
| 27 | + <Box className="mt-8 p-6"> | |
| 28 | + {user ? ( | |
| 29 | + <Alert kind="success"> | |
| 30 | + {user.email} is confirmed as the address of @{user.username}. | |
| 31 | + </Alert> | |
| 32 | + ) : ( | |
| 33 | + <Alert> | |
| 34 | + This link has expired, or the account’s email address has changed since. Send a new one from your profile settings. | |
| 35 | + </Alert> | |
| 36 | + )} | |
| 37 | + </Box> | |
| 38 | + <p className="mt-6 text-center type-body text-ink2"> | |
| 39 | + {viewer ? ( | |
| 40 | + <Link render={<NextLink href={user ? "/" : "/settings/profile"} />}>{user ? "Go to your dashboard" : "Open profile settings"}</Link> | |
| 41 | + ) : ( | |
| 42 | + <Link render={<NextLink href={user ? "/login" : "/login?next=%2Fsettings%2Fprofile"} />}>Sign in</Link> | |
| 43 | + )} | |
| 44 | + </p> | |
| 45 | + </div> | |
| 46 | + </main> | |
| 47 | + ); | |
| 48 | + } | |
| @@ -23,10 +23,12 @@ import { | ||
| 23 | 23 | import { CardDescription, CardTitle } from "@/components/metal"; |
| 24 | 24 | import { RepoFilterList } from "@/components/repo-filter-list"; |
| 25 | 25 | import { Avatar, avatarUrl, Blankslate, Box, ButtonLink, Counter, Label, RefChip, RepoAvatar } from "@/components/ui"; |
| 26 | + import { VerifyEmailNotice } from "@/components/verify-email-notice"; | |
| 26 | 27 | import { getViewer } from "@/lib/auth"; |
| 27 | 28 | import { shortSha, timeAgo } from "@/lib/format"; |
| 28 | 29 | import { recentActivity, withActivity } from "@/lib/listing"; |
| 29 | 30 | import { listListsOf, listPath } from "@/lib/lists"; |
| 31 | + import { MAIL_ENABLED } from "@/lib/mail"; | |
| 30 | 32 | import { listInvitationsFor, listOrgsOf } from "@/lib/orgs"; |
| 31 | 33 | import { listReposForUser, searchRepos } from "@/lib/repos"; |
| 32 | 34 | import { serverHost } from "@/lib/request-origin"; |
| @@ -217,6 +219,12 @@ async function Dashboard({ viewer }: { viewer: User }) { | ||
| 217 | 219 | </div> |
| 218 | 220 | </div> |
| 219 | 221 | |
| 222 | + {MAIL_ENABLED && !viewer.verified && ( | |
| 223 | + <div className="mb-6"> | |
| 224 | + <VerifyEmailNotice email={viewer.email} /> | |
| 225 | + </div> | |
| 226 | + )} | |
| 227 | + | |
| 220 | 228 | {invitations.length > 0 && ( |
| 221 | 229 | <div className="mb-6"> |
| 222 | 230 | <OrgInvitations invitations={invitations} /> |
| @@ -1,7 +1,9 @@ | ||
| 1 | 1 | import type { Metadata } from "next"; |
| 2 | 2 | import { PlateSection } from "@/components/settings-plate"; |
| 3 | 3 | import { PageTitle } from "@/components/ui"; |
| 4 | + import { VerifyEmailNotice } from "@/components/verify-email-notice"; | |
| 4 | 5 | import { requireViewer } from "@/lib/auth"; |
| 6 | + import { MAIL_ENABLED } from "@/lib/mail"; | |
| 5 | 7 | import { PasswordForm, ProfileForm } from "./forms"; |
| 6 | 8 | |
| 7 | 9 | export const metadata: Metadata = { title: "Your profile" }; |
| @@ -12,6 +14,7 @@ export default async function ProfileSettingsPage() { | ||
| 12 | 14 | <div> |
| 13 | 15 | <PageTitle title="Public profile" description="How you appear to others across the instance." /> |
| 14 | 16 | <div className="flex flex-col gap-7"> |
| 17 | + {MAIL_ENABLED && !viewer.verified && <VerifyEmailNotice email={viewer.email} />} | |
| 15 | 18 | <PlateSection title="Profile"> |
| 16 | 19 | <ProfileForm user={viewer} /> |
| 17 | 20 | </PlateSection> |
| @@ -3,18 +3,26 @@ | ||
| 3 | 3 | import NextLink from "next/link"; |
| 4 | 4 | import { useActionState } from "react"; |
| 5 | 5 | import { Link } from "@unlocalhosted/metalui"; |
| 6 | - import { loginAction, signupAction, twoFactorLoginAction } from "@/app/actions/auth"; | |
| 6 | + import { forgotPasswordAction, loginAction, resetPasswordAction, signupAction, twoFactorLoginAction } from "@/app/actions/auth"; | |
| 7 | 7 | import { TextField } from "./settings-fields"; |
| 8 | 8 | import { Alert, Button } from "./ui"; |
| 9 | 9 | |
| 10 | - export function LoginForm({ next }: { next: string }) { | |
| 10 | + /** With `canReset`, links to resetting a forgotten password (only when the server can send email). */ | |
| 11 | + export function LoginForm({ next, canReset }: { next: string; canReset?: boolean }) { | |
| 11 | 12 | const [state, action, pending] = useActionState(loginAction, undefined); |
| 12 | 13 | return ( |
| 13 | 14 | <form action={action} className="flex flex-col gap-4"> |
| 14 | 15 | <input type="hidden" name="next" value={next} /> |
| 15 | 16 | {state?.error && <Alert>{state.error}</Alert>} |
| 16 | 17 | <TextField label="Username or email address" name="login" required autoFocus autoComplete="username" /> |
| 17 | - <TextField label="Password" name="password" type="password" required autoComplete="current-password" /> | |
| 18 | + <TextField | |
| 19 | + label="Password" | |
| 20 | + description={canReset && <Link render={<NextLink href="/login/forgot-password" />}>Forgot your password?</Link>} | |
| 21 | + name="password" | |
| 22 | + type="password" | |
| 23 | + required | |
| 24 | + autoComplete="current-password" | |
| 25 | + /> | |
| 18 | 26 | <Button variant="primary" className="mt-1 w-full" disabled={pending}> |
| 19 | 27 | {pending ? "Signing in…" : "Sign In"} |
| 20 | 28 | </Button> |
| @@ -82,3 +90,43 @@ export function SignupForm({ next }: { next: string }) { | ||
| 82 | 90 | </form> |
| 83 | 91 | ); |
| 84 | 92 | } |
| 93 | + | |
| 94 | + export function ForgotPasswordForm() { | |
| 95 | + const [state, action, pending] = useActionState(forgotPasswordAction, undefined); | |
| 96 | + if (state?.success) return <Alert kind="success">{state.success}</Alert>; | |
| 97 | + return ( | |
| 98 | + <form action={action} className="flex flex-col gap-4"> | |
| 99 | + {state?.error && <Alert>{state.error}</Alert>} | |
| 100 | + <TextField label="Username or email address" name="login" required autoFocus autoComplete="username" /> | |
| 101 | + <Button variant="primary" className="mt-1 w-full" disabled={pending}> | |
| 102 | + {pending ? "Sending…" : "Send Link"} | |
| 103 | + </Button> | |
| 104 | + </form> | |
| 105 | + ); | |
| 106 | + } | |
| 107 | + | |
| 108 | + export function ResetPasswordForm({ token, username }: { token: string; username: string }) { | |
| 109 | + const [state, action, pending] = useActionState(resetPasswordAction, undefined); | |
| 110 | + return ( | |
| 111 | + <form action={action} className="flex flex-col gap-4"> | |
| 112 | + <input type="hidden" name="token" value={token} /> | |
| 113 | + {/* For password managers. */} | |
| 114 | + <input type="hidden" name="username" value={username} autoComplete="username" /> | |
| 115 | + {state?.error && <Alert>{state.error}</Alert>} | |
| 116 | + <TextField | |
| 117 | + label="New password" | |
| 118 | + description="At least 8 characters." | |
| 119 | + name="password" | |
| 120 | + type="password" | |
| 121 | + required | |
| 122 | + minLength={8} | |
| 123 | + autoFocus | |
| 124 | + autoComplete="new-password" | |
| 125 | + /> | |
| 126 | + <TextField label="Confirm new password" name="confirm" type="password" required minLength={8} autoComplete="new-password" /> | |
| 127 | + <Button variant="primary" className="mt-1 w-full" disabled={pending}> | |
| 128 | + {pending ? "Saving…" : "Set Password"} | |
| 129 | + </Button> | |
| 130 | + </form> | |
| 131 | + ); | |
| 132 | + } | |
| @@ -0,0 +1,21 @@ | ||
| 1 | + import { resendVerificationAction } from "@/app/actions/settings"; | |
| 2 | + import { ActionButton } from "./action-button"; | |
| 3 | + import { MailIcon } from "./icons"; | |
| 4 | + import { Alert } from "./ui"; | |
| 5 | + | |
| 6 | + /** For a signed-in person who hasn't confirmed their email address yet (lib/emails.ts). */ | |
| 7 | + export function VerifyEmailNotice({ email }: { email: string }) { | |
| 8 | + return ( | |
| 9 | + <Alert kind="info"> | |
| 10 | + <div className="flex flex-wrap items-center justify-between gap-3"> | |
| 11 | + <span> | |
| 12 | + Confirm your email address: we sent a link to <strong className="font-semibold">{email}</strong>. Until then, you won’t get | |
| 13 | + emails about invitations and your account. | |
| 14 | + </span> | |
| 15 | + <ActionButton action={resendVerificationAction} icon={<MailIcon />}> | |
| 16 | + Send Again | |
| 17 | + </ActionButton> | |
| 18 | + </div> | |
| 19 | + </Alert> | |
| 20 | + ); | |
| 21 | + } | |
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | import "server-only"; |
| 2 | 2 | import { person, type Actor } from "./actor"; |
| 3 | 3 | import { checkSecondFactor, userForSession } from "./auth"; |
| 4 | + import { sendVerificationEmailLater } from "./emails"; | |
| 4 | 5 | import { describeEvent } from "./event-text"; |
| 5 | 6 | import { listEvents as listRepoEvents, type RepoEvent } from "./events"; |
| 6 | 7 | import { commitCount, defaultBranch, diffCommit, getCommit, GitError, listBranches, listTags, listTree, log, normalizeRepoName, objectType, readBlob, revParse, type Commit, type FileDiff } from "./git"; |
| @@ -148,6 +149,7 @@ export async function signUp(request: Request) { | ||
| 148 | 149 | const password = text(body.password, 200); |
| 149 | 150 | try { |
| 150 | 151 | const user = await createUser({ username: text(body.username, 100), email: text(body.email, 200), password, name: text(body.name, 100) }); |
| 152 | + sendVerificationEmailLater(user); | |
| 151 | 153 | const auth = await authenticateUser(user.email, password); |
| 152 | 154 | if (!auth) throw new ApiError(500, "The account was created, but signing in failed. Try signing in."); |
| 153 | 155 | return { token: auth.token, user: userJson(auth.user) }; |
| @@ -0,0 +1,187 @@ | ||
| 1 | + import "server-only"; | |
| 2 | + import { createHash, randomBytes } from "node:crypto"; | |
| 3 | + import { db, filter, findFirst, withLock } from "./db"; | |
| 4 | + import { linkTo, MAIL_ENABLED, sendMail, sendMailLater, type Message } from "./mail"; | |
| 5 | + import { ROLE_INFO, type OrgRole } from "./org-roles"; | |
| 6 | + import { getPersonByLogin, getUserById, setPassword, type User } from "./users"; | |
| 7 | + | |
| 8 | + /* | |
| 9 | + * The emails Vectrum sends (through lib/mail.ts), and the links in them that confirm an address | |
| 10 | + * or set a new password. A link carries a random token; only its hash is kept (in email_tokens), | |
| 11 | + * with the address it went to, so it stops working once the account's email changes. | |
| 12 | + * | |
| 13 | + * Notifications (invitations, access, security notices) only go to confirmed addresses: anyone can | |
| 14 | + * sign up with someone else's. | |
| 15 | + */ | |
| 16 | + | |
| 17 | + const LIFETIME_MS = { verify: 3 * 86400_000, reset: 60 * 60_000 }; | |
| 18 | + /** At most one link of a kind per account in this time, so nobody can flood an inbox with them. */ | |
| 19 | + const RESEND_MS = 60_000; | |
| 20 | + | |
| 21 | + type Purpose = keyof typeof LIFETIME_MS; | |
| 22 | + | |
| 23 | + const hash = (token: string) => createHash("sha256").update(token).digest("hex"); | |
| 24 | + const tokens = async () => (await db()).collection("email_tokens"); | |
| 25 | + | |
| 26 | + /** A new token for a link, or null if one went out too recently. */ | |
| 27 | + async function issueToken(user: User, purpose: Purpose) { | |
| 28 | + return withLock(`email-tokens:${user.id}`, async () => { | |
| 29 | + const now = Date.now(); | |
| 30 | + const since = now - RESEND_MS; | |
| 31 | + if (await findFirst("email_tokens", filter("user = {:id} && purpose = {:purpose} && created_at > {:since}", { id: user.id, purpose, since }), { fields: "id" })) { | |
| 32 | + return null; | |
| 33 | + } | |
| 34 | + const collection = await tokens(); | |
| 35 | + const expired = await collection.getFullList({ filter: filter("user = {:id} && expires_at < {:now}", { id: user.id, now }), fields: "id" }); | |
| 36 | + await Promise.all(expired.map((r) => collection.delete(r.id))); | |
| 37 | + const token = randomBytes(32).toString("base64url"); | |
| 38 | + await collection.create({ user: user.id, purpose, email: user.email, token_hash: hash(token), expires_at: now + LIFETIME_MS[purpose], created_at: now }); | |
| 39 | + return token; | |
| 40 | + }); | |
| 41 | + } | |
| 42 | + | |
| 43 | + /** Sends an email with a new link, or returns false if one went out too recently. A link that couldn't be sent doesn't count. */ | |
| 44 | + async function sendLink(user: User, purpose: Purpose, message: (url: string) => Message) { | |
| 45 | + const token = await issueToken(user, purpose); | |
| 46 | + if (!token) return false; | |
| 47 | + try { | |
| 48 | + await sendMail(message(linkTo(`/login/${purpose === "verify" ? "verify-email" : "reset-password"}?token=${token}`))); | |
| 49 | + } catch (e) { | |
| 50 | + const row = await findFirst("email_tokens", filter("token_hash = {:hash}", { hash: hash(token) }), { fields: "id" }); | |
| 51 | + if (row) await (await tokens()).delete(row.id); | |
| 52 | + throw e; | |
| 53 | + } | |
| 54 | + return true; | |
| 55 | + } | |
| 56 | + | |
| 57 | + /** The account a link is for, while it's good. */ | |
| 58 | + async function tokenUser(token: string, purpose: Purpose) { | |
| 59 | + if (!token || token.length > 100) return null; | |
| 60 | + const row = await findFirst("email_tokens", filter("token_hash = {:hash} && purpose = {:purpose}", { hash: hash(token), purpose })); | |
| 61 | + if (!row || row.expires_at < Date.now()) return null; | |
| 62 | + const user = await getUserById(row.user); | |
| 63 | + if (!user || user.email.toLowerCase() !== String(row.email).toLowerCase()) return null; | |
| 64 | + return user; | |
| 65 | + } | |
| 66 | + | |
| 67 | + const markVerified = async (userId: string) => void (await (await db()).collection("users").update(userId, { verified: true })); | |
| 68 | + | |
| 69 | + const logFailure = (what: string) => (e: unknown) => console.error(`[mail] couldn't send ${what}:`, e instanceof Error ? e.message : e); | |
| 70 | + | |
| 71 | + // --------------------------------------------------------------------------- | |
| 72 | + // Confirming an address | |
| 73 | + | |
| 74 | + /** Emails a link that confirms the user's address. "wait" if one went out less than a minute ago. */ | |
| 75 | + export async function sendVerificationEmail(user: User): Promise<"sent" | "wait" | "verified"> { | |
| 76 | + if (user.verified) return "verified"; | |
| 77 | + const sent = await sendLink(user, "verify", (url) => ({ | |
| 78 | + to: user.email, | |
| 79 | + subject: "Confirm your email address", | |
| 80 | + body: [`Hi ${user.name || user.username},`, `Confirm that ${user.email} is the email address of your account, @${user.username}.`], | |
| 81 | + action: { label: "Confirm email address", url }, | |
| 82 | + footer: "The link works for 3 days. If you didn't sign up or change your email address, ignore this email.", | |
| 83 | + })); | |
| 84 | + return sent ? "sent" : "wait"; | |
| 85 | + } | |
| 86 | + | |
| 87 | + /** After signing up or changing the address: sends the link without holding up the request. */ | |
| 88 | + export function sendVerificationEmailLater(user: User) { | |
| 89 | + if (MAIL_ENABLED) sendVerificationEmail(user).catch(logFailure("the verification email")); | |
| 90 | + } | |
| 91 | + | |
| 92 | + /** | |
| 93 | + * Confirms the address a link was sent to. The link keeps working until it expires: mail | |
| 94 | + * scanners open links before people do. | |
| 95 | + */ | |
| 96 | + export async function confirmEmail(token: string) { | |
| 97 | + const user = await tokenUser(token, "verify"); | |
| 98 | + if (user && !user.verified) await markVerified(user.id); | |
| 99 | + return user; | |
| 100 | + } | |
| 101 | + | |
| 102 | + // --------------------------------------------------------------------------- | |
| 103 | + // Resetting a forgotten password | |
| 104 | + | |
| 105 | + /** | |
| 106 | + * Emails a link to set a new password if `login` (a username or email address) is someone's | |
| 107 | + * account. It doesn't tell whether it is, and doesn't wait for the email, as how long that takes | |
| 108 | + * would tell too. | |
| 109 | + */ | |
| 110 | + export async function requestPasswordReset(login: string) { | |
| 111 | + const user = await getPersonByLogin(login); | |
| 112 | + if (!user?.email) return; | |
| 113 | + sendLink(user, "reset", (url) => ({ | |
| 114 | + to: user.email, | |
| 115 | + subject: "Reset your password", | |
| 116 | + body: [`Hi ${user.name || user.username},`, `Someone asked to reset the password of your account, @${user.username}. Choose a new one here:`], | |
| 117 | + action: { label: "Choose a new password", url }, | |
| 118 | + footer: "The link works for one hour, once. If you didn't ask for it, ignore this email: your password stays the same.", | |
| 119 | + })).catch(logFailure("the password reset email")); | |
| 120 | + } | |
| 121 | + | |
| 122 | + /** The account a password reset link is for, while it's good. */ | |
| 123 | + export const passwordResetUser = (token: string) => tokenUser(token, "reset"); | |
| 124 | + | |
| 125 | + /** Sets a new password with a reset link, which then stops working (as do the account's sessions). */ | |
| 126 | + export async function resetPassword(token: string, password: string) { | |
| 127 | + const user = await passwordResetUser(token); | |
| 128 | + if (!user) return false; | |
| 129 | + await setPassword(user.id, password); | |
| 130 | + // The link reached them, so the address is theirs. | |
| 131 | + if (!user.verified) await markVerified(user.id); | |
| 132 | + notifyPasswordChanged({ ...user, verified: true }); | |
| 133 | + return true; | |
| 134 | + } | |
| 135 | + | |
| 136 | + // --------------------------------------------------------------------------- | |
| 137 | + // Notifications | |
| 138 | + | |
| 139 | + function notify(user: User, message: Omit<Message, "to">) { | |
| 140 | + if (MAIL_ENABLED && user.verified && user.email) sendMailLater({ ...message, to: user.email }); | |
| 141 | + } | |
| 142 | + | |
| 143 | + export function notifyInvitation(user: User, org: User, inviter: User, role: OrgRole) { | |
| 144 | + notify(user, { | |
| 145 | + subject: `@${inviter.username} invited you to join ${org.username}`, | |
| 146 | + body: [ | |
| 147 | + `Hi ${user.name || user.username},`, | |
| 148 | + `@${inviter.username} invited you to join the organization ${org.name || org.username}, with the ${ROLE_INFO[role].title} role.`, | |
| 149 | + "Accept or decline the invitation on your dashboard.", | |
| 150 | + ], | |
| 151 | + action: { label: "View invitation", url: linkTo("/settings/organizations") }, | |
| 152 | + footer: `You're getting this because @${inviter.username} invited your account, @${user.username}.`, | |
| 153 | + }); | |
| 154 | + } | |
| 155 | + | |
| 156 | + export function notifyCollaborator(user: User, repo: { owner: string; name: string }, by: User) { | |
| 157 | + notify(user, { | |
| 158 | + subject: `You can now push to ${repo.owner}/${repo.name}`, | |
| 159 | + body: [`Hi ${user.name || user.username},`, `@${by.username} added you as a collaborator to ${repo.owner}/${repo.name}. You can now push to it.`], | |
| 160 | + action: { label: "Open repository", url: linkTo(`/${repo.owner}/${repo.name}`) }, | |
| 161 | + footer: `You're getting this because @${by.username} gave your account, @${user.username}, access.`, | |
| 162 | + }); | |
| 163 | + } | |
| 164 | + | |
| 165 | + export function notifyPasswordChanged(user: User) { | |
| 166 | + notify(user, { | |
| 167 | + subject: "Your password was changed", | |
| 168 | + body: [ | |
| 169 | + `Hi ${user.name || user.username},`, | |
| 170 | + `The password of your account, @${user.username}, was just changed, and its other sessions were signed out.`, | |
| 171 | + "If that wasn't you, reset your password right away.", | |
| 172 | + ], | |
| 173 | + action: { label: "Reset password", url: linkTo("/login/forgot-password") }, | |
| 174 | + }); | |
| 175 | + } | |
| 176 | + | |
| 177 | + /** Tells the old address (`before`) that the account's email changed, in case someone else did it. */ | |
| 178 | + export function notifyEmailChanged(before: User, newEmail: string) { | |
| 179 | + notify(before, { | |
| 180 | + subject: "Your email address was changed", | |
| 181 | + body: [ | |
| 182 | + `Hi ${before.name || before.username},`, | |
| 183 | + `The email address of your account, @${before.username}, was changed from ${before.email} to ${newEmail}. Emails go to the new address from now on.`, | |
| 184 | + "If that wasn't you, someone else has your account: tell whoever runs this server.", | |
| 185 | + ], | |
| 186 | + }); | |
| 187 | + } | |
| @@ -0,0 +1,76 @@ | ||
| 1 | + import "server-only"; | |
| 2 | + import { createTransport } from "nodemailer"; | |
| 3 | + import { BRAND } from "@/components/brand"; | |
| 4 | + import { PUBLIC_ORIGIN } from "./request-origin"; | |
| 5 | + | |
| 6 | + /* | |
| 7 | + * Email goes out through the SMTP server in SMTP_HOST (with SMTP_PORT, SMTP_USER, SMTP_PASSWORD and | |
| 8 | + * SMTP_SECURE), from MAIL_FROM. Without SMTP_HOST nothing is sent, and what needs email (confirming | |
| 9 | + * addresses, resetting a forgotten password) isn't offered. The messages are in lib/emails.ts. | |
| 10 | + */ | |
| 11 | + | |
| 12 | + export const MAIL_ENABLED = !!process.env.SMTP_HOST; | |
| 13 | + | |
| 14 | + const PORT = Number(process.env.SMTP_PORT) || 587; | |
| 15 | + // Port 465 speaks TLS from the start; others upgrade with STARTTLS when the server offers it. | |
| 16 | + const SECURE = process.env.SMTP_SECURE ? ["1", "true"].includes(process.env.SMTP_SECURE.toLowerCase()) : PORT === 465; | |
| 17 | + const FROM = process.env.MAIL_FROM || process.env.SMTP_USER; | |
| 18 | + | |
| 19 | + const g = globalThis as unknown as { __mailer?: ReturnType<typeof createTransport>; __mailOriginWarned?: boolean }; | |
| 20 | + | |
| 21 | + function mailer() { | |
| 22 | + if (!FROM) throw new Error("Set MAIL_FROM to the address emails are sent from."); | |
| 23 | + return (g.__mailer ??= createTransport({ | |
| 24 | + host: process.env.SMTP_HOST, | |
| 25 | + port: PORT, | |
| 26 | + secure: SECURE, | |
| 27 | + auth: process.env.SMTP_USER ? { user: process.env.SMTP_USER, pass: process.env.SMTP_PASSWORD ?? "" } : undefined, | |
| 28 | + })); | |
| 29 | + } | |
| 30 | + | |
| 31 | + /** An absolute link to a page of this server. */ | |
| 32 | + export function linkTo(path: string) { | |
| 33 | + if (!PUBLIC_ORIGIN && !g.__mailOriginWarned) { | |
| 34 | + g.__mailOriginWarned = true; | |
| 35 | + console.warn("[mail] PUBLIC_URL isn't set, so links in emails point at localhost."); | |
| 36 | + } | |
| 37 | + return `${PUBLIC_ORIGIN ?? `http://localhost:${process.env.PORT ?? 3000}`}${path}`; | |
| 38 | + } | |
| 39 | + | |
| 40 | + export type Message = { | |
| 41 | + to: string; | |
| 42 | + subject: string; | |
| 43 | + /** Paragraphs of plain text. */ | |
| 44 | + body: string[]; | |
| 45 | + /** The one thing to do, as a button. */ | |
| 46 | + action?: { label: string; url: string }; | |
| 47 | + /** Smaller print below, e.g. why the email was sent. */ | |
| 48 | + footer?: string; | |
| 49 | + }; | |
| 50 | + | |
| 51 | + const escape = (s: string) => s.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`); | |
| 52 | + | |
| 53 | + function html({ body, action, footer }: Message) { | |
| 54 | + const p = (text: string) => `<p style="margin:0 0 16px">${escape(text)}</p>`; | |
| 55 | + const button = action | |
| 56 | + ? `<p style="margin:24px 0"><a href="${escape(action.url)}" style="display:inline-block;padding:10px 18px;border-radius:8px;background:#111827;color:#ffffff;text-decoration:none;font-weight:600">${escape(action.label)}</a></p>` + | |
| 57 | + `<p style="margin:0 0 16px;font-size:13px;color:#6b7280">Or open this link: <a href="${escape(action.url)}" style="color:#6b7280">${escape(action.url)}</a></p>` | |
| 58 | + : ""; | |
| 59 | + const small = footer ? `<p style="margin:24px 0 0;font-size:13px;color:#6b7280">${escape(footer)}</p>` : ""; | |
| 60 | + return `<!doctype html><html><body style="margin:0;padding:24px;background:#f9fafb"><div style="max-width:520px;margin:0 auto;padding:28px;background:#ffffff;border-radius:12px;font:15px/1.5 -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;color:#111827">${body.map(p).join("")}${button}${small}</div></body></html>`; | |
| 61 | + } | |
| 62 | + | |
| 63 | + function text({ body, action, footer }: Message) { | |
| 64 | + return [...body, ...(action ? [`${action.label}: ${action.url}`] : []), ...(footer ? [`--\n${footer}`] : [])].join("\n\n"); | |
| 65 | + } | |
| 66 | + | |
| 67 | + /** Sends an email; throws when the SMTP server refuses it. */ | |
| 68 | + export async function sendMail(message: Message) { | |
| 69 | + if (!MAIL_ENABLED) return; | |
| 70 | + await mailer().sendMail({ from: FROM, to: message.to, subject: `[${BRAND.name}] ${message.subject}`, text: text(message), html: html(message) }); | |
| 71 | + } | |
| 72 | + | |
| 73 | + /** Sends an email without waiting for it, for when the request shouldn't fail (or take longer) because of it. */ | |
| 74 | + export function sendMailLater(message: Message) { | |
| 75 | + sendMail(message).catch((e) => console.error(`[mail] couldn't send "${message.subject}":`, e instanceof Error ? e.message : e)); | |
| 76 | + } | |
| @@ -423,15 +423,18 @@ export async function listCollaborators(repoId: string) { | ||
| 423 | 423 | .sort((a, b) => a.username.localeCompare(b.username)); |
| 424 | 424 | } |
| 425 | 425 | |
| 426 | + /** Returns the person, and whether they weren't a collaborator yet. */ | |
| 426 | 427 | export async function addCollaborator(repo: RepoRecord, username: string) { |
| 427 | 428 | const user = await getPersonByUsername(username.trim()); |
| 428 | 429 | if (!user) throw new RepoError(`No user named ${username}.`); |
| 429 | 430 | if (user.id === repo.ownerId) throw new RepoError("The owner already has full access."); |
| 430 | 431 | try { |
| 431 | 432 | await (await db()).collection("collaborators").create({ repo: repo.id, user: user.id, created_at: Date.now() }); |
| 433 | + return { user, added: true }; | |
| 432 | 434 | } catch (e) { |
| 433 | 435 | // Already a collaborator. |
| 434 | 436 | if (!isUniqueViolation(e)) throw e; |
| 437 | + return { user, added: false }; | |
| 435 | 438 | } |
| 436 | 439 | } |
| 437 | 440 | |
| @@ -9,6 +9,9 @@ const PUBLIC_URL = process.env.PUBLIC_URL ? new URL(process.env.PUBLIC_URL) : nu | ||
| 9 | 9 | /** Session cookies are marked Secure when the instance is served over HTTPS (or SECURE_COOKIES=1). */ |
| 10 | 10 | export const SECURE_COOKIES = process.env.SECURE_COOKIES === "1" || PUBLIC_URL?.protocol === "https:"; |
| 11 | 11 | |
| 12 | + /** PUBLIC_URL's origin, or null. Links in emails use it, never the Host header (lib/mail.ts). */ | |
| 13 | + export const PUBLIC_ORIGIN = PUBLIC_URL?.origin ?? null; | |
| 14 | + | |
| 12 | 15 | /** The address users reach this server at, e.g. https://vectrum.cc. */ |
| 13 | 16 | export async function serverOrigin() { |
| 14 | 17 | if (PUBLIC_URL) return PUBLIC_URL.origin; |
| @@ -16,6 +16,8 @@ export type User = { | ||
| 16 | 16 | isOrg: boolean; |
| 17 | 17 | /** When an avatar was uploaded (used to bust caches), or null to draw the initials. */ |
| 18 | 18 | avatarVersion: number | null; |
| 19 | + /** Whether the person confirmed their email address with a link sent to it (lib/emails.ts). */ | |
| 20 | + verified: boolean; | |
| 19 | 21 | }; |
| 20 | 22 | |
| 21 | 23 | /** PocketBase dates read "2026-01-02 03:04:05.678Z". */ |
| @@ -31,6 +33,7 @@ export function toUser(r: RecordModel): User { | ||
| 31 | 33 | createdAt: parseDate(r.created), |
| 32 | 34 | isOrg: r.kind === "org", |
| 33 | 35 | avatarVersion: r.avatar ? orNull(r.avatar_updated_at) : null, |
| 36 | + verified: !!r.verified, | |
| 34 | 37 | }; |
| 35 | 38 | } |
| 36 | 39 | |
| @@ -79,10 +82,10 @@ export async function authenticateUser(login: string, password: string) { | ||
| 79 | 82 | login = login.trim(); |
| 80 | 83 | if (!login || !password) return null; |
| 81 | 84 | // PocketBase signs in by email; usernames are resolved here, case-insensitively. |
| 82 | - const record = await findFirst("users", filter("kind != 'org' && (username:lower = {:login} || email:lower = {:login})", { login: login.toLowerCase() })); | |
| 85 | + const person = await getPersonByLogin(login); | |
| 83 | 86 | try { |
| 84 | 87 | // Unknown logins are still checked, so usernames can't be probed by timing. |
| 85 | - const auth = await client().collection("users").authWithPassword(record?.email ?? login, password); | |
| 88 | + const auth = await client().collection("users").authWithPassword(person?.email ?? login, password); | |
| 86 | 89 | return { user: toUser(auth.record), token: auth.token }; |
| 87 | 90 | } catch (e) { |
| 88 | 91 | if (e instanceof ClientResponseError && e.status === 400) return null; |
| @@ -95,9 +98,25 @@ export async function verifyUserPassword(userId: string, password: string) { | ||
| 95 | 98 | return !!user && !!(await authenticateUser(user.email, password)); |
| 96 | 99 | } |
| 97 | 100 | |
| 98 | - /** Changes the password. PocketBase then rejects the user's existing session tokens. */ | |
| 101 | + /** Changes the password. PocketBase then rejects the user's existing session tokens, and links to reset it stop working. */ | |
| 99 | 102 | export async function setPassword(userId: string, password: string) { |
| 100 | 103 | await (await db()).collection("users").update(userId, { password, passwordConfirm: password }); |
| 104 | + await revokeEmailTokens(userId, "reset"); | |
| 105 | + } | |
| 106 | + | |
| 107 | + /** Makes the unused links emailed to a user (lib/emails.ts) for `purpose` stop working. */ | |
| 108 | + export async function revokeEmailTokens(userId: string, purpose: "verify" | "reset") { | |
| 109 | + const tokens = (await db()).collection("email_tokens"); | |
| 110 | + const rows = await tokens.getFullList({ filter: filter("user = {:userId} && purpose = {:purpose}", { userId, purpose }), fields: "id" }); | |
| 111 | + await Promise.all(rows.map((r) => tokens.delete(r.id))); | |
| 112 | + } | |
| 113 | + | |
| 114 | + /** A person by username or email address, as they sign in. */ | |
| 115 | + export async function getPersonByLogin(login: string) { | |
| 116 | + login = login.trim().toLowerCase(); | |
| 117 | + if (!login) return null; | |
| 118 | + const record = await findFirst("users", filter("kind != 'org' && (username:lower = {:login} || email:lower = {:login})", { login })); | |
| 119 | + return record ? toUser(record) : null; | |
| 101 | 120 | } |
| 102 | 121 | |
| 103 | 122 | export async function getUserById(id: string) { |
| @@ -116,19 +135,28 @@ export async function getPersonByUsername(username: string) { | ||
| 116 | 135 | return user && !user.isOrg ? user : null; |
| 117 | 136 | } |
| 118 | 137 | |
| 119 | - /** Returns whether the email changed; PocketBase then rejects the user's existing session tokens. */ | |
| 138 | + /** | |
| 139 | + * Returns whether the email changed; PocketBase then rejects the user's existing session tokens. A | |
| 140 | + * new address needs confirming again, and links sent to the old one stop working. | |
| 141 | + */ | |
| 120 | 142 | export async function updateProfile(userId: string, data: { name: string; email: string; bio: string }) { |
| 121 | 143 | const email = data.email.trim(); |
| 122 | 144 | if (!isEmail(email)) throw new UserError("Email address is invalid."); |
| 123 | 145 | if (await emailTaken(email, userId)) throw new UserError("That email address is already in use."); |
| 124 | 146 | const before = await getUserById(userId); |
| 125 | 147 | try { |
| 126 | - await (await db()).collection("users").update(userId, { name: data.name.trim().slice(0, 100), email, bio: data.bio.trim().slice(0, 300) }); | |
| 148 | + const emailChanged = before?.email !== email; | |
| 149 | + // Not just a change of case. | |
| 150 | + const newAddress = before?.email.toLowerCase() !== email.toLowerCase(); | |
| 151 | + await (await db()) | |
| 152 | + .collection("users") | |
| 153 | + .update(userId, { name: data.name.trim().slice(0, 100), email, bio: data.bio.trim().slice(0, 300), ...(newAddress && { verified: false }) }); | |
| 154 | + if (newAddress) await Promise.all([revokeEmailTokens(userId, "verify"), revokeEmailTokens(userId, "reset")]); | |
| 155 | + return { emailChanged, newAddress }; | |
| 127 | 156 | } catch (e) { |
| 128 | 157 | if (isUniqueViolation(e)) throw new UserError("That email address is already in use."); |
| 129 | 158 | throw e; |
| 130 | 159 | } |
| 131 | - return { emailChanged: before?.email !== email }; | |
| 132 | 160 | } |
| 133 | 161 | |
| 134 | 162 | export async function searchUsers(query: string, limit = 8) { |
| @@ -20,6 +20,7 @@ | ||
| 20 | 20 | "@uiw/react-codemirror": "^4.25.12", |
| 21 | 21 | "@unlocalhosted/metalui": "^0.3.3", |
| 22 | 22 | "next": "16.3.7", |
| 23 | + "nodemailer": "^10.0.16", | |
| 23 | 24 | "pocketbase": "^0.28.1", |
| 24 | 25 | "react": "19.2.8", |
| 25 | 26 | "react-dom": "19.2.8", |
| @@ -0,0 +1,35 @@ | ||
| 1 | + // Links sent by email (lib/emails.ts): "verify" confirms an address, "reset" sets a new password. | |
| 2 | + // | |
| 3 | + // Only a SHA-256 hash of the token in the link is stored. `email` is the address it went to, so a | |
| 4 | + // link stops working once the account's email changes. Only the server (a superuser) reads them. | |
| 5 | + | |
| 6 | + migrate( | |
| 7 | + (app) => { | |
| 8 | + const users = app.findCollectionByNameOrId("users"); | |
| 9 | + const collection = new Collection({ | |
| 10 | + type: "base", | |
| 11 | + name: "email_tokens", | |
| 12 | + listRule: null, | |
| 13 | + viewRule: null, | |
| 14 | + createRule: null, | |
| 15 | + updateRule: null, | |
| 16 | + deleteRule: null, | |
| 17 | + fields: [ | |
| 18 | + { type: "relation", name: "user", collectionId: users.id, maxSelect: 1, required: true, cascadeDelete: true }, | |
| 19 | + { type: "select", name: "purpose", values: ["verify", "reset"], maxSelect: 1, required: true }, | |
| 20 | + { type: "text", name: "email", max: 200, required: true }, | |
| 21 | + { type: "text", name: "token_hash", max: 64, required: true }, | |
| 22 | + { type: "number", name: "expires_at", onlyInt: true, min: 0, required: true }, | |
| 23 | + { type: "number", name: "created_at", onlyInt: true, min: 0, required: true }, | |
| 24 | + ], | |
| 25 | + indexes: [ | |
| 26 | + "CREATE UNIQUE INDEX idx_email_tokens_hash ON email_tokens (token_hash)", | |
| 27 | + "CREATE INDEX idx_email_tokens_user ON email_tokens (user, purpose)", | |
| 28 | + ], | |
| 29 | + }); | |
| 30 | + app.save(collection); | |
| 31 | + }, | |
| 32 | + (app) => { | |
| 33 | + app.delete(app.findCollectionByNameOrId("email_tokens")); | |
| 34 | + }, | |
| 35 | + ); | |
| @@ -21,6 +21,7 @@ | ||
| 21 | 21 | "@uiw/react-codemirror": "^4.25.12", |
| 22 | 22 | "@unlocalhosted/metalui": "^0.3.3", |
| 23 | 23 | "next": "16.3.7", |
| 24 | + "nodemailer": "^10.0.16", | |
| 24 | 25 | "pocketbase": "^0.28.1", |
| 25 | 26 | "react": "19.2.8", |
| 26 | 27 | "react-dom": "19.2.8", |
| @@ -1030,6 +1031,8 @@ | ||
| 1030 | 1031 | |
| 1031 | 1032 | "node-releases": ["node-releases@2.0.57", "", {}, "sha512-kQK9LGGFiHtrWiNhZtA7Qbw17AQz+dmsEKODRIVTXA9+e5MS/2gZEBhYJt13GrAz5/IOZKddH/0Z3TP/Zgo+yw=="], |
| 1032 | 1033 | |
| 1034 | + "nodemailer": ["nodemailer@10.0.16", "", {}, "sha512-VL9xKErxN+el77o8eBOuYKUPMyEx7F9hfnDCuiUKJRp9v3wnWMOudYgP/SWbwSHy8rUL4cylobuXtki1LpQS7A=="], | |
| 1035 | + | |
| 1033 | 1036 | "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], |
| 1034 | 1037 | |
| 1035 | 1038 | "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], |
| @@ -18,6 +18,12 @@ services: | ||
| 18 | 18 | # SSH_HOST: vectrum.example.com |
| 19 | 19 | # Set to 1 to turn off the built-in SSH server (and remove the 2222 port above). |
| 20 | 20 | # SSH_DISABLED: "1" |
| 21 | + # Send email (address confirmation, password resets, invitations) through this SMTP server. | |
| 22 | + # SMTP_HOST: smtp.example.com | |
| 23 | + # SMTP_PORT: "587" # 465 uses TLS from the start (or set SMTP_SECURE: "1") | |
| 24 | + # SMTP_USER: vectrum@example.com | |
| 25 | + # SMTP_PASSWORD: change-me | |
| 26 | + # MAIL_FROM: Vectrum <vectrum@example.com> | |
| 21 | 27 | volumes: |
| 22 | 28 | - vectrum-data:/data # repositories, the PocketBase database and the SSH host key |
| 23 | 29 | |
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | # Features |
| 2 | 2 | |
| 3 | 3 | - **Accounts**: sign up, sign in, profiles, password change (PocketBase auth: bcrypt-hashed passwords, token sessions) |
| 4 | + - **Email** through an SMTP server of your own ([Configuration](installation.md#email)): confirming addresses, resetting a forgotten password, organization invitations, collaborator access, and notices when a password or email address changes | |
| 4 | 5 | - **Two-factor authentication** with an authenticator app (TOTP: 1Password, Google Authenticator, Authy…): set up under *Settings → Two-factor auth* by scanning a QR code, with ten one-time recovery codes for a lost device. It guards signing in with a password, on the website and in the Android app; SSH keys, API tokens and connected apps work as before |
| 5 | 6 | - **Repositories**: public or private, rename, transfer to an organization (or back to your account), delete, optional README on creation |
| 6 | 7 | - **Collaborators**: give other users push access to a repository |
| @@ -46,6 +46,28 @@ To build the image without Compose, run `docker build -f apps/web/Dockerfile -t | ||
| 46 | 46 | | `SSH_DISABLED` | — | Set to `1` to not start the SSH server | |
| 47 | 47 | | `SIGNUPS_DISABLED` | — | Set to `1` to close registration (add users with `scripts/create-user.mjs`) | |
| 48 | 48 | | `WEBHOOKS_ALLOW_PRIVATE` | — | Set to `1` to let webhooks reach private and local addresses (a trusted, single-team server) | |
| 49 | + | `SMTP_HOST` | — | SMTP server to send email through (see [Email](#email)); without it, Vectrum sends none | | |
| 50 | + | `SMTP_PORT` | `587` | Its port | | |
| 51 | + | `SMTP_SECURE` | on for port 465 | `1` for TLS from the start (port 465); otherwise the connection upgrades with STARTTLS when the server offers it | | |
| 52 | + | `SMTP_USER`, `SMTP_PASSWORD` | — | Login for the SMTP server, if it needs one | | |
| 53 | + | `MAIL_FROM` | `SMTP_USER` | Sender, e.g. `Vectrum <noreply@vectrum.cc>` | | |
| 54 | + | |
| 55 | ||
| 56 | + | |
| 57 | + With `SMTP_HOST` set, Vectrum sends email: a link to confirm the address after signing up or changing it, a link to reset a forgotten password (*Forgot your password?* on the sign-in page), organization invitations, being added as a collaborator, and notices when an account's password or email address changes. Notifications only go to confirmed addresses. Set `PUBLIC_URL` too, as the links in emails point there. | |
| 58 | + | |
| 59 | + Outside Docker, put the settings in `apps/web/.env` (see `apps/web/.env.example`), which Next.js reads on start: | |
| 60 | + | |
| 61 | + ```bash | |
| 62 | + SMTP_HOST=smtp.example.com | |
| 63 | + SMTP_PORT=587 | |
| 64 | + SMTP_USER=vectrum@example.com | |
| 65 | + SMTP_PASSWORD=… | |
| 66 | + MAIL_FROM="Vectrum <vectrum@example.com>" | |
| 67 | + PUBLIC_URL=https://vectrum.example.com | |
| 68 | + ``` | |
| 69 | + | |
| 70 | + With Docker Compose, add them under `environment:`, or put them in a `.env` file next to `docker-compose.yml` and list it under `env_file:`. Failures to send are logged. | |
| 49 | 71 | |
| 50 | 72 | Data layout: |
| 51 | 73 | |
| @@ -8,4 +8,5 @@ | ||
| 8 | 8 | - OAuth for agents follows the MCP authorization spec: public clients only, PKCE (S256) required, exact redirect URIs (any port for `localhost`), one-time codes that expire in 5 minutes, access tokens that last an hour, and refresh tokens that rotate on every use (presenting an old one revokes the grant). Only hashes of codes and tokens are stored. The consent page can't be framed, and it shows where it will send you, because anyone can register an application under any name. |
| 9 | 9 | - Release files are always served as `application/octet-stream` attachments with `nosniff` and a sandboxing CSP. Uploads signed in with the browser session must come from the site's own origin; scripts use tokens. |
| 10 | 10 | - Raw files are served with `nosniff` and a sandboxing CSP, so repository HTML/SVG can't run scripts in the app's origin. |
| 11 | + - Links in emails (confirming an address, resetting a password) carry 256-bit random tokens; only their SHA-256 hashes are stored, with the address they went to, so they stop working when the email changes. Reset links work for an hour, once, and are revoked by any password change. At most one link of a kind goes to an account per minute. Asking for a reset link answers the same for unknown accounts and doesn't wait for the email. Links always use `PUBLIC_URL`, never the request's `Host` header. Notifications only go to confirmed addresses, as anyone can sign up with someone else's. | |
| 11 | 12 | - Two-factor authentication (TOTP, RFC 6238) is checked after the password, before any session exists: the password's session token waits on the server for 5 minutes and 5 codes. Wrong codes also count per account (10 in 15 minutes locks codes out for that long). A code works once (also no older one after it), recovery codes are stored as hashes and deleted when used, and the secret is a hidden field only the server reads. Turning 2FA off takes a code. |