PVR Tech Studio
Settings access

Settings & Access

A unified five-tab workspace settings area — Profile, Site Settings, Permissions, Payment, and Mail — sharing one cover-hero scaffold with a two-column body and a sticky rail.

9 min read
Updated July 15, 2026

The Settings hub is the product-style settings area a real SaaS app would have — five routes that read as one surface, sharing one scaffold: a cover-image profile hero with the hub tabs built in, a two-column body, and a sticky right rail. Everything is presentational (local state + a "Save changes" toast, no persistence), ready to be wired to a real API.

Overview

RoutePageWhat it demos
/settings/profileProfilePagePersonal info, social profiles, change-password form.
/settingsSiteSettingsPageWorkspace identity + logo upload, regional selects, appearance, feature toggles, danger zone.
/settings/permissionsPermissionsPageRole cards, an editable role×permission matrix, team-member list, invite modal.
/settings/paymentPaymentPagePlan hero with billing-cycle toggle, usage tiles, realistic credit-card visuals, billing history.
/settings/mailMailSettingsPageProvider segmented control, SMTP / API-key configuration, send-a-test-email panel.

Every tab renders inside the shared SettingsLayout scaffold: a PageHeader with a presentational "Save changes" action, the ProfileHeader banner (cover-image hero + avatar + identity, with the SettingsNav tabs integrated along its bottom edge), then a two-column body (lg:grid-cols-[1fr_20rem]) — the tab's Panels on the left and the shared, sticky SettingsAside rail (plan widget + help card) on the right.

Architecture & files

FileResponsibility
src/components/settings/SettingsLayout.tsxShared scaffold: PageHeader + size="xs" "Save changes" button (fires settings:savedToast), ProfileHeader, and the two-column grid with the sticky (lg:sticky lg:top-32) aside.
src/components/settings/SettingsNav.tsxThe hub tabs — a horizontal pill row of five NavLinks (icon + nav:* label; the /settings link uses end so it isn't active on subpages).
src/components/settings/ProfileHeader.tsxShared banner on every tab: a cover-image hero (default /photos/photo-2.jpg) with a dark scrim, change-cover + change-photo file inputs, ringed avatar with a bg-success presence dot, name + role chip + meta chips, three AnimatedNumber stats, and SettingsNav below.
src/components/settings/SettingsAside.tsxShared right rail: a plan widget (Pro badge, seats/storage UsageBars, "Manage billing" → /settings/payment) and a help card (Documentation → /faq, Contact support → /apps/chat, Keyboard shortcuts → the open-shortcuts window event).
src/pages/settings/ProfilePage.tsxPersonal information, social profiles, and password panels.
src/pages/settings/SiteSettingsPage.tsxGeneral (logo upload ≤ 1 MB, workspace name / support email / tagline), Regional selects, Appearance (theme-mode tiles via useTheme), feature Switch rows, and the danger-zone panel + confirm Modal.
src/pages/settings/PermissionsPage.tsxRole overview cards, the permission matrix, the team-member list with per-member role Select, and the invite Modal.
src/pages/settings/PaymentPage.tsxPlan hero + billing summary, usage tiles, CardFace payment-method visuals, add-card Modal, and the billing-history table.
src/pages/settings/MailSettingsPage.tsxProvider segmented control, SMTP-vs-API config branch, sender identity, and the send-test panel.
src/data/settings.tsAll demo data: TIMEZONES, DATE_FORMATS, FEATURE_TOGGLES, ROLES, PERMISSION_GROUPS + defaultMatrix(), MEMBERS, SAVED_CARDS, BILLING_HISTORY, MAIL_PROVIDERS, ENCRYPTION_OPTS.
src/locales/en/settings.jsonThe settings i18n namespace; tab titles reuse nav:* keys.

No persistence

There is no STORAGE_KEY and nothing registered in src/lib/appStorage.ts — the hub keeps no persisted state. Avatar / cover / logo uploads become in-memory data URLs; edited fields, matrix cells, member roles, and saved cards live in component useState and reset on navigation. Currency amounts on the Payment page use the shared formatMoney(amount, currency, locale) from src/data/invoice.ts, and the Regional currency Select reuses its CURRENCIES list. The Payment billing summary derives its figures (days left, period progress, next payment date) from today's date so the demo always looks current.

Usage

The hub is already wired in — the five leaves live under Settings → Settings in the sidebar, and the routes are plain (non-lazy) entries in src/routes.tsx:

{path: '/settings/profile', element: <ProfilePage />},
{path: '/settings', element: <SiteSettingsPage />},
{path: '/settings/permissions', element: <PermissionsPage />},
{path: '/settings/payment', element: <PaymentPage />},
{path: '/settings/mail', element: <MailSettingsPage />},

Each page is just SettingsLayout + Panels, so a new tab is a few lines:

import {useTranslation} from '@/platform/i18n'
import {Panel} from '@/components/ui'
import {SettingsLayout} from '@/components/settings/SettingsLayout'
 
export function NotificationsSettingsPage() {
    const {t} = useTranslation('settings')
    return (
        <SettingsLayout title={t('nav:notificationSettings')}>
            <Panel title={t('settings:notifTitle')} subtitle={t('settings:notifHint')}>
                {/* form controls */}
            </Panel>
        </SettingsLayout>
    )
}

Create the page

Compose SettingsLayout + Panels (every Panel needs title and subtitle).

Register the route

Add an appRoutes entry in src/routes.tsx.

Add the nav item

Add the menu leaf under the Settings group in src/data/menu.ts + its nav: label.

Add the hub tab

Append the tab to ITEMS in src/components/settings/SettingsNav.tsx.

API / Props

SettingsLayoutProps

PropTypeDescription
titlestringThe PageHeader title (each page passes its nav:* label).
childrenReactNodeThe tab's content — Panels rendered in the main (left) column.

The layout itself owns the "Save changes" action (a size="xs" Button that toasts settings:savedToast), the banner, and the aside — pages provide only their panels.

SettingsNav tab registry

SettingsNav renders a fixed ITEMS array — {to, navKey, icon, end?} per tab:

tonavKeyIconNotes
/settings/profilenav:profileUserRound
/settingsnav:siteSettingsSettings2end: true (exact-match only)
/settings/permissionsnav:permissionsShieldCheck
/settings/paymentnav:paymentSettingsCreditCard
/settings/mailnav:mailSettingsMail

Demo data (src/data/settings.ts)

ExportTypeUsed by
TIMEZONES / DATE_FORMATS{value; label}[]Site Settings → Regional Selects (labels are literal/technical).
FEATURE_TOGGLESFeatureToggle[] (id, labelKey, descKey, on)Site Settings → Features Switch rows.
ROLESRole[] (id, labelKey, members, locked?)Permissions — role cards, matrix columns, role selects (owner is locked).
PERMISSION_GROUPSPermissionGroup[] (id, labelKey, defaults)Permissions — matrix rows; defaults lists role ids granted by default.
defaultMatrix()() => Record<string, Record<string, boolean>>Builds matrix[groupId][roleId] from the groups' defaults.
MEMBERSMember[]Permissions — team-member list.
SAVED_CARDSSavedCard[]Payment — CardFace visuals.
BILLING_HISTORYInvoiceRow[]Payment — billing-history table.
MAIL_PROVIDERS{id; labelKey?; label?}[]Mail — segmented control (smtp uses an i18n key; SendGrid / Mailgun / Postmark are literal brand names).
ENCRYPTION_OPTS{value; label}[]Mail — SMTP encryption Select (None / SSL / TLS).

Per-page behaviors worth knowing

  • Permissions matrix — one Checkbox per group×role cell; the Owner column is locked (role.lockeddisabled) with an aria-label of "permission — role". The owner member's role Select is disabled and their remove button is hidden.
  • Payment CardFace — a realistic card mockup: per-brand gradient face (BRAND_STYLE: Visa blue, Mastercard near-black, slate fallback), a drawn EMV CardChip, contactless glyph, brand.name as the issuer line, masked number from last4, holder + expiry, and a BrandLogo. Non-primary cards get Make default and remove actions; the dashed add tile opens the add-card Modal.
  • Mail config branchprovider === 'smtp' renders host / port / encryption / username / password; any other provider renders a single API-key field. From-name / from-email are shared below.
  • Site Settings danger zone — a headerVariant="muted", border-danger/30 Panel whose delete action opens a size="sm" confirm Modal (both buttons just close it — presentational).

Configuration & customization

Edit roles & permissions

Both are data-driven from src/data/settings.ts. A new role is a Role entry (its column, role-select option, and matrix cells all derive automatically); a new permission is a PermissionGroup with the role ids that get it by default:

// src/data/settings.ts
export const PERMISSION_GROUPS: PermissionGroup[] = [
    // …
    {id: 'manageApiKeys', labelKey: 'settings:permManageApiKeys', defaults: ['owner', 'admin']},
]
// src/locales/en/settings.json
{"permManageApiKeys": "Manage API keys"}

The matrix row, checkboxes, and default checks appear automatically (via defaultMatrix()).

Wire a form to a real API

import {api} from '@/lib/api'
 
const onSave = async () => {
    await api.put('/workspace/settings', {name, tagline, email})
    toast({title: t('settings:savedToast'), tone: 'success'})
}

A note on colors

Everything themable uses semantic tokens so all tabs re-skin and dark-mode automatically. The only deliberate exceptions are decorative branded graphics: the CardFace gradients / gold EMV chip and the Mastercard circles (#eb001b / #f79e1b) are intentionally fixed, and the cover-hero scrim/chips use white/black overlays because they sit on a photograph.

Best practices

  • Keep pages as Panel compositions. Every content section is a Panel with title + subtitle; page files stay wiring-only.
  • Route theme changes through useTheme. The Appearance tiles call setMode on the shared theme store — never shadow it with local state.
  • Data in src/data/settings.ts, copy in the settings namespace. Proper nouns (SendGrid, Visa), technical values (ports, timezone labels), and card numbers stay literal.
  • If you add persistence, register the key in APP_LOCAL_KEYS (src/lib/appStorage.ts).
  • Reuse the shared money formatter. formatMoney from src/data/invoice.ts is the repo's only Intl.NumberFormat currency helper.

Troubleshooting

SymptomCause / fix
The Site Settings tab lights up on every /settings/* pageIts NavLink needs end: true/settings is a path prefix of all the others.
Edits vanish when switching tabsBy design — state is local per page and nothing persists.
"Save changes" doesn't save anythingIt is presentational: a success toast only.
Owner checkboxes / role select / remove can't be changedDeliberate — the owner role is locked in ROLES and the owner member is guarded in PermissionsPage.
The right rail scrolls away / overlaps the sticky page headerThe aside is lg:sticky lg:top-32 — tuned to clear the app header + slim PageHeader. Adjust the offset if header heights change.
Avatar / cover / logo upload does nothingFiles over the size cap are silently ignored (cover ≤ 2 MB, avatar/logo ≤ 1 MB) and only image/* is accepted.
Card faces don't re-skinIntentional — BRAND_STYLE gradients are fixed decorative brand graphics, not tokens.

FAQ

How is this different from /layout-settings? The Settings hub demos application settings and is presentational. Layout Settings edits the real shell configuration and persists it via LayoutContext.

Does anything here persist? Only the theme mode changed from the Appearance section (through useTheme, localStorage('theme')). Everything else resets on navigation.

Where do the Payment page's dates and totals come from? Computed from today's date at render and from the cycle toggle ($29 monthly / $24 annual per seat × 12 seats), so the demo never looks stale.

Can members actually be invited / removed? Removals and role changes update local state; the invite modal validates nothing and just toasts settings:invitedToast.

Why is the profile banner shown on every tab? It doubles as the hub's frame — the tabs are integrated along its bottom strip, so the five pages read as one settings area.

Was this page helpful?