feat: replace fsrVersion with upscalerType/upscalerVersion, add battery & custom system columns
This commit is contained in:
+172
-70
@@ -1,7 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { Loader2 } from "lucide-react"
|
||||
import { useState, useEffect, useRef, useCallback } from "react"
|
||||
import { Loader2, Key } from "lucide-react"
|
||||
import { authClient } from "@/lib/auth-client"
|
||||
import {
|
||||
loginEmailSchema,
|
||||
@@ -9,22 +9,70 @@ import {
|
||||
} from "@/lib/auth/validation"
|
||||
import SocialButtons from "./social-buttons"
|
||||
import Link from "next/link"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
|
||||
function isWebAuthnAbortError(err: unknown): boolean {
|
||||
if (err instanceof DOMException && err.name === "AbortError") return true
|
||||
const msg = err instanceof Error ? err.message : String(err ?? "")
|
||||
return msg.includes("abort signal") || msg.includes("AbortError")
|
||||
}
|
||||
|
||||
// Suppress the console.error that @better-auth/passkey logs internally
|
||||
// when a WebAuthn ceremony is aborted (expected on navigation/remount).
|
||||
let suppressPasskeyErrors = false
|
||||
const originalConsoleError = console.error
|
||||
const passkeyErrorPattern = /\[Better Auth\] Error verifying passkey/
|
||||
|
||||
console.error = (...args: unknown[]) => {
|
||||
if (suppressPasskeyErrors) {
|
||||
const msg = typeof args[0] === 'string' ? args[0] : ''
|
||||
if (passkeyErrorPattern.test(msg) || args.some(a => isWebAuthnAbortError(a))) {
|
||||
return
|
||||
}
|
||||
}
|
||||
originalConsoleError(...args)
|
||||
}
|
||||
|
||||
export default function LoginForm() {
|
||||
const router = useRouter()
|
||||
const [phase, setPhase] = useState<"email" | "password">("email")
|
||||
const searchParams = useSearchParams()
|
||||
const redirectTo = searchParams.get("redirect") || "/"
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [email, setEmail] = useState("")
|
||||
const [password, setPassword] = useState("")
|
||||
const [error, setError] = useState("")
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [emailChecked, setEmailChecked] = useState(false)
|
||||
const mountedRef = useRef(true)
|
||||
const passkeyInitiatedRef = useRef(false)
|
||||
|
||||
// Preload passkeys for conditional UI
|
||||
// Redirect to the intended page after successful login
|
||||
const handleLoginSuccess = useCallback(() => {
|
||||
if (mountedRef.current) router.push(redirectTo)
|
||||
}, [router, redirectTo])
|
||||
|
||||
// Preload passkeys for conditional UI — must be called on mount when
|
||||
// both email + password fields are in the DOM.
|
||||
useEffect(() => {
|
||||
if (phase === "password" && "PublicKeyCredential" in window) {
|
||||
authClient.signIn.passkey({ autoFill: true })
|
||||
mountedRef.current = true
|
||||
if ("PublicKeyCredential" in window && !passkeyInitiatedRef.current) {
|
||||
passkeyInitiatedRef.current = true
|
||||
suppressPasskeyErrors = true
|
||||
authClient.signIn.passkey({
|
||||
autoFill: true,
|
||||
fetchOptions: {
|
||||
onSuccess: handleLoginSuccess,
|
||||
},
|
||||
}).catch((err) => {
|
||||
if (!isWebAuthnAbortError(err)) {
|
||||
console.warn("[passkey-conditional-ui]", err)
|
||||
}
|
||||
}).finally(() => {
|
||||
suppressPasskeyErrors = false
|
||||
})
|
||||
}
|
||||
}, [phase])
|
||||
return () => { mountedRef.current = false }
|
||||
}, [handleLoginSuccess])
|
||||
|
||||
const handleEmailSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
@@ -56,8 +104,9 @@ export default function LoginForm() {
|
||||
return
|
||||
}
|
||||
|
||||
// Email exists, move to password phase
|
||||
setPhase("password")
|
||||
// Email exists, show password field
|
||||
setEmailChecked(true)
|
||||
setShowPassword(true)
|
||||
} catch {
|
||||
setError("Something went wrong. Please try again.")
|
||||
} finally {
|
||||
@@ -84,13 +133,54 @@ export default function LoginForm() {
|
||||
return
|
||||
}
|
||||
|
||||
router.push("/")
|
||||
router.push(redirectTo)
|
||||
}
|
||||
|
||||
const handleChangeEmail = () => {
|
||||
setPhase("email")
|
||||
setShowPassword(false)
|
||||
setPassword("")
|
||||
setError("")
|
||||
setEmailChecked(false)
|
||||
}
|
||||
|
||||
const handlePasskeyError = useCallback((ctx: { error?: { message?: string } }) => {
|
||||
// Only show errors that aren't from conditional UI cancellation
|
||||
// (user dismissing the browser prompt is expected and not an error)
|
||||
const msg = ctx.error?.message || ""
|
||||
if (
|
||||
!msg.includes("No available") &&
|
||||
!msg.includes("cancelled") &&
|
||||
!msg.includes("NotAllowed") &&
|
||||
!msg.includes("aborted")
|
||||
) {
|
||||
setError(msg || "Passkey sign-in failed. Please try again.")
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handlePasskeyLogin = async () => {
|
||||
setError("")
|
||||
setIsLoading(true)
|
||||
suppressPasskeyErrors = true
|
||||
try {
|
||||
const result = await authClient.signIn.passkey({
|
||||
autoFill: false,
|
||||
fetchOptions: {
|
||||
onSuccess: handleLoginSuccess,
|
||||
onError: handlePasskeyError,
|
||||
},
|
||||
}).catch((err) => {
|
||||
if (isWebAuthnAbortError(err)) return { data: null, error: null }
|
||||
return { data: null, error: { message: err?.message || "Passkey sign-in failed" } }
|
||||
})
|
||||
if (result?.error) {
|
||||
handlePasskeyError({ error: result.error })
|
||||
}
|
||||
} catch {
|
||||
setError("Passkey sign-in failed. Please try again.")
|
||||
} finally {
|
||||
suppressPasskeyErrors = false
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -100,10 +190,10 @@ export default function LoginForm() {
|
||||
Welcome back
|
||||
</h1>
|
||||
<p className="text-sm text-text/50 mt-1">
|
||||
{phase === "email"
|
||||
? "Sign in to DeckyVault"
|
||||
: `Signing in as `}
|
||||
{phase === "password" && (
|
||||
{showPassword
|
||||
? "Signing in as "
|
||||
: "Sign in to DeckyVault"}
|
||||
{showPassword && (
|
||||
<>
|
||||
<strong className="text-text">{email}</strong>
|
||||
{" "}
|
||||
@@ -145,48 +235,43 @@ export default function LoginForm() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase === "email" ? (
|
||||
<form onSubmit={handleEmailSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="text-sm text-text/60 block mb-1.5">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@example.com"
|
||||
autoComplete="username webauthn"
|
||||
className="w-full px-4 py-3 rounded-lg border border-border bg-text/5 text-text text-sm placeholder:text-text/40 outline-none focus:border-primary focus:ring-2 focus:ring-primary/50 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="w-full py-3 rounded-lg bg-primary text-white text-sm font-semibold hover:bg-primary/90 transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
{isLoading && (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
)}
|
||||
Continue
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<form onSubmit={handleLogin} className="space-y-4">
|
||||
<div>
|
||||
<label className="text-sm text-text/60 block mb-1.5">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Enter your password"
|
||||
autoComplete="current-password webauthn"
|
||||
autoFocus
|
||||
className="w-full px-4 py-3 rounded-lg border border-border bg-text/5 text-text text-sm placeholder:text-text/40 outline-none focus:border-primary focus:ring-2 focus:ring-primary/50 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
{/* Single form always contains both email and password inputs
|
||||
so that WebAuthn conditional UI (autofill) works correctly.
|
||||
The password field is visually hidden until the email is verified. */}
|
||||
<form onSubmit={showPassword ? handleLogin : handleEmailSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="text-sm text-text/60 block mb-1.5">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@example.com"
|
||||
autoComplete="username webauthn"
|
||||
className="w-full px-4 py-3 rounded-lg border border-border bg-text/5 text-text text-sm placeholder:text-text/40 outline-none focus:border-primary focus:ring-2 focus:ring-primary/50 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
{/* Always render the password input for WebAuthn conditional UI,
|
||||
but visually hide it until the email is verified */}
|
||||
<div className={showPassword ? "" : "h-0 overflow-hidden opacity-0 pointer-events-none"}>
|
||||
<label className="text-sm text-text/60 block mb-1.5">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Enter your password"
|
||||
autoComplete="current-password webauthn"
|
||||
autoFocus={showPassword}
|
||||
tabIndex={showPassword ? 0 : -1}
|
||||
className="w-full px-4 py-3 rounded-lg border border-border bg-text/5 text-text text-sm placeholder:text-text/40 outline-none focus:border-primary focus:ring-2 focus:ring-primary/50 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
{showPassword && (
|
||||
<div className="text-right">
|
||||
<Link
|
||||
href="/forgot-password"
|
||||
@@ -195,23 +280,40 @@ export default function LoginForm() {
|
||||
Forgot password?
|
||||
</Link>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="w-full py-3 rounded-lg bg-primary text-white text-sm font-semibold hover:bg-primary/90 transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
{isLoading && (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
)}
|
||||
Sign in
|
||||
</button>
|
||||
{/* Passkey hint */}
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="w-full py-3 rounded-lg bg-primary text-white text-sm font-semibold hover:bg-primary/90 transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
{isLoading && (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
)}
|
||||
{showPassword ? "Sign in" : "Continue"}
|
||||
</button>
|
||||
{showPassword && (
|
||||
<div className="text-center p-3 rounded-lg bg-primary/5 border border-primary/10">
|
||||
<p className="text-xs text-text/50">
|
||||
Your browser may offer to sign in with a passkey
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</form>
|
||||
|
||||
{/* Explicit passkey login button */}
|
||||
{"PublicKeyCredential" in window && !showPassword && (
|
||||
<button
|
||||
onClick={handlePasskeyLogin}
|
||||
disabled={isLoading}
|
||||
className="w-full flex items-center justify-center gap-2 py-3 rounded-lg border border-border bg-text/5 text-text/70 text-sm font-medium hover:bg-text/10 hover:text-text transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Key className="h-4 w-4" />
|
||||
)}
|
||||
Sign in with a passkey
|
||||
</button>
|
||||
)}
|
||||
|
||||
<p className="text-center text-sm text-text/50">
|
||||
|
||||
+46
-11
@@ -5,7 +5,15 @@ import logo from "@/app/icon.png"
|
||||
import Image from "next/image"
|
||||
import Link from "next/link"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { Bookmark, CircleXIcon, Gamepad2Icon, LogOut, MenuIcon, User, XIcon } from "lucide-react"
|
||||
import {
|
||||
Bookmark,
|
||||
CircleXIcon,
|
||||
Gamepad2Icon,
|
||||
LogOut,
|
||||
MenuIcon,
|
||||
User,
|
||||
XIcon,
|
||||
} from "lucide-react"
|
||||
import { routes, authRoutes } from "@/lib/routes"
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation"
|
||||
import { useDebounce } from "@/lib/hooks/useDebounce"
|
||||
@@ -17,7 +25,11 @@ export default function Navbar() {
|
||||
const searchParams = useSearchParams()
|
||||
|
||||
const isLanding = pathname === "/"
|
||||
const isAuthRoute = pathname.startsWith("/login") || pathname.startsWith("/signup") || pathname.startsWith("/forgot-password") || pathname.startsWith("/reset-password")
|
||||
const isAuthRoute =
|
||||
pathname.startsWith("/login") ||
|
||||
pathname.startsWith("/signup") ||
|
||||
pathname.startsWith("/forgot-password") ||
|
||||
pathname.startsWith("/reset-password")
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState(
|
||||
() => searchParams.get("q") || "",
|
||||
@@ -140,6 +152,7 @@ export default function Navbar() {
|
||||
src={logo}
|
||||
alt='DeckyVault Logo'
|
||||
className='h-6 my-1 w-auto'
|
||||
loading='eager'
|
||||
/>
|
||||
{!isLanding && (
|
||||
<motion.span className='hidden md:inline-block'>
|
||||
@@ -243,7 +256,9 @@ export default function Navbar() {
|
||||
className='relative'
|
||||
>
|
||||
<button
|
||||
onClick={() => setUserMenuOpen(!userMenuOpen)}
|
||||
onClick={() =>
|
||||
setUserMenuOpen(!userMenuOpen)
|
||||
}
|
||||
className='text-sm font-medium hover:text-primary transition-colors uppercase cursor-pointer'
|
||||
>
|
||||
Profile
|
||||
@@ -252,18 +267,30 @@ export default function Navbar() {
|
||||
<>
|
||||
<div
|
||||
className='fixed inset-0 z-40'
|
||||
onClick={() => setUserMenuOpen(false)}
|
||||
onClick={() =>
|
||||
setUserMenuOpen(false)
|
||||
}
|
||||
/>
|
||||
<div className='absolute right-0 top-full mt-1 w-48 bg-[#1a1020] border border-white/10 rounded-lg shadow-lg z-50 py-1'>
|
||||
{authRoutes.map((route) => (
|
||||
<Link
|
||||
key={route.href}
|
||||
href={route.href}
|
||||
onClick={() => setUserMenuOpen(false)}
|
||||
onClick={() =>
|
||||
setUserMenuOpen(
|
||||
false,
|
||||
)
|
||||
}
|
||||
className='w-full flex items-center gap-2 px-3 py-2 text-sm text-text/70 hover:text-text hover:bg-text/5 transition-colors cursor-pointer'
|
||||
>
|
||||
{route.icon === "User" && <User className='h-4 w-4' />}
|
||||
{route.icon === "Bookmark" && <Bookmark className='h-4 w-4' />}
|
||||
{route.icon ===
|
||||
"User" && (
|
||||
<User className='h-4 w-4' />
|
||||
)}
|
||||
{route.icon ===
|
||||
"Bookmark" && (
|
||||
<Bookmark className='h-4 w-4' />
|
||||
)}
|
||||
{route.title}
|
||||
</Link>
|
||||
))}
|
||||
@@ -370,11 +397,17 @@ export default function Navbar() {
|
||||
<Link
|
||||
key={route.href}
|
||||
href={route.href}
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
onClick={() =>
|
||||
setMobileMenuOpen(false)
|
||||
}
|
||||
className='flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-text/70 hover:text-text hover:bg-text/5 transition-colors cursor-pointer'
|
||||
>
|
||||
{route.icon === "User" && <User className='h-4 w-4' />}
|
||||
{route.icon === "Bookmark" && <Bookmark className='h-4 w-4' />}
|
||||
{route.icon === "User" && (
|
||||
<User className='h-4 w-4' />
|
||||
)}
|
||||
{route.icon === "Bookmark" && (
|
||||
<Bookmark className='h-4 w-4' />
|
||||
)}
|
||||
{route.title}
|
||||
</Link>
|
||||
))}
|
||||
@@ -393,7 +426,9 @@ export default function Navbar() {
|
||||
<div className='space-y-2'>
|
||||
<Link
|
||||
href='/login'
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
onClick={() =>
|
||||
setMobileMenuOpen(false)
|
||||
}
|
||||
className='block w-full text-center px-3 py-2 rounded-lg border border-white/10 text-sm text-text hover:bg-text/5 transition-colors cursor-pointer'
|
||||
>
|
||||
Login
|
||||
|
||||
@@ -1,114 +1,142 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect, useRef } from "react"
|
||||
import { useState, useEffect } from "react"
|
||||
import { User, Shield, Link as LinkIcon } from "lucide-react"
|
||||
import { SettingsProfileTab } from "@/components/profile/settings-profile-tab"
|
||||
import { SettingsSecurityTab } from "@/components/profile/settings-security-tab"
|
||||
import { SettingsAccountsTab } from "@/components/profile/settings-accounts-tab"
|
||||
|
||||
interface AuthMethods {
|
||||
hasPassword: boolean
|
||||
passkeyCount: number
|
||||
oauthProviders: Array<{ providerId: string; id: string }>
|
||||
totalAuthMethods: number
|
||||
hasPassword: boolean
|
||||
passkeyCount: number
|
||||
oauthProviders: Array<{ providerId: string; id: string }>
|
||||
totalAuthMethods: number
|
||||
}
|
||||
|
||||
type SettingsSubTab = "profile" | "security" | "accounts"
|
||||
|
||||
const subTabs: { id: SettingsSubTab; label: string; icon: typeof User }[] = [
|
||||
{ id: "profile", label: "Profile", icon: User },
|
||||
{ id: "security", label: "Security", icon: Shield },
|
||||
{ id: "accounts", label: "Linked Accounts", icon: LinkIcon },
|
||||
{ id: "profile", label: "Profile", icon: User },
|
||||
{ id: "security", label: "Security", icon: Shield },
|
||||
{ id: "accounts", label: "Linked Accounts", icon: LinkIcon },
|
||||
]
|
||||
|
||||
interface SettingsContainerProps {
|
||||
name: string
|
||||
email: string
|
||||
role: string | null
|
||||
createdAt: string
|
||||
name: string
|
||||
email: string
|
||||
role: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export function SettingsContainer({ name, email, role, createdAt }: SettingsContainerProps) {
|
||||
const [activeSubTab, setActiveSubTab] = useState<SettingsSubTab>("profile")
|
||||
const [authMethods, setAuthMethods] = useState<AuthMethods | null>(null)
|
||||
const [isLoadingAuthMethods, setIsLoadingAuthMethods] = useState(true)
|
||||
export function SettingsContainer({
|
||||
name,
|
||||
email,
|
||||
role,
|
||||
createdAt,
|
||||
}: SettingsContainerProps) {
|
||||
const [activeSubTab, setActiveSubTab] = useState<SettingsSubTab>("profile")
|
||||
const [authMethods, setAuthMethods] = useState<AuthMethods | null>(null)
|
||||
const [isLoadingAuthMethods, setIsLoadingAuthMethods] = useState(true)
|
||||
|
||||
const fetchRef = useRef(false)
|
||||
useEffect(() => {
|
||||
if (fetchRef.current) return
|
||||
fetchRef.current = true
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), 10000)
|
||||
|
||||
let cancelled = false
|
||||
fetch("/api/user/me/auth-methods", { credentials: "include" })
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(data => {
|
||||
if (cancelled) return
|
||||
if (data) setAuthMethods(data)
|
||||
setIsLoadingAuthMethods(false)
|
||||
})
|
||||
.catch(() => {
|
||||
setIsLoadingAuthMethods(false)
|
||||
})
|
||||
fetch("/api/user/me/auth-methods", {
|
||||
credentials: "include",
|
||||
signal: controller.signal,
|
||||
})
|
||||
.then(async (r) => {
|
||||
if (r.ok) {
|
||||
return r.json()
|
||||
}
|
||||
// Log non-OK responses for debugging
|
||||
console.warn(`[auth-methods] fetch returned ${r.status}`)
|
||||
return null
|
||||
})
|
||||
.then((data) => {
|
||||
if (data) setAuthMethods(data)
|
||||
setIsLoadingAuthMethods(false)
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err.name !== "AbortError") {
|
||||
console.error("[auth-methods] fetch failed:", err)
|
||||
}
|
||||
setIsLoadingAuthMethods(false)
|
||||
})
|
||||
.finally(() => clearTimeout(timeoutId))
|
||||
|
||||
return () => { cancelled = true }
|
||||
}, [])
|
||||
return () => {
|
||||
controller.abort()
|
||||
clearTimeout(timeoutId)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const refreshAuthMethods = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/user/me/auth-methods", { credentials: "include" })
|
||||
if (res.ok) setAuthMethods(await res.json())
|
||||
} catch {
|
||||
// silently fail
|
||||
const refreshAuthMethods = async () => {
|
||||
setIsLoadingAuthMethods(true)
|
||||
try {
|
||||
const res = await fetch("/api/user/me/auth-methods", {
|
||||
credentials: "include",
|
||||
})
|
||||
if (res.ok) {
|
||||
setAuthMethods(await res.json())
|
||||
} else {
|
||||
setAuthMethods(null)
|
||||
}
|
||||
} catch {
|
||||
setAuthMethods(null)
|
||||
} finally {
|
||||
setIsLoadingAuthMethods(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col md:flex-row gap-6">
|
||||
{/* Sidebar Navigation */}
|
||||
<nav className="md:w-48 shrink-0">
|
||||
<div className="flex md:flex-col gap-1 overflow-x-auto md:overflow-visible pb-2 md:pb-0 md:border-r md:border-border">
|
||||
{subTabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveSubTab(tab.id)}
|
||||
className={`flex items-center gap-2 px-4 py-2.5 text-sm font-medium transition-colors whitespace-nowrap rounded-lg md:rounded-none md:border-l-2 md:border-r-0 md:border-transparent cursor-pointer ${
|
||||
activeSubTab === tab.id
|
||||
? "bg-primary/10 text-primary md:border-l-primary md:bg-primary/10"
|
||||
: "text-text/50 hover:text-text/70 hover:bg-text/5"
|
||||
}`}
|
||||
>
|
||||
<tab.icon className="h-4 w-4 shrink-0" />
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
return (
|
||||
<div className='flex flex-col md:flex-row gap-6'>
|
||||
{/* Sidebar Navigation */}
|
||||
<nav className='md:w-48 shrink-0'>
|
||||
<div className='flex md:flex-col gap-1 overflow-x-auto md:overflow-visible pb-2 md:pb-0 md:border-r md:border-border'>
|
||||
{subTabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveSubTab(tab.id)}
|
||||
className={`flex items-center gap-2 px-4 py-2.5 text-sm font-medium transition-colors whitespace-nowrap rounded-lg md:rounded-none md:border-l-2 md:border-r-0 md:border-transparent cursor-pointer ${
|
||||
activeSubTab === tab.id
|
||||
? "bg-primary/10 text-primary md:border-l-primary md:bg-primary/10"
|
||||
: "text-text/50 hover:text-text/70 hover:bg-text/5"
|
||||
}`}
|
||||
>
|
||||
<tab.icon className='h-4 w-4 shrink-0' />
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Content Area */}
|
||||
<div className='flex-1 min-w-0'>
|
||||
{activeSubTab === "profile" && (
|
||||
<SettingsProfileTab
|
||||
name={name}
|
||||
email={email}
|
||||
role={role}
|
||||
createdAt={createdAt}
|
||||
/>
|
||||
)}
|
||||
{activeSubTab === "security" && (
|
||||
<SettingsSecurityTab
|
||||
authMethods={authMethods}
|
||||
isLoadingAuthMethods={isLoadingAuthMethods}
|
||||
onRefreshAuthMethods={refreshAuthMethods}
|
||||
/>
|
||||
)}
|
||||
{activeSubTab === "accounts" && (
|
||||
<SettingsAccountsTab
|
||||
authMethods={authMethods}
|
||||
isLoadingAuthMethods={isLoadingAuthMethods}
|
||||
onRefreshAuthMethods={refreshAuthMethods}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Content Area */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{activeSubTab === "profile" && (
|
||||
<SettingsProfileTab
|
||||
name={name}
|
||||
email={email}
|
||||
role={role}
|
||||
createdAt={createdAt}
|
||||
/>
|
||||
)}
|
||||
{activeSubTab === "security" && (
|
||||
<SettingsSecurityTab
|
||||
authMethods={authMethods}
|
||||
isLoadingAuthMethods={isLoadingAuthMethods}
|
||||
onRefreshAuthMethods={refreshAuthMethods}
|
||||
/>
|
||||
)}
|
||||
{activeSubTab === "accounts" && (
|
||||
<SettingsAccountsTab
|
||||
authMethods={authMethods}
|
||||
isLoadingAuthMethods={isLoadingAuthMethods}
|
||||
onRefreshAuthMethods={refreshAuthMethods}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user