fix 2fa and move communication language to setting
Build & Push / Pipeline Tests (push) Failing after 1m26s
Build & Push / Build & Push Docker Image (push) Has been skipped
Test / Type Check (all packages) (push) Successful in 50s
Test / API Unit Tests (push) Failing after 1m6s
Test / Homepage Unit Tests (push) Successful in 48s
Test / Carplace Unit Tests (push) Successful in 44s
Test / Admin Unit Tests (push) Successful in 40s
Test / Dashboard Unit Tests (push) Successful in 41s
Test / API Integration Tests (push) Successful in 1m6s
Build & Push / Pipeline Tests (push) Failing after 1m26s
Build & Push / Build & Push Docker Image (push) Has been skipped
Test / Type Check (all packages) (push) Successful in 50s
Test / API Unit Tests (push) Failing after 1m6s
Test / Homepage Unit Tests (push) Successful in 48s
Test / Carplace Unit Tests (push) Successful in 44s
Test / Admin Unit Tests (push) Successful in 40s
Test / Dashboard Unit Tests (push) Successful in 41s
Test / API Integration Tests (push) Successful in 1m6s
This commit is contained in:
@@ -41,6 +41,7 @@ export default function AdminDashboardLayout({ children }: { children: React.Rea
|
||||
const [ready, setReady] = useState(false)
|
||||
const [admin, setAdmin] = useState<AdminSessionUser | null>(null)
|
||||
const [unreadNotifications, setUnreadNotifications] = useState(0)
|
||||
const [securitySetupOpen, setSecuritySetupOpen] = useState(false)
|
||||
const redirectingToLogin = useRef(false)
|
||||
|
||||
function redirectToLogin() {
|
||||
@@ -68,14 +69,12 @@ export default function AdminDashboardLayout({ children }: { children: React.Rea
|
||||
}
|
||||
setAdmin(resolvedAdmin)
|
||||
setReady(true)
|
||||
if (resolvedAdmin.totpEnabled) {
|
||||
fetch(`${ADMIN_API_BASE}/admin/notifications/me`, { credentials: 'include', cache: 'no-store' })
|
||||
.then((inboxResponse) => inboxResponse.ok ? inboxResponse.json() : null)
|
||||
.then((inbox) => {
|
||||
if (!cancelled) setUnreadNotifications(Number(inbox?.data?.unread ?? 0))
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
fetch(`${ADMIN_API_BASE}/admin/notifications/me`, { credentials: 'include', cache: 'no-store' })
|
||||
.then((inboxResponse) => inboxResponse.ok ? inboxResponse.json() : null)
|
||||
.then((inbox) => {
|
||||
if (!cancelled) setUnreadNotifications(Number(inbox?.data?.unread ?? 0))
|
||||
})
|
||||
.catch(() => {})
|
||||
} else {
|
||||
redirectToLogin()
|
||||
}
|
||||
@@ -104,16 +103,6 @@ export default function AdminDashboardLayout({ children }: { children: React.Rea
|
||||
)
|
||||
}
|
||||
|
||||
if (admin && !admin.totpEnabled) {
|
||||
return (
|
||||
<Admin2FAEnrollmentGate
|
||||
admin={admin}
|
||||
onEnrolled={setAdmin}
|
||||
onLogout={handleLogout}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminSessionProvider admin={admin as AdminSessionUser}>
|
||||
<div className="flex h-screen bg-[linear-gradient(180deg,#ffffff_0%,#f5f8ff_28%,#eef4ff_58%,#ffffff_100%)] text-stone-900 transition-colors dark:bg-[linear-gradient(180deg,#0a1128_0%,#0d1b38_35%,#07101e_100%)] dark:text-slate-100">
|
||||
@@ -145,6 +134,17 @@ export default function AdminDashboardLayout({ children }: { children: React.Rea
|
||||
})}
|
||||
</nav>
|
||||
<div className="px-3 py-4">
|
||||
{admin && !admin.totpEnabled ? (
|
||||
<button
|
||||
onClick={() => setSecuritySetupOpen(true)}
|
||||
className="mb-2 flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium text-orange-700 transition-colors hover:bg-orange-50 hover:text-orange-800 dark:text-orange-300 dark:hover:bg-[#162038] dark:hover:text-orange-200"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75l2 2 4-4M12 3l7 4v5c0 5-3.5 8-7 9-3.5-1-7-4-7-9V7l7-4z" />
|
||||
</svg>
|
||||
Enable 2FA
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium text-stone-500 transition-colors hover:bg-stone-100 hover:text-red-500 dark:text-stone-400 dark:hover:bg-[#162038] dark:hover:text-red-300"
|
||||
@@ -161,51 +161,67 @@ export default function AdminDashboardLayout({ children }: { children: React.Rea
|
||||
</div>
|
||||
</aside>
|
||||
<main className="flex-1 overflow-y-auto transition-colors">{children}</main>
|
||||
{admin && securitySetupOpen ? (
|
||||
<Admin2FASetupDialog
|
||||
admin={admin}
|
||||
onEnrolled={(updatedAdmin) => {
|
||||
setAdmin(updatedAdmin)
|
||||
setSecuritySetupOpen(false)
|
||||
}}
|
||||
onClose={() => setSecuritySetupOpen(false)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</AdminSessionProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function Admin2FAEnrollmentGate({
|
||||
function Admin2FASetupDialog({
|
||||
admin,
|
||||
onEnrolled,
|
||||
onLogout,
|
||||
onClose,
|
||||
}: {
|
||||
admin: AdminSessionUser
|
||||
onEnrolled: (admin: AdminSessionUser) => void
|
||||
onLogout: () => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
const { dict } = useAdminI18n()
|
||||
type SetupMethod = 'email' | 'authenticator'
|
||||
const [method, setMethod] = useState<SetupMethod | null>(null)
|
||||
const [secret, setSecret] = useState('')
|
||||
const [qrCode, setQrCode] = useState('')
|
||||
const [code, setCode] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [loadingSetup, setLoadingSetup] = useState(true)
|
||||
const [loadingSetup, setLoadingSetup] = useState(false)
|
||||
const [verifying, setVerifying] = useState(false)
|
||||
const [verifiedAdmin, setVerifiedAdmin] = useState<AdminSessionUser | null>(null)
|
||||
const [recoveryCodes, setRecoveryCodes] = useState<string[]>([])
|
||||
const setupStarted = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (setupStarted.current) return
|
||||
setupStarted.current = true
|
||||
|
||||
fetch(`${ADMIN_API_BASE}/admin/auth/2fa/setup`, {
|
||||
async function startSetup(nextMethod: SetupMethod) {
|
||||
setMethod(nextMethod)
|
||||
setCode('')
|
||||
setError(null)
|
||||
setLoadingSetup(true)
|
||||
const endpoint = nextMethod === 'email'
|
||||
? `${ADMIN_API_BASE}/admin/auth/2fa/email/setup`
|
||||
: `${ADMIN_API_BASE}/admin/auth/2fa/setup`
|
||||
try {
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
.then(async (response) => {
|
||||
const json = await response.json().catch(() => null)
|
||||
if (!response.ok) throw new Error(json?.message ?? 'Failed to start 2FA setup.')
|
||||
const data = json?.data ?? json
|
||||
setSecret(data?.secret ?? '')
|
||||
setQrCode(data?.qrCode ?? '')
|
||||
})
|
||||
.catch((err: any) => setError(err?.message ?? 'Failed to start 2FA setup.'))
|
||||
.finally(() => setLoadingSetup(false))
|
||||
}, [])
|
||||
const json = await response.json().catch(() => null)
|
||||
if (!response.ok) throw new Error(json?.message ?? 'Failed to start 2FA setup.')
|
||||
const data = json?.data ?? json
|
||||
setSecret(data?.secret ?? '')
|
||||
setQrCode(data?.qrCode ?? '')
|
||||
} catch (err: any) {
|
||||
setError(err?.message ?? 'Failed to start 2FA setup.')
|
||||
} finally {
|
||||
setLoadingSetup(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyCode(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
@@ -217,8 +233,11 @@ function Admin2FAEnrollmentGate({
|
||||
|
||||
setError(null)
|
||||
setVerifying(true)
|
||||
const endpoint = method === 'email'
|
||||
? `${ADMIN_API_BASE}/admin/auth/2fa/email/verify`
|
||||
: `${ADMIN_API_BASE}/admin/auth/2fa/verify`
|
||||
try {
|
||||
const response = await fetch(`${ADMIN_API_BASE}/admin/auth/2fa/verify`, {
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -237,23 +256,20 @@ function Admin2FAEnrollmentGate({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-[linear-gradient(180deg,#ffffff_0%,#f5f8ff_28%,#eef4ff_58%,#ffffff_100%)] p-6 text-stone-900 transition-colors dark:bg-[linear-gradient(180deg,#0a1128_0%,#0d1b38_35%,#07101e_100%)] dark:text-slate-100">
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/45 p-6 text-stone-900 backdrop-blur-sm dark:text-slate-100">
|
||||
<section className="w-full max-w-2xl rounded-3xl border border-stone-200/80 bg-white/90 p-8 shadow-xl backdrop-blur dark:border-blue-900 dark:bg-[#07101e]/90">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-orange-700 dark:text-orange-300">{dict.admin}</p>
|
||||
<h1 className="mt-2 text-2xl font-black text-blue-950 dark:text-stone-50">Set up admin 2FA</h1>
|
||||
<p className="mt-2 text-sm leading-6 text-stone-600 dark:text-slate-300">
|
||||
Admin 2FA enrollment is required before using privileged admin routes.
|
||||
</p>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-orange-700 dark:text-orange-300">Security</p>
|
||||
<h1 className="mt-2 text-2xl font-black text-blue-950 dark:text-stone-50">Enable 2FA</h1>
|
||||
<p className="mt-1 text-xs text-stone-500 dark:text-slate-400">{admin.email}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onLogout}
|
||||
onClick={onClose}
|
||||
className="rounded-xl border border-stone-200 px-4 py-2 text-sm font-semibold text-stone-600 transition hover:bg-stone-100 dark:border-blue-800 dark:text-slate-300 dark:hover:bg-[#162038]"
|
||||
>
|
||||
{dict.logout}
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -284,10 +300,33 @@ function Admin2FAEnrollmentGate({
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={verifyCode} className="mt-8 space-y-6">
|
||||
{loadingSetup ? (
|
||||
{!method ? (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => startSetup('email')}
|
||||
className="rounded-2xl border border-stone-200 bg-stone-50 p-4 text-left transition hover:border-orange-300 hover:bg-orange-50 dark:border-blue-900 dark:bg-[#0d1b38] dark:hover:border-orange-400/70 dark:hover:bg-[#162038]"
|
||||
>
|
||||
<span className="block text-sm font-semibold text-blue-950 dark:text-stone-100">Email code</span>
|
||||
<span className="mt-2 block text-sm text-stone-600 dark:text-slate-300">{admin.email}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => startSetup('authenticator')}
|
||||
className="rounded-2xl border border-stone-200 bg-stone-50 p-4 text-left transition hover:border-orange-300 hover:bg-orange-50 dark:border-blue-900 dark:bg-[#0d1b38] dark:hover:border-orange-400/70 dark:hover:bg-[#162038]"
|
||||
>
|
||||
<span className="block text-sm font-semibold text-blue-950 dark:text-stone-100">Authenticator app</span>
|
||||
<span className="mt-2 block text-sm text-stone-600 dark:text-slate-300">TOTP</span>
|
||||
</button>
|
||||
</div>
|
||||
) : loadingSetup ? (
|
||||
<div className="flex items-center gap-3 rounded-2xl border border-stone-200 bg-stone-50 p-4 text-sm text-stone-600 dark:border-blue-900 dark:bg-[#0d1b38] dark:text-slate-300">
|
||||
<div className="h-5 w-5 animate-spin rounded-full border-2 border-orange-500 border-t-transparent" />
|
||||
Preparing authenticator setup...
|
||||
Preparing setup...
|
||||
</div>
|
||||
) : method === 'email' ? (
|
||||
<div className="rounded-2xl border border-stone-200 bg-stone-50 p-4 text-sm text-stone-600 dark:border-blue-900 dark:bg-[#0d1b38] dark:text-slate-300">
|
||||
Enter the 6-digit code sent to {admin.email}.
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-5 md:grid-cols-[180px,1fr]">
|
||||
@@ -317,7 +356,7 @@ function Admin2FAEnrollmentGate({
|
||||
autoComplete="one-time-code"
|
||||
className="w-full rounded-2xl border border-stone-200 bg-white px-4 py-3 text-lg font-semibold tracking-[0.2em] text-stone-900 outline-none transition focus:ring-2 focus:ring-orange-500 dark:border-blue-800 dark:bg-blue-950/80 dark:text-stone-100"
|
||||
placeholder="000000"
|
||||
disabled={loadingSetup || verifying}
|
||||
disabled={!method || loadingSetup || verifying}
|
||||
/>
|
||||
</label>
|
||||
|
||||
@@ -329,11 +368,26 @@ function Admin2FAEnrollmentGate({
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loadingSetup || verifying || code.length !== 6}
|
||||
disabled={!method || loadingSetup || verifying || code.length !== 6}
|
||||
className="w-full rounded-full bg-orange-600 px-6 py-3 text-sm font-semibold text-white transition hover:bg-orange-700 disabled:cursor-not-allowed disabled:opacity-60 dark:bg-orange-500 dark:hover:bg-orange-400"
|
||||
>
|
||||
{verifying ? 'Verifying...' : 'Enable 2FA'}
|
||||
</button>
|
||||
{method ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMethod(null)
|
||||
setCode('')
|
||||
setError(null)
|
||||
setSecret('')
|
||||
setQrCode('')
|
||||
}}
|
||||
className="w-full rounded-full border border-stone-200 px-6 py-3 text-sm font-semibold text-stone-600 transition hover:bg-stone-100 dark:border-blue-800 dark:text-slate-300 dark:hover:bg-[#162038]"
|
||||
>
|
||||
Choose another method
|
||||
</button>
|
||||
) : null}
|
||||
</form>
|
||||
)}
|
||||
</section>
|
||||
|
||||
Reference in New Issue
Block a user