Cosskit

Account & Profile Settings

Profile form with avatar, handle preview and bio counter, a verified email row, notification switches, and a danger zone.

pnpm dlx shadcn@latest add @cosskit/settings-account

Requires the @cosskit namespace in your components.json.

Open full page

Code

"use client";

import { useState } from "react";
import {
  AtSignIcon,
  BadgeCheckIcon,
  KeyRoundIcon,
  TrashIcon,
  UploadIcon,
} from "lucide-react";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
  Card,
  CardDescription,
  CardFooter,
  CardHeader,
  CardPanel,
  CardTitle,
} from "@/components/ui/card";
import { Field, FieldDescription, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import {
  InputGroup,
  InputGroupAddon,
  InputGroupInput,
  InputGroupText,
} from "@/components/ui/input-group";
import {
  Select,
  SelectItem,
  SelectPopup,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";

// ---------------------------------------------------------------------------
// Data
// ---------------------------------------------------------------------------

const TIMEZONES = [
  { label: "Pacific Time — Los Angeles", value: "america/los_angeles" },
  { label: "Mountain Time — Denver", value: "america/denver" },
  { label: "Central Time — Chicago", value: "america/chicago" },
  { label: "Eastern Time — New York", value: "america/new_york" },
  { label: "Greenwich Mean Time — London", value: "europe/london" },
  { label: "Central European Time — Berlin", value: "europe/berlin" },
];

const NOTIFICATIONS = [
  {
    id: "product",
    label: "Product updates",
    description: "New features, improvements, and changelog highlights.",
    defaultOn: true,
  },
  {
    id: "digest",
    label: "Weekly digest",
    description: "A Monday summary of workspace activity.",
    defaultOn: false,
  },
  {
    id: "security",
    label: "Security alerts",
    description: "Sign-ins from new devices and password changes.",
    defaultOn: true,
    // Security mail is not optional. Rendering the switch disabled-on is
    // honest; hiding the row entirely makes people wonder if it exists.
    locked: true,
  },
];

const BIO_LIMIT = 160;

// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------

export default function SettingsAccount() {
  const [name, setName] = useState("Nadia Rahim");
  const [handle, setHandle] = useState("nadia");
  const [bio, setBio] = useState(
    "Head of platform at Kestrel. Previously infrastructure at Northwind.",
  );
  const [timezone, setTimezone] = useState("america/los_angeles");
  const [notifications, setNotifications] = useState(() =>
    Object.fromEntries(NOTIFICATIONS.map((n) => [n.id, n.defaultOn])),
  );

  const bioRemaining = BIO_LIMIT - bio.length;
  const bioOver = bioRemaining < 0;

  return (
    <div className="mx-auto flex w-full max-w-2xl flex-col gap-8 p-4 sm:p-6 md:p-8 [&_[data-slot=switch]]:cursor-pointer [&_[data-slot=button]]:transition-[box-shadow,scale] [&_[data-slot=button]:not(.w-full)]:motion-safe:hover:scale-[1.05] [&_[data-slot=button].w-full]:motion-safe:hover:scale-[1.03]">
      {/* Page heading */}
      <div className="flex flex-col gap-1">
        <h1 className="font-semibold text-2xl tracking-tight">Account</h1>
        <p className="text-muted-foreground text-sm">
          Update your profile, how you sign in, and what we email you about.
        </p>
      </div>

      {/* Profile */}
      <Card>
        <CardHeader>
          <CardTitle>Profile</CardTitle>
          <CardDescription>
            This is how you appear to other people in the workspace.
          </CardDescription>
        </CardHeader>
        <CardPanel className="flex flex-col gap-6">
          {/* Avatar */}
          <div className="flex items-center gap-4">
            <Avatar className="size-16 shrink-0 rounded-full">
              <AvatarFallback className="text-lg">NR</AvatarFallback>
            </Avatar>
            <div className="flex min-w-0 flex-col gap-2">
              <div className="flex flex-wrap items-center gap-2">
                <Button size="sm" variant="outline">
                  <UploadIcon />
                  Change photo
                </Button>
                <Button className="text-muted-foreground" size="sm" variant="ghost">
                  Remove
                </Button>
              </div>
              <p className="text-muted-foreground text-xs">
                JPG, PNG or GIF. 2&nbsp;MB maximum.
              </p>
            </div>
          </div>

          <Separator />

          <Field>
            <FieldLabel>Full name</FieldLabel>
            <Input onValueChange={setName} value={name} />
          </Field>

          <Field>
            <FieldLabel>Username</FieldLabel>
            <InputGroup>
              <InputGroupAddon>
                <InputGroupText>
                  <AtSignIcon />
                </InputGroupText>
              </InputGroupAddon>
              <InputGroupInput
                onValueChange={(value) =>
                  setHandle(value.toLowerCase().replace(/[^a-z0-9-]/g, ""))
                }
                value={handle}
              />
            </InputGroup>
            <FieldDescription>
              Your profile lives at kestrel.io/{handle || "…"}
            </FieldDescription>
          </Field>

          <Field>
            {/* `Field` is `flex flex-col items-start`, so a child row shrinks to
                its content and `justify-between` has nothing to distribute.
                `w-full` is what makes this row actually span the field. */}
            <div className="flex w-full items-center justify-between gap-2">
              <FieldLabel>Bio</FieldLabel>
              <span
                className={
                  bioOver
                    ? "text-destructive-foreground text-xs tabular-nums"
                    : "text-muted-foreground text-xs tabular-nums"
                }
              >
                {bioRemaining}
              </span>
            </div>
            <Textarea
              aria-invalid={bioOver || undefined}
              onChange={(event) => setBio(event.target.value)}
              value={bio}
            />
            <FieldDescription>
              A short introduction shown on your profile. Markdown is not
              supported.
            </FieldDescription>
          </Field>

          <Field>
            <FieldLabel>Timezone</FieldLabel>
            <Select
              items={TIMEZONES}
              onValueChange={(value) => setTimezone(value ?? timezone)}
              value={timezone}
            >
              {/* Pointer cursor is a house rule; coss ships `cursor-default`
                  here by native-menu convention. It goes on the popup — which
                  portals out of this block, so no ancestor rule can reach it —
                  and never by editing coss source. */}
              <SelectTrigger aria-label="Timezone" className="cursor-pointer">
                <SelectValue />
              </SelectTrigger>
              <SelectPopup className="[&_[data-slot$='-item']]:cursor-pointer">
                {TIMEZONES.map((zone) => (
                  <SelectItem key={zone.value} value={zone.value}>
                    {zone.label}
                  </SelectItem>
                ))}
              </SelectPopup>
            </Select>
            <FieldDescription>
              Used for scheduled reports and activity timestamps.
            </FieldDescription>
          </Field>
        </CardPanel>
        <CardFooter className="flex justify-end gap-2 border-t">
          <Button size="sm" variant="ghost">
            Cancel
          </Button>
          <Button disabled={bioOver} size="sm">
            Save changes
          </Button>
        </CardFooter>
      </Card>

      {/* Sign-in */}
      <Card>
        <CardHeader>
          <CardTitle>Sign-in</CardTitle>
          <CardDescription>
            How you access your account. Changing your email requires
            confirmation from both addresses.
          </CardDescription>
        </CardHeader>
        <CardPanel className="flex flex-col gap-6">
          <Field>
            <div className="flex w-full flex-wrap items-center gap-2">
              <FieldLabel>Email address</FieldLabel>
              <Badge size="sm" variant="success">
                <BadgeCheckIcon />
                Verified
              </Badge>
            </div>
            <Input defaultValue="nadia@kestrel.io" type="email" />
          </Field>

          <Separator />

          <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
            <div className="flex flex-col gap-1">
              <p className="font-medium text-sm">Password</p>
              <p className="text-muted-foreground text-xs">
                Last changed 4 months ago.
              </p>
            </div>
            <Button size="sm" variant="outline">
              <KeyRoundIcon />
              Change password
            </Button>
          </div>
        </CardPanel>
      </Card>

      {/* Notifications */}
      <Card>
        <CardHeader>
          <CardTitle>Email notifications</CardTitle>
          <CardDescription>
            Choose what lands in your inbox. This does not affect in-app
            notifications.
          </CardDescription>
        </CardHeader>
        <CardPanel className="flex flex-col gap-4">
          {NOTIFICATIONS.map((item, index) => (
            <div key={item.id}>
              <div className="flex items-start justify-between gap-4">
                <div className="flex min-w-0 flex-col gap-1">
                  <label
                    className="font-medium text-sm"
                    htmlFor={`notify-${item.id}`}
                  >
                    {item.label}
                  </label>
                  <p className="text-muted-foreground text-xs">
                    {item.description}
                    {item.locked && " Always on."}
                  </p>
                </div>
                <Switch
                  checked={notifications[item.id]}
                  disabled={item.locked}
                  id={`notify-${item.id}`}
                  onCheckedChange={(checked) =>
                    setNotifications((current) => ({
                      ...current,
                      [item.id]: checked,
                    }))
                  }
                />
              </div>
              {index < NOTIFICATIONS.length - 1 && <Separator className="mt-4" />}
            </div>
          ))}
        </CardPanel>
      </Card>

      {/* Danger zone */}
      <Card className="border-destructive/32">
        <CardHeader>
          <CardTitle>Delete account</CardTitle>
          <CardDescription>
            Permanently removes your account, your profile, and everything you
            own in this workspace. Projects owned by the workspace stay put.
            This cannot be undone.
          </CardDescription>
        </CardHeader>
        <CardPanel>
          <Button size="sm" variant="destructive-outline">
            <TrashIcon />
            Delete my account
          </Button>
        </CardPanel>
      </Card>
    </div>
  );
}

Dependencies

  • @coss/ui