From 5da5606f79554f708534e452bbceda4ef3ead473 Mon Sep 17 00:00:00 2001 From: stroblme Date: Thu, 27 Aug 2026 16:04:42 +0200 Subject: [PATCH] Settings as panels, the way the portal does it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- bun.lock | 3 + frontend/package.json | 1 + frontend/src/components/Common/UserAvatar.tsx | 36 +++ .../components/UserSettings/Appearance.tsx | 74 +++--- .../UserSettings/ChangePassword.tsx | 174 ++++++++------ .../components/UserSettings/DeleteAccount.tsx | 15 -- .../UserSettings/DeleteConfirmation.tsx | 93 ++++---- .../components/UserSettings/RemoteAccess.tsx | 213 ++++++++++-------- .../components/UserSettings/SettingRow.tsx | 30 +++ .../UserSettings/UserInformation.tsx | 182 +++++++++------ frontend/src/components/ui/alert-dialog.tsx | 196 ++++++++++++++++ frontend/src/routes/_layout/settings.tsx | 67 +++--- 12 files changed, 717 insertions(+), 367 deletions(-) create mode 100644 frontend/src/components/Common/UserAvatar.tsx delete mode 100644 frontend/src/components/UserSettings/DeleteAccount.tsx create mode 100644 frontend/src/components/UserSettings/SettingRow.tsx create mode 100644 frontend/src/components/ui/alert-dialog.tsx diff --git a/bun.lock b/bun.lock index 7a96926..4d1ada4 100644 --- a/bun.lock +++ b/bun.lock @@ -33,6 +33,7 @@ "@tanstack/react-table": "^8.21.3", "@xyflow/react": "^12.11.3", "axios": "1.13.2", + "boring-avatars": "^2.0.4", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", @@ -535,6 +536,8 @@ "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=="], "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=="], diff --git a/frontend/package.json b/frontend/package.json index 3acecb9..3183d2b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -39,6 +39,7 @@ "@tanstack/react-table": "^8.21.3", "@xyflow/react": "^12.11.3", "axios": "1.13.2", + "boring-avatars": "^2.0.4", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", diff --git a/frontend/src/components/Common/UserAvatar.tsx b/frontend/src/components/Common/UserAvatar.tsx new file mode 100644 index 0000000..5218081 --- /dev/null +++ b/frontend/src/components/Common/UserAvatar.tsx @@ -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 ( + + ) +} diff --git a/frontend/src/components/UserSettings/Appearance.tsx b/frontend/src/components/UserSettings/Appearance.tsx index e0aa118..681feac 100644 --- a/frontend/src/components/UserSettings/Appearance.tsx +++ b/frontend/src/components/UserSettings/Appearance.tsx @@ -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 ( -
-

Appearance

-

- Choose how Fluksio looks to you. 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). */} -
- {options.map((option) => ( - - ))} -
-
+ {options.map((option) => ( + + ))} + + + + ) } diff --git a/frontend/src/components/UserSettings/ChangePassword.tsx b/frontend/src/components/UserSettings/ChangePassword.tsx index aeb8537..d19bcf8 100644 --- a/frontend/src/components/UserSettings/ChangePassword.tsx +++ b/frontend/src/components/UserSettings/ChangePassword.tsx @@ -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 const ChangePassword = () => { + const [isOpen, setIsOpen] = useState(false) const { showSuccessToast, showErrorToast } = useCustomToast() const form = useForm({ 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 ( -
-

Change Password

-
- - ( - - Current Password - - - - - - )} - /> - - ( - - New Password - - - - - - )} - /> - - ( - - Confirm Password - - - - - - )} - /> - - + + + + + + Change password + Update your account password + + + - Update Password - - - -
+ ( + + Current Password + + + + + + )} + /> + + ( + + New Password + + + + + + )} + /> + + ( + + Confirm Password + + + + + + )} + /> + + + Update Password + + + + + ) } diff --git a/frontend/src/components/UserSettings/DeleteAccount.tsx b/frontend/src/components/UserSettings/DeleteAccount.tsx deleted file mode 100644 index 7b9e895..0000000 --- a/frontend/src/components/UserSettings/DeleteAccount.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import DeleteConfirmation from "./DeleteConfirmation" - -const DeleteAccount = () => { - return ( -
-

Delete Account

-

- Permanently delete your account and all associated data. -

- -
- ) -} - -export default DeleteAccount diff --git a/frontend/src/components/UserSettings/DeleteConfirmation.tsx b/frontend/src/components/UserSettings/DeleteConfirmation.tsx index 06d76d9..1ba1cd0 100644 --- a/frontend/src/components/UserSettings/DeleteConfirmation.tsx +++ b/frontend/src/components/UserSettings/DeleteConfirmation.tsx @@ -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 ( - - - - - -
- - Confirmation Required - - All your account data will be{" "} - permanently deleted. If you are sure, please - click "Confirm" to proceed. This action cannot be - undone. - - + <> + + + + + + Confirmation Required + + All your account data will be{" "} + permanently deleted. If you are sure, please + click "Confirm" to proceed. This action cannot + be undone. + + - - - - - - Delete - - - -
-
+ + Delete + + + + + + ) } diff --git a/frontend/src/components/UserSettings/RemoteAccess.tsx b/frontend/src/components/UserSettings/RemoteAccess.tsx index 3ea946a..2c06e1b 100644 --- a/frontend/src/components/UserSettings/RemoteAccess.tsx +++ b/frontend/src/components/UserSettings/RemoteAccess.tsx @@ -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 ( -
-
-

Remote access

-

+ + + Remote access + Reach this installation from fluksio.com. Entirely optional — without it, this installation talks to nothing outside your network. -

-
- - {status.enrolled ? ( - <> -
-
- - {status.connected - ? "Connected" - : status.last_error - ? `Reconnecting — ${status.last_error}` - : "Reconnecting…"} - - {status.portal_url ?? "—"} - - {status.portal_account ?? "—"} - - Portal sessions of this account get this account's rights - here. Anyone else gets in only once added below, as their own - user. - - - - - {status.installation_id ?? "—"} - - -
-
- + + + + {status.enrolled ? ( + <> +
+
+ + {status.connected + ? "Connected" + : status.last_error + ? `Reconnecting — ${status.last_error}` + : "Reconnecting…"} + + {status.portal_url ?? "—"} + + {status.portal_account ?? "—"} + + Portal sessions of this account get this account's rights + here. Anyone else gets in only once added below, as their + own user. + + + + + {status.installation_id ?? "—"} + + +
+
+ +
-
-
-
-

Remote users

-

- 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. -

+ + +
+
+

Remote users

+

+ 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. +

+
+
+ + + setJoinCode(event.target.value.toUpperCase()) + } + /> +

+ They get a code at fluksio.com → Installations → Join an + installation. Added users appear under Admin → Users; deleting + them there ends their access. +

+
+
+ +
+
+ + ) : ( +
+
+ + setPortalUrl(event.target.value)} + />
- + - setJoinCode(event.target.value.toUpperCase()) - } + onChange={(event) => setCode(event.target.value.toUpperCase())} />

- 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.

- - ) : ( -
-
- - setPortalUrl(event.target.value)} - /> -
-
- - setCode(event.target.value.toUpperCase())} - /> -

- Get a code at fluksio.com → Installations → Add installation. -

-
-
- -
-
- )} + )} + @@ -245,7 +258,7 @@ export function RemoteAccess() { -
+ ) } diff --git a/frontend/src/components/UserSettings/SettingRow.tsx b/frontend/src/components/UserSettings/SettingRow.tsx new file mode 100644 index 0000000..1d0207d --- /dev/null +++ b/frontend/src/components/UserSettings/SettingRow.tsx @@ -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 +}) => ( +
+ {/* 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. */} +
+ +

{hint}

+
+
{children}
+
+) + +export default SettingRow diff --git a/frontend/src/components/UserSettings/UserInformation.tsx b/frontend/src/components/UserSettings/UserInformation.tsx index 4bfaf60..1b30acc 100644 --- a/frontend/src/components/UserSettings/UserInformation.tsx +++ b/frontend/src/components/UserSettings/UserInformation.tsx @@ -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 +/** Compact value row; the edit affordance appears on hover, focus or touch. */ +const ReadOnlyField = ({ + label, + value, + onEdit, +}: { + label: string + value: string | undefined + onEdit: () => void +}) => ( + +
+ {label} +

+ {value || "N/A"} +

+
+ +
+) + const UserInformation = () => { const queryClient = useQueryClient() const { showSuccessToast, showErrorToast } = useCustomToast() @@ -81,65 +125,67 @@ const UserInformation = () => { } return ( -
-

User Information

-
- - - editMode ? ( - - Full name - - - - - - ) : ( - - Full name -

- {field.value || "N/A"} -

-
- ) - } - /> + + + Account + Your profile details and sign-in + + + {currentUser?.is_superuser && Admin} + + + + + + + editMode ? ( + + Full name + + + + + + ) : ( + + ) + } + /> - - editMode ? ( - - Email - - - - - - ) : ( - - Email -

{field.value}

-
- ) - } - /> + + editMode ? ( + + Email + + + + + + ) : ( + + ) + } + /> -
- {editMode ? ( - <> + {editMode && ( +
{ > Cancel - - ) : ( - +
)} + + + + + + {/* 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 && ( +
+
- - -
+ )} + +
) } diff --git a/frontend/src/components/ui/alert-dialog.tsx b/frontend/src/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..3179d5a --- /dev/null +++ b/frontend/src/components/ui/alert-dialog.tsx @@ -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) { + return +} + +function AlertDialogTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogPortal({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogContent({ + className, + size = "default", + ...props +}: React.ComponentProps & { + size?: "default" | "sm" +}) { + return ( + + + + + ) +} + +function AlertDialogHeader({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDialogFooter({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogMedia({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDialogAction({ + className, + variant = "default", + size = "default", + ...props +}: React.ComponentProps & + Pick, "variant" | "size">) { + return ( + + ) +} + +function AlertDialogCancel({ + className, + variant = "outline", + size = "default", + ...props +}: React.ComponentProps & + Pick, "variant" | "size">) { + return ( + + ) +} + +export { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogMedia, + AlertDialogOverlay, + AlertDialogPortal, + AlertDialogTitle, + AlertDialogTrigger, +} diff --git a/frontend/src/routes/_layout/settings.tsx b/frontend/src/routes/_layout/settings.tsx index 6efa62d..582244f 100644 --- a/frontend/src/routes/_layout/settings.tsx +++ b/frontend/src/routes/_layout/settings.tsx @@ -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 (
-

User Settings

-

+

Settings

+

Manage your account settings and preferences

- - - {finalTabs.map((tab) => ( - - {tab.title} - - ))} - - {finalTabs.map((tab) => ( - - - - ))} - + {/* 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. */} + + + + + + + + {/* Whether this whole installation can be reached from outside is not + a personal preference, so it only exists for an operator. */} + {currentUser.is_superuser && ( + + + + )} +
) }