Settings

The Settings page is one of the most feature-rich areas of SvelteForge Admin, combining multiple management sections into a single tabbed interface. Built entirely with Svelte 5 runes and SvelteKit form actions, it covers profile management, password changes, session auditing, application configuration, and appearance preferences — all with progressive enhancement and server-side validation.

Each section is a separate tab powered by $state for reactive tab switching. All mutations use SvelteKit form actions with use:enhance for non-blocking submissions, and success/error feedback is delivered via svelte-sonner toast notifications.

Profile Settings

The Profile tab allows users to update their display name, email address, and avatar URL. The form action validates all inputs server-side before persisting changes via Drizzle ORM.

How It Works

  1. Display name — Free text field, required, trimmed before storing
  2. Email — Validated for format and checked for uniqueness against the users table
  3. Avatar URL — Optional URL string for a profile picture (Gravatar, uploaded image, etc.)
// Profile update form action (simplified)
profile: async ({ request, locals }) => {
  if (!locals.user) return fail(401, { message: "Unauthorized" });

  const formData = await request.formData();
  const name = formData.get("name")?.toString().trim();
  const email = formData.get("email")?.toString().trim().toLowerCase();
  const avatarUrl = formData.get("avatarUrl")?.toString().trim() || null;

  // Validate required fields
  if (!name || !email) {
    return fail(400, { message: "Name and email are required" });
  }

  // Check email uniqueness (exclude current user)
  const existing = await db.query.users.findFirst({
    where: and(eq(users.email, email), ne(users.id, locals.user.id)),
  });
  if (existing) {
    return fail(400, { message: "Email is already in use" });
  }

  await db.update(users)
    .set({ name, email, avatarUrl })
    .where(eq(users.id, locals.user.id));

  return { success: true, message: "Profile updated" };
}

The email uniqueness check explicitly excludes the current user's ID, so submitting the form without changing the email does not trigger a conflict. All queries use Drizzle ORM's type-safe query builder.

Password Change

The Password tab requires the current password for verification before accepting a new one. This prevents unauthorized password changes even if a session is compromised on a shared device.

Validation Rules

  • Current password — Verified against the stored hash using Argon2id
  • New password — Minimum 6 characters, maximum 255 characters
  • Confirm password — Must exactly match the new password
// Password change form action (simplified)
password: async ({ request, locals }) => {
  const formData = await request.formData();
  const currentPassword = formData.get("currentPassword");
  const newPassword = formData.get("newPassword");
  const confirmPassword = formData.get("confirmPassword");

  // Validate lengths
  if (newPassword.length < 6 || newPassword.length > 255) {
    return fail(400, { message: "Password must be 6-255 characters" });
  }

  if (newPassword !== confirmPassword) {
    return fail(400, { message: "Passwords do not match" });
  }

  // Verify current password with Argon2id
  const user = await db.query.users.findFirst({
    where: eq(users.id, locals.user.id),
  });

  const valid = await verify(user.passwordHash, currentPassword, {
    memoryCost: 19456, timeCost: 2, outputLen: 32, parallelism: 1,
  });

  if (!valid) {
    return fail(400, { message: "Current password is incorrect" });
  }

  // Hash and store new password
  const passwordHash = await hash(newPassword, {
    memoryCost: 19456, timeCost: 2, outputLen: 32, parallelism: 1,
  });

  await db.update(users)
    .set({ passwordHash })
    .where(eq(users.id, locals.user.id));

  return { success: true, message: "Password updated" };
}

The new password is re-hashed with the same Argon2id parameters used during registration (19 MB memory cost, 2 iterations). This ensures consistent security across all password operations in the application.

Session Management

The Sessions tab provides a complete audit trail of all active sessions for the current user. This is a security-critical feature that gives users visibility into where their account is active and the ability to revoke suspicious sessions.

Session Information

Each session row displays:

  • Device type — Desktop, mobile, or tablet (detected from user-agent)
  • Browser — Name and version (Chrome 120, Firefox 121, Safari 17, etc.)
  • Operating system — Windows, macOS, Linux, iOS, Android
  • IP address — The last-seen IP for this session
  • Last activity — Relative timestamp of the most recent request
  • Current session badge — Highlights the session you are currently using

User-agent parsing is handled by the parseUserAgent() utility in src/lib/utils/user-agent.ts. It extracts browser name/version, OS name/version, and device type from the raw user-agent string using pattern matching — no external parsing library required.

Revoking Sessions

Two revocation options are available:

  1. Revoke individual session — Deletes a specific session from the database. If the revoked session is the current one, the auth_session cookie is also cleared, effectively logging the user out.
  2. Revoke all other sessions — Keeps the current session active but deletes every other session for the user. This is the "log me out everywhere else" action.
// Revoke a single session
revokeSession: async ({ request, locals, cookies }) => {
  const formData = await request.formData();
  const sessionId = formData.get("sessionId");

  await db.delete(sessions).where(
    and(eq(sessions.id, sessionId), eq(sessions.userId, locals.user.id))
  );

  // If revoking current session, clear the cookie
  if (sessionId === locals.session.id) {
    deleteSessionCookie(cookies);
  }

  return { success: true, message: "Session revoked" };
}

// Revoke all other sessions
revokeAllOtherSessions: async ({ locals }) => {
  await db.delete(sessions).where(
    and(
      eq(sessions.userId, locals.user.id),
      ne(sessions.id, locals.session.id)
    )
  );

  return { success: true, message: "All other sessions revoked" };
}

The revocation queries always scope to the current user's ID, preventing users from revoking sessions belonging to other accounts. This is enforced at the database query level, not just through UI restrictions.

Session Metadata Updates

Session metadata (user agent, IP address) is updated on every request via the SvelteKit server hook in hooks.server.ts. This means the Sessions tab always reflects the most recent device and location information, even for long-lived sessions.

App Settings (Admin-Only)

The App Settings tab is only visible to users with the admin role. It provides a key-value configuration interface backed by the appSettings table in Drizzle ORM.

Available Settings

KeyPurposeDefault
siteNameApplication display name shown in the sidebar and browser titleSvelteForge Admin
timezoneServer timezone for date formatting and schedulingUTC
defaultRoleRole assigned to newly registered usersviewer
maintenanceModeWhen "true", blocks all non-admin users with a 503 errorfalse

Upsert Pattern

Settings are saved using a Drizzle ORM upsert (insert with onConflictDoUpdate). This means the form action works identically whether a setting exists or is being created for the first time:

// App settings form action
appSettings: async ({ request, locals }) => {
  if (locals.user.role !== "admin") {
    return fail(403, { message: "Admin access required" });
  }

  const formData = await request.formData();
  const settings = Object.fromEntries(formData.entries());

  for (const [key, value] of Object.entries(settings)) {
    await db.insert(appSettings)
      .values({ key, value: value.toString() })
      .onConflictDoUpdate({
        target: appSettings.key,
        set: { value: value.toString() },
      });
  }

  return { success: true, message: "Settings saved" };
}

Maintenance mode is particularly powerful — toggling it on immediately blocks all non-admin users from accessing any route in the (app) route group. The auth guard in src/routes/(app)/+layout.server.ts checks this setting on every request and returns a 503 error for non-admin users. Admins retain full access to disable maintenance mode when ready.

Appearance

The Appearance section controls the dark/light mode toggle, powered by mode-watcher with system preference detection.

How It Works

  • Three modes — Light, Dark, and System (follows OS preference)
  • Persistence — Mode preference is stored in localStorage and persists across sessions
  • No flash — mode-watcher applies the theme before the page renders, preventing the flash of wrong theme

Svelte 5 Integration

A critical Svelte 5 pattern to note: mode-watcher v1 exports a mode object that is a runes-based reactive object, not a Svelte store. You must use mode.current to read the current mode — using the legacy $mode store syntax will not work:

// CORRECT — Svelte 5 runes pattern
import { mode } from "mode-watcher";

// In your component
const isDark = $derived(mode.current === "dark");

// WRONG — This is Svelte 4 store syntax, does NOT work
// $mode === "dark"  // <-- Will not compile in Svelte 5

The toggle component renders different icons based on the current mode and uses mode.set() to switch between "light", "dark", and "system".

Svelte 5 Patterns in Settings

The Settings page demonstrates several key Svelte 5 patterns that are used throughout SvelteForge Admin:

Tabbed Interface with $state

<script lang="ts">
  let activeTab = $state("profile");

  const tabs = [
    { id: "profile", label: "Profile", icon: UserIcon },
    { id: "password", label: "Password", icon: LockIcon },
    { id: "sessions", label: "Sessions", icon: MonitorIcon },
    { id: "appearance", label: "Appearance", icon: PaletteIcon },
    { id: "app", label: "App Settings", icon: SettingsIcon },
  ];
</script>

{#each tabs as tab}
  <button
    class:active={activeTab === tab.id}
    onclick={() => (activeTab = tab.id)}
  >
    <tab.icon class="size-4" />
    {tab.label}
  </button>
{/each}

{#if activeTab === "profile"}
  <!-- Profile form -->
{:else if activeTab === "password"}
  <!-- Password form -->
{/if}

Form Data with $state

<script lang="ts">
  let { data } = $props();

  let name = $state(data.user.name);
  let email = $state(data.user.email);
  let avatarUrl = $state(data.user.avatarUrl ?? "");
</script>

<form method="POST" action="?/profile" use:enhance>
  <input name="name" bind:value={name} />
  <input name="email" type="email" bind:value={email} />
  <input name="avatarUrl" bind:value={avatarUrl} />
  <button type="submit">Save Changes</button>
</form>

Progressive Enhancement with use:enhance

All forms on the Settings page use SvelteKit's use:enhance directive. This means forms work without JavaScript (full page reload on submit) and upgrade to AJAX-style submissions when JavaScript is available. The enhance callback handles toast notifications:

<form
  method="POST"
  action="?/profile"
  use:enhance={() => {
    return async ({ result, update }) => {
      if (result.type === "success") {
        toast.success("Profile updated successfully");
      } else if (result.type === "failure") {
        toast.error(result.data?.message ?? "Something went wrong");
      }
      await update();
    };
  }}
>

Need More?

Go Premium with DashboardPack

SvelteForge Admin provides a complete settings foundation with Svelte 5 and SvelteKit. Need profile picture upload with drag-and-drop, two-factor authentication (TOTP/SMS), billing management with Stripe integration, and team administration with invite flows? Our premium templates at DashboardPack include all of these and more.

  • Profile picture upload — Drag-and-drop with crop, resize, and CDN storage
  • Two-factor authentication — TOTP setup with QR code and backup codes
  • Billing management — Stripe subscription management with invoices
  • Team administration — Invite members, assign roles, manage permissions
  • Notification preferences — Granular email and in-app notification controls