fix billing and 2fa admin
Build & Push / Pipeline Tests (push) Failing after 1m58s
Build & Push / Build & Push Docker Image (push) Has been skipped
Test / Type Check (all packages) (push) Successful in 58s
Test / API Unit Tests (push) Successful in 1m9s
Test / Homepage Unit Tests (push) Successful in 46s
Test / Carplace Unit Tests (push) Successful in 43s
Test / Admin Unit Tests (push) Successful in 41s
Test / Dashboard Unit Tests (push) Successful in 45s
Test / API Integration Tests (push) Failing after 1m9s
Build & Push / Pipeline Tests (push) Failing after 1m58s
Build & Push / Build & Push Docker Image (push) Has been skipped
Test / Type Check (all packages) (push) Successful in 58s
Test / API Unit Tests (push) Successful in 1m9s
Test / Homepage Unit Tests (push) Successful in 46s
Test / Carplace Unit Tests (push) Successful in 43s
Test / Admin Unit Tests (push) Successful in 41s
Test / Dashboard Unit Tests (push) Successful in 45s
Test / API Integration Tests (push) Failing after 1m9s
This commit is contained in:
@@ -9,6 +9,7 @@ interface AdminUser {
|
||||
lastName: string
|
||||
email: string
|
||||
role: string
|
||||
preferredLocale: 'ar' | 'en' | 'fr'
|
||||
isActive: boolean
|
||||
createdAt: string
|
||||
permissions?: { id: string; resource: string; actions: string[] }[]
|
||||
@@ -21,6 +22,7 @@ const EMPTY_FORM = {
|
||||
email: '',
|
||||
password: '',
|
||||
role: 'SUPPORT',
|
||||
preferredLocale: 'en',
|
||||
isActive: true,
|
||||
}
|
||||
|
||||
@@ -68,6 +70,7 @@ export default function AdminUsersPage() {
|
||||
email: admin.email,
|
||||
password: '',
|
||||
role: admin.role,
|
||||
preferredLocale: admin.preferredLocale,
|
||||
isActive: admin.isActive,
|
||||
})
|
||||
setError(null)
|
||||
@@ -92,6 +95,7 @@ export default function AdminUsersPage() {
|
||||
lastName: form.lastName,
|
||||
email: form.email,
|
||||
role: form.role,
|
||||
preferredLocale: form.preferredLocale,
|
||||
isActive: form.isActive,
|
||||
...(form.password ? { password: form.password } : {}),
|
||||
}
|
||||
@@ -273,6 +277,18 @@ export default function AdminUsersPage() {
|
||||
{ROLES.map((r) => <option key={r} value={r}>{r}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-zinc-400 mb-1">Notification language</label>
|
||||
<select
|
||||
className="w-full px-3 py-2 rounded-xl bg-zinc-800 border border-zinc-700 text-zinc-100 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500"
|
||||
value={form.preferredLocale}
|
||||
onChange={(e) => setForm({ ...form, preferredLocale: e.target.value as 'ar' | 'en' | 'fr' })}
|
||||
>
|
||||
<option value="en">English</option>
|
||||
<option value="fr">Français</option>
|
||||
<option value="ar">العربية</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-zinc-400 mb-1">Status</label>
|
||||
<select
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -80,7 +80,6 @@ interface CompanyDetail {
|
||||
terms: string
|
||||
fuelPolicyType: string
|
||||
lateFeePerHour: number | null
|
||||
taxRate: number | null
|
||||
signatureRequired: boolean
|
||||
showTax: boolean
|
||||
} | null
|
||||
@@ -180,7 +179,6 @@ interface FormState {
|
||||
terms: string
|
||||
fuelPolicyType: string
|
||||
lateFeePerHour: string
|
||||
taxRate: string
|
||||
signatureRequired: boolean
|
||||
showTax: boolean
|
||||
}
|
||||
@@ -297,7 +295,6 @@ function createFormState(company: CompanyDetail): FormState {
|
||||
terms: company.contractSettings?.terms ?? '',
|
||||
fuelPolicyType: company.contractSettings?.fuelPolicyType ?? 'FULL_TO_FULL',
|
||||
lateFeePerHour: company.contractSettings?.lateFeePerHour?.toString() ?? '',
|
||||
taxRate: company.contractSettings?.taxRate?.toString() ?? '',
|
||||
signatureRequired: company.contractSettings?.signatureRequired ?? true,
|
||||
showTax: company.contractSettings?.showTax ?? false,
|
||||
},
|
||||
@@ -441,7 +438,6 @@ export default function AdminCompanyDetailPage() {
|
||||
terms: form.contractSettings.terms,
|
||||
fuelPolicyType: form.contractSettings.fuelPolicyType,
|
||||
lateFeePerHour: form.contractSettings.lateFeePerHour ? Number(form.contractSettings.lateFeePerHour) : null,
|
||||
taxRate: form.contractSettings.taxRate ? Number(form.contractSettings.taxRate) : null,
|
||||
signatureRequired: form.contractSettings.signatureRequired,
|
||||
showTax: form.contractSettings.showTax,
|
||||
},
|
||||
@@ -915,10 +911,6 @@ export default function AdminCompanyDetailPage() {
|
||||
<span className={LABEL_CLASS}>Late fee per hour</span>
|
||||
<input className={INPUT_CLASS} type="number" value={form.contractSettings.lateFeePerHour} onChange={(e) => updateSection('contractSettings', { lateFeePerHour: e.target.value })} />
|
||||
</label>
|
||||
<label>
|
||||
<span className={LABEL_CLASS}>Tax rate</span>
|
||||
<input className={INPUT_CLASS} type="number" step="0.01" value={form.contractSettings.taxRate} onChange={(e) => updateSection('contractSettings', { taxRate: e.target.value })} />
|
||||
</label>
|
||||
<label className="flex items-center gap-3 pt-7 text-sm text-zinc-300">
|
||||
<input type="checkbox" checked={form.contractSettings.signatureRequired} onChange={(e) => updateSection('contractSettings', { signatureRequired: e.target.checked })} />
|
||||
Signature required
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useRef, useState, type FormEvent } from 'react'
|
||||
import {
|
||||
AdminLanguageSwitcher,
|
||||
AdminThemeSwitcher,
|
||||
@@ -40,6 +40,7 @@ export default function AdminDashboardLayout({ children }: { children: React.Rea
|
||||
const pathname = usePathname()
|
||||
const [ready, setReady] = useState(false)
|
||||
const [admin, setAdmin] = useState<AdminSessionUser | null>(null)
|
||||
const [unreadNotifications, setUnreadNotifications] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
@@ -58,6 +59,14 @@ 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(() => {})
|
||||
}
|
||||
} else {
|
||||
window.location.replace(buildUnifiedLoginUrl(pathname))
|
||||
}
|
||||
@@ -84,6 +93,16 @@ 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">
|
||||
@@ -107,6 +126,9 @@ export default function AdminDashboardLayout({ children }: { children: React.Rea
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d={link.icon} />
|
||||
</svg>
|
||||
{dict.nav[link.key]}
|
||||
{link.key === 'notifications' && unreadNotifications > 0 ? (
|
||||
<span className="ms-auto rounded-full bg-red-500 px-1.5 py-0.5 text-[10px] font-bold text-white">{unreadNotifications > 99 ? '99+' : unreadNotifications}</span>
|
||||
) : null}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
@@ -132,3 +154,178 @@ export default function AdminDashboardLayout({ children }: { children: React.Rea
|
||||
</AdminSessionProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function Admin2FAEnrollmentGate({
|
||||
admin,
|
||||
onEnrolled,
|
||||
onLogout,
|
||||
}: {
|
||||
admin: AdminSessionUser
|
||||
onEnrolled: (admin: AdminSessionUser) => void
|
||||
onLogout: () => void
|
||||
}) {
|
||||
const { dict } = useAdminI18n()
|
||||
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 [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`, {
|
||||
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))
|
||||
}, [])
|
||||
|
||||
async function verifyCode(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
const normalizedCode = code.trim()
|
||||
if (!/^\d{6}$/.test(normalizedCode)) {
|
||||
setError('Enter the 6-digit code from your authenticator app.')
|
||||
return
|
||||
}
|
||||
|
||||
setError(null)
|
||||
setVerifying(true)
|
||||
try {
|
||||
const response = await fetch(`${ADMIN_API_BASE}/admin/auth/2fa/verify`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ code: normalizedCode }),
|
||||
})
|
||||
const json = await response.json().catch(() => null)
|
||||
if (!response.ok) throw new Error(json?.message ?? 'Invalid 2FA code.')
|
||||
const data = json?.data ?? json
|
||||
setVerifiedAdmin((data?.admin ?? { ...admin, totpEnabled: true }) as AdminSessionUser)
|
||||
setRecoveryCodes(Array.isArray(data?.recoveryCodes) ? data.recoveryCodes : [])
|
||||
} catch (err: any) {
|
||||
setError(err?.message ?? 'Invalid 2FA code.')
|
||||
} finally {
|
||||
setVerifying(false)
|
||||
}
|
||||
}
|
||||
|
||||
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">
|
||||
<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="mt-1 text-xs text-stone-500 dark:text-slate-400">{admin.email}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onLogout}
|
||||
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}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{verifiedAdmin ? (
|
||||
<div className="mt-8 space-y-5">
|
||||
<div className="rounded-2xl border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-800 dark:border-emerald-900/50 dark:bg-emerald-950/30 dark:text-emerald-200">
|
||||
2FA is enabled. Save your recovery codes before continuing.
|
||||
</div>
|
||||
{recoveryCodes.length > 0 ? (
|
||||
<div className="rounded-2xl border border-stone-200 bg-stone-50 p-4 dark:border-blue-900 dark:bg-[#0d1b38]">
|
||||
<p className="text-sm font-semibold text-blue-950 dark:text-stone-100">Recovery codes</p>
|
||||
<div className="mt-3 grid gap-2 sm:grid-cols-2">
|
||||
{recoveryCodes.map((recoveryCode) => (
|
||||
<code key={recoveryCode} className="rounded-lg bg-white px-3 py-2 text-sm text-stone-800 dark:bg-[#07101e] dark:text-slate-200">
|
||||
{recoveryCode}
|
||||
</code>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onEnrolled(verifiedAdmin)}
|
||||
className="w-full rounded-full bg-orange-600 px-6 py-3 text-sm font-semibold text-white transition hover:bg-orange-700 dark:bg-orange-500 dark:hover:bg-orange-400"
|
||||
>
|
||||
Continue to admin dashboard
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={verifyCode} className="mt-8 space-y-6">
|
||||
{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...
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-5 md:grid-cols-[180px,1fr]">
|
||||
<div className="flex h-44 items-center justify-center rounded-2xl border border-stone-200 bg-white p-3 dark:border-blue-900 dark:bg-white">
|
||||
{qrCode ? <img src={qrCode} alt="Admin 2FA QR code" className="h-full w-full object-contain" /> : <span className="text-sm text-stone-500">No QR code</span>}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-blue-950 dark:text-stone-100">Authenticator app</p>
|
||||
<p className="mt-2 text-sm leading-6 text-stone-600 dark:text-slate-300">
|
||||
Scan the QR code with your authenticator app, or enter the setup key manually.
|
||||
</p>
|
||||
{secret ? (
|
||||
<code className="mt-3 block break-all rounded-xl border border-stone-200 bg-stone-50 px-3 py-2 text-sm text-stone-800 dark:border-blue-900 dark:bg-[#0d1b38] dark:text-slate-200">
|
||||
{secret}
|
||||
</code>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-sm font-semibold text-blue-950 dark:text-stone-100">6-digit code</span>
|
||||
<input
|
||||
value={code}
|
||||
onChange={(event) => setCode(event.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||
inputMode="numeric"
|
||||
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}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{error ? (
|
||||
<div className="rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 dark:border-red-900/60 dark:bg-red-950/40 dark:text-red-300">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={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>
|
||||
</form>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,14 @@ import {
|
||||
formatNotificationDate,
|
||||
type NotificationsPageResult,
|
||||
} from '@/lib/adminNotifications'
|
||||
import { ADMIN_API_BASE } from '@/lib/api'
|
||||
|
||||
interface AdminInboxItem {
|
||||
id: string
|
||||
readAt: string | null
|
||||
createdAt: string
|
||||
notificationEvent: { title: string; body: string; type: string; locale: string; data?: Record<string, unknown> }
|
||||
}
|
||||
|
||||
const CHANNELS = ['EMAIL', 'SMS', 'WHATSAPP', 'IN_APP', 'PUSH']
|
||||
const STATUSES = ['PENDING', 'QUEUED', 'SENT', 'DELIVERED', 'FAILED', 'SKIPPED', 'DEAD_LETTER', 'READ']
|
||||
@@ -49,6 +57,14 @@ export default function AdminNotificationsPage() {
|
||||
const [filterStatus, setFilterStatus] = useState('')
|
||||
const [filterCompany, setFilterCompany] = useState('')
|
||||
const [page, setPage] = useState(1)
|
||||
const [inbox, setInbox] = useState<{ data: AdminInboxItem[]; unread: number }>({ data: [], unread: 0 })
|
||||
|
||||
function loadInbox() {
|
||||
fetch(`${ADMIN_API_BASE}/admin/notifications/me`, { credentials: 'include', cache: 'no-store' })
|
||||
.then((response) => response.ok ? response.json() : Promise.reject(new Error('Failed to load personal inbox')))
|
||||
.then((json) => setInbox(json.data))
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
function load(p: number, signal?: AbortSignal) {
|
||||
setLoading(true)
|
||||
@@ -73,6 +89,7 @@ export default function AdminNotificationsPage() {
|
||||
const controller = new AbortController()
|
||||
setPage(1)
|
||||
load(1, controller.signal)
|
||||
loadInbox()
|
||||
return () => controller.abort()
|
||||
}, [filterChannel, filterStatus, filterCompany])
|
||||
|
||||
@@ -83,6 +100,11 @@ export default function AdminNotificationsPage() {
|
||||
|
||||
const totalPages = result ? (result.totalPages ?? Math.ceil(result.total / result.pageSize)) : 0
|
||||
|
||||
async function markRead(recipientId: string) {
|
||||
const response = await fetch(`${ADMIN_API_BASE}/admin/notifications/me/${recipientId}/read`, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: '{}' })
|
||||
if (response.ok) loadInbox()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="shell py-8 space-y-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
@@ -100,6 +122,27 @@ export default function AdminNotificationsPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<section className="panel p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-orange-400">My inbox</p>
|
||||
<h2 className="mt-1 text-lg font-semibold text-zinc-100">Assigned operational notices</h2>
|
||||
</div>
|
||||
<span className="rounded-full bg-red-500/15 px-3 py-1 text-xs font-semibold text-red-300">{inbox.unread} unread</span>
|
||||
</div>
|
||||
{inbox.data.length === 0 ? <p className="mt-4 text-sm text-zinc-500">No assigned notices.</p> : (
|
||||
<div className="mt-4 grid gap-3 lg:grid-cols-2">
|
||||
{inbox.data.map((item) => (
|
||||
<button key={item.id} type="button" onClick={() => markRead(item.id)} className={`rounded-xl border p-4 text-left ${item.readAt ? 'border-zinc-800 bg-zinc-950/50' : 'border-orange-500/40 bg-orange-500/5'}`}>
|
||||
<p className="text-xs uppercase tracking-wide text-zinc-500">{item.notificationEvent.type.replaceAll('_', ' ')} · {item.notificationEvent.locale}</p>
|
||||
<p className="mt-2 font-semibold text-zinc-100">{item.notificationEvent.title}</p>
|
||||
<p className="mt-1 text-sm text-zinc-400">{item.notificationEvent.body}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<select
|
||||
|
||||
@@ -154,6 +154,10 @@ html.dark .panel {
|
||||
color: rgb(28 25 23);
|
||||
}
|
||||
|
||||
.light .text-zinc-50 {
|
||||
color: rgb(28 25 23);
|
||||
}
|
||||
|
||||
.light .text-zinc-500 {
|
||||
color: rgb(120 113 108);
|
||||
}
|
||||
@@ -171,4 +175,30 @@ html.dark .panel {
|
||||
.light .hover\:text-zinc-200:hover {
|
||||
color: rgb(28 25 23);
|
||||
}
|
||||
|
||||
.light .text-amber-100,
|
||||
.light .text-amber-100\/80,
|
||||
.light .text-amber-200,
|
||||
.light .text-amber-300 {
|
||||
color: rgb(146 64 14);
|
||||
}
|
||||
|
||||
.light .text-emerald-300 {
|
||||
color: rgb(4 120 87);
|
||||
}
|
||||
|
||||
.light .text-rose-200,
|
||||
.light .text-rose-300,
|
||||
.light .text-red-400 {
|
||||
color: rgb(190 18 60);
|
||||
}
|
||||
|
||||
.light .text-sky-300 {
|
||||
color: rgb(3 105 161);
|
||||
}
|
||||
|
||||
.light .text-red-200,
|
||||
.light .text-red-300 {
|
||||
color: rgb(185 28 28);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user