"use server"; import { revalidatePath } from "next/cache"; import { renewSession } from "@/lib/auth"; import { notifyEmailChanged, notifyPasswordChanged, sendVerificationEmail, sendVerificationEmailLater } from "@/lib/emails"; import { addSshKey, deleteSshKey, KeyError } from "@/lib/keys"; import { createToken, deleteToken, TokenError, type TokenPermission } from "@/lib/tokens"; import { cancelTwoFactorSetup, disableTwoFactor, enableTwoFactor, regenerateRecoveryCodes, startTwoFactorSetup, TwoFactorError, } from "@/lib/two-factor"; import { setPassword, updateProfile, UserError, validatePassword, verifyUserPassword } from "@/lib/users"; import { grantableRepoIds, viewerOrThrow } from "./guard"; import type { FormState } from "./types"; export async function updateProfileAction(_prev: FormState, formData: FormData): Promise { const viewer = await viewerOrThrow(); try { const email = String(formData.get("email") ?? "").trim(); const { emailChanged, newAddress } = await updateProfile(viewer.id, { name: String(formData.get("name") ?? ""), email, bio: String(formData.get("bio") ?? ""), }); // PocketBase revokes a user's tokens when their email changes; keep this browser signed in. if (emailChanged) await renewSession(viewer.id); if (newAddress) { notifyEmailChanged(viewer, email); sendVerificationEmailLater({ ...viewer, email, verified: false }); } } catch (e) { if (e instanceof UserError) return { error: e.message }; throw e; } revalidatePath("/", "layout"); return { success: "Profile updated." }; } export async function resendVerificationAction(): Promise { const viewer = await viewerOrThrow(); try { const result = await sendVerificationEmail(viewer); if (result === "wait") return { error: "We just sent one. Wait a minute before asking again." }; if (result === "verified") return { success: "Your email address is already confirmed." }; } catch (e) { console.error("[mail] couldn't send the verification email:", e instanceof Error ? e.message : e); return { error: "The email couldn't be sent. Try again later." }; } return { success: `Sent. Check ${viewer.email} for the link.` }; } export async function changePasswordAction(_prev: FormState, formData: FormData): Promise { const viewer = await viewerOrThrow(); const current = String(formData.get("current") ?? ""); const next = String(formData.get("password") ?? ""); if (!(await verifyUserPassword(viewer.id, current))) return { error: "Your current password is incorrect." }; if (next !== formData.get("confirm")) return { error: "The new passwords don't match." }; const problem = validatePassword(next); if (problem) return { error: problem }; // This signs the user out everywhere, so sign this browser back in. await setPassword(viewer.id, next); await renewSession(viewer.id); notifyPasswordChanged(viewer); return { success: "Password changed." }; } export async function startTwoFactorSetupAction() { const viewer = await viewerOrThrow(); await startTwoFactorSetup(viewer.id); revalidatePath("/settings/security"); } export async function cancelTwoFactorSetupAction() { const viewer = await viewerOrThrow(); await cancelTwoFactorSetup(viewer.id); revalidatePath("/settings/security"); } // These show recovery codes once, so they don't refresh the page; its Done button does. export async function enableTwoFactorAction(_prev: FormState, formData: FormData): Promise { const viewer = await viewerOrThrow(); try { return { codes: await enableTwoFactor(viewer.id, String(formData.get("code") ?? "")) }; } catch (e) { if (e instanceof TwoFactorError) return { error: e.message }; throw e; } } export async function regenerateRecoveryCodesAction(_prev: FormState, formData: FormData): Promise { const viewer = await viewerOrThrow(); try { return { codes: await regenerateRecoveryCodes(viewer.id, String(formData.get("code") ?? "")) }; } catch (e) { if (e instanceof TwoFactorError) return { error: e.message }; throw e; } } export async function disableTwoFactorAction(_prev: FormState, formData: FormData): Promise { const viewer = await viewerOrThrow(); try { await disableTwoFactor(viewer.id, String(formData.get("code") ?? "")); } catch (e) { if (e instanceof TwoFactorError) return { error: e.message }; throw e; } revalidatePath("/settings/security"); return { success: "Two-factor authentication is off." }; } export async function addSshKeyAction(_prev: FormState, formData: FormData): Promise { const viewer = await viewerOrThrow(); try { await addSshKey(viewer.id, String(formData.get("title") ?? ""), String(formData.get("key") ?? "")); } catch (e) { if (e instanceof KeyError) return { error: e.message }; throw e; } revalidatePath("/settings/keys"); return { success: "SSH key added." }; } export async function deleteSshKeyAction(keyId: string) { const viewer = await viewerOrThrow(); await deleteSshKey(viewer.id, String(keyId)); revalidatePath("/settings/keys"); } export async function createTokenAction(_prev: FormState, formData: FormData): Promise { const viewer = await viewerOrThrow(); const permission: TokenPermission = formData.get("permission") === "read" ? "read" : "write"; const expiry = String(formData.get("expiry") ?? "30"); const repoIds = formData.get("scope") === "selected" ? await grantableRepoIds(viewer.id, formData.getAll("repos").map(String), permission) : null; try { const token = await createToken(viewer.id, { name: String(formData.get("name") ?? ""), permission, expiresInDays: expiry === "never" ? null : Number(expiry) || 30, repoIds, }); revalidatePath("/settings/tokens"); return { token }; } catch (e) { if (e instanceof TokenError) return { error: e.message }; throw e; } } export async function deleteTokenAction(tokenId: string) { const viewer = await viewerOrThrow(); await deleteToken(viewer.id, String(tokenId)); revalidatePath("/settings/tokens"); }