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:
@@ -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=="],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user