From 250b128632db57f9cbf604cf920e9e091aa7d0a2 Mon Sep 17 00:00:00 2001 From: krakenhavoc Date: Tue, 10 Mar 2026 20:08:04 +0000 Subject: [PATCH 1/2] feat: autorenewal notifications --- ...772900000000-AddAutoRenewalConfirmation.ts | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 backend/src/migrations/1772900000000-AddAutoRenewalConfirmation.ts diff --git a/backend/src/migrations/1772900000000-AddAutoRenewalConfirmation.ts b/backend/src/migrations/1772900000000-AddAutoRenewalConfirmation.ts new file mode 100644 index 0000000..a2aac6b --- /dev/null +++ b/backend/src/migrations/1772900000000-AddAutoRenewalConfirmation.ts @@ -0,0 +1,21 @@ +import type { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddAutoRenewalConfirmation1772900000000 + implements MigrationInterface +{ + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "user" + ADD COLUMN IF NOT EXISTS "autoRenewalConfirmedAt" TIMESTAMP DEFAULT NOW(), + ADD COLUMN IF NOT EXISTS "autoRenewalReminderSentAt" TIMESTAMP DEFAULT NULL + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "user" + DROP COLUMN IF EXISTS "autoRenewalConfirmedAt", + DROP COLUMN IF EXISTS "autoRenewalReminderSentAt" + `); + } +} From 60c7ee1905910c7d0d4c365e7b1c046228d404fc Mon Sep 17 00:00:00 2001 From: krakenhavoc Date: Tue, 10 Mar 2026 20:08:12 +0000 Subject: [PATCH 2/2] feat: autorenewal notifications --- backend/src/auth/auth.controller.ts | 11 ++++ backend/src/auth/auth.service.ts | 14 +++++ .../tls/services/cert-monitor.service.spec.ts | 28 +++++++++ .../tls/services/cert-monitor.service.ts | 44 ++++++++++++- backend/src/certs/tls/tls.module.ts | 3 +- .../services/domain-monitor.service.spec.ts | 2 + backend/src/notifications/email.service.ts | 19 ++++++ backend/src/notifications/templates/index.ts | 22 +++++++ backend/src/users/entities/user.entity.ts | 6 ++ frontend/src/pages/Overview.tsx | 62 ++++++++++++++++++- shared/src/constants/routes.ts | 1 + shared/src/types/user.ts | 2 + 12 files changed, 211 insertions(+), 3 deletions(-) diff --git a/backend/src/auth/auth.controller.ts b/backend/src/auth/auth.controller.ts index a5218b1..92fe760 100644 --- a/backend/src/auth/auth.controller.ts +++ b/backend/src/auth/auth.controller.ts @@ -117,6 +117,17 @@ export class AuthController { ); } + @Post('confirm-auto-renewal') + @UseGuards(JwtOrApiKeyGuard) + @ApiBearerAuth() + @ApiOperation({ summary: 'Confirm intent to keep auto-renewal active (free tier)' }) + @ApiResponse({ status: 201, description: 'Auto-renewal confirmation timestamp reset' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @RateLimitCategoryDecorator(RateLimitCategory.AUTHENTICATED_WRITE) + confirmAutoRenewal(@Req() req: RequestWithUser) { + return this.authService.confirmAutoRenewal(req.user.userId); + } + @Delete('api-keys/:id') @UseGuards(JwtOrApiKeyGuard) @ApiBearerAuth() diff --git a/backend/src/auth/auth.service.ts b/backend/src/auth/auth.service.ts index 503b000..0c3ce39 100644 --- a/backend/src/auth/auth.service.ts +++ b/backend/src/auth/auth.service.ts @@ -279,6 +279,8 @@ export class AuthService { notificationPreferences: user.notificationPreferences, createdAt: user.createdAt.toISOString(), plan, + autoRenewalConfirmedAt: + user.autoRenewalConfirmedAt?.toISOString() ?? null, resourceCounts: { domains: domainCount, certificates: certCount, @@ -311,6 +313,17 @@ export class AuthService { return this.getFullProfile(userId); } + async confirmAutoRenewal(userId: string): Promise<{ confirmedAt: string }> { + const user = await this.userRepo.findOneBy({ id: userId }); + if (!user) { + throw new NotFoundException('User not found'); + } + user.autoRenewalConfirmedAt = new Date(); + user.autoRenewalReminderSentAt = null; + await this.userRepo.save(user); + return { confirmedAt: user.autoRenewalConfirmedAt.toISOString() }; + } + private async ensureUserExists(authUser: { sub: string; preferred_username: string; @@ -324,6 +337,7 @@ export class AuthService { username: authUser.preferred_username, email: authUser.email, groups: authUser.groups || [], + autoRenewalConfirmedAt: new Date(), }); await this.userRepo.save(user); } diff --git a/backend/src/certs/tls/services/cert-monitor.service.spec.ts b/backend/src/certs/tls/services/cert-monitor.service.spec.ts index 7a2b3c7..09cde60 100644 --- a/backend/src/certs/tls/services/cert-monitor.service.spec.ts +++ b/backend/src/certs/tls/services/cert-monitor.service.spec.ts @@ -3,6 +3,7 @@ import { getRepositoryToken } from '@nestjs/typeorm'; import { CertMonitorService } from './cert-monitor.service'; import { TlsService } from '../tls.service'; import { TlsCrt } from '../entities/tls-crt.entity'; +import { User } from '../../../users/entities/user.entity'; import { CertStatus } from '@krakenkey/shared'; import { MetricsService } from '../../../metrics/metrics.service'; import { EmailService } from '../../../notifications/email.service'; @@ -11,11 +12,28 @@ import { BillingService } from '../../../billing/billing.service'; describe('CertMonitorService', () => { let service: CertMonitorService; let mockRepository: Record; + let mockUserRepository: Record; let mockTlsService: Record; + const mockUser: User = { + id: 'user-123', + username: 'testuser', + email: 'test@example.com', + groups: [], + displayName: null, + notificationPreferences: {}, + createdAt: new Date(), + autoRenewalConfirmedAt: new Date(), // freshly confirmed — not lapsed + autoRenewalReminderSentAt: null, + apiKeys: [], + domains: [], + tlsCrts: [], + }; + const expiringCert = { id: 1, userId: 'user-123', + user: mockUser, status: 'issued', autoRenew: true, expiresAt: new Date(Date.now() + 3 * 24 * 60 * 60 * 1000), // 3 days from now (within free tier 5-day window) @@ -27,6 +45,11 @@ describe('CertMonitorService', () => { count: jest.fn().mockResolvedValue(0), }; + mockUserRepository = { + findOneBy: jest.fn().mockResolvedValue(mockUser), + save: jest.fn().mockResolvedValue(mockUser), + }; + mockTlsService = { renewInternal: jest.fn(), }; @@ -38,6 +61,10 @@ describe('CertMonitorService', () => { provide: getRepositoryToken(TlsCrt), useValue: mockRepository, }, + { + provide: getRepositoryToken(User), + useValue: mockUserRepository, + }, { provide: TlsService, useValue: mockTlsService, @@ -53,6 +80,7 @@ describe('CertMonitorService', () => { provide: EmailService, useValue: { sendCertExpiryWarning: jest.fn(), + sendAutoRenewalPaused: jest.fn(), }, }, { diff --git a/backend/src/certs/tls/services/cert-monitor.service.ts b/backend/src/certs/tls/services/cert-monitor.service.ts index 7e0a6c7..7a82489 100644 --- a/backend/src/certs/tls/services/cert-monitor.service.ts +++ b/backend/src/certs/tls/services/cert-monitor.service.ts @@ -10,6 +10,7 @@ import { MetricsService } from '../../../metrics/metrics.service'; import { EmailService } from '../../../notifications/email.service'; import { BillingService } from '../../../billing/billing.service'; import { PLAN_LIMITS } from '../../../billing/constants/plan-limits'; +import { User } from '../../../users/entities/user.entity'; @Injectable() export class CertMonitorService { @@ -18,6 +19,8 @@ export class CertMonitorService { constructor( @InjectRepository(TlsCrt) private readonly tlsCrtRepository: Repository, + @InjectRepository(User) + private readonly userRepository: Repository, private readonly tlsService: TlsService, private readonly metricsService: MetricsService, private readonly emailService: EmailService, @@ -66,8 +69,9 @@ export class CertMonitorService { `Certificate expiry check: ${expiring.length} certificate(s) expiring within 30 days`, ); - // Cache plan lookups to avoid redundant calls for certs owned by the same user + // Cache plan lookups and lapsed status to avoid redundant calls per user const planCache = new Map(); + const lapsedCache = new Map(); for (const cert of expiring) { if (!cert.expiresAt) continue; @@ -90,6 +94,26 @@ export class CertMonitorService { // Skip certs outside this user's renewal window if (daysUntilExpiry > windowDays) continue; + // Free-tier auto-renewal confirmation check + if (userPlan === 'free') { + let lapsed = lapsedCache.get(cert.userId); + if (lapsed === undefined) { + const user = + cert.user ?? + (await this.userRepository.findOneBy({ id: cert.userId })); + const sixMonthsAgo = new Date(); + sixMonthsAgo.setMonth(sixMonthsAgo.getMonth() - 6); + lapsed = + !user?.autoRenewalConfirmedAt || + user.autoRenewalConfirmedAt < sixMonthsAgo; + lapsedCache.set(cert.userId, lapsed); + if (lapsed && user) { + await this.maybeSendRenewalPausedReminder(user); + } + } + if (lapsed) continue; + } + if (cert.user) { const commonName = (cert.parsedCsr?.subject?.find((a) => a.shortName === 'CN') @@ -117,4 +141,22 @@ export class CertMonitorService { } } } + + private async maybeSendRenewalPausedReminder(user: User): Promise { + const thirtyDaysAgo = new Date(); + thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); + if ( + user.autoRenewalReminderSentAt && + user.autoRenewalReminderSentAt > thirtyDaysAgo + ) { + return; + } + await this.emailService.sendAutoRenewalPaused({ + userId: user.id, + username: user.username, + email: user.email, + }); + user.autoRenewalReminderSentAt = new Date(); + await this.userRepository.save(user); + } } diff --git a/backend/src/certs/tls/tls.module.ts b/backend/src/certs/tls/tls.module.ts index 91aafaa..988dce1 100644 --- a/backend/src/certs/tls/tls.module.ts +++ b/backend/src/certs/tls/tls.module.ts @@ -5,6 +5,7 @@ import { TlsController } from './tls.controller'; import { CsrUtilService } from './util/csr-util.service'; import { CertUtilService } from './util/cert-util.service'; import { TlsCrt } from './entities/tls-crt.entity'; +import { User } from '../../users/entities/user.entity'; import { BullModule } from '@nestjs/bullmq'; import { CertIssuerConsumer } from './processors/tls-crt-issuer.processor'; import { AcmeIssuerStrategy } from './strategies/acme-issuer.strategy'; @@ -18,7 +19,7 @@ import { CertMonitorService } from './services/cert-monitor.service'; @Module({ imports: [ - TypeOrmModule.forFeature([TlsCrt]), + TypeOrmModule.forFeature([TlsCrt, User]), BullModule.registerQueue({ name: 'tlsCertIssuance', }), diff --git a/backend/src/domains/services/domain-monitor.service.spec.ts b/backend/src/domains/services/domain-monitor.service.spec.ts index 8cf437e..98c9f3f 100644 --- a/backend/src/domains/services/domain-monitor.service.spec.ts +++ b/backend/src/domains/services/domain-monitor.service.spec.ts @@ -27,6 +27,8 @@ describe('DomainMonitorService', () => { apiKeys: [], tlsCrts: [], notificationPreferences: {}, + autoRenewalConfirmedAt: new Date(), + autoRenewalReminderSentAt: null, }, createdAt: new Date(), updatedAt: new Date(), diff --git a/backend/src/notifications/email.service.ts b/backend/src/notifications/email.service.ts index c438982..f11f07d 100644 --- a/backend/src/notifications/email.service.ts +++ b/backend/src/notifications/email.service.ts @@ -12,6 +12,7 @@ import { certRevokedTemplate, domainVerificationFailedTemplate, planLimitReachedTemplate, + autoRenewalPausedTemplate, } from './templates'; import { User } from '../users/entities/user.entity'; import { NotificationType } from '@krakenkey/shared'; @@ -45,6 +46,12 @@ export interface PlanLimitReachedContext { limit: number; } +export interface AutoRenewalPausedContext { + userId?: string; + username: string; + email: string; +} + @Injectable() export class EmailService { private readonly logger = new Logger(EmailService.name); @@ -187,6 +194,18 @@ export class EmailService { ); } + async sendAutoRenewalPaused(ctx: AutoRenewalPausedContext): Promise { + if ( + !(await this.shouldSend(ctx.userId, NotificationType.AUTO_RENEWAL_PAUSED)) + ) + return; + await this.send( + ctx.email, + 'KrakenKey auto-renewal paused — action required', + autoRenewalPausedTemplate(ctx), + ); + } + async sendPlanLimitReached(ctx: PlanLimitReachedContext): Promise { if ( !(await this.shouldSend( diff --git a/backend/src/notifications/templates/index.ts b/backend/src/notifications/templates/index.ts index 1f34f45..18be609 100644 --- a/backend/src/notifications/templates/index.ts +++ b/backend/src/notifications/templates/index.ts @@ -153,3 +153,25 @@ export function domainVerificationFailedTemplate( ].join(''), ); } + +export function autoRenewalPausedTemplate(ctx: { + username: string; +}): string { + return layout( + 'Auto-renewal paused — action required', + [ + p( + `Hi ${ctx.username}, your KrakenKey auto-renewal has been paused because 6 months have passed without re-confirmation.`, + ), + p( + 'Your certificates are safe — they will not be deleted or revoked. Auto-renewal resumes as soon as you confirm.', + ), + ``, + p( + 'If you no longer need auto-renewal, you can ignore this email. You can always re-enable it from your dashboard.', + ), + ].join(''), + ); +} diff --git a/backend/src/users/entities/user.entity.ts b/backend/src/users/entities/user.entity.ts index 60a6b88..13990a7 100644 --- a/backend/src/users/entities/user.entity.ts +++ b/backend/src/users/entities/user.entity.ts @@ -33,6 +33,12 @@ export class User { @CreateDateColumn() createdAt: Date; + @Column({ type: 'timestamp', nullable: true, default: () => 'CURRENT_TIMESTAMP' }) + autoRenewalConfirmedAt: Date | null; + + @Column({ type: 'timestamp', nullable: true, default: null }) + autoRenewalReminderSentAt: Date | null; + @OneToMany(() => UserApiKey, (apiKey) => apiKey.user) apiKeys: UserApiKey[]; diff --git a/frontend/src/pages/Overview.tsx b/frontend/src/pages/Overview.tsx index aba647c..1a0cb93 100644 --- a/frontend/src/pages/Overview.tsx +++ b/frontend/src/pages/Overview.tsx @@ -1,11 +1,12 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { useNavigate } from 'react-router-dom'; -import { LayoutDashboard, Globe, Shield, Key, Plus } from 'lucide-react'; +import { LayoutDashboard, Globe, Shield, Key, Plus, AlertTriangle, X } from 'lucide-react'; import { useAuth } from '../hooks/useAuth'; import { Card } from '../components/ui/Card'; import { Button } from '../components/ui/Button'; import { PageHeader } from '../components/ui/PageHeader'; import api from '../services/api'; +import { API_ROUTES } from '@krakenkey/shared'; interface ResourceCounts { domains: number; @@ -19,6 +20,8 @@ export default function Overview() { const [fetchedCounts, setFetchedCounts] = useState( null, ); + const [bannerDismissed, setBannerDismissed] = useState(false); + const [confirming, setConfirming] = useState(false); const profileCounts = useMemo(() => { const profile = user as { resourceCounts?: ResourceCounts } | null; @@ -44,6 +47,41 @@ export default function Overview() { const counts = profileCounts ?? fetchedCounts; + // Free-tier auto-renewal confirmation banner + const autoRenewalConfirmedAt = (user as { autoRenewalConfirmedAt?: string | null } | null) + ?.autoRenewalConfirmedAt; + const isPaidPlan = user && (user as { plan?: string }).plan && (user as { plan?: string }).plan !== 'free'; + const sixMonthsAgo = new Date(); + sixMonthsAgo.setMonth(sixMonthsAgo.getMonth() - 6); + const fourteenDaysFromLapse = autoRenewalConfirmedAt + ? new Date(new Date(autoRenewalConfirmedAt).getTime() + (6 * 30 - 14) * 86_400_000) + : null; + const showBanner = + !bannerDismissed && + !isPaidPlan && + (!autoRenewalConfirmedAt || new Date(autoRenewalConfirmedAt) < sixMonthsAgo || + (fourteenDaysFromLapse && fourteenDaysFromLapse <= new Date())); + + const daysUntilLapse = autoRenewalConfirmedAt + ? Math.max( + 0, + Math.ceil( + (new Date(autoRenewalConfirmedAt).getTime() + 6 * 30 * 86_400_000 - Date.now()) / + 86_400_000, + ), + ) + : 0; + + const handleConfirmAutoRenewal = useCallback(async () => { + setConfirming(true); + try { + await api.post(API_ROUTES.AUTH.CONFIRM_AUTO_RENEWAL); + setBannerDismissed(true); + } finally { + setConfirming(false); + } + }, []); + const resources = [ { label: 'Domains', @@ -76,6 +114,28 @@ export default function Overview() { icon={} /> + {/* Auto-renewal confirmation banner */} + {showBanner && ( +
+ +
+ {daysUntilLapse > 0 ? ( + <>Auto-renewal re-confirmation required in {daysUntilLapse} days. Confirm now to keep certificates renewing automatically. + ) : ( + <>Auto-renewal is paused. Your certificates are safe but will not renew automatically until you confirm. + )} +
+
+ + +
+
+ )} + {/* Resource cards */}
{resources.map(({ label, count, icon: Icon, path, color }) => ( diff --git a/shared/src/constants/routes.ts b/shared/src/constants/routes.ts index 3288855..17ecb12 100644 --- a/shared/src/constants/routes.ts +++ b/shared/src/constants/routes.ts @@ -4,6 +4,7 @@ export const API_ROUTES = { REGISTER: '/auth/register', PROFILE: '/auth/profile', CALLBACK: '/auth/callback', + CONFIRM_AUTO_RENEWAL: '/auth/confirm-auto-renewal', }, USERS: { BASE: '/users', diff --git a/shared/src/types/user.ts b/shared/src/types/user.ts index 19324cd..e4c1602 100644 --- a/shared/src/types/user.ts +++ b/shared/src/types/user.ts @@ -12,6 +12,7 @@ export interface User { export interface UserProfile extends User { createdAt: string; plan?: string; + autoRenewalConfirmedAt?: string | null; resourceCounts: { domains: number; certificates: number; @@ -27,6 +28,7 @@ export const NotificationType = { CERT_REVOKED: 'cert_revoked', DOMAIN_VERIFICATION_FAILED: 'domain_verification_failed', PLAN_LIMIT_REACHED: 'plan_limit_reached', + AUTO_RENEWAL_PAUSED: 'auto_renewal_paused', } as const; export type NotificationType =