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,13 +20,16 @@ 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.">
{/* The one segmented shape: a single border pill, transparent
segments, bg-accent on the selected one (root
DESIGN-GUIDELINES.md). */}
<div <div
data-testid="theme-button" data-testid="theme-button"
className="flex w-fit items-center gap-1 rounded-full border border-border p-1" className="flex w-fit items-center gap-1 rounded-full border border-border p-1"
@@ -43,7 +53,9 @@ const Appearance = () => {
</button> </button>
))} ))}
</div> </div>
</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,9 +76,23 @@ 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>
<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 {...form}>
<form <form
onSubmit={form.handleSubmit(onSubmit)} onSubmit={form.handleSubmit(onSubmit)}
@@ -133,13 +158,14 @@ const ChangePassword = () => {
<LoadingButton <LoadingButton
type="submit" type="submit"
loading={mutation.isPending} loading={mutation.isPending}
className="self-start" className="self-end"
> >
Update Password Update Password
</LoadingButton> </LoadingButton>
</form> </form>
</Form> </Form>
</div> </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"
className="text-destructive hover:text-destructive"
onClick={() => setIsOpen(true)}
>
Delete account
</Button> </Button>
</DialogTrigger> <AlertDialog open={isOpen} onOpenChange={setIsOpen}>
<DialogContent> <AlertDialogContent>
<form onSubmit={handleSubmit(onSubmit)}> <form onSubmit={handleSubmit(onSubmit)}>
<DialogHeader> <AlertDialogHeader>
<DialogTitle>Confirmation Required</DialogTitle> <AlertDialogTitle>Confirmation Required</AlertDialogTitle>
<DialogDescription> <AlertDialogDescription>
All your account data will be{" "} All your account data will be{" "}
<strong>permanently deleted.</strong> If you are sure, please <strong>permanently deleted.</strong> If you are sure, please
click <strong>"Confirm"</strong> to proceed. This action cannot be click <strong>"Confirm"</strong> to proceed. This action cannot
undone. be undone.
</DialogDescription> </AlertDialogDescription>
</DialogHeader> </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
variant="destructive"
type="submit" type="submit"
variant="destructive"
loading={mutation.isPending} loading={mutation.isPending}
> >
Delete Delete
</LoadingButton> </LoadingButton>
</DialogFooter> </AlertDialogFooter>
</form> </form>
</DialogContent> </AlertDialogContent>
</Dialog> </AlertDialog>
</>
) )
} }
@@ -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,18 +104,18 @@ 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
@@ -121,8 +129,8 @@ export function RemoteAccess() {
{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">
@@ -142,7 +150,9 @@ export function RemoteAccess() {
</div> </div>
</div> </div>
<div className="flex flex-col gap-4 rounded-lg border border-border p-4"> <Separator />
<div className="flex flex-col gap-4">
<div> <div>
<h3 className="font-medium">Remote users</h3> <h3 className="font-medium">Remote users</h3>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
@@ -181,7 +191,7 @@ export function RemoteAccess() {
</div> </div>
</> </>
) : ( ) : (
<div className="flex flex-col gap-4 rounded-lg border border-border p-4"> <div className="flex flex-col gap-4">
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="portal-url">Portal</Label> <Label htmlFor="portal-url">Portal</Label>
<Input <Input
@@ -207,7 +217,9 @@ export function RemoteAccess() {
<Button <Button
variant="brand" variant="brand"
className="rounded-full" className="rounded-full"
disabled={!code.trim() || !portalUrl.trim() || connect.isPending} disabled={
!code.trim() || !portalUrl.trim() || connect.isPending
}
onClick={() => connect.mutate()} onClick={() => connect.mutate()}
> >
{connect.isPending ? "Connecting…" : "Connect"} {connect.isPending ? "Connecting…" : "Connect"}
@@ -215,6 +227,7 @@ export function RemoteAccess() {
</div> </div>
</div> </div>
)} )}
</CardContent>
<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,8 +125,16 @@ const UserInformation = () => {
} }
return ( return (
<div className="max-w-md"> <Card>
<h3 className="text-lg font-semibold py-4">User Information</h3> <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 {...form}>
<form <form
onSubmit={form.handleSubmit(onSubmit)} onSubmit={form.handleSubmit(onSubmit)}
@@ -101,17 +153,11 @@ const UserInformation = () => {
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
) : ( ) : (
<FormItem> <ReadOnlyField
<FormLabel>Full name</FormLabel> label="Full name"
<p value={field.value}
className={cn( onEdit={toggleEditMode}
"py-2 truncate max-w-sm", />
!field.value && "text-muted-foreground",
)}
>
{field.value || "N/A"}
</p>
</FormItem>
) )
} }
/> />
@@ -129,17 +175,17 @@ const UserInformation = () => {
<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}
/>
) )
} }
/> />
{editMode && (
<div className="flex gap-3"> <div className="flex gap-3">
{editMode ? (
<>
<LoadingButton <LoadingButton
type="submit" type="submit"
loading={mutation.isPending} loading={mutation.isPending}
@@ -155,16 +201,22 @@ const UserInformation = () => {
> >
Cancel Cancel
</Button> </Button>
</>
) : (
<Button type="button" onClick={toggleEditMode}>
Edit
</Button>
)}
</div> </div>
)}
</form> </form>
</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>
)}
</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 { 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>
) )
} }