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
@@ -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 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>
)
}
+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 { motion } from "motion/react"
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 UserInformation from "@/components/UserSettings/UserInformation"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import useAuth from "@/hooks/useAuth"
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 },
]
import { listStagger, slideUp } from "@/lib/motion"
export const Route = createFileRoute("/_layout/settings")({
component: UserSettings,
@@ -34,13 +20,6 @@ export const Route = createFileRoute("/_layout/settings")({
function UserSettings() {
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) {
return null
@@ -49,26 +28,36 @@ function UserSettings() {
return (
<div className="flex flex-col gap-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">User Settings</h1>
<p className="text-muted-foreground">
<h1 className="font-display text-3xl font-semibold">Settings</h1>
<p className="max-w-2xl text-sm text-muted-foreground">
Manage your account settings and preferences
</p>
</div>
<Tabs defaultValue="my-profile">
<TabsList>
{finalTabs.map((tab) => (
<TabsTrigger key={tab.value} value={tab.value}>
{tab.title}
</TabsTrigger>
))}
</TabsList>
{finalTabs.map((tab) => (
<TabsContent key={tab.value} value={tab.value}>
<tab.component />
</TabsContent>
))}
</Tabs>
{/* Panels rather than tabs, the way the portal does it: there are few
enough of these to read at once, and hiding four behind a tab bar was
navigation over three cards. Changing a password and deleting an
account belong to the account, so they live in that card's footer. */}
<motion.div
className="grid items-start gap-6 lg:grid-cols-2"
variants={listStagger}
initial="hidden"
animate="visible"
>
<motion.div variants={slideUp}>
<UserInformation />
</motion.div>
<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>
)
}