Settings as panels, the way the portal does it

Four tabs over three cards was navigation for its own sake, and the two shells
disagreed about what a settings screen looks like. It is one grid of cards now,
matching the portal: the account card carries changing a password and deleting
the account in its footer, appearance is a card with one row, and remote access
— an operator's concern, not a personal preference — is a card of its own for a
superuser.

SettingRow, alert-dialog and UserAvatar come across from the portal, so an
account renders the same face in both shells.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-27 16:04:42 +02:00
co-authored by Claude Opus 5
parent 6d09500a73
commit 5da5606f79
12 changed files with 717 additions and 367 deletions
@@ -1,6 +1,13 @@
import { type LucideIcon, Monitor, Moon, Sun } from "lucide-react"
import { type Theme, useTheme } from "@/components/theme-provider"
import SettingRow from "@/components/UserSettings/SettingRow"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { cn } from "@/lib/utils"
const options: { value: Theme; title: string; icon: LucideIcon }[] = [
@@ -13,37 +20,42 @@ const Appearance = () => {
const { theme, setTheme } = useTheme()
return (
<div className="max-w-md">
<h3 className="text-lg font-semibold py-4">Appearance</h3>
<p className="text-sm text-muted-foreground pb-4">
Choose how Fluksio looks to you. System follows your device setting.
</p>
{/* The one segmented shape: a single border pill, transparent segments,
bg-accent on the selected one (root DESIGN-GUIDELINES.md). */}
<div
data-testid="theme-button"
className="flex w-fit items-center gap-1 rounded-full border border-border p-1"
>
{options.map((option) => (
<button
key={option.value}
type="button"
aria-pressed={theme === option.value}
data-testid={`${option.value}-mode`}
onClick={() => setTheme(option.value)}
className={cn(
"flex items-center gap-2 rounded-full px-3 py-1.5 text-sm transition-colors",
theme === option.value
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:bg-accent/50",
)}
<Card>
<CardHeader>
<CardTitle>Appearance</CardTitle>
<CardDescription>How Fluksio looks to you</CardDescription>
</CardHeader>
<CardContent>
<SettingRow label="Theme" hint="System follows your device setting.">
{/* The one segmented shape: a single border pill, transparent
segments, bg-accent on the selected one (root
DESIGN-GUIDELINES.md). */}
<div
data-testid="theme-button"
className="flex w-fit items-center gap-1 rounded-full border border-border p-1"
>
<option.icon className="size-4" />
{option.title}
</button>
))}
</div>
</div>
{options.map((option) => (
<button
key={option.value}
type="button"
aria-pressed={theme === option.value}
data-testid={`${option.value}-mode`}
onClick={() => setTheme(option.value)}
className={cn(
"flex items-center gap-2 rounded-full px-3 py-1.5 text-sm transition-colors",
theme === option.value
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:bg-accent/50",
)}
>
<option.icon className="size-4" />
{option.title}
</button>
))}
</div>
</SettingRow>
</CardContent>
</Card>
)
}
@@ -1,9 +1,18 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { useMutation } from "@tanstack/react-query"
import { useState } from "react"
import { useForm } from "react-hook-form"
import { z } from "zod"
import { type UpdatePassword, UsersService } from "@/client"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import {
Form,
FormControl,
@@ -39,6 +48,7 @@ const formSchema = z
type FormData = z.infer<typeof formSchema>
const ChangePassword = () => {
const [isOpen, setIsOpen] = useState(false)
const { showSuccessToast, showErrorToast } = useCustomToast()
const form = useForm<FormData>({
resolver: zodResolver(formSchema),
@@ -57,6 +67,7 @@ const ChangePassword = () => {
onSuccess: () => {
showSuccessToast("Password updated successfully")
form.reset()
setIsOpen(false)
},
onError: handleError.bind(showErrorToast),
})
@@ -65,81 +76,96 @@ const ChangePassword = () => {
mutation.mutate(data)
}
const onOpenChange = (open: boolean) => {
if (!open) form.reset()
setIsOpen(open)
}
return (
<div className="max-w-md">
<h3 className="text-lg font-semibold py-4">Change Password</h3>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="flex flex-col gap-4"
>
<FormField
control={form.control}
name="current_password"
render={({ field, fieldState }) => (
<FormItem>
<FormLabel>Current Password</FormLabel>
<FormControl>
<PasswordInput
data-testid="current-password-input"
placeholder="••••••••"
aria-invalid={fieldState.invalid}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="new_password"
render={({ field, fieldState }) => (
<FormItem>
<FormLabel>New Password</FormLabel>
<FormControl>
<PasswordInput
data-testid="new-password-input"
placeholder="••••••••"
aria-invalid={fieldState.invalid}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="confirm_password"
render={({ field, fieldState }) => (
<FormItem>
<FormLabel>Confirm Password</FormLabel>
<FormControl>
<PasswordInput
data-testid="confirm-password-input"
placeholder="••••••••"
aria-invalid={fieldState.invalid}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<LoadingButton
type="submit"
loading={mutation.isPending}
className="self-start"
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
Change password
</Button>
</DialogTrigger>
<DialogContent showCloseButton>
<DialogHeader>
<DialogTitle>Change password</DialogTitle>
<DialogDescription>Update your account password</DialogDescription>
</DialogHeader>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="flex flex-col gap-4"
>
Update Password
</LoadingButton>
</form>
</Form>
</div>
<FormField
control={form.control}
name="current_password"
render={({ field, fieldState }) => (
<FormItem>
<FormLabel>Current Password</FormLabel>
<FormControl>
<PasswordInput
data-testid="current-password-input"
placeholder="••••••••"
aria-invalid={fieldState.invalid}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="new_password"
render={({ field, fieldState }) => (
<FormItem>
<FormLabel>New Password</FormLabel>
<FormControl>
<PasswordInput
data-testid="new-password-input"
placeholder="••••••••"
aria-invalid={fieldState.invalid}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="confirm_password"
render={({ field, fieldState }) => (
<FormItem>
<FormLabel>Confirm Password</FormLabel>
<FormControl>
<PasswordInput
data-testid="confirm-password-input"
placeholder="••••••••"
aria-invalid={fieldState.invalid}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<LoadingButton
type="submit"
loading={mutation.isPending}
className="self-end"
>
Update Password
</LoadingButton>
</form>
</Form>
</DialogContent>
</Dialog>
)
}
@@ -1,15 +0,0 @@
import DeleteConfirmation from "./DeleteConfirmation"
const DeleteAccount = () => {
return (
<div className="max-w-md mt-4 rounded-lg border border-destructive/50 p-4">
<h3 className="font-semibold text-destructive">Delete Account</h3>
<p className="mt-1 text-sm text-muted-foreground">
Permanently delete your account and all associated data.
</p>
<DeleteConfirmation />
</div>
)
}
export default DeleteAccount
@@ -1,24 +1,23 @@
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { useState } from "react"
import { useForm } from "react-hook-form"
import { UsersService } from "@/client"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
AlertDialog,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { Button } from "@/components/ui/button"
import { LoadingButton } from "@/components/ui/loading-button"
import useAuth from "@/hooks/useAuth"
import useCustomToast from "@/hooks/useCustomToast"
import { handleError } from "@/utils"
const DeleteConfirmation = () => {
const [isOpen, setIsOpen] = useState(false)
const queryClient = useQueryClient()
const { showSuccessToast, showErrorToast } = useCustomToast()
const { handleSubmit } = useForm()
@@ -41,41 +40,49 @@ const DeleteConfirmation = () => {
}
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="destructive" className="mt-3">
Delete Account
</Button>
</DialogTrigger>
<DialogContent>
<form onSubmit={handleSubmit(onSubmit)}>
<DialogHeader>
<DialogTitle>Confirmation Required</DialogTitle>
<DialogDescription>
All your account data will be{" "}
<strong>permanently deleted.</strong> If you are sure, please
click <strong>"Confirm"</strong> to proceed. This action cannot be
undone.
</DialogDescription>
</DialogHeader>
<>
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive"
onClick={() => setIsOpen(true)}
>
Delete account
</Button>
<AlertDialog open={isOpen} onOpenChange={setIsOpen}>
<AlertDialogContent>
<form onSubmit={handleSubmit(onSubmit)}>
<AlertDialogHeader>
<AlertDialogTitle>Confirmation Required</AlertDialogTitle>
<AlertDialogDescription>
All your account data will be{" "}
<strong>permanently deleted.</strong> If you are sure, please
click <strong>"Confirm"</strong> to proceed. This action cannot
be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<DialogFooter className="mt-4">
<DialogClose asChild>
<Button variant="outline" disabled={mutation.isPending}>
<AlertDialogFooter className="mt-4">
<Button
type="button"
variant="outline"
disabled={mutation.isPending}
onClick={() => setIsOpen(false)}
>
Cancel
</Button>
</DialogClose>
<LoadingButton
variant="destructive"
type="submit"
loading={mutation.isPending}
>
Delete
</LoadingButton>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<LoadingButton
type="submit"
variant="destructive"
loading={mutation.isPending}
>
Delete
</LoadingButton>
</AlertDialogFooter>
</form>
</AlertDialogContent>
</AlertDialog>
</>
)
}
@@ -3,6 +3,13 @@ import { useState } from "react"
import { CloudService } from "@/client"
import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import {
Dialog,
DialogContent,
@@ -13,6 +20,7 @@ import {
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Separator } from "@/components/ui/separator"
import useCustomToast from "@/hooks/useCustomToast"
import { handleError } from "@/utils"
@@ -96,125 +104,130 @@ export function RemoteAccess() {
if (!status) return null
return (
<div className="flex max-w-2xl flex-col gap-6 py-4">
<div>
<h2 className="text-lg font-medium">Remote access</h2>
<p className="text-sm text-muted-foreground">
<Card>
<CardHeader>
<CardTitle>Remote access</CardTitle>
<CardDescription>
Reach this installation from fluksio.com. Entirely optional — without
it, this installation talks to nothing outside your network.
</p>
</div>
{status.enrolled ? (
<>
<div className="flex flex-col gap-4 rounded-lg border border-border p-4">
<dl className="grid gap-3 sm:grid-cols-2">
<Field label="Status">
{status.connected
? "Connected"
: status.last_error
? `Reconnecting — ${status.last_error}`
: "Reconnecting…"}
</Field>
<Field label="Portal">{status.portal_url ?? "—"}</Field>
<Field label="Acting as">
{status.portal_account ?? "—"}
<span className="mt-1 block text-xs text-muted-foreground">
Portal sessions of this account get this account's rights
here. Anyone else gets in only once added below, as their own
user.
</span>
</Field>
<Field label="Installation">
<span className="font-mono text-xs">
{status.installation_id ?? "—"}
</span>
</Field>
</dl>
<div>
<Button
variant="destructive"
className="rounded-full"
onClick={() => setConfirmDisconnect(true)}
>
Disconnect
</Button>
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-6">
{status.enrolled ? (
<>
<div className="flex flex-col gap-4">
<dl className="grid gap-3 sm:grid-cols-2">
<Field label="Status">
{status.connected
? "Connected"
: status.last_error
? `Reconnecting — ${status.last_error}`
: "Reconnecting…"}
</Field>
<Field label="Portal">{status.portal_url ?? "—"}</Field>
<Field label="Acting as">
{status.portal_account ?? "—"}
<span className="mt-1 block text-xs text-muted-foreground">
Portal sessions of this account get this account's rights
here. Anyone else gets in only once added below, as their
own user.
</span>
</Field>
<Field label="Installation">
<span className="font-mono text-xs">
{status.installation_id ?? "—"}
</span>
</Field>
</dl>
<div>
<Button
variant="destructive"
className="rounded-full"
onClick={() => setConfirmDisconnect(true)}
>
Disconnect
</Button>
</div>
</div>
</div>
<div className="flex flex-col gap-4 rounded-lg border border-border p-4">
<div>
<h3 className="font-medium">Remote users</h3>
<p className="text-sm text-muted-foreground">
Let someone else reach this installation through the portal.
They get a user of their own here — not yours, and never a
superuser, so they cannot pass access on.
</p>
<Separator />
<div className="flex flex-col gap-4">
<div>
<h3 className="font-medium">Remote users</h3>
<p className="text-sm text-muted-foreground">
Let someone else reach this installation through the portal.
They get a user of their own here — not yours, and never a
superuser, so they cannot pass access on.
</p>
</div>
<div className="grid gap-2">
<Label htmlFor="join-code">Code</Label>
<Input
id="join-code"
value={joinCode}
placeholder="XXXX-XXXX"
className="font-mono tracking-widest"
onChange={(event) =>
setJoinCode(event.target.value.toUpperCase())
}
/>
<p className="text-xs text-muted-foreground">
They get a code at fluksio.com → Installations → Join an
installation. Added users appear under Admin → Users; deleting
them there ends their access.
</p>
</div>
<div>
<Button
className="rounded-full"
variant="outline"
disabled={!joinCode.trim() || addRemoteUser.isPending}
onClick={() => addRemoteUser.mutate()}
>
{addRemoteUser.isPending ? "Adding…" : "Add remote user"}
</Button>
</div>
</div>
</>
) : (
<div className="flex flex-col gap-4">
<div className="grid gap-2">
<Label htmlFor="portal-url">Portal</Label>
<Input
id="portal-url"
value={portalUrl}
onChange={(event) => setPortalUrl(event.target.value)}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="join-code">Code</Label>
<Label htmlFor="claim-code">Code</Label>
<Input
id="join-code"
value={joinCode}
id="claim-code"
value={code}
placeholder="XXXX-XXXX"
className="font-mono tracking-widest"
onChange={(event) =>
setJoinCode(event.target.value.toUpperCase())
}
onChange={(event) => setCode(event.target.value.toUpperCase())}
/>
<p className="text-xs text-muted-foreground">
They get a code at fluksio.com → Installations → Join an
installation. Added users appear under Admin → Users; deleting
them there ends their access.
Get a code at fluksio.com → Installations → Add installation.
</p>
</div>
<div>
<Button
variant="brand"
className="rounded-full"
variant="outline"
disabled={!joinCode.trim() || addRemoteUser.isPending}
onClick={() => addRemoteUser.mutate()}
disabled={
!code.trim() || !portalUrl.trim() || connect.isPending
}
onClick={() => connect.mutate()}
>
{addRemoteUser.isPending ? "Adding…" : "Add remote user"}
{connect.isPending ? "Connecting…" : "Connect"}
</Button>
</div>
</div>
</>
) : (
<div className="flex flex-col gap-4 rounded-lg border border-border p-4">
<div className="grid gap-2">
<Label htmlFor="portal-url">Portal</Label>
<Input
id="portal-url"
value={portalUrl}
onChange={(event) => setPortalUrl(event.target.value)}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="claim-code">Code</Label>
<Input
id="claim-code"
value={code}
placeholder="XXXX-XXXX"
className="font-mono tracking-widest"
onChange={(event) => setCode(event.target.value.toUpperCase())}
/>
<p className="text-xs text-muted-foreground">
Get a code at fluksio.com → Installations → Add installation.
</p>
</div>
<div>
<Button
variant="brand"
className="rounded-full"
disabled={!code.trim() || !portalUrl.trim() || connect.isPending}
onClick={() => connect.mutate()}
>
{connect.isPending ? "Connecting…" : "Connect"}
</Button>
</div>
</div>
)}
)}
</CardContent>
<Dialog open={confirmDisconnect} onOpenChange={setConfirmDisconnect}>
<DialogContent>
@@ -245,7 +258,7 @@ export function RemoteAccess() {
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</Card>
)
}
@@ -0,0 +1,30 @@
import type { ReactNode } from "react"
import { Label } from "@/components/ui/label"
/**
* Label + hint on the left, its control on the right. Shared by the settings
* cards. The row wraps when the two no longer fit side by side, so on a phone
* the control sits under its label instead of squeezing it to one word.
*/
const SettingRow = ({
label,
hint,
children,
}: {
label: string
hint: string
children: ReactNode
}) => (
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2">
{/* basis-40 is the width the label keeps before the control drops to its
own line; without it a long hint would push every control down, even
on a wide card where both fit next to each other. */}
<div className="min-w-0 grow basis-40">
<Label className="font-normal">{label}</Label>
<p className="text-xs text-muted-foreground">{hint}</p>
</div>
<div className="shrink-0">{children}</div>
</div>
)
export default SettingRow
@@ -1,11 +1,25 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { Pencil } from "lucide-react"
import { useState } from "react"
import { useForm } from "react-hook-form"
import { z } from "zod"
import { UsersService, type UserUpdateMe } from "@/client"
import { UserAvatar } from "@/components/Common/UserAvatar"
import ChangePassword from "@/components/UserSettings/ChangePassword"
import DeleteConfirmation from "@/components/UserSettings/DeleteConfirmation"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
Card,
CardAction,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import {
Form,
FormControl,
@@ -28,6 +42,36 @@ const formSchema = z.object({
type FormData = z.infer<typeof formSchema>
/** Compact value row; the edit affordance appears on hover, focus or touch. */
const ReadOnlyField = ({
label,
value,
onEdit,
}: {
label: string
value: string | undefined
onEdit: () => void
}) => (
<FormItem className="group flex flex-row items-center justify-between gap-3">
<div className="min-w-0">
<FormLabel className="text-xs text-muted-foreground">{label}</FormLabel>
<p className={cn("truncate", !value && "text-muted-foreground")}>
{value || "N/A"}
</p>
</div>
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={`Edit ${label.toLowerCase()}`}
onClick={onEdit}
className="shrink-0 opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100 pointer-coarse:opacity-100"
>
<Pencil className="size-4" />
</Button>
</FormItem>
)
const UserInformation = () => {
const queryClient = useQueryClient()
const { showSuccessToast, showErrorToast } = useCustomToast()
@@ -81,65 +125,67 @@ const UserInformation = () => {
}
return (
<div className="max-w-md">
<h3 className="text-lg font-semibold py-4">User Information</h3>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="flex flex-col gap-4"
>
<FormField
control={form.control}
name="full_name"
render={({ field }) =>
editMode ? (
<FormItem>
<FormLabel>Full name</FormLabel>
<FormControl>
<Input type="text" {...field} />
</FormControl>
<FormMessage />
</FormItem>
) : (
<FormItem>
<FormLabel>Full name</FormLabel>
<p
className={cn(
"py-2 truncate max-w-sm",
!field.value && "text-muted-foreground",
)}
>
{field.value || "N/A"}
</p>
</FormItem>
)
}
/>
<Card>
<CardHeader>
<CardTitle>Account</CardTitle>
<CardDescription>Your profile details and sign-in</CardDescription>
<CardAction className="flex flex-col items-end gap-2">
<UserAvatar size={56} className="rounded-lg" />
{currentUser?.is_superuser && <Badge>Admin</Badge>}
</CardAction>
</CardHeader>
<CardContent>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="flex flex-col gap-4"
>
<FormField
control={form.control}
name="full_name"
render={({ field }) =>
editMode ? (
<FormItem>
<FormLabel>Full name</FormLabel>
<FormControl>
<Input type="text" {...field} />
</FormControl>
<FormMessage />
</FormItem>
) : (
<ReadOnlyField
label="Full name"
value={field.value}
onEdit={toggleEditMode}
/>
)
}
/>
<FormField
control={form.control}
name="email"
render={({ field }) =>
editMode ? (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input type="email" {...field} />
</FormControl>
<FormMessage />
</FormItem>
) : (
<FormItem>
<FormLabel>Email</FormLabel>
<p className="py-2 truncate max-w-sm">{field.value}</p>
</FormItem>
)
}
/>
<FormField
control={form.control}
name="email"
render={({ field }) =>
editMode ? (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input type="email" {...field} />
</FormControl>
<FormMessage />
</FormItem>
) : (
<ReadOnlyField
label="Email"
value={field.value}
onEdit={toggleEditMode}
/>
)
}
/>
<div className="flex gap-3">
{editMode ? (
<>
{editMode && (
<div className="flex gap-3">
<LoadingButton
type="submit"
loading={mutation.isPending}
@@ -155,16 +201,22 @@ const UserInformation = () => {
>
Cancel
</Button>
</>
) : (
<Button type="button" onClick={toggleEditMode}>
Edit
</Button>
</div>
)}
</form>
</Form>
</CardContent>
<CardFooter className="flex flex-wrap items-center gap-2 border-t pt-4">
<ChangePassword />
{/* A superuser deleting its own account would lock everyone out, so
that is the one thing this card does not offer them. */}
{!currentUser?.is_superuser && (
<div className="ml-auto">
<DeleteConfirmation />
</div>
</form>
</Form>
</div>
)}
</CardFooter>
</Card>
)
}