Compare commits

...

2 Commits

Author SHA1 Message Date
pluja
7e0d41cc7a Release 202505280851 2025-05-28 08:51:59 +00:00
pluja
70a097054b Release 202505271800 2025-05-27 18:00:08 +00:00
8 changed files with 185 additions and 46 deletions

View File

@@ -177,6 +177,12 @@ export default defineConfig({
url: true, url: true,
optional: false, optional: false,
}), }),
LOGS_UI_URL: envField.string({
context: 'server',
access: 'secret',
url: true,
optional: true,
}),
RELEASE_NUMBER: envField.number({ RELEASE_NUMBER: envField.number({
context: 'server', context: 'server',

View File

@@ -15,6 +15,8 @@ type EventTypeInfo<T extends string | null | undefined = string> = {
} }
icon: string icon: string
color: ComponentProps<typeof BadgeSmall>['color'] color: ComponentProps<typeof BadgeSmall>['color']
isSolved: boolean
showBanner: boolean
} }
export const { export const {
@@ -36,6 +38,8 @@ export const {
}, },
icon: 'ri:question-fill', icon: 'ri:question-fill',
color: 'gray', color: 'gray',
isSolved: false,
showBanner: false,
}), }),
[ [
{ {
@@ -46,8 +50,10 @@ export const {
classNames: { classNames: {
dot: 'bg-amber-900 text-amber-300 ring-amber-900/50', dot: 'bg-amber-900 text-amber-300 ring-amber-900/50',
}, },
icon: 'ri:error-warning-fill', icon: 'ri:alert-fill',
color: 'yellow', color: 'yellow',
isSolved: false,
showBanner: true,
}, },
{ {
id: 'WARNING_SOLVED', id: 'WARNING_SOLVED',
@@ -55,10 +61,12 @@ export const {
label: 'Warning Solved', label: 'Warning Solved',
description: 'A previously reported warning has been solved', description: 'A previously reported warning has been solved',
classNames: { classNames: {
dot: 'bg-green-900 text-green-300 ring-green-900/50', dot: 'bg-amber-900 text-amber-300 ring-amber-900/50',
}, },
icon: 'ri:check-fill', icon: 'ri:alert-fill',
color: 'green', color: 'green',
isSolved: true,
showBanner: false,
}, },
{ {
id: 'ALERT', id: 'ALERT',
@@ -68,8 +76,10 @@ export const {
classNames: { classNames: {
dot: 'bg-red-900 text-red-300 ring-red-900/50', dot: 'bg-red-900 text-red-300 ring-red-900/50',
}, },
icon: 'ri:alert-fill', icon: 'ri:spam-fill',
color: 'red', color: 'red',
isSolved: false,
showBanner: true,
}, },
{ {
id: 'ALERT_SOLVED', id: 'ALERT_SOLVED',
@@ -77,10 +87,12 @@ export const {
label: 'Alert Solved', label: 'Alert Solved',
description: 'A previously reported alert has been solved', description: 'A previously reported alert has been solved',
classNames: { classNames: {
dot: 'bg-green-900 text-green-300 ring-green-900/50', dot: 'bg-red-900 text-red-300 ring-red-900/50',
}, },
icon: 'ri:check-fill', icon: 'ri:spam-fill',
color: 'green', color: 'green',
isSolved: true,
showBanner: false,
}, },
{ {
id: 'INFO', id: 'INFO',
@@ -92,6 +104,8 @@ export const {
}, },
icon: 'ri:information-fill', icon: 'ri:information-fill',
color: 'sky', color: 'sky',
isSolved: false,
showBanner: false,
}, },
{ {
id: 'NORMAL', id: 'NORMAL',
@@ -103,6 +117,8 @@ export const {
}, },
icon: 'ri:notification-fill', icon: 'ri:notification-fill',
color: 'green', color: 'green',
isSolved: false,
showBanner: false,
}, },
{ {
id: 'UPDATE', id: 'UPDATE',
@@ -114,6 +130,8 @@ export const {
}, },
icon: 'ri:pencil-fill', icon: 'ri:pencil-fill',
color: 'sky', color: 'sky',
isSolved: false,
showBanner: false,
}, },
] as const satisfies EventTypeInfo<EventType>[] ] as const satisfies EventTypeInfo<EventType>[]
) )

View File

@@ -1 +1 @@
export const SUPPORT_EMAIL = 'support@kycnot.me' export const SUPPORT_EMAIL = 'contact@kycnot.me'

View File

@@ -1,6 +1,6 @@
--- ---
import { Icon } from 'astro-icon/components' import { Icon } from 'astro-icon/components'
import { DATABASE_UI_URL } from 'astro:env/server' import { DATABASE_UI_URL, LOGS_UI_URL } from 'astro:env/server'
import BaseLayout from '../../layouts/BaseLayout.astro' import BaseLayout from '../../layouts/BaseLayout.astro'
import { cn } from '../../lib/cn' import { cn } from '../../lib/cn'
@@ -81,6 +81,18 @@ const adminLinks: AdminLink[] = [
base: 'text-gray-300', base: 'text-gray-300',
}, },
}, },
...(LOGS_UI_URL
? [
{
icon: 'ri:menu-search-line',
title: 'Logs',
href: LOGS_UI_URL,
classNames: {
base: 'text-cyan-300',
},
},
]
: []),
] ]
--- ---
@@ -93,25 +105,27 @@ const adminLinks: AdminLink[] = [
<nav> <nav>
<ol class="grid grid-cols-[repeat(auto-fill,minmax(calc(var(--spacing)*40),1fr))] gap-4"> <ol class="grid grid-cols-[repeat(auto-fill,minmax(calc(var(--spacing)*40),1fr))] gap-4">
{ {
adminLinks.map((link) => ( adminLinks
<li .filter((link) => link.href)
class={cn( .map((link) => (
'group ease-out-back transition-transform duration-250 hover:-translate-y-0.5 hover:scale-105', <li
link.classNames.base class={cn(
)} 'group ease-out-back transition-transform duration-250 hover:-translate-y-0.5 hover:scale-105',
> link.classNames.base
<a )}
href={link.href}
class="flex min-h-24 flex-col items-center justify-around rounded-lg border border-current/4 bg-current/3 py-3 text-center transition-all duration-250 group-hover:border-current/10 group-hover:bg-current/10 group-hover:shadow-xl"
> >
<Icon <a
name={link.icon} href={link.href}
class="size-8 text-current opacity-50 transition-opacity duration-250 group-hover:opacity-100" class="flex min-h-24 flex-col items-center justify-around rounded-lg border border-current/4 bg-current/3 py-3 text-center transition-all duration-250 group-hover:border-current/10 group-hover:bg-current/10 group-hover:shadow-xl"
/> >
<span class="font-title text-xl leading-none font-semibold text-current">{link.title}</span> <Icon
</a> name={link.icon}
</li> class="size-8 text-current opacity-50 transition-opacity duration-250 group-hover:opacity-100"
)) />
<span class="font-title text-xl leading-none font-semibold text-current">{link.title}</span>
</a>
</li>
))
} }
</ol> </ol>
</nav> </nav>

View File

@@ -685,9 +685,14 @@ if (!service) return Astro.rewrite('/404')
label="Started At" label="Started At"
name="startedAt" name="startedAt"
inputProps={{ inputProps={{
type: 'date', type: 'datetime-local',
required: true, required: true,
value: new Date(event.startedAt).toISOString().split('T')[0], value: new Date(
new Date(event.startedAt).getTime() -
new Date(event.startedAt).getTimezoneOffset() * 60000
)
.toISOString()
.slice(0, 16),
}} }}
error={eventUpdateInputErrors.startedAt} error={eventUpdateInputErrors.startedAt}
/> />
@@ -696,7 +701,15 @@ if (!service) return Astro.rewrite('/404')
label="Ended At" label="Ended At"
name="endedAt" name="endedAt"
inputProps={{ inputProps={{
value: event.endedAt ? new Date(event.endedAt).toISOString().split('T')[0] : '', type: 'datetime-local',
value: event.endedAt
? new Date(
new Date(event.endedAt).getTime() -
new Date(event.endedAt).getTimezoneOffset() * 60000
)
.toISOString()
.slice(0, 16)
: '',
}} }}
error={eventUpdateInputErrors.endedAt} error={eventUpdateInputErrors.endedAt}
description="- Empty: Event is ongoing.\n- Filled: Event with specific end date.\n- Same as start date: One-time event." description="- Empty: Event is ongoing.\n- Filled: Event with specific end date.\n- Same as start date: One-time event."
@@ -756,9 +769,11 @@ if (!service) return Astro.rewrite('/404')
label="Started At" label="Started At"
name="startedAt" name="startedAt"
inputProps={{ inputProps={{
type: 'date', type: 'datetime-local',
required: true, required: true,
value: new Date().toISOString().split('T')[0], value: new Date(Date.now() - new Date().getTimezoneOffset() * 60000)
.toISOString()
.slice(0, 16),
}} }}
error={eventInputErrors.startedAt} error={eventInputErrors.startedAt}
/> />
@@ -767,7 +782,10 @@ if (!service) return Astro.rewrite('/404')
label="Ended At" label="Ended At"
name="endedAt" name="endedAt"
inputProps={{ inputProps={{
value: new Date().toISOString().split('T')[0], type: 'datetime-local',
value: new Date(Date.now() - new Date().getTimezoneOffset() * 60000)
.toISOString()
.slice(0, 16),
}} }}
error={eventInputErrors.endedAt} error={eventInputErrors.endedAt}
description="- Empty: Event is ongoing.\n- Filled: Event with specific end date.\n- Same as start date: One-time event." description="- Empty: Event is ongoing.\n- Filled: Event with specific end date.\n- Same as start date: One-time event."

View File

@@ -19,7 +19,7 @@ import type { Prisma } from '@prisma/client'
const { data: filters } = zodParseQueryParamsStoringErrors( const { data: filters } = zodParseQueryParamsStoringErrors(
{ {
'sort-by': z.enum(['name', 'role', 'createdAt', 'karma']).default('createdAt'), 'sort-by': z.enum(['name', 'role', 'lastLoginAt', 'karma', 'createdAt']).default('createdAt'),
'sort-order': z.enum(['asc', 'desc']).default('desc'), 'sort-order': z.enum(['asc', 'desc']).default('desc'),
search: z.string().optional(), search: z.string().optional(),
role: z.enum(['user', 'admin', 'moderator', 'verified', 'spammer']).optional(), role: z.enum(['user', 'admin', 'moderator', 'verified', 'spammer']).optional(),
@@ -29,7 +29,10 @@ const { data: filters } = zodParseQueryParamsStoringErrors(
// Set up Prisma orderBy with correct typing // Set up Prisma orderBy with correct typing
const prismaOrderBy = const prismaOrderBy =
filters['sort-by'] === 'name' || filters['sort-by'] === 'createdAt' || filters['sort-by'] === 'karma' filters['sort-by'] === 'name' ||
filters['sort-by'] === 'createdAt' ||
filters['sort-by'] === 'lastLoginAt' ||
filters['sort-by'] === 'karma'
? { ? {
[filters['sort-by'] === 'karma' ? 'totalKarma' : filters['sort-by']]: [filters['sort-by'] === 'karma' ? 'totalKarma' : filters['sort-by']]:
filters['sort-order'] === 'asc' ? 'asc' : 'desc', filters['sort-order'] === 'asc' ? 'asc' : 'desc',
@@ -86,6 +89,7 @@ const dbUsers = await prisma.user.findMany({
totalKarma: true, totalKarma: true,
createdAt: true, createdAt: true,
updatedAt: true, updatedAt: true,
lastLoginAt: true,
internalNotes: { internalNotes: {
select: { select: {
id: true, id: true,
@@ -218,16 +222,29 @@ const makeSortUrl = (sortBy: NonNullable<(typeof filters)['sort-by']>) => {
<th <th
class="w-[15%] px-4 py-3 text-center text-xs font-medium tracking-wider text-zinc-400 uppercase" class="w-[15%] px-4 py-3 text-center text-xs font-medium tracking-wider text-zinc-400 uppercase"
> >
<a <div class="flex flex-wrap items-center justify-center gap-1">
href={makeSortUrl('createdAt')} <a
class="flex items-center justify-center hover:text-zinc-200" href={makeSortUrl('lastLoginAt')}
> class="flex items-center justify-center hover:text-zinc-200"
Joined <SortArrowIcon >
active={filters['sort-by'] === 'createdAt'} Login <SortArrowIcon
sortOrder={filters['sort-order']} active={filters['sort-by'] === 'lastLoginAt'}
/> sortOrder={filters['sort-order']}
</a> />
</a>
<span class="text-zinc-600">/</span>
<a
href={makeSortUrl('createdAt')}
class="flex items-center justify-center hover:text-zinc-200"
>
Joined <SortArrowIcon
active={filters['sort-by'] === 'createdAt'}
sortOrder={filters['sort-order']}
/>
</a>
</div>
</th> </th>
<th <th
class="w-[15%] px-4 py-3 text-center text-xs font-medium tracking-wider text-zinc-400 uppercase" class="w-[15%] px-4 py-3 text-center text-xs font-medium tracking-wider text-zinc-400 uppercase"
> >
@@ -305,8 +322,24 @@ const makeSortUrl = (sortBy: NonNullable<(typeof filters)['sort-by']>) => {
{user.totalKarma} {user.totalKarma}
</span> </span>
</td> </td>
<td class="px-4 py-3 text-center text-sm text-zinc-400"> <td class="px-4 py-3 text-center text-sm">
<TimeFormatted date={user.createdAt} hourPrecision hoursShort prefix={false} /> <div class="flex flex-wrap items-center justify-center gap-1 text-center">
<TimeFormatted
class="text-zinc-300"
date={user.lastLoginAt}
hourPrecision
hoursShort
prefix={false}
/>
<span class="text-zinc-600">/</span>
<TimeFormatted
class="text-zinc-400"
date={user.createdAt}
hourPrecision
hoursShort
prefix={false}
/>
</div>
</td> </td>
<td class="px-4 py-3"> <td class="px-4 py-3">
<div class="flex justify-center gap-3"> <div class="flex justify-center gap-3">

View File

@@ -1,5 +1,5 @@
--- ---
import { VerificationStepStatus } from '@prisma/client' import { VerificationStepStatus, EventType } from '@prisma/client'
import { Icon } from 'astro-icon/components' import { Icon } from 'astro-icon/components'
import { Markdown } from 'astro-remote' import { Markdown } from 'astro-remote'
import { Schema } from 'astro-seo-schema' import { Schema } from 'astro-seo-schema'
@@ -380,6 +380,10 @@ const ogImageTemplateData = {
} satisfies OgImageAllTemplatesWithProps } satisfies OgImageAllTemplatesWithProps
const serviceVisibilityInfo = getServiceVisibilityInfo(service.serviceVisibility) const serviceVisibilityInfo = getServiceVisibilityInfo(service.serviceVisibility)
const activeAlertOrWarningEvents = service.events.filter(
(event) => getEventTypeInfo(event.type).showBanner && (event.endedAt === null || event.endedAt >= now)
)
--- ---
<BaseLayout <BaseLayout
@@ -480,6 +484,32 @@ const serviceVisibilityInfo = getServiceVisibilityInfo(service.serviceVisibility
}), }),
]} ]}
> >
{
activeAlertOrWarningEvents.length > 0 && (
<a
href="#events"
class={cn(
'mb-4 block rounded-md px-3 py-2 text-sm font-medium',
activeAlertOrWarningEvents.some((e) => e.type === EventType.ALERT)
? 'bg-red-900/50 text-red-300 hover:bg-red-800/60'
: 'bg-yellow-900/50 text-yellow-300 hover:bg-yellow-800/60'
)}
>
<Icon
name={
activeAlertOrWarningEvents.some((e) => e.type === EventType.ALERT)
? 'ri:alert-fill'
: 'ri:alarm-warning-fill'
}
class="me-1.5 inline-block size-4 align-[-0.15em]"
/>
{activeAlertOrWarningEvents.some((e) => e.type === EventType.ALERT)
? 'There is an active alert for this service. Click to see details.'
: 'There is an active warning for this service. Click to see details.'}
</a>
)
}
{ {
(serviceVisibilityInfo.value === 'UNLISTED' || serviceVisibilityInfo.value === 'ARCHIVED') && ( (serviceVisibilityInfo.value === 'UNLISTED' || serviceVisibilityInfo.value === 'ARCHIVED') && (
<div class={cn('mb-4 rounded-md bg-yellow-900/50 px-3 py-2 text-sm text-yellow-400')}> <div class={cn('mb-4 rounded-md bg-yellow-900/50 px-3 py-2 text-sm text-yellow-400')}>
@@ -1182,6 +1212,7 @@ const serviceVisibilityInfo = getServiceVisibilityInfo(service.serviceVisibility
<div class="mt-3 max-w-md pe-8"> <div class="mt-3 max-w-md pe-8">
<h3 class="font-title text-lg leading-tight font-semibold text-pretty text-white"> <h3 class="font-title text-lg leading-tight font-semibold text-pretty text-white">
{typeInfo.isSolved && <BadgeSmall text="Solved" icon="ri:check-line" color="green" />}
{event.title} {event.title}
</h3> </h3>

View File

@@ -48,6 +48,7 @@ const user = await Astro.locals.banners.try('user', async () => {
verifiedLink: true, verifiedLink: true,
totalKarma: true, totalKarma: true,
createdAt: true, createdAt: true,
lastLoginAt: true,
_count: { _count: {
select: { select: {
comments: true, comments: true,
@@ -469,6 +470,24 @@ const isCurrentUser = !!Astro.locals.user && user.id === Astro.locals.user.id
</div> </div>
</li> </li>
<AdminOnly>
<li class="flex items-start">
<span class="text-day-500 mt-0.5 mr-2"><Icon name="ri:calendar-line" class="size-4" /></span>
<div>
<p class="text-day-500 text-xs">Last login</p>
<p class="text-day-300">
{
formatDateShort(user.lastLoginAt, {
prefix: false,
hourPrecision: true,
caseType: 'sentence',
})
}
</p>
</div>
</li>
</AdminOnly>
{ {
user.verifiedLink && ( user.verifiedLink && (
<li class="flex items-start"> <li class="flex items-start">