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) => (
-
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",
- )}
+
+
+ Appearance
+ How Fluksio looks to you
+
+
+
+ {/* The one segmented shape: a single border pill, transparent
+ segments, bg-accent on the selected one (root
+ DESIGN-GUIDELINES.md). */}
+
-
- {option.title}
-
- ))}
-
-
+ {options.map((option) => (
+
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.title}
+
+ ))}
+
+
+
+
)
}
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 (
-
+ (
+
+ 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 (
-
-
-
- Delete Account
-
-
-
-
-
- Confirmation Required
-
- All your account data will be{" "}
- permanently deleted. If you are sure, please
- click "Confirm" to proceed. This action cannot be
- undone.
-
-
+ <>
+ setIsOpen(true)}
+ >
+ Delete account
+
+
+
+
+
+ Confirmation Required
+
+ All your account data will be{" "}
+ permanently deleted. If you are sure, please
+ click "Confirm" to proceed. This action cannot
+ be undone.
+
+
-
-
-
+
+ setIsOpen(false)}
+ >
Cancel
-
-
- 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 ?? "—"}
-
-
-
-
-
setConfirmDisconnect(true)}
- >
- Disconnect
-
+
+
+
+ {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 ?? "—"}
+
+
+
+
+ setConfirmDisconnect(true)}
+ >
+ Disconnect
+
+
-
-
-
-
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.
+
+
+
+
Code
+
+ 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.
+
+
+
+ addRemoteUser.mutate()}
+ >
+ {addRemoteUser.isPending ? "Adding…" : "Add remote user"}
+
+
+
+ >
+ ) : (
+
+
+ Portal
+ setPortalUrl(event.target.value)}
+ />
-
Code
+
Code
- 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.
addRemoteUser.mutate()}
+ disabled={
+ !code.trim() || !portalUrl.trim() || connect.isPending
+ }
+ onClick={() => connect.mutate()}
>
- {addRemoteUser.isPending ? "Adding…" : "Add remote user"}
+ {connect.isPending ? "Connecting…" : "Connect"}
- >
- ) : (
-
-
- Portal
- setPortalUrl(event.target.value)}
- />
-
-
-
Code
-
setCode(event.target.value.toUpperCase())}
- />
-
- Get a code at fluksio.com → Installations → Add installation.
-
-
-
- connect.mutate()}
- >
- {connect.isPending ? "Connecting…" : "Connect"}
-
-
-
- )}
+ )}
+
@@ -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. */}
+
+
{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
- >
- ) : (
-
- Edit
-
+
)}
+
+
+
+
+
+ {/* 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 && (
+
+
+
+ )}
+
)
}