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
+3
View File
@@ -33,6 +33,7 @@
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"@xyflow/react": "^12.11.3", "@xyflow/react": "^12.11.3",
"axios": "1.13.2", "axios": "1.13.2",
"boring-avatars": "^2.0.4",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
@@ -535,6 +536,8 @@
"binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="], "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
"boring-avatars": ["boring-avatars@2.0.4", "", { "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-xhZO/w/6aFmRfkaWohcl2NfyIy87gK5SBbys8kctZeTGF1Apjpv/10pfUuv+YEfVPkESU/h2Y6tt/Dwp+bIZPw=="],
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
"browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": "cli.js" }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="], "browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": "cli.js" }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="],
+1
View File
@@ -39,6 +39,7 @@
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"@xyflow/react": "^12.11.3", "@xyflow/react": "^12.11.3",
"axios": "1.13.2", "axios": "1.13.2",
"boring-avatars": "^2.0.4",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
@@ -0,0 +1,36 @@
import Avatar from "boring-avatars"
import useAuth from "@/hooks/useAuth"
// Shared avatar identity config, kept in step with the portal's copy
// (index/frontend/src/components/Common/UserAvatar.tsx) so one account renders
// the same face in both shells. The seed is the email, the only stable per-user
// id both sides hold. These are decorative identity colours, not design tokens.
export const AVATAR_VARIANT = "marble" as const
export const AVATAR_COLORS = [
"#EAAC0F",
"#e9e1cf",
"#18150f",
"#5d9a69",
"#9b8450",
]
/** Deterministic boring-avatars icon for the current user (seeded by email). */
export function UserAvatar({
size = 40,
className,
}: {
size?: number
className?: string
}) {
const { user } = useAuth()
return (
<Avatar
name={user?.email ?? "user"}
variant={AVATAR_VARIANT}
colors={AVATAR_COLORS}
size={size}
className={className}
/>
)
}
@@ -1,6 +1,13 @@
import { type LucideIcon, Monitor, Moon, Sun } from "lucide-react" import { type LucideIcon, Monitor, Moon, Sun } from "lucide-react"
import { type Theme, useTheme } from "@/components/theme-provider" 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" import { cn } from "@/lib/utils"
const options: { value: Theme; title: string; icon: LucideIcon }[] = [ const options: { value: Theme; title: string; icon: LucideIcon }[] = [
@@ -13,37 +20,42 @@ const Appearance = () => {
const { theme, setTheme } = useTheme() const { theme, setTheme } = useTheme()
return ( return (
<div className="max-w-md"> <Card>
<h3 className="text-lg font-semibold py-4">Appearance</h3> <CardHeader>
<p className="text-sm text-muted-foreground pb-4"> <CardTitle>Appearance</CardTitle>
Choose how Fluksio looks to you. System follows your device setting. <CardDescription>How Fluksio looks to you</CardDescription>
</p> </CardHeader>
{/* The one segmented shape: a single border pill, transparent segments, <CardContent>
bg-accent on the selected one (root DESIGN-GUIDELINES.md). */} <SettingRow label="Theme" hint="System follows your device setting.">
<div {/* The one segmented shape: a single border pill, transparent
data-testid="theme-button" segments, bg-accent on the selected one (root
className="flex w-fit items-center gap-1 rounded-full border border-border p-1" DESIGN-GUIDELINES.md). */}
> <div
{options.map((option) => ( data-testid="theme-button"
<button className="flex w-fit items-center gap-1 rounded-full border border-border p-1"
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" /> {options.map((option) => (
{option.title} <button
</button> key={option.value}
))} type="button"
</div> aria-pressed={theme === option.value}
</div> 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 { zodResolver } from "@hookform/resolvers/zod"
import { useMutation } from "@tanstack/react-query" import { useMutation } from "@tanstack/react-query"
import { useState } from "react"
import { useForm } from "react-hook-form" import { useForm } from "react-hook-form"
import { z } from "zod" import { z } from "zod"
import { type UpdatePassword, UsersService } from "@/client" import { type UpdatePassword, UsersService } from "@/client"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { import {
Form, Form,
FormControl, FormControl,
@@ -39,6 +48,7 @@ const formSchema = z
type FormData = z.infer<typeof formSchema> type FormData = z.infer<typeof formSchema>
const ChangePassword = () => { const ChangePassword = () => {
const [isOpen, setIsOpen] = useState(false)
const { showSuccessToast, showErrorToast } = useCustomToast() const { showSuccessToast, showErrorToast } = useCustomToast()
const form = useForm<FormData>({ const form = useForm<FormData>({
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
@@ -57,6 +67,7 @@ const ChangePassword = () => {
onSuccess: () => { onSuccess: () => {
showSuccessToast("Password updated successfully") showSuccessToast("Password updated successfully")
form.reset() form.reset()
setIsOpen(false)
}, },
onError: handleError.bind(showErrorToast), onError: handleError.bind(showErrorToast),
}) })
@@ -65,81 +76,96 @@ const ChangePassword = () => {
mutation.mutate(data) mutation.mutate(data)
} }
const onOpenChange = (open: boolean) => {
if (!open) form.reset()
setIsOpen(open)
}
return ( return (
<div className="max-w-md"> <Dialog open={isOpen} onOpenChange={onOpenChange}>
<h3 className="text-lg font-semibold py-4">Change Password</h3> <DialogTrigger asChild>
<Form {...form}> <Button variant="outline" size="sm">
<form Change password
onSubmit={form.handleSubmit(onSubmit)} </Button>
className="flex flex-col gap-4" </DialogTrigger>
> <DialogContent showCloseButton>
<FormField <DialogHeader>
control={form.control} <DialogTitle>Change password</DialogTitle>
name="current_password" <DialogDescription>Update your account password</DialogDescription>
render={({ field, fieldState }) => ( </DialogHeader>
<FormItem> <Form {...form}>
<FormLabel>Current Password</FormLabel> <form
<FormControl> onSubmit={form.handleSubmit(onSubmit)}
<PasswordInput className="flex flex-col gap-4"
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"
> >
Update Password <FormField
</LoadingButton> control={form.control}
</form> name="current_password"
</Form> render={({ field, fieldState }) => (
</div> <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 { useMutation, useQueryClient } from "@tanstack/react-query"
import { useState } from "react"
import { useForm } from "react-hook-form" import { useForm } from "react-hook-form"
import { UsersService } from "@/client" import { UsersService } from "@/client"
import { Button } from "@/components/ui/button"
import { import {
Dialog, AlertDialog,
DialogClose, AlertDialogContent,
DialogContent, AlertDialogDescription,
DialogDescription, AlertDialogFooter,
DialogFooter, AlertDialogHeader,
DialogHeader, AlertDialogTitle,
DialogTitle, } from "@/components/ui/alert-dialog"
DialogTrigger, import { Button } from "@/components/ui/button"
} from "@/components/ui/dialog"
import { LoadingButton } from "@/components/ui/loading-button" import { LoadingButton } from "@/components/ui/loading-button"
import useAuth from "@/hooks/useAuth" import useAuth from "@/hooks/useAuth"
import useCustomToast from "@/hooks/useCustomToast" import useCustomToast from "@/hooks/useCustomToast"
import { handleError } from "@/utils" import { handleError } from "@/utils"
const DeleteConfirmation = () => { const DeleteConfirmation = () => {
const [isOpen, setIsOpen] = useState(false)
const queryClient = useQueryClient() const queryClient = useQueryClient()
const { showSuccessToast, showErrorToast } = useCustomToast() const { showSuccessToast, showErrorToast } = useCustomToast()
const { handleSubmit } = useForm() const { handleSubmit } = useForm()
@@ -41,41 +40,49 @@ const DeleteConfirmation = () => {
} }
return ( return (
<Dialog> <>
<DialogTrigger asChild> <Button
<Button variant="destructive" className="mt-3"> variant="ghost"
Delete Account size="sm"
</Button> className="text-destructive hover:text-destructive"
</DialogTrigger> onClick={() => setIsOpen(true)}
<DialogContent> >
<form onSubmit={handleSubmit(onSubmit)}> Delete account
<DialogHeader> </Button>
<DialogTitle>Confirmation Required</DialogTitle> <AlertDialog open={isOpen} onOpenChange={setIsOpen}>
<DialogDescription> <AlertDialogContent>
All your account data will be{" "} <form onSubmit={handleSubmit(onSubmit)}>
<strong>permanently deleted.</strong> If you are sure, please <AlertDialogHeader>
click <strong>"Confirm"</strong> to proceed. This action cannot be <AlertDialogTitle>Confirmation Required</AlertDialogTitle>
undone. <AlertDialogDescription>
</DialogDescription> All your account data will be{" "}
</DialogHeader> <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"> <AlertDialogFooter className="mt-4">
<DialogClose asChild> <Button
<Button variant="outline" disabled={mutation.isPending}> type="button"
variant="outline"
disabled={mutation.isPending}
onClick={() => setIsOpen(false)}
>
Cancel Cancel
</Button> </Button>
</DialogClose> <LoadingButton
<LoadingButton type="submit"
variant="destructive" variant="destructive"
type="submit" loading={mutation.isPending}
loading={mutation.isPending} >
> Delete
Delete </LoadingButton>
</LoadingButton> </AlertDialogFooter>
</DialogFooter> </form>
</form> </AlertDialogContent>
</DialogContent> </AlertDialog>
</Dialog> </>
) )
} }
@@ -3,6 +3,13 @@ import { useState } from "react"
import { CloudService } from "@/client" import { CloudService } from "@/client"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -13,6 +20,7 @@ import {
} from "@/components/ui/dialog" } from "@/components/ui/dialog"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { Separator } from "@/components/ui/separator"
import useCustomToast from "@/hooks/useCustomToast" import useCustomToast from "@/hooks/useCustomToast"
import { handleError } from "@/utils" import { handleError } from "@/utils"
@@ -96,125 +104,130 @@ export function RemoteAccess() {
if (!status) return null if (!status) return null
return ( return (
<div className="flex max-w-2xl flex-col gap-6 py-4"> <Card>
<div> <CardHeader>
<h2 className="text-lg font-medium">Remote access</h2> <CardTitle>Remote access</CardTitle>
<p className="text-sm text-muted-foreground"> <CardDescription>
Reach this installation from fluksio.com. Entirely optional without Reach this installation from fluksio.com. Entirely optional without
it, this installation talks to nothing outside your network. it, this installation talks to nothing outside your network.
</p> </CardDescription>
</div> </CardHeader>
<CardContent className="flex flex-col gap-6">
{status.enrolled ? ( {status.enrolled ? (
<> <>
<div className="flex flex-col gap-4 rounded-lg border border-border p-4"> <div className="flex flex-col gap-4">
<dl className="grid gap-3 sm:grid-cols-2"> <dl className="grid gap-3 sm:grid-cols-2">
<Field label="Status"> <Field label="Status">
{status.connected {status.connected
? "Connected" ? "Connected"
: status.last_error : status.last_error
? `Reconnecting — ${status.last_error}` ? `Reconnecting — ${status.last_error}`
: "Reconnecting…"} : "Reconnecting…"}
</Field> </Field>
<Field label="Portal">{status.portal_url ?? "—"}</Field> <Field label="Portal">{status.portal_url ?? "—"}</Field>
<Field label="Acting as"> <Field label="Acting as">
{status.portal_account ?? "—"} {status.portal_account ?? "—"}
<span className="mt-1 block text-xs text-muted-foreground"> <span className="mt-1 block text-xs text-muted-foreground">
Portal sessions of this account get this account's rights Portal sessions of this account get this account's rights
here. Anyone else gets in only once added below, as their own here. Anyone else gets in only once added below, as their
user. own user.
</span> </span>
</Field> </Field>
<Field label="Installation"> <Field label="Installation">
<span className="font-mono text-xs"> <span className="font-mono text-xs">
{status.installation_id ?? "—"} {status.installation_id ?? "—"}
</span> </span>
</Field> </Field>
</dl> </dl>
<div> <div>
<Button <Button
variant="destructive" variant="destructive"
className="rounded-full" className="rounded-full"
onClick={() => setConfirmDisconnect(true)} onClick={() => setConfirmDisconnect(true)}
> >
Disconnect Disconnect
</Button> </Button>
</div>
</div> </div>
</div>
<div className="flex flex-col gap-4 rounded-lg border border-border p-4"> <Separator />
<div>
<h3 className="font-medium">Remote users</h3> <div className="flex flex-col gap-4">
<p className="text-sm text-muted-foreground"> <div>
Let someone else reach this installation through the portal. <h3 className="font-medium">Remote users</h3>
They get a user of their own here — not yours, and never a <p className="text-sm text-muted-foreground">
superuser, so they cannot pass access on. Let someone else reach this installation through the portal.
</p> 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>
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="join-code">Code</Label> <Label htmlFor="claim-code">Code</Label>
<Input <Input
id="join-code" id="claim-code"
value={joinCode} value={code}
placeholder="XXXX-XXXX" placeholder="XXXX-XXXX"
className="font-mono tracking-widest" className="font-mono tracking-widest"
onChange={(event) => onChange={(event) => setCode(event.target.value.toUpperCase())}
setJoinCode(event.target.value.toUpperCase())
}
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
They get a code at fluksio.com → Installations → Join an Get a code at fluksio.com → Installations → Add installation.
installation. Added users appear under Admin → Users; deleting
them there ends their access.
</p> </p>
</div> </div>
<div> <div>
<Button <Button
variant="brand"
className="rounded-full" className="rounded-full"
variant="outline" disabled={
disabled={!joinCode.trim() || addRemoteUser.isPending} !code.trim() || !portalUrl.trim() || connect.isPending
onClick={() => addRemoteUser.mutate()} }
onClick={() => connect.mutate()}
> >
{addRemoteUser.isPending ? "Adding…" : "Add remote user"} {connect.isPending ? "Connecting…" : "Connect"}
</Button> </Button>
</div> </div>
</div> </div>
</> )}
) : ( </CardContent>
<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>
)}
<Dialog open={confirmDisconnect} onOpenChange={setConfirmDisconnect}> <Dialog open={confirmDisconnect} onOpenChange={setConfirmDisconnect}>
<DialogContent> <DialogContent>
@@ -245,7 +258,7 @@ export function RemoteAccess() {
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </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 { zodResolver } from "@hookform/resolvers/zod"
import { useMutation, useQueryClient } from "@tanstack/react-query" import { useMutation, useQueryClient } from "@tanstack/react-query"
import { Pencil } from "lucide-react"
import { useState } from "react" import { useState } from "react"
import { useForm } from "react-hook-form" import { useForm } from "react-hook-form"
import { z } from "zod" import { z } from "zod"
import { UsersService, type UserUpdateMe } from "@/client" 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 { Button } from "@/components/ui/button"
import {
Card,
CardAction,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { import {
Form, Form,
FormControl, FormControl,
@@ -28,6 +42,36 @@ const formSchema = z.object({
type FormData = z.infer<typeof formSchema> 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 UserInformation = () => {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const { showSuccessToast, showErrorToast } = useCustomToast() const { showSuccessToast, showErrorToast } = useCustomToast()
@@ -81,65 +125,67 @@ const UserInformation = () => {
} }
return ( return (
<div className="max-w-md"> <Card>
<h3 className="text-lg font-semibold py-4">User Information</h3> <CardHeader>
<Form {...form}> <CardTitle>Account</CardTitle>
<form <CardDescription>Your profile details and sign-in</CardDescription>
onSubmit={form.handleSubmit(onSubmit)} <CardAction className="flex flex-col items-end gap-2">
className="flex flex-col gap-4" <UserAvatar size={56} className="rounded-lg" />
> {currentUser?.is_superuser && <Badge>Admin</Badge>}
<FormField </CardAction>
control={form.control} </CardHeader>
name="full_name" <CardContent>
render={({ field }) => <Form {...form}>
editMode ? ( <form
<FormItem> onSubmit={form.handleSubmit(onSubmit)}
<FormLabel>Full name</FormLabel> className="flex flex-col gap-4"
<FormControl> >
<Input type="text" {...field} /> <FormField
</FormControl> control={form.control}
<FormMessage /> name="full_name"
</FormItem> render={({ field }) =>
) : ( editMode ? (
<FormItem> <FormItem>
<FormLabel>Full name</FormLabel> <FormLabel>Full name</FormLabel>
<p <FormControl>
className={cn( <Input type="text" {...field} />
"py-2 truncate max-w-sm", </FormControl>
!field.value && "text-muted-foreground", <FormMessage />
)} </FormItem>
> ) : (
{field.value || "N/A"} <ReadOnlyField
</p> label="Full name"
</FormItem> value={field.value}
) onEdit={toggleEditMode}
} />
/> )
}
/>
<FormField <FormField
control={form.control} control={form.control}
name="email" name="email"
render={({ field }) => render={({ field }) =>
editMode ? ( editMode ? (
<FormItem> <FormItem>
<FormLabel>Email</FormLabel> <FormLabel>Email</FormLabel>
<FormControl> <FormControl>
<Input type="email" {...field} /> <Input type="email" {...field} />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
) : ( ) : (
<FormItem> <ReadOnlyField
<FormLabel>Email</FormLabel> label="Email"
<p className="py-2 truncate max-w-sm">{field.value}</p> value={field.value}
</FormItem> onEdit={toggleEditMode}
) />
} )
/> }
/>
<div className="flex gap-3"> {editMode && (
{editMode ? ( <div className="flex gap-3">
<>
<LoadingButton <LoadingButton
type="submit" type="submit"
loading={mutation.isPending} loading={mutation.isPending}
@@ -155,16 +201,22 @@ const UserInformation = () => {
> >
Cancel Cancel
</Button> </Button>
</> </div>
) : (
<Button type="button" onClick={toggleEditMode}>
Edit
</Button>
)} )}
</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> </div>
</form> )}
</Form> </CardFooter>
</div> </Card>
) )
} }
+196
View File
@@ -0,0 +1,196 @@
"use client"
import * as React from "react"
import { AlertDialog as AlertDialogPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
function AlertDialog({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
}
function AlertDialogTrigger({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
)
}
function AlertDialogPortal({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
)
}
function AlertDialogOverlay({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
return (
<AlertDialogPrimitive.Overlay
data-slot="alert-dialog-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function AlertDialogContent({
className,
size = "default",
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content> & {
size?: "default" | "sm"
}) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
data-slot="alert-dialog-content"
data-size={size}
className={cn(
"group/alert-dialog-content fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-popover p-6 shadow-e3 data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
)
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
className={cn(
"grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-6 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]",
className
)}
{...props}
/>
)
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn(
"text-lg font-semibold sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
className
)}
{...props}
/>
)
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function AlertDialogMedia({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-media"
className={cn(
"mb-2 inline-flex size-16 items-center justify-center rounded-md bg-muted sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-8",
className
)}
{...props}
/>
)
}
function AlertDialogAction({
className,
variant = "default",
size = "default",
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Action> &
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
return (
<Button variant={variant} size={size} asChild>
<AlertDialogPrimitive.Action
data-slot="alert-dialog-action"
className={cn(className)}
{...props}
/>
</Button>
)
}
function AlertDialogCancel({
className,
variant = "outline",
size = "default",
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel> &
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
return (
<Button variant={variant} size={size} asChild>
<AlertDialogPrimitive.Cancel
data-slot="alert-dialog-cancel"
className={cn(className)}
{...props}
/>
</Button>
)
}
export {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogTitle,
AlertDialogTrigger,
}
+28 -39
View File
@@ -1,25 +1,11 @@
import { createFileRoute } from "@tanstack/react-router" import { createFileRoute } from "@tanstack/react-router"
import { motion } from "motion/react"
import Appearance from "@/components/UserSettings/Appearance" import Appearance from "@/components/UserSettings/Appearance"
import ChangePassword from "@/components/UserSettings/ChangePassword"
import DeleteAccount from "@/components/UserSettings/DeleteAccount"
import RemoteAccess from "@/components/UserSettings/RemoteAccess" import RemoteAccess from "@/components/UserSettings/RemoteAccess"
import UserInformation from "@/components/UserSettings/UserInformation" import UserInformation from "@/components/UserSettings/UserInformation"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import useAuth from "@/hooks/useAuth" import useAuth from "@/hooks/useAuth"
import { listStagger, slideUp } from "@/lib/motion"
const tabsConfig = [
{ value: "my-profile", title: "My profile", component: UserInformation },
{ value: "password", title: "Password", component: ChangePassword },
{ value: "appearance", title: "Appearance", component: Appearance },
{ value: "danger-zone", title: "Danger zone", component: DeleteAccount },
]
// Whether this whole installation can be reached from outside is not a
// personal preference, so the tab only exists for an operator.
const superuserTabs = [
{ value: "remote-access", title: "Remote access", component: RemoteAccess },
]
export const Route = createFileRoute("/_layout/settings")({ export const Route = createFileRoute("/_layout/settings")({
component: UserSettings, component: UserSettings,
@@ -34,13 +20,6 @@ export const Route = createFileRoute("/_layout/settings")({
function UserSettings() { function UserSettings() {
const { user: currentUser } = useAuth() const { user: currentUser } = useAuth()
// A superuser deleting its own account would lock everyone out.
const finalTabs = currentUser?.is_superuser
? [
...tabsConfig.filter((tab) => tab.value !== "danger-zone"),
...superuserTabs,
]
: tabsConfig
if (!currentUser) { if (!currentUser) {
return null return null
@@ -49,26 +28,36 @@ function UserSettings() {
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div> <div>
<h1 className="text-2xl font-bold tracking-tight">User Settings</h1> <h1 className="font-display text-3xl font-semibold">Settings</h1>
<p className="text-muted-foreground"> <p className="max-w-2xl text-sm text-muted-foreground">
Manage your account settings and preferences Manage your account settings and preferences
</p> </p>
</div> </div>
<Tabs defaultValue="my-profile"> {/* Panels rather than tabs, the way the portal does it: there are few
<TabsList> enough of these to read at once, and hiding four behind a tab bar was
{finalTabs.map((tab) => ( navigation over three cards. Changing a password and deleting an
<TabsTrigger key={tab.value} value={tab.value}> account belong to the account, so they live in that card's footer. */}
{tab.title} <motion.div
</TabsTrigger> className="grid items-start gap-6 lg:grid-cols-2"
))} variants={listStagger}
</TabsList> initial="hidden"
{finalTabs.map((tab) => ( animate="visible"
<TabsContent key={tab.value} value={tab.value}> >
<tab.component /> <motion.div variants={slideUp}>
</TabsContent> <UserInformation />
))} </motion.div>
</Tabs> <motion.div variants={slideUp}>
<Appearance />
</motion.div>
{/* Whether this whole installation can be reached from outside is not
a personal preference, so it only exists for an operator. */}
{currentUser.is_superuser && (
<motion.div variants={slideUp}>
<RemoteAccess />
</motion.div>
)}
</motion.div>
</div> </div>
) )
} }