Skip to content
6

Settings 2

Security and notifications settings in tabs: change password with strength meter, two-factor toggle, revocable session list, and grouped notification switches under a Pause all master. Ships composable parts.

Preview

Installation

npx shadcn@latest add https://hirael.com/r/settings-02.json

Code

components/blocks/settings-02.tsx
'use client';

import * as React from 'react';
import { Laptop, MonitorSmartphone, ShieldCheck, Smartphone, type LucideIcon } from 'lucide-react';

import { cn } from '@/lib/utils';
import { Badge } from '@/registry/hirael/bases/radix/ui/badge';
import { Button } from '@/registry/hirael/bases/radix/ui/button';
import {
  Field,
  FieldContent,
  FieldDescription,
  FieldError,
  FieldGroup,
  FieldLabel,
  FieldTitle,
} from '@/registry/hirael/bases/radix/ui/field';
import { Switch } from '@/registry/hirael/bases/radix/ui/switch';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/registry/hirael/bases/radix/ui/tabs';
import {
  PasswordInput,
  PasswordInputField,
  PasswordInputStrength,
} from '@/registry/hirael/bases/radix/components/password-input';

/* -------------------------------------------------------------------------- */
/*  Parts                                                                     */
/* -------------------------------------------------------------------------- */

type SettingsPanelProps = React.ComponentProps<'section'>;

const SettingsPanel = ({ className, ...props }: SettingsPanelProps) => {
  return (
    <section
      data-slot="settings-panel"
      className={cn(
        'flex flex-col overflow-hidden rounded-lg border border-border bg-card text-card-foreground',
        className,
      )}
      {...props}
    />
  );
};

type SettingsPanelHeaderProps = React.ComponentProps<'div'>;

const SettingsPanelHeader = ({ className, ...props }: SettingsPanelHeaderProps) => {
  return (
    <div
      data-slot="settings-panel-header"
      className={cn('flex items-start justify-between gap-4 border-b border-border px-5 py-4', className)}
      {...props}
    />
  );
};

type SettingsPanelTitleProps = React.ComponentProps<'h2'>;

const SettingsPanelTitle = ({ className, ...props }: SettingsPanelTitleProps) => {
  return (
    <h2
      data-slot="settings-panel-title"
      className={cn('text-base font-semibold tracking-[-0.01em] text-foreground', className)}
      {...props}
    />
  );
};

type SettingsPanelDescriptionProps = React.ComponentProps<'p'>;

const SettingsPanelDescription = ({ className, ...props }: SettingsPanelDescriptionProps) => {
  return (
    <p data-slot="settings-panel-description" className={cn('text-sm text-muted-foreground', className)} {...props} />
  );
};

interface SettingsPanelGroupProps extends React.ComponentProps<'div'> {
  /** Mono eyebrow above the group. */
  label?: React.ReactNode;
}

const SettingsPanelGroup = ({ label, className, children, ...props }: SettingsPanelGroupProps) => {
  const labelId = React.useId();
  return (
    <div
      data-slot="settings-panel-group"
      role="group"
      aria-labelledby={label ? labelId : undefined}
      className={cn('flex flex-col', className)}
      {...props}
    >
      {label ? (
        <span
          id={labelId}
          className="border-b border-border bg-muted/30 px-5 py-2 font-mono text-[10px] uppercase tracking-[0.12em] text-muted-foreground"
        >
          {label}
        </span>
      ) : null}
      <div className="divide-y divide-border">{children}</div>
    </div>
  );
};

interface SettingsPanelItemProps extends Omit<React.ComponentProps<'div'>, 'children'> {
  label: React.ReactNode;
  description?: React.ReactNode;
  /** Connects the label to the control (a switch, an input, a button). */
  htmlFor?: string;
  /** Sits next to the label, e.g. a "Recommended" badge. */
  badge?: React.ReactNode;
  /** The control or action on the end side. */
  children?: React.ReactNode;
}

const SettingsPanelItem = ({
  label,
  description,
  htmlFor,
  badge,
  className,
  children,
  ...props
}: SettingsPanelItemProps) => {
  return (
    <Field
      orientation="horizontal"
      data-slot="settings-panel-item"
      className={cn('justify-between gap-6 px-5 py-3.5', className)}
      {...props}
    >
      <FieldContent className="min-w-0 gap-0.5">
        <div className="flex flex-wrap items-center gap-2">
          {htmlFor ? <FieldLabel htmlFor={htmlFor}>{label}</FieldLabel> : <FieldTitle>{label}</FieldTitle>}
          {badge}
        </div>
        {description ? <FieldDescription className="text-xs">{description}</FieldDescription> : null}
      </FieldContent>
      {children ? <div className="flex shrink-0 items-center gap-2 self-center">{children}</div> : null}
    </Field>
  );
};

type SettingsPanelSessionsProps = React.ComponentProps<'ul'>;

const SettingsPanelSessions = ({ className, ...props }: SettingsPanelSessionsProps) => {
  return <ul data-slot="settings-panel-sessions" className={cn('divide-y divide-border', className)} {...props} />;
};

interface SettingsPanelSessionProps extends Omit<React.ComponentProps<'li'>, 'children'> {
  device: React.ReactNode;
  location?: React.ReactNode;
  lastActive?: React.ReactNode;
  /** The session the viewer is using right now; it cannot be revoked. */
  current?: boolean;
  icon?: LucideIcon;
  onRevoke?: () => void;
  revokeLabel?: string;
}

const SettingsPanelSession = ({
  device,
  location,
  lastActive,
  current = false,
  icon: Icon = MonitorSmartphone,
  onRevoke,
  revokeLabel = 'Revoke',
  className,
  ...props
}: SettingsPanelSessionProps) => {
  return (
    <li
      data-slot="settings-panel-session"
      data-current={current || undefined}
      className={cn('flex items-center gap-3 px-5 py-3', className)}
      {...props}
    >
      <span
        aria-hidden
        className="inline-flex size-8 shrink-0 items-center justify-center rounded-md border border-border bg-background text-muted-foreground"
      >
        <Icon className="size-4" />
      </span>
      <div className="flex min-w-0 flex-1 flex-col">
        <span className="flex flex-wrap items-center gap-2">
          <span className="truncate text-sm font-medium text-foreground">{device}</span>
          {current ? (
            <Badge variant="outline" className="gap-1.5 border-accent-cool/40 text-accent-cool">
              <span aria-hidden className="size-1.5 rounded-full bg-accent-cool" />
              This device
            </Badge>
          ) : null}
        </span>
        <span className="truncate font-mono text-[10px] uppercase tracking-[0.08em] text-muted-foreground">
          {location}
          {location && lastActive ? ' · ' : null}
          {lastActive}
        </span>
      </div>
      {current ? null : (
        <Button
          type="button"
          variant="ghost"
          size="sm"
          onClick={onRevoke}
          className="text-muted-foreground hover:text-destructive"
        >
          {revokeLabel}
        </Button>
      )}
    </li>
  );
};

type SettingsPanelFooterProps = React.ComponentProps<'div'>;

const SettingsPanelFooter = ({ className, ...props }: SettingsPanelFooterProps) => {
  return (
    <div
      data-slot="settings-panel-footer"
      className={cn('flex items-center justify-end gap-2 border-t border-border bg-muted/30 px-5 py-3', className)}
      {...props}
    />
  );
};

export {
  SettingsPanel,
  SettingsPanelHeader,
  SettingsPanelTitle,
  SettingsPanelDescription,
  SettingsPanelGroup,
  SettingsPanelItem,
  SettingsPanelSessions,
  SettingsPanelSession,
  SettingsPanelFooter,
};

/* -------------------------------------------------------------------------- */
/*  Preview                                                                   */
/* -------------------------------------------------------------------------- */

interface Session {
  id: string;
  device: string;
  location: string;
  lastActive: string;
  current?: boolean;
  icon: LucideIcon;
}

const SESSIONS: readonly Session[] = [
  {
    id: 's1',
    device: 'MacBook Pro · Chrome',
    location: 'Amman, JO',
    lastActive: 'Active now',
    current: true,
    icon: Laptop,
  },
  {
    id: 's2',
    device: 'iPhone 15 · Safari',
    location: 'Amman, JO',
    lastActive: '2 hours ago',
    icon: Smartphone,
  },
  {
    id: 's3',
    device: 'Windows · Edge',
    location: 'Berlin, DE',
    lastActive: '6 days ago',
    icon: Laptop,
  },
];

type NotificationKey = 'digest' | 'mentions' | 'billing' | 'product';

interface NotificationRow {
  key: NotificationKey;
  label: string;
  description: string;
}

const NOTIFICATION_GROUPS: readonly {
  label: string;
  rows: readonly NotificationRow[];
}[] = [
  {
    label: 'Activity',
    rows: [
      {
        key: 'digest',
        label: 'Email digest',
        description: 'A summary of what happened, every weekday morning.',
      },
      {
        key: 'mentions',
        label: 'Mentions',
        description: 'When someone @mentions you in a comment or thread.',
      },
    ],
  },
  {
    label: 'Account',
    rows: [
      {
        key: 'billing',
        label: 'Billing alerts',
        description: 'Failed payments, receipts, and plan changes.',
      },
      {
        key: 'product',
        label: 'Product updates',
        description: 'New features and changelog highlights, about once a month.',
      },
    ],
  },
];

const NOTIFICATION_DEFAULTS: Record<NotificationKey, boolean> = {
  digest: true,
  mentions: true,
  billing: true,
  product: false,
};

const Settings02 = () => {
  const [current, setCurrent] = React.useState('');
  const [next, setNext] = React.useState('');
  const [confirm, setConfirm] = React.useState('');
  const confirmMismatch = confirm.length > 0 && confirm !== next;
  const canUpdatePassword = current.length > 0 && next.length >= 8 && confirm === next;

  const [twoFactor, setTwoFactor] = React.useState(false);
  const [sessions, setSessions] = React.useState<readonly Session[]>(SESSIONS);
  const others = sessions.filter((s) => !s.current);

  const [paused, setPaused] = React.useState(false);
  const [prefs, setPrefs] = React.useState(NOTIFICATION_DEFAULTS);
  const [saved, setSaved] = React.useState({
    paused: false,
    prefs: NOTIFICATION_DEFAULTS,
  });
  const notificationsDirty =
    paused !== saved.paused || (Object.keys(prefs) as NotificationKey[]).some((k) => prefs[k] !== saved.prefs[k]);

  return (
    <section data-slot="settings-02-block" className="min-h-svh w-full bg-background">
      <div className="mx-auto w-full max-w-3xl px-4 py-8 sm:px-6 sm:py-10">
        <div className="mb-6 flex flex-col gap-1">
          <span className="font-mono text-[10px] uppercase tracking-[0.12em] text-muted-foreground">Account</span>
          <h1 className="text-2xl font-semibold tracking-[-0.02em]">Security and notifications</h1>
          <p className="text-sm text-muted-foreground">
            Keep your account locked down and decide what reaches your inbox.
          </p>
        </div>

        <Tabs defaultValue="security" className="gap-6">
          <TabsList variant="line" className="w-full justify-start border-b border-border">
            <TabsTrigger value="security" className="flex-none px-3">
              Security
            </TabsTrigger>
            <TabsTrigger value="notifications" className="flex-none px-3">
              Notifications
            </TabsTrigger>
          </TabsList>

          <TabsContent value="security" className="flex flex-col gap-6">
            <SettingsPanel>
              <SettingsPanelHeader>
                <div className="flex flex-col gap-1">
                  <SettingsPanelTitle>Password</SettingsPanelTitle>
                  <SettingsPanelDescription>
                    Use at least 8 characters. A passphrase is easier to remember and harder to guess.
                  </SettingsPanelDescription>
                </div>
              </SettingsPanelHeader>
              <form
                id="settings-password-form"
                className="px-5 py-5"
                onSubmit={(e) => {
                  e.preventDefault();
                  setCurrent('');
                  setNext('');
                  setConfirm('');
                }}
              >
                <FieldGroup className="gap-5">
                  <Field className="gap-2">
                    <FieldLabel htmlFor="settings-current-password">Current password</FieldLabel>
                    <PasswordInput id="settings-current-password" value={current} onValueChange={setCurrent}>
                      <PasswordInputField autoComplete="current-password" />
                    </PasswordInput>
                  </Field>
                  <div className="grid gap-5 sm:grid-cols-2">
                    <Field className="gap-2">
                      <FieldLabel htmlFor="settings-new-password">New password</FieldLabel>
                      <PasswordInput id="settings-new-password" value={next} onValueChange={setNext}>
                        <PasswordInputField autoComplete="new-password" />
                        <PasswordInputStrength showLabel={next.length > 0} />
                      </PasswordInput>
                    </Field>
                    <Field className="gap-2" data-invalid={confirmMismatch || undefined}>
                      <FieldLabel htmlFor="settings-confirm-password">Confirm new password</FieldLabel>
                      <PasswordInput id="settings-confirm-password" value={confirm} onValueChange={setConfirm}>
                        <PasswordInputField
                          autoComplete="new-password"
                          aria-invalid={confirmMismatch || undefined}
                          aria-describedby={confirmMismatch ? 'settings-confirm-error' : undefined}
                        />
                      </PasswordInput>
                      <FieldError id="settings-confirm-error" className="text-[11px]">
                        {confirmMismatch ? 'Passwords do not match.' : null}
                      </FieldError>
                    </Field>
                  </div>
                </FieldGroup>
              </form>
              <SettingsPanelFooter>
                <Button
                  type="button"
                  variant="link"
                  size="sm"
                  className="me-auto h-auto p-0 text-xs text-muted-foreground"
                >
                  Forgot your password?
                </Button>
                <Button type="submit" form="settings-password-form" size="sm" disabled={!canUpdatePassword}>
                  Update password
                </Button>
              </SettingsPanelFooter>
            </SettingsPanel>

            <SettingsPanel>
              <SettingsPanelHeader>
                <div className="flex flex-col gap-1">
                  <SettingsPanelTitle>Two-factor authentication</SettingsPanelTitle>
                  <SettingsPanelDescription>
                    A second step at sign-in, using an authenticator app.
                  </SettingsPanelDescription>
                </div>
              </SettingsPanelHeader>
              <SettingsPanelGroup>
                <SettingsPanelItem
                  htmlFor="settings-2fa"
                  label="Authenticator app"
                  description={
                    twoFactor
                      ? 'Enabled. You will be asked for a code on new devices.'
                      : 'Codes from Google Authenticator, 1Password, or similar.'
                  }
                  badge={
                    twoFactor ? (
                      <Badge variant="outline" className="gap-1 text-success">
                        <ShieldCheck aria-hidden />
                        On
                      </Badge>
                    ) : (
                      <Badge variant="secondary">Recommended</Badge>
                    )
                  }
                >
                  <Switch id="settings-2fa" checked={twoFactor} onCheckedChange={setTwoFactor} />
                </SettingsPanelItem>
                {twoFactor ? (
                  <SettingsPanelItem
                    label="Recovery codes"
                    description="Ten one-time codes for when you lose your phone. 10 of 10 left."
                  >
                    <Button type="button" variant="outline" size="sm">
                      View codes
                    </Button>
                  </SettingsPanelItem>
                ) : null}
              </SettingsPanelGroup>
            </SettingsPanel>

            <SettingsPanel>
              <SettingsPanelHeader>
                <div className="flex flex-col gap-1">
                  <SettingsPanelTitle>Active sessions</SettingsPanelTitle>
                  <SettingsPanelDescription>
                    Devices signed in to your account. Revoke anything you do not recognise.
                  </SettingsPanelDescription>
                </div>
                <span className="shrink-0 font-mono text-[10px] uppercase tracking-[0.12em] text-muted-foreground">
                  {sessions.length} {sessions.length === 1 ? 'device' : 'devices'}
                </span>
              </SettingsPanelHeader>
              <SettingsPanelSessions>
                {sessions.map((s) => (
                  <SettingsPanelSession
                    key={s.id}
                    device={s.device}
                    location={s.location}
                    lastActive={s.lastActive}
                    current={s.current}
                    icon={s.icon}
                    onRevoke={() => setSessions((list) => list.filter((x) => x.id !== s.id))}
                  />
                ))}
              </SettingsPanelSessions>
              <SettingsPanelFooter className="justify-between">
                <span className="text-xs text-muted-foreground">
                  {others.length === 0
                    ? 'Only this device is signed in.'
                    : `${others.length} other ${others.length === 1 ? 'device' : 'devices'} signed in.`}
                </span>
                <Button
                  type="button"
                  variant="outline"
                  size="sm"
                  disabled={others.length === 0}
                  onClick={() => setSessions((list) => list.filter((x) => x.current))}
                >
                  Sign out all other devices
                </Button>
              </SettingsPanelFooter>
            </SettingsPanel>
          </TabsContent>

          <TabsContent value="notifications" className="flex flex-col gap-6">
            <SettingsPanel>
              <SettingsPanelHeader>
                <div className="flex flex-col gap-1">
                  <SettingsPanelTitle>Email notifications</SettingsPanelTitle>
                  <SettingsPanelDescription>
                    Sent to mohammad@plinth.dev. Pausing keeps your choices for when you come back.
                  </SettingsPanelDescription>
                </div>
                <Field orientation="horizontal" className="w-auto shrink-0 gap-2">
                  <FieldLabel htmlFor="settings-pause-all" className="text-xs font-normal text-muted-foreground">
                    Pause all
                  </FieldLabel>
                  <Switch id="settings-pause-all" checked={paused} onCheckedChange={setPaused} />
                </Field>
              </SettingsPanelHeader>
              {NOTIFICATION_GROUPS.map((group) => (
                <SettingsPanelGroup key={group.label} label={group.label}>
                  {group.rows.map((row) => {
                    const id = `settings-notify-${row.key}`;
                    return (
                      <SettingsPanelItem
                        key={row.key}
                        htmlFor={id}
                        label={row.label}
                        description={row.description}
                        className={cn(paused && 'opacity-60')}
                      >
                        <Switch
                          id={id}
                          checked={!paused && prefs[row.key]}
                          disabled={paused}
                          onCheckedChange={(checked) => setPrefs((p) => ({ ...p, [row.key]: checked }))}
                        />
                      </SettingsPanelItem>
                    );
                  })}
                </SettingsPanelGroup>
              ))}
              <SettingsPanelFooter>
                <Button
                  type="button"
                  variant="ghost"
                  size="sm"
                  disabled={!notificationsDirty}
                  onClick={() => {
                    setPaused(saved.paused);
                    setPrefs(saved.prefs);
                  }}
                >
                  Cancel
                </Button>
                <Button
                  type="button"
                  size="sm"
                  disabled={!notificationsDirty}
                  onClick={() => setSaved({ paused, prefs })}
                >
                  Save changes
                </Button>
              </SettingsPanelFooter>
            </SettingsPanel>
          </TabsContent>
        </Tabs>
      </div>
    </section>
  );
};

export default Settings02;

Dependencies

shadcn registry

badgebuttonfieldpassword-inputswitchtabs

npm

lucide-react

Shipped