better-auth 1.0.0-alpha.4

The most comprehensive authentication framework for Rust
Documentation
"use client";

import { useSession, authClient } from "@/lib/auth-client";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import Link from "next/link";

type PasskeyRecord = {
  id: string;
  name?: string;
  createdAt: string;
  deviceType: string;
  backedUp: boolean;
};

export default function SettingsPage() {
  const router = useRouter();
  const { data: session, isPending } = useSession();

  // Profile form
  const [name, setName] = useState("");
  const [profileLoading, setProfileLoading] = useState(false);
  const [profileMessage, setProfileMessage] = useState("");
  const [profileError, setProfileError] = useState("");

  // Password form
  const [currentPassword, setCurrentPassword] = useState("");
  const [newPassword, setNewPassword] = useState("");
  const [confirmPassword, setConfirmPassword] = useState("");
  const [passwordLoading, setPasswordLoading] = useState(false);
  const [passwordMessage, setPasswordMessage] = useState("");
  const [passwordError, setPasswordError] = useState("");

  // Passkey management
  const [passkeys, setPasskeys] = useState<PasskeyRecord[]>([]);
  const [passkeysLoading, setPasskeysLoading] = useState(false);
  const [passkeyName, setPasskeyName] = useState("");
  const [passkeyMessage, setPasskeyMessage] = useState("");
  const [passkeyError, setPasskeyError] = useState("");
  const [registeringPasskey, setRegisteringPasskey] = useState(false);
  const [deletingPasskeyId, setDeletingPasskeyId] = useState<string | null>(null);

  useEffect(() => {
    if (!isPending && !session) {
      router.push("/sign-in");
    }
  }, [isPending, session, router]);

  // Populate name field when session loads
  useEffect(() => {
    if (session?.user?.name) {
      setName(session.user.name);
    }
  }, [session]);

  useEffect(() => {
    if (!session) {
      return;
    }

    void loadPasskeys();
  }, [session]);

  async function handleUpdateProfile(e: React.FormEvent) {
    e.preventDefault();
    setProfileError("");
    setProfileMessage("");
    setProfileLoading(true);

    await authClient.updateUser(
      { name },
      {
        onSuccess: () => {
          setProfileMessage("Profile updated successfully");
        },
        onError: (ctx) => {
          setProfileError(ctx.error.message || "Failed to update profile");
        },
      },
    );

    setProfileLoading(false);
  }

  async function handleChangePassword(e: React.FormEvent) {
    e.preventDefault();
    setPasswordError("");
    setPasswordMessage("");

    if (newPassword !== confirmPassword) {
      setPasswordError("Passwords do not match");
      return;
    }

    if (newPassword.length < 8) {
      setPasswordError("New password must be at least 8 characters");
      return;
    }

    setPasswordLoading(true);

    await authClient.changePassword(
      {
        currentPassword,
        newPassword,
        revokeOtherSessions: true,
      },
      {
        onSuccess: () => {
          setPasswordMessage("Password changed successfully");
          setCurrentPassword("");
          setNewPassword("");
          setConfirmPassword("");
        },
        onError: (ctx) => {
          setPasswordError(ctx.error.message || "Failed to change password");
        },
      },
    );

    setPasswordLoading(false);
  }

  async function loadPasskeys() {
    setPasskeysLoading(true);

    const result = await authClient.$fetch<PasskeyRecord[]>("/passkey/list-user-passkeys", {
      method: "GET",
      throw: false,
    });

    if (result.data) {
      setPasskeys(result.data);
      setPasskeyError("");
    } else if (result.error) {
      setPasskeyError(result.error.message || "Failed to load passkeys");
    }

    setPasskeysLoading(false);
  }

  async function handleRegisterPasskey() {
    setPasskeyError("");
    setPasskeyMessage("");
    setRegisteringPasskey(true);

    const name =
      passkeyName.trim() || session?.user.email || session?.user.name || "My Passkey";

    const result = await authClient.passkey.addPasskey({
      name,
      authenticatorAttachment: "cross-platform",
    });

    if (result.error) {
      setPasskeyError(result.error.message || "Failed to register passkey");
      setRegisteringPasskey(false);
      return;
    }

    setPasskeyName("");
    setPasskeyMessage("Passkey registered successfully");
    await loadPasskeys();
    setRegisteringPasskey(false);
  }

  async function handleDeletePasskey(id: string) {
    setPasskeyError("");
    setPasskeyMessage("");
    setDeletingPasskeyId(id);

    const result = await authClient.$fetch<{ status: boolean }>("/passkey/delete-passkey", {
      method: "POST",
      body: { id },
      throw: false,
    });

    if (result.error) {
      setPasskeyError(result.error.message || "Failed to delete passkey");
      setDeletingPasskeyId(null);
      return;
    }

    setPasskeyMessage("Passkey removed");
    await loadPasskeys();
    setDeletingPasskeyId(null);
  }

  if (isPending) {
    return (
      <div className="container" style={{ marginTop: "4rem" }}>
        <p style={{ textAlign: "center", color: "var(--muted)" }}>
          Loading...
        </p>
      </div>
    );
  }

  if (!session) {
    return null;
  }

  return (
    <div className="container" style={{ marginTop: "2rem" }}>
      <div style={{ marginBottom: "1.5rem" }}>
        <Link href="/dashboard" style={{ fontSize: "0.875rem" }}>
          ← Back to Dashboard
        </Link>
      </div>

      <h1>Settings</h1>
      <p className="muted">Manage your account settings</p>

      {/* Update Profile */}
      <div className="card" style={{ marginBottom: "1.5rem" }}>
        <h2>Profile</h2>
        <form onSubmit={handleUpdateProfile}>
          {profileError && <div className="error">{profileError}</div>}
          {profileMessage && (
            <div className="success">{profileMessage}</div>
          )}

          <label htmlFor="email">Email</label>
          <input
            id="email"
            type="email"
            value={session.user.email}
            disabled
            style={{ opacity: 0.5 }}
          />

          <label htmlFor="name">Name</label>
          <input
            id="name"
            type="text"
            value={name}
            onChange={(e) => setName(e.target.value)}
            required
          />

          <button type="submit" disabled={profileLoading}>
            {profileLoading ? "Saving..." : "Update Profile"}
          </button>
        </form>
      </div>

      {/* Change Password */}
      <div className="card" style={{ marginBottom: "1.5rem" }}>
        <h2>Change Password</h2>
        <form onSubmit={handleChangePassword}>
          {passwordError && <div className="error">{passwordError}</div>}
          {passwordMessage && (
            <div className="success">{passwordMessage}</div>
          )}

          <label htmlFor="currentPassword">Current Password</label>
          <input
            id="currentPassword"
            type="password"
            value={currentPassword}
            onChange={(e) => setCurrentPassword(e.target.value)}
            required
            minLength={8}
          />

          <label htmlFor="newPassword">New Password</label>
          <input
            id="newPassword"
            type="password"
            value={newPassword}
            onChange={(e) => setNewPassword(e.target.value)}
            required
            minLength={8}
          />

          <label htmlFor="confirmPassword">Confirm New Password</label>
          <input
            id="confirmPassword"
            type="password"
            value={confirmPassword}
            onChange={(e) => setConfirmPassword(e.target.value)}
            required
            minLength={8}
          />

          <button type="submit" disabled={passwordLoading}>
            {passwordLoading ? "Changing..." : "Change Password"}
          </button>
        </form>
      </div>

      {/* Passkeys */}
      <div className="card">
        <h2>Passkeys</h2>
        <p className="muted">Register a passkey for passwordless sign-in.</p>

        {passkeyError && <div className="error">{passkeyError}</div>}
        {passkeyMessage && <div className="success">{passkeyMessage}</div>}

        <div style={{ marginBottom: "1rem" }}>
          <label htmlFor="passkeyName">Passkey Name</label>
          <input
            id="passkeyName"
            type="text"
            value={passkeyName}
            onChange={(e) => setPasskeyName(e.target.value)}
            placeholder="Laptop, Phone, Security Key"
          />

          <button type="button" onClick={handleRegisterPasskey} disabled={registeringPasskey}>
            {registeringPasskey ? "Waiting for passkey..." : "Register Passkey"}
          </button>
        </div>

        <div className="stack">
          {passkeysLoading ? (
            <p className="muted">Loading passkeys...</p>
          ) : passkeys.length === 0 ? (
            <p className="muted">No passkeys registered yet.</p>
          ) : (
            passkeys.map((passkey) => (
              <div key={passkey.id} className="passkey-row">
                <div>
                  <div className="passkey-name">{passkey.name || "Unnamed Passkey"}</div>
                  <div className="passkey-meta">
                    {passkey.deviceType}
                    {passkey.backedUp ? " • backed up" : ""}
                    {" • "}
                    {new Date(passkey.createdAt).toLocaleDateString()}
                  </div>
                </div>
                <button
                  type="button"
                  className="danger-inline"
                  onClick={() => handleDeletePasskey(passkey.id)}
                  disabled={deletingPasskeyId === passkey.id}
                >
                  {deletingPasskeyId === passkey.id ? "Removing..." : "Remove"}
                </button>
              </div>
            ))
          )}
        </div>
      </div>
    </div>
  );
}