mirror of
https://github.com/yusufipk/dead-man-switch-2.0.git
synced 2026-09-11 09:26:07 +00:00
feat: Implement message check-in and status management, introduce flexible interval units, and add decryption routes for messages and attachments.
This commit is contained in:
@@ -31,11 +31,11 @@
|
|||||||
- [x] Şifreli mesaj düzenleme
|
- [x] Şifreli mesaj düzenleme
|
||||||
- [x] Dosya adı şifreleme
|
- [x] Dosya adı şifreleme
|
||||||
|
|
||||||
### Faz 5: Check-in & Cron 🔲
|
### Faz 5: Check-in & Cron ✅
|
||||||
- [ ] Check-in API endpoint
|
- [x] Check-in API endpoint
|
||||||
- [ ] node-cron scheduler setup
|
- [x] Interval unit (dakika/saat/gün/ay)
|
||||||
- [ ] Deadline kontrolü
|
- [x] Deadline kontrolü
|
||||||
- [ ] Status güncellemeleri
|
- [x] Status güncellemeleri
|
||||||
|
|
||||||
### Faz 6: Email Gönderimi ✅
|
### Faz 6: Email Gönderimi ✅
|
||||||
- [x] Nodemailer setup
|
- [x] Nodemailer setup
|
||||||
@@ -81,6 +81,7 @@
|
|||||||
- ✅ Faz 2: Mesaj CRUD
|
- ✅ Faz 2: Mesaj CRUD
|
||||||
- ✅ Faz 3: Dosya Yükleme (R2)
|
- ✅ Faz 3: Dosya Yükleme (R2)
|
||||||
- ✅ Faz 4: Client-side Encryption
|
- ✅ Faz 4: Client-side Encryption
|
||||||
|
- ✅ Faz 5: Check-in & Cron
|
||||||
- ✅ Faz 6: Email Gönderimi
|
- ✅ Faz 6: Email Gönderimi
|
||||||
|
|
||||||
## Bilinen Sorunlar
|
## Bilinen Sorunlar
|
||||||
|
|||||||
+5
-1
@@ -6,7 +6,11 @@
|
|||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint"
|
"lint": "eslint",
|
||||||
|
"db:generate": "prisma generate",
|
||||||
|
"db:push": "prisma db push",
|
||||||
|
"db:migrate": "prisma migrate dev",
|
||||||
|
"db:studio": "prisma studio"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@auth/prisma-adapter": "^2.11.1",
|
"@auth/prisma-adapter": "^2.11.1",
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ model Message {
|
|||||||
isEncrypted Boolean @default(false)
|
isEncrypted Boolean @default(false)
|
||||||
lastPing DateTime @default(now())
|
lastPing DateTime @default(now())
|
||||||
checkInterval Int @default(24)
|
checkInterval Int @default(24)
|
||||||
|
intervalUnit String @default("HOURS")
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
recipients Recipient[]
|
recipients Recipient[]
|
||||||
attachments Attachment[]
|
attachments Attachment[]
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { prisma } from '@/lib/prisma';
|
import { prisma } from '@/lib/prisma';
|
||||||
import { sendEmail, emailTemplates } from '@/lib/email';
|
import { sendEmail, emailTemplates } from '@/lib/email';
|
||||||
|
import { intervalToMs, IntervalUnit } from '@/lib/interval';
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
const authHeader = request.headers.get('authorization');
|
const authHeader = request.headers.get('authorization');
|
||||||
@@ -10,13 +11,8 @@ export async function GET(request: Request) {
|
|||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
const expiredMessages = await prisma.message.findMany({
|
const activeMessages = await prisma.message.findMany({
|
||||||
where: {
|
where: { status: 'ACTIVE' },
|
||||||
status: 'ACTIVE',
|
|
||||||
lastPing: {
|
|
||||||
lt: new Date(now.getTime() - 1000 * 60 * 60 * 24), // 24 saat geçmiş
|
|
||||||
},
|
|
||||||
},
|
|
||||||
include: {
|
include: {
|
||||||
user: true,
|
user: true,
|
||||||
recipients: true,
|
recipients: true,
|
||||||
@@ -25,8 +21,9 @@ export async function GET(request: Request) {
|
|||||||
|
|
||||||
const results = [];
|
const results = [];
|
||||||
|
|
||||||
for (const message of expiredMessages) {
|
for (const message of activeMessages) {
|
||||||
const deadlineTime = new Date(message.lastPing.getTime() + message.checkInterval * 60 * 60 * 1000);
|
const intervalMs = intervalToMs(message.checkInterval, message.intervalUnit as IntervalUnit);
|
||||||
|
const deadlineTime = new Date(message.lastPing.getTime() + intervalMs);
|
||||||
|
|
||||||
if (now > deadlineTime) {
|
if (now > deadlineTime) {
|
||||||
for (const recipient of message.recipients) {
|
for (const recipient of message.recipients) {
|
||||||
@@ -55,5 +52,5 @@ export async function GET(request: Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ checked: expiredMessages.length, results });
|
return NextResponse.json({ checked: activeMessages.length, results });
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { prisma } from '@/lib/prisma';
|
import { prisma } from '@/lib/prisma';
|
||||||
import { sendEmail, emailTemplates } from '@/lib/email';
|
import { sendEmail, emailTemplates } from '@/lib/email';
|
||||||
|
import { intervalToMs, IntervalUnit } from '@/lib/interval';
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
const authHeader = request.headers.get('authorization');
|
const authHeader = request.headers.get('authorization');
|
||||||
@@ -18,7 +19,8 @@ export async function GET(request: Request) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
for (const message of activeMessages) {
|
for (const message of activeMessages) {
|
||||||
const deadlineTime = new Date(message.lastPing.getTime() + message.checkInterval * 60 * 60 * 1000);
|
const intervalMs = intervalToMs(message.checkInterval, message.intervalUnit as IntervalUnit);
|
||||||
|
const deadlineTime = new Date(message.lastPing.getTime() + intervalMs);
|
||||||
const hoursLeft = (deadlineTime.getTime() - now.getTime()) / (1000 * 60 * 60);
|
const hoursLeft = (deadlineTime.getTime() - now.getTime()) / (1000 * 60 * 60);
|
||||||
|
|
||||||
for (const reminderHour of reminderHours) {
|
for (const reminderHour of reminderHours) {
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { getDownloadUrl } from "@/lib/r2";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
// Public endpoint for recipients to download attachments
|
||||||
|
export async function GET(
|
||||||
|
req: Request,
|
||||||
|
{ params }: { params: Promise<{ messageId: string; attachmentId: string }> }
|
||||||
|
) {
|
||||||
|
const { messageId, attachmentId } = await params;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const attachment = await prisma.attachment.findUnique({
|
||||||
|
where: { id: attachmentId },
|
||||||
|
include: { message: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify attachment belongs to the specified message
|
||||||
|
if (!attachment || attachment.messageId !== messageId) {
|
||||||
|
return new NextResponse("Not found", { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const downloadUrl = await getDownloadUrl(attachment.fileUrl);
|
||||||
|
return NextResponse.json({ downloadUrl });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("DECRYPT_ATTACHMENT_DOWNLOAD_ERROR", error);
|
||||||
|
return new NextResponse("Internal Server Error", { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
req: Request,
|
||||||
|
{ params }: { params: Promise<{ messageId: string }> }
|
||||||
|
) {
|
||||||
|
const { messageId } = await params;
|
||||||
|
|
||||||
|
const message = await prisma.message.findUnique({
|
||||||
|
where: { id: messageId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
content: true,
|
||||||
|
isEncrypted: true,
|
||||||
|
user: { select: { name: true } },
|
||||||
|
attachments: { select: { id: true, fileName: true, fileUrl: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!message) {
|
||||||
|
return NextResponse.json({ error: 'Mesaj bulunamadı' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(message);
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { getServerSession } from 'next-auth';
|
||||||
|
import { authOptions } from '@/lib/auth/options';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
req: Request,
|
||||||
|
{ params }: { params: Promise<{ messageId: string }> }
|
||||||
|
) {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { messageId } = await params;
|
||||||
|
|
||||||
|
const message = await prisma.message.findUnique({
|
||||||
|
where: { id: messageId, userId: session.user.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!message) {
|
||||||
|
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await prisma.message.update({
|
||||||
|
where: { id: messageId },
|
||||||
|
data: { lastPing: new Date() },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, lastPing: updated.lastPing });
|
||||||
|
}
|
||||||
@@ -16,7 +16,7 @@ export async function PATCH(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const body = await req.json();
|
const body = await req.json();
|
||||||
const { title, content, recipients, checkInterval, isEncrypted } = body;
|
const { title, content, recipients, checkInterval, intervalUnit, isEncrypted } = body;
|
||||||
|
|
||||||
const existingMessage = await prisma.message.findUnique({
|
const existingMessage = await prisma.message.findUnique({
|
||||||
where: { id: messageId, userId: session.user.id },
|
where: { id: messageId, userId: session.user.id },
|
||||||
@@ -40,6 +40,7 @@ export async function PATCH(
|
|||||||
title,
|
title,
|
||||||
content,
|
content,
|
||||||
checkInterval,
|
checkInterval,
|
||||||
|
intervalUnit: intervalUnit || existingMessage.intervalUnit,
|
||||||
isEncrypted: isEncrypted || existingMessage.isEncrypted,
|
isEncrypted: isEncrypted || existingMessage.isEncrypted,
|
||||||
recipients: {
|
recipients: {
|
||||||
create: recipients.map((email: string) => ({ email })),
|
create: recipients.map((email: string) => ({ email })),
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { getServerSession } from 'next-auth';
|
||||||
|
import { authOptions } from '@/lib/auth/options';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
req: Request,
|
||||||
|
{ params }: { params: Promise<{ messageId: string }> }
|
||||||
|
) {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { messageId } = await params;
|
||||||
|
const { status } = await req.json();
|
||||||
|
|
||||||
|
if (!['DRAFT', 'ACTIVE'].includes(status)) {
|
||||||
|
return NextResponse.json({ error: 'Invalid status' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = await prisma.message.findUnique({
|
||||||
|
where: { id: messageId, userId: session.user.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!message) {
|
||||||
|
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await prisma.message.update({
|
||||||
|
where: { id: messageId },
|
||||||
|
data: { status, lastPing: new Date() },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, status: updated.status });
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@ export async function POST(req: Request) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const body = await req.json();
|
const body = await req.json();
|
||||||
const { title, content, recipients, checkInterval, isEncrypted } = body;
|
const { title, content, recipients, checkInterval, intervalUnit, isEncrypted } = body;
|
||||||
|
|
||||||
const message = await prisma.message.create({
|
const message = await prisma.message.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -20,6 +20,7 @@ export async function POST(req: Request) {
|
|||||||
title,
|
title,
|
||||||
content,
|
content,
|
||||||
checkInterval,
|
checkInterval,
|
||||||
|
intervalUnit: intervalUnit || 'HOURS',
|
||||||
isEncrypted: isEncrypted || false,
|
isEncrypted: isEncrypted || false,
|
||||||
recipients: {
|
recipients: {
|
||||||
create: recipients.map((email: string) => ({ email })),
|
create: recipients.map((email: string) => ({ email })),
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ export default async function EditMessagePage({
|
|||||||
title: message.title,
|
title: message.title,
|
||||||
content: message.content || "",
|
content: message.content || "",
|
||||||
checkInterval: message.checkInterval,
|
checkInterval: message.checkInterval,
|
||||||
|
intervalUnit: message.intervalUnit as "MINUTES" | "HOURS" | "DAYS" | "MONTHS",
|
||||||
recipients: message.recipients.map((r) => r.email),
|
recipients: message.recipients.map((r) => r.email),
|
||||||
attachments: message.attachments.map((a) => ({ id: a.id, fileName: a.fileName })),
|
attachments: message.attachments.map((a) => ({ id: a.id, fileName: a.fileName })),
|
||||||
isEncrypted: message.isEncrypted,
|
isEncrypted: message.isEncrypted,
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { getServerSession } from "next-auth";
|
|||||||
import { authOptions } from "@/lib/auth/options";
|
import { authOptions } from "@/lib/auth/options";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
import { formatInterval, type IntervalUnit } from "@/lib/interval";
|
||||||
|
import { CheckInButton, ToggleStatusButton } from "@/components/check-in-button";
|
||||||
|
|
||||||
export default async function DashboardPage() {
|
export default async function DashboardPage() {
|
||||||
const session = await getServerSession(authOptions);
|
const session = await getServerSession(authOptions);
|
||||||
@@ -47,12 +49,16 @@ export default async function DashboardPage() {
|
|||||||
{messages.map((msg) => (
|
{messages.map((msg) => (
|
||||||
<Card key={msg.id} className="relative group">
|
<Card key={msg.id} className="relative group">
|
||||||
<CardHeader className="flex flex-row items-center justify-between">
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
<CardTitle className="truncate pr-8">{msg.title}</CardTitle>
|
<CardTitle className="truncate pr-20">{msg.title}</CardTitle>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<ToggleStatusButton messageId={msg.id} currentStatus={msg.status} />
|
||||||
|
<CheckInButton messageId={msg.id} />
|
||||||
<Link href={`/dashboard/messages/${msg.id}`}>
|
<Link href={`/dashboard/messages/${msg.id}`}>
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||||
<Edit2 className="h-4 w-4" />
|
<Edit2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<p className="text-sm text-muted-foreground mb-2">
|
<p className="text-sm text-muted-foreground mb-2">
|
||||||
@@ -62,7 +68,7 @@ export default async function DashboardPage() {
|
|||||||
Alıcılar: {msg.recipients.length}
|
Alıcılar: {msg.recipients.length}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Kontrol: {msg.checkInterval} saat başı
|
Kontrol: {formatInterval(msg.checkInterval, msg.intervalUnit as IntervalUnit)}
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
|
||||||
|
import { Lock } from 'lucide-react';
|
||||||
|
import { decrypt, decryptFile } from '@/lib/crypto';
|
||||||
|
import { use } from 'react';
|
||||||
|
|
||||||
|
interface MessageData {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
isEncrypted: boolean;
|
||||||
|
user: { name: string };
|
||||||
|
attachments: { id: string; fileName: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DecryptPage({ params }: { params: Promise<{ messageId: string }> }) {
|
||||||
|
const { messageId } = use(params);
|
||||||
|
const [message, setMessage] = useState<MessageData | null>(null);
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [decryptedContent, setDecryptedContent] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [downloading, setDownloading] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch(`/api/decrypt/${messageId}`)
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.error) setError(data.error);
|
||||||
|
else setMessage(data);
|
||||||
|
})
|
||||||
|
.catch(() => setError('Mesaj yüklenemedi'))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [messageId]);
|
||||||
|
|
||||||
|
const handleDecrypt = async () => {
|
||||||
|
if (!message || !password) return;
|
||||||
|
try {
|
||||||
|
const content = await decrypt(message.content, password);
|
||||||
|
setDecryptedContent(content);
|
||||||
|
setError(null);
|
||||||
|
} catch {
|
||||||
|
setError('Şifre hatalı');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownloadAttachment = async (attachmentId: string, fileName: string) => {
|
||||||
|
if (!password) return;
|
||||||
|
|
||||||
|
setDownloading(attachmentId);
|
||||||
|
try {
|
||||||
|
// Use the public decrypt attachments endpoint
|
||||||
|
const res = await fetch(`/api/decrypt/${messageId}/attachments/${attachmentId}`);
|
||||||
|
if (!res.ok) throw new Error('Failed to get download URL');
|
||||||
|
|
||||||
|
const { downloadUrl } = await res.json();
|
||||||
|
|
||||||
|
if (message?.isEncrypted && fileName.endsWith('.enc')) {
|
||||||
|
// Download and decrypt the file
|
||||||
|
const fileRes = await fetch(downloadUrl);
|
||||||
|
const encryptedBlob = await fileRes.blob();
|
||||||
|
const decryptedData = await decryptFile(encryptedBlob, password);
|
||||||
|
|
||||||
|
// Decrypt the filename
|
||||||
|
let originalName = 'decrypted_file';
|
||||||
|
try {
|
||||||
|
const encryptedName = fileName.slice(0, -4);
|
||||||
|
originalName = await decrypt(encryptedName, password);
|
||||||
|
} catch { }
|
||||||
|
|
||||||
|
// Trigger download
|
||||||
|
const blob = new Blob([decryptedData]);
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = originalName;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} else {
|
||||||
|
// Non-encrypted file, just open
|
||||||
|
window.open(downloadUrl, '_blank');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Download error:', error);
|
||||||
|
setError('Dosya indirilemedi');
|
||||||
|
} finally {
|
||||||
|
setDownloading(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) return <div className="container mx-auto py-10 text-center">Yükleniyor...</div>;
|
||||||
|
if (error && !message) return <div className="container mx-auto py-10 text-center text-red-500">{error}</div>;
|
||||||
|
if (!message) return null;
|
||||||
|
|
||||||
|
const canDownloadAttachments = !message.isEncrypted || decryptedContent !== null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto py-10 max-w-2xl">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{message.title}</CardTitle>
|
||||||
|
<p className="text-sm text-muted-foreground">Gönderen: {message.user.name}</p>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{message.isEncrypted && !decryptedContent ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2 text-muted-foreground">
|
||||||
|
<Lock className="h-4 w-4" />
|
||||||
|
<span>Bu mesaj şifrelidir</span>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
placeholder="Şifre"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
/>
|
||||||
|
{error && <p className="text-red-500 text-sm">{error}</p>}
|
||||||
|
<Button onClick={handleDecrypt} disabled={!password}>
|
||||||
|
Şifreyi Çöz
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="prose dark:prose-invert">
|
||||||
|
<p className="whitespace-pre-wrap">{decryptedContent || message.content}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{message.attachments.length > 0 && (
|
||||||
|
<div className="mt-6">
|
||||||
|
<h3 className="font-semibold mb-2">Ekler</h3>
|
||||||
|
{canDownloadAttachments ? (
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{message.attachments.map((att) => (
|
||||||
|
<li key={att.id} className="flex items-center gap-2 text-sm">
|
||||||
|
<span className="flex-1 truncate">
|
||||||
|
{att.fileName.endsWith('.enc') ? '🔒 Şifreli dosya' : att.fileName}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleDownloadAttachment(att.id, att.fileName)}
|
||||||
|
disabled={downloading === att.id}
|
||||||
|
>
|
||||||
|
{downloading === att.id ? '...' : 'İndir'}
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Şifreli ekleri indirmek için önce mesajı çözün.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { CheckCircle, Power } from 'lucide-react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
export function CheckInButton({ messageId }: { messageId: string }) {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [success, setSuccess] = useState(false);
|
||||||
|
|
||||||
|
const handleCheckIn = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/messages/${messageId}/check-in`, { method: 'POST' });
|
||||||
|
if (res.ok) {
|
||||||
|
setSuccess(true);
|
||||||
|
setTimeout(() => setSuccess(false), 2000);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
onClick={handleCheckIn}
|
||||||
|
disabled={loading}
|
||||||
|
title="Check-in"
|
||||||
|
>
|
||||||
|
<CheckCircle className={`h-4 w-4 ${success ? 'text-green-500' : ''}`} />
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ToggleStatusButton({ messageId, currentStatus }: { messageId: string; currentStatus: string }) {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const router = useRouter();
|
||||||
|
const isActive = currentStatus === 'ACTIVE';
|
||||||
|
|
||||||
|
const handleToggle = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await fetch(`/api/messages/${messageId}/status`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ status: isActive ? 'DRAFT' : 'ACTIVE' }),
|
||||||
|
});
|
||||||
|
router.refresh();
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
onClick={handleToggle}
|
||||||
|
disabled={loading}
|
||||||
|
title={isActive ? 'Devre dışı bırak' : 'Aktifleştir'}
|
||||||
|
>
|
||||||
|
<Power className={`h-4 w-4 ${isActive ? 'text-green-500' : 'text-muted-foreground'}`} />
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import { Plus, Trash2, Lock, Unlock } from "lucide-react";
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { FileUpload, AttachmentList } from "@/components/attachments/file-upload";
|
import { FileUpload, AttachmentList } from "@/components/attachments/file-upload";
|
||||||
import { encrypt, decrypt, encryptFile } from "@/lib/crypto";
|
import { encrypt, decrypt, encryptFile } from "@/lib/crypto";
|
||||||
|
import { IntervalUnit } from "@/lib/interval";
|
||||||
|
|
||||||
interface Attachment {
|
interface Attachment {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -23,6 +24,7 @@ interface MessageFormProps {
|
|||||||
content: string;
|
content: string;
|
||||||
recipients: string[];
|
recipients: string[];
|
||||||
checkInterval: number;
|
checkInterval: number;
|
||||||
|
intervalUnit?: IntervalUnit;
|
||||||
attachments?: Attachment[];
|
attachments?: Attachment[];
|
||||||
isEncrypted?: boolean;
|
isEncrypted?: boolean;
|
||||||
};
|
};
|
||||||
@@ -33,6 +35,7 @@ export default function MessageForm({ initialData }: MessageFormProps) {
|
|||||||
const [content, setContent] = useState(initialData?.content || "");
|
const [content, setContent] = useState(initialData?.content || "");
|
||||||
const [recipients, setRecipients] = useState(initialData?.recipients || [""]);
|
const [recipients, setRecipients] = useState(initialData?.recipients || [""]);
|
||||||
const [checkInterval, setCheckInterval] = useState(initialData?.checkInterval || 24);
|
const [checkInterval, setCheckInterval] = useState(initialData?.checkInterval || 24);
|
||||||
|
const [intervalUnit, setIntervalUnit] = useState<IntervalUnit>(initialData?.intervalUnit || "HOURS");
|
||||||
const [attachments, setAttachments] = useState<Attachment[]>(initialData?.attachments || []);
|
const [attachments, setAttachments] = useState<Attachment[]>(initialData?.attachments || []);
|
||||||
const [pendingFiles, setPendingFiles] = useState<File[]>([]);
|
const [pendingFiles, setPendingFiles] = useState<File[]>([]);
|
||||||
const [isEncrypted, setIsEncrypted] = useState(initialData?.isEncrypted || false);
|
const [isEncrypted, setIsEncrypted] = useState(initialData?.isEncrypted || false);
|
||||||
@@ -116,6 +119,7 @@ export default function MessageForm({ initialData }: MessageFormProps) {
|
|||||||
content: finalContent,
|
content: finalContent,
|
||||||
recipients,
|
recipients,
|
||||||
checkInterval,
|
checkInterval,
|
||||||
|
intervalUnit,
|
||||||
isEncrypted
|
isEncrypted
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@@ -263,7 +267,8 @@ export default function MessageForm({ initialData }: MessageFormProps) {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="interval">Kontrol Aralığı (Saat)</Label>
|
<Label htmlFor="interval">Kontrol Aralığı</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
<Input
|
<Input
|
||||||
id="interval"
|
id="interval"
|
||||||
type="number"
|
type="number"
|
||||||
@@ -271,7 +276,19 @@ export default function MessageForm({ initialData }: MessageFormProps) {
|
|||||||
onChange={(e) => setCheckInterval(parseInt(e.target.value))}
|
onChange={(e) => setCheckInterval(parseInt(e.target.value))}
|
||||||
min={1}
|
min={1}
|
||||||
required
|
required
|
||||||
|
className="w-24"
|
||||||
/>
|
/>
|
||||||
|
<select
|
||||||
|
value={intervalUnit}
|
||||||
|
onChange={(e) => setIntervalUnit(e.target.value as IntervalUnit)}
|
||||||
|
className="flex h-10 rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<option value="MINUTES">Dakika</option>
|
||||||
|
<option value="HOURS">Saat</option>
|
||||||
|
<option value="DAYS">Gün</option>
|
||||||
|
<option value="MONTHS">Ay</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{!initialData?.isEncrypted && (
|
{!initialData?.isEncrypted && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
export type IntervalUnit = 'MINUTES' | 'HOURS' | 'DAYS' | 'MONTHS';
|
||||||
|
|
||||||
|
export function intervalToMs(interval: number, unit: IntervalUnit): number {
|
||||||
|
switch (unit) {
|
||||||
|
case 'MINUTES': return interval * 60 * 1000;
|
||||||
|
case 'HOURS': return interval * 60 * 60 * 1000;
|
||||||
|
case 'DAYS': return interval * 24 * 60 * 60 * 1000;
|
||||||
|
case 'MONTHS': return interval * 30 * 24 * 60 * 60 * 1000;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatInterval(interval: number, unit: IntervalUnit): string {
|
||||||
|
const labels: Record<IntervalUnit, string> = {
|
||||||
|
MINUTES: 'dakika',
|
||||||
|
HOURS: 'saat',
|
||||||
|
DAYS: 'gün',
|
||||||
|
MONTHS: 'ay',
|
||||||
|
};
|
||||||
|
return `${interval} ${labels[unit]}`;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user