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
- Display name — Free text field, required, trimmed before storing
- Email — Validated for format and checked for uniqueness against the
userstable - 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:
- Revoke individual session — Deletes a specific session from the database. If
the revoked session is the current one, the
auth_sessioncookie is also cleared, effectively logging the user out. - 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
| Key | Purpose | Default |
|---|---|---|
siteName | Application display name shown in the sidebar and browser title | SvelteForge Admin |
timezone | Server timezone for date formatting and scheduling | UTC |
defaultRole | Role assigned to newly registered users | viewer |
maintenanceMode | When "true", blocks all non-admin users with a 503 error | false |
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
localStorageand 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