Early Release

This project is working as expected, might have a few bugs here and there but nothing anormal.
This commit is contained in:
Nixyi 2024-12-16 22:47:16 -03:00
parent 17ce35ed6c
commit fc8110bcad
27 changed files with 1923 additions and 469 deletions

Binary file not shown.

281
package-lock.json generated
View file

@ -17,6 +17,8 @@
"@radix-ui/react-scroll-area": "^1.2.1",
"@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-switch": "^1.1.2",
"@radix-ui/react-tabs": "^1.1.2",
"@radix-ui/react-toast": "^1.2.2",
"@radix-ui/react-tooltip": "^1.1.4",
"@supabase/ssr": "^0.5.2",
@ -1852,6 +1854,270 @@
}
}
},
"node_modules/@radix-ui/react-switch": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.1.2.tgz",
"integrity": "sha512-zGukiWHjEdBCRyXvKR6iXAQG6qXm2esuAD6kDOi9Cn+1X6ev3ASo4+CsYaD6Fov9r/AQFekqnD/7+V0Cs6/98g==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.1",
"@radix-ui/react-compose-refs": "1.1.1",
"@radix-ui/react-context": "1.1.1",
"@radix-ui/react-primitive": "2.0.1",
"@radix-ui/react-use-controllable-state": "1.1.0",
"@radix-ui/react-use-previous": "1.1.0",
"@radix-ui/react-use-size": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-switch/node_modules/@radix-ui/primitive": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.1.tgz",
"integrity": "sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA==",
"license": "MIT"
},
"node_modules/@radix-ui/react-switch/node_modules/@radix-ui/react-compose-refs": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.1.tgz",
"integrity": "sha512-Y9VzoRDSJtgFMUCoiZBDVo084VQ5hfpXxVE+NgkdNsjiDBByiImMZKKhxMwCbdHvhlENG6a833CbFkOQvTricw==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-switch/node_modules/@radix-ui/react-primitive": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.1.tgz",
"integrity": "sha512-sHCWTtxwNn3L3fH8qAfnF3WbUZycW93SM1j3NFDzXBiz8D6F5UTTy8G1+WFEaiCdvCVRJWj6N2R4Xq6HdiHmDg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-switch/node_modules/@radix-ui/react-slot": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.1.tgz",
"integrity": "sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tabs": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.2.tgz",
"integrity": "sha512-9u/tQJMcC2aGq7KXpGivMm1mgq7oRJKXphDwdypPd/j21j/2znamPU8WkXgnhUaTrSFNIt8XhOyCAupg8/GbwQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.1",
"@radix-ui/react-context": "1.1.1",
"@radix-ui/react-direction": "1.1.0",
"@radix-ui/react-id": "1.1.0",
"@radix-ui/react-presence": "1.1.2",
"@radix-ui/react-primitive": "2.0.1",
"@radix-ui/react-roving-focus": "1.1.1",
"@radix-ui/react-use-controllable-state": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/primitive": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.1.tgz",
"integrity": "sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA==",
"license": "MIT"
},
"node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-collection": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.1.tgz",
"integrity": "sha512-LwT3pSho9Dljg+wY2KN2mrrh6y3qELfftINERIzBUO9e0N+t0oMTyn3k9iv+ZqgrwGkRnLpNJrsMv9BZlt2yuA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.1",
"@radix-ui/react-context": "1.1.1",
"@radix-ui/react-primitive": "2.0.1",
"@radix-ui/react-slot": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-compose-refs": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.1.tgz",
"integrity": "sha512-Y9VzoRDSJtgFMUCoiZBDVo084VQ5hfpXxVE+NgkdNsjiDBByiImMZKKhxMwCbdHvhlENG6a833CbFkOQvTricw==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-presence": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.2.tgz",
"integrity": "sha512-18TFr80t5EVgL9x1SwF/YGtfG+l0BS0PRAlCWBDoBEiDQjeKgnNZRVJp/oVBl24sr3Gbfwc/Qpj4OcWTQMsAEg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.1",
"@radix-ui/react-use-layout-effect": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-primitive": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.1.tgz",
"integrity": "sha512-sHCWTtxwNn3L3fH8qAfnF3WbUZycW93SM1j3NFDzXBiz8D6F5UTTy8G1+WFEaiCdvCVRJWj6N2R4Xq6HdiHmDg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-roving-focus": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.1.tgz",
"integrity": "sha512-QE1RoxPGJ/Nm8Qmk0PxP8ojmoaS67i0s7hVssS7KuI2FQoc/uzVlZsqKfQvxPE6D8hICCPHJ4D88zNhT3OOmkw==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.1",
"@radix-ui/react-collection": "1.1.1",
"@radix-ui/react-compose-refs": "1.1.1",
"@radix-ui/react-context": "1.1.1",
"@radix-ui/react-direction": "1.1.0",
"@radix-ui/react-id": "1.1.0",
"@radix-ui/react-primitive": "2.0.1",
"@radix-ui/react-use-callback-ref": "1.1.0",
"@radix-ui/react-use-controllable-state": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-slot": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.1.tgz",
"integrity": "sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-toast": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.2.tgz",
@ -1981,6 +2247,21 @@
}
}
},
"node_modules/@radix-ui/react-use-previous": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.0.tgz",
"integrity": "sha512-Z/e78qg2YFnnXcW88A4JmTtm4ADckLno6F7OXotmkQfeuCVaKuYzqAATPhVzl3delXE7CxIV8shofPn3jPc5Og==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-rect": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.0.tgz",

View file

@ -1,48 +1,50 @@
{
"name": "sipher",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@radix-ui/react-accordion": "^1.2.1",
"@radix-ui/react-alert-dialog": "^1.1.2",
"@radix-ui/react-avatar": "^1.1.1",
"@radix-ui/react-dropdown-menu": "^2.1.2",
"@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-scroll-area": "^1.2.1",
"@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-toast": "^1.2.2",
"@radix-ui/react-tooltip": "^1.1.4",
"@supabase/ssr": "^0.5.2",
"@supabase/supabase-js": "^2.47.3",
"argon2": "^0.41.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"framer-motion": "^11.13.5",
"lucide-react": "^0.468.0",
"next": "15.0.4",
"next-themes": "^0.4.4",
"random-words": "^2.0.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"tailwind-merge": "^2.5.5",
"tailwindcss-animate": "^1.0.7"
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "9.16.0",
"eslint-config-next": "15.1.0",
"postcss": "^8",
"tailwindcss": "^3.4.1",
"typescript": "^5"
}
"name": "sipher",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@radix-ui/react-accordion": "^1.2.1",
"@radix-ui/react-alert-dialog": "^1.1.2",
"@radix-ui/react-avatar": "^1.1.1",
"@radix-ui/react-dropdown-menu": "^2.1.2",
"@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-scroll-area": "^1.2.1",
"@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-switch": "^1.1.2",
"@radix-ui/react-tabs": "^1.1.2",
"@radix-ui/react-toast": "^1.2.2",
"@radix-ui/react-tooltip": "^1.1.4",
"@supabase/ssr": "^0.5.2",
"@supabase/supabase-js": "^2.47.3",
"argon2": "^0.41.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"framer-motion": "^11.13.5",
"lucide-react": "^0.468.0",
"next": "15.0.4",
"next-themes": "^0.4.4",
"random-words": "^2.0.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"tailwind-merge": "^2.5.5",
"tailwindcss-animate": "^1.0.7"
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "9.16.0",
"eslint-config-next": "15.1.0",
"postcss": "^8",
"tailwindcss": "^3.4.1",
"typescript": "^5"
}
}

396
src/app/[id]/page.tsx Normal file
View file

@ -0,0 +1,396 @@
"use client"
import {useEffect, useState} from 'react';
import {AnimatePresence, motion} from 'framer-motion';
import {useTheme} from 'next-themes';
import {Button} from '@/components/ui/button';
import {Input} from '@/components/ui/input';
import {ScrollArea} from '@/components/ui/scroll-area';
import {Avatar, AvatarFallback} from '@/components/ui/avatar';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger,} from '@/components/ui/tooltip';
import {
Archive,
Ban,
Clock,
Download,
Info,
Key,
KeyRound,
MoreVertical,
Send,
ShieldCheck,
UserCheck,
UserX
} from 'lucide-react';
import {usePathname} from "next/navigation";
import {useUser} from "@/contexts/user";
import {useToast} from "@/hooks/use-toast";
import {useSharedState} from "@/hooks/shared-states";
import {createBrowserClient} from '@/lib/supabase/browser'
import {CryptoManager} from "@/lib/crypto/keys";
import {REALTIME_SUBSCRIBE_STATES} from "@supabase/realtime-js";
export default function ChatPage() {
const {theme} = useTheme();
const {toast} = useToast();
const supabase = createBrowserClient();
const [messages, setMessages] = useState<SiPher.Thread["messages"]>([]);
const [inputMessage, setInputMessage] = useState('');
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [showKeyDialog, setShowKeyDialog] = useState(false);
const [showUserDialog, setShowUserDialog] = useState(false);
const [isEncrypted, setIsEncrypted] = useState(true);
const [realtimeSubscribed, setRealtimeSubscribed] = useState<REALTIME_SUBSCRIBE_STATES>()
const [isLoaded, setIsLoaded] = useState<boolean>(false);
const [user, setUser] = useState<SiPher.User | null>(null);
const pathName = usePathname();
const threadId = pathName.replace("/", "");
const {
user: currentUser,
getUser
} = useUser()
const {threads} = useSharedState();
useEffect(() => {
const channel = supabase
.channel(`messages:${threadId}`)
.on(
'postgres_changes',
{
event: '*',
schema: 'public',
table: 'messages',
},
async (payload) => {
if (payload.eventType === "INSERT") {
const messageData = payload.new as SiPher.RealtimeMessageData;
const isSender = messageData.sender_uuid === currentUser.uuid;
const decryptedMsg = await CryptoManager.decryptMessage(messageData.sender_content)
console.log(`Hello there`)
setMessages((prevState) => {
return [
...prevState,
{
id: messageData.id,
content: decryptedMsg,
sender_uuid: messageData.sender_uuid,
created_at: messageData.created_at,
isSender
}
]
})
}
}
)
.subscribe((status) => {
setRealtimeSubscribed(status)
console.log('Realtime subscription status:', status)
})
return () => {
supabase.removeChannel(channel)
}
}, [threadId])
useEffect(() => {
const getUserDataAndChat = async () => {
const {thread: getThread} = await (await fetch(`/api/user/get/thread?threadId=${threadId}`)).json() as {
thread: SiPher.Thread
};
const otherUser = getThread.participant_suuids.filter((ids) => ids !== currentUser.suuid);
const user = await getUser(`Being called from chat page (${threadId}`, otherUser[0], "suuid", true)
if (!(user.user[0].suuid && user.user[0].username)) {
toast({
title: "Error",
description: "Could not verify the existence of this user",
variant: "destructive",
duration: 5000
});
}
setUser(user.user[0])
const decryptedMsg = await CryptoManager.decryptThreadMessages(getThread["messages"], currentUser.uuid)
setMessages(decryptedMsg)
}
if (threads.length > 0) {
setIsLoaded(true)
getUserDataAndChat()
}
return () => {
setUser(null)
setMessages([])
setIsLoaded(false)
}
}, [setUser, setMessages, setIsLoaded, threads])
if (!isLoaded || !user || realtimeSubscribed !== "SUBSCRIBED") {
return (
<>
a
</>
)
}
// Mock functions - replace with actual implementations
const checkUserValidity = async () => {
// Implementation for checking user validity
setShowUserDialog(true);
};
const checkCurrentKey = async () => {
// Implementation for checking current key
setShowKeyDialog(true);
};
const deleteUser = async () => {
// Implementation for deleting user
setShowDeleteDialog(true);
};
const sendMessage = async (content: string) => {
if (!content.trim()) return;
setInputMessage('');
await CryptoManager.prepareAndSendMessage(
content,
currentUser.public_key,
user.public_key,
threadId
)
};
return (
<div className="flex flex-col h-screen max-h-[900px] w-full">
{/* Chat Header */}
<div className="flex items-center justify-between p-4 border-b">
<div className="flex items-center space-x-4">
<Avatar>
<AvatarFallback>
{
user.username.charAt(0).toLocaleUpperCase()
}
</AvatarFallback>
</Avatar>
<div>
<h2 className="font-semibold">
{
user.username.charAt(0).toLocaleUpperCase() + user.username.slice(1)
}
</h2>
</div>
</div>
<div className="flex items-center space-x-2">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" className="text-primary">
{isEncrypted ? <ShieldCheck className="h-5 w-5"/> : <Ban className="h-5 w-5"/>}
</Button>
</TooltipTrigger>
<TooltipContent>
{isEncrypted ? 'Encrypted Chat' : 'Encryption Issue'}
</TooltipContent>
</Tooltip>
</TooltipProvider>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<MoreVertical className="h-5 w-5"/>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel>Chat Options</DropdownMenuLabel>
<DropdownMenuSeparator/>
<DropdownMenuItem onClick={checkUserValidity}>
<UserCheck className="mr-2 h-4 w-4"/>
<span>Check User</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={checkCurrentKey}>
<Key className="mr-2 h-4 w-4"/>
<span>Check Current Key</span>
</DropdownMenuItem>
<DropdownMenuSeparator/>
<DropdownMenuItem>
<Clock className="mr-2 h-4 w-4"/>
<span>Message History</span>
</DropdownMenuItem>
<DropdownMenuItem>
<Archive className="mr-2 h-4 w-4"/>
<span>Archive Chat</span>
</DropdownMenuItem>
<DropdownMenuItem>
<Download className="mr-2 h-4 w-4"/>
<span>Export Chat</span>
</DropdownMenuItem>
<DropdownMenuSeparator/>
<DropdownMenuItem onClick={deleteUser} className="text-red-500">
<UserX className="mr-2 h-4 w-4"/>
<span>Delete User</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
{/* Chat Messages */}
<ScrollArea className="flex-1 p-4">
<div className="space-y-4">
<AnimatePresence>
{messages.map((message) => (
<motion.div
key={message.id}
initial={{opacity: 0, y: 20}}
animate={{opacity: 1, y: 0}}
exit={{opacity: 0}}
className={`flex ${message.isSender ? 'justify-end' : 'justify-start'}`}
>
<div className={`max-w-[70%] rounded-lg p-3 ${
message.isSender
? 'bg-primary text-primary-foreground'
: 'bg-secondary'
}`}>
<p>{message.content}</p>
<div className="flex items-center justify-end space-x-1 mt-1">
<span className="text-xs opacity-70">
{new Date(message.created_at).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit'
})}
</span>
</div>
</div>
</motion.div>
))}
</AnimatePresence>
</div>
</ScrollArea>
{/* Input Area */}
<div className="p-4 border-t">
<div className="flex space-x-2">
<Input
value={inputMessage}
onChange={(e) => setInputMessage(e.target.value)}
placeholder="Type a message..."
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage(inputMessage);
}
}}
/>
<Button onClick={() => sendMessage(inputMessage)}>
<Send className="h-4 w-4"/>
</Button>
</div>
</div>
{/* Dialogs */}
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete User</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete this user? This will remove them from your contacts
and delete all messages. This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction className="bg-red-500">Delete</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog open={showKeyDialog} onOpenChange={setShowKeyDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Encryption Status</AlertDialogTitle>
<AlertDialogDescription className="space-y-4">
<div className="flex items-center space-x-2">
<KeyRound className="h-4 w-4 text-green-500"/>
<span>Local private key is valid and active</span>
</div>
<div className="flex items-center space-x-2">
<Key className="h-4 w-4 text-green-500"/>
<span>Remote public key is verified</span>
</div>
<div className="flex items-center space-x-2">
<ShieldCheck className="h-4 w-4 text-green-500"/>
<span>End-to-end encryption is active</span>
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogAction>Close</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog open={showUserDialog} onOpenChange={setShowUserDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>User Verification</AlertDialogTitle>
<AlertDialogDescription className="space-y-4">
<div className="flex items-center space-x-2">
<UserCheck className="h-4 w-4 text-green-500"/>
<span>User is verified and active</span>
</div>
<div className="flex items-center space-x-2">
<Info className="h-4 w-4"/>
<span>Last active: 2 minutes ago</span>
</div>
<div className="flex items-center space-x-2">
<ShieldCheck className="h-4 w-4 text-green-500"/>
<span>Secure connection established</span>
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogAction>Close</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

View file

@ -4,17 +4,32 @@ import getUserByUUID from "@/lib/api/helpers/getUserByUUID";
// Helper function to get user data by UUID
export async function GET(request: Request) {
try {
const supabase = await createClient();
const {searchParams} = new URL(request.url);
const uuid = searchParams.get('uuid');
const suuid = searchParams.get('suuid');
const getDetails = searchParams.get("detailed")
if (uuid) {
// Get specific user by UUID
const userData = await getUserByUUID(supabase, uuid);
return NextResponse.json({user: userData});
} else if (suuid) {
const {data, error} = await supabase.rpc('search_users', {
search_term: suuid
});
if (error) {
return NextResponse.json({error: error}, {status: 500});
}
if (getDetails) {
return NextResponse.json({user: data})
}
return NextResponse.json({exists: !!(data[0].suuid && data[0].username)}, {status: 200});
} else {
// Get current authenticated user
const {data: {user}, error: authError} = await supabase.auth.getUser();
@ -28,6 +43,13 @@ export async function GET(request: Request) {
return NextResponse.json({user: userData});
}
} catch (error) {
if (typeof error === "object") {
return NextResponse.json(
{error: `Failed to fetch user: ${JSON.stringify(error)}`},
{status: 500}
);
}
return NextResponse.json(
{error: `Failed to fetch user: ${error}`},
{status: 500}

View file

@ -1,7 +1,6 @@
import {NextResponse} from "next/server";
import {createClient} from "@/lib/supabase/server";
import getUserByUUID from "@/lib/api/helpers/getUserByUUID";
import updateUserRequests from "@/lib/api/helpers/updateUserRequests";
export async function POST(req: Request) {
const {participant} = await req.json();

View file

@ -0,0 +1,53 @@
import {createClient} from "@/lib/supabase/server";
import {NextResponse} from "next/server";
export async function GET(request: Request) {
try {
const {searchParams} = new URL(request.url);
const threadId = searchParams.get('threadId');
if (!threadId) {
return NextResponse.json({
error: "No thread id provided"
}, {status: 400})
}
const supabase = await createClient();
const {data: {user}, error: userError} = await supabase.auth.getUser()
if (userError) {
NextResponse.json(
{error: userError},
{status: userError?.status}
)
} else if (!user) {
NextResponse.json(
{error: "User not found"},
{status: 401}
)
}
const {data, error} = await supabase.rpc(
"get_thread",
{
thread_uuid: threadId,
user_id: user!.id
}
)
if (error) {
return NextResponse.json({error}, {status: 400})
}
return NextResponse.json({thread: data[0]}, {status: 200});
} catch (e: any) {
console.log(e)
if (typeof e === "object") {
return NextResponse.json({error: JSON.stringify(e)}, {status: 500})
}
return NextResponse.json({error: e}, {status: 500})
}
}

View file

@ -6,7 +6,7 @@ export async function GET(request: Request) {
const supabase = await createClient();
const {searchParams} = new URL(request.url);
const uuid = searchParams.get('uuid');
console.log('Searching for UUID:', uuid);
const getDetails = searchParams.get("detailed")
if (!uuid) {
return NextResponse.json({error: "Missing UUID from request"}, {status: 400})
@ -36,6 +36,10 @@ export async function GET(request: Request) {
return NextResponse.json({error: error}, {status: 500});
}
if (getDetails) {
return NextResponse.json({user: data})
}
return NextResponse.json({exists: !!(data[0].suuid && data[0].username)}, {status: 200});
} catch (error) {

View file

@ -0,0 +1,31 @@
import {createClient} from "@/lib/supabase/server";
import {NextResponse} from "next/server";
export async function POST(request: Request) {
try {
const {threadId, senderContent, recipientContent} = await request.json();
const supabase = await createClient();
const {data, error} = await supabase.rpc('send_message', {
thread_uuid: threadId,
sender_content: senderContent,
recipient_content: recipientContent
});
if (error) throw error;
return NextResponse.json({messageId: data});
} catch (error: any) {
if (typeof error === "object") {
return NextResponse.json(
{error},
{status: 500}
);
}
return NextResponse.json(
{error: 'Failed to send message', details: error.message},
{status: 500}
);
}
}

View file

@ -1,6 +1,5 @@
import {createClient} from "@/lib/supabase/server";
import {NextResponse} from "next/server";
import {SupabaseClient} from "@supabase/supabase-js";
import getUserByUUID from "@/lib/api/helpers/getUserByUUID";
import updateUserRequests from "@/lib/api/helpers/updateUserRequests";

View file

@ -9,7 +9,6 @@ import {SharedStateProvider} from "@/hooks/shared-states";
import ThemeProvider from "@/components/ui/theme-provider";
import {headers} from "next/headers";
import {Toaster} from "@/components/ui/toaster";
import {RealtimeRequests} from "@/components/main/realtime/request";
const publicSans = Public_Sans({
subsets: ['latin'],

View file

@ -26,6 +26,7 @@ export default function SiPher() {
/** CryptoManager Alert */
const [privateKeyPresent, setPrivateKeyPresent] = useState(true);
const [backupPanel, setBackupPanel] = useState(false);
/** Consent Form states */
const [showConsentForm, setShowConsentForm] = useState(false);
@ -164,8 +165,8 @@ export default function SiPher() {
<AlertDialogHeader>
<AlertDialogTitle>Private Key Missing</AlertDialogTitle>
<AlertDialogDescription className={"flex flex-col space-y-1"}>
<span>This app could not retrieve your private key, which means it's either lost, never stored or corrupted. Want to try again or insert it from a backup?</span>
<span>You can also regenerate it if you do not have it backed up, but this would mean that you'll loose access to all old messages.</span>
<span>This app could not retrieve your private key, which means it&apos;s either lost, never stored or corrupted. Want to try again or insert it from a backup?</span>
<span>You can also regenerate it if you do not have it backed up, but this would mean that you&apos;ll loose access to all old messages.</span>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>

287
src/app/settings/page.tsx Normal file
View file

@ -0,0 +1,287 @@
"use client"
import {motion} from "framer-motion";
import {useTheme} from "next-themes";
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card";
import {Button} from "@/components/ui/button";
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs";
import {Input} from "@/components/ui/input";
import {Label} from "@/components/ui/label";
import {Switch} from "@/components/ui/switch";
import {Separator} from "@/components/ui/separator";
import {useUser} from "@/contexts/user";
import {useState} from "react";
import {AlertTriangle, Copy, Download, Eye, EyeOff, Key, Lock, Save, User} from "lucide-react";
import {CryptoManager} from "@/lib/crypto/keys";
import {Alert, AlertDescription, AlertTitle} from "@/components/ui/alert";
export default function SettingsPage() {
const {theme, setTheme} = useTheme();
const {user} = useUser();
const [loading, setLoading] = useState(false);
const [privateKeyVisible, setPrivateKeyVisible] = useState(false);
const [privateKeyData, setPrivateKeyData] = useState<{ text: string; file: File } | null>(null);
const [backupError, setBackupError] = useState("");
const containerVariants = {
hidden: {opacity: 0, y: 20},
visible: {
opacity: 1,
y: 0,
transition: {
duration: 0.6,
staggerChildren: 0.1
}
}
};
const itemVariants = {
hidden: {opacity: 0, y: 20},
visible: {opacity: 1, y: 0}
};
return (
<motion.div
className="flex-1 space-y-8 p-8 pt-6"
initial="hidden"
animate="visible"
variants={containerVariants}
>
<div className="flex items-center justify-between">
<div>
<h2 className="text-3xl font-bold tracking-tight">Settings</h2>
<p className="text-muted-foreground">
Manage your account settings and preferences
</p>
</div>
</div>
<Tabs defaultValue="profile" className="space-y-6">
<TabsList className="w-full justify-start">
<TabsTrigger value="profile" className="flex items-center gap-2">
<User size={16}/>
Profile
</TabsTrigger>
<TabsTrigger value="privacy" className="flex items-center gap-2">
<Lock size={16}/>
Privacy
</TabsTrigger>
</TabsList>
<motion.div variants={itemVariants}>
<TabsContent value="profile" className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Profile Information</CardTitle>
<CardDescription>
Update your profile information and settings
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="username">Username</Label>
<Input
id="username"
defaultValue={user.username}
placeholder="Your username"
/>
</div>
<div className="space-y-2">
<Label htmlFor="suuid">Your SUUID</Label>
<div className="flex gap-2">
<Input
id="suuid"
value={user.suuid}
readOnly
className="font-mono"
/>
<Button
onClick={() => {
navigator.clipboard.writeText(user.suuid);
}}
variant="outline"
>
Copy
</Button>
</div>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="privacy" className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Privacy Settings</CardTitle>
<CardDescription>
Manage your privacy and security preferences
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="flex items-center justify-between">
<div className="space-y-1">
<Label>Message Encryption</Label>
<p className="text-sm text-muted-foreground">
End-to-end encryption is always enabled
</p>
</div>
<Key className="h-4 w-4 text-primary"/>
</div>
<Separator/>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="space-y-1">
<Label>Private Key Backup</Label>
<p className="text-sm text-muted-foreground">
View and download your private key for backup
</p>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={async () => {
try {
const data = await CryptoManager.exportPrivateKey();
if (data) {
setPrivateKeyData(data);
setBackupError("");
} else {
setBackupError("Failed to export private key");
}
} catch (error) {
setBackupError("Error accessing private key");
}
}}
>
<Eye className="h-4 w-4 mr-2"/>
View Key
</Button>
<Button
variant="outline"
size="sm"
onClick={async () => {
try {
const data = await CryptoManager.exportPrivateKey();
if (data) {
const url = URL.createObjectURL(data.file);
const a = document.createElement('a');
a.href = url;
a.download = data.file.name;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
setBackupError("");
} else {
setBackupError("Failed to download private key");
}
} catch (error) {
setBackupError("Error downloading private key");
}
}}
>
<Download className="h-4 w-4 mr-2"/>
Download
</Button>
</div>
</div>
{backupError && (
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4"/>
<AlertTitle>Error</AlertTitle>
<AlertDescription>{backupError}</AlertDescription>
</Alert>
)}
{privateKeyData && (
<Card className="mt-4 w-full">
<CardHeader className="py-3">
<div className="flex justify-between items-center">
<CardTitle className="text-sm">Private Key</CardTitle>
<div className="flex gap-2">
<Button
size="sm"
variant="ghost"
onClick={() => {
navigator.clipboard.writeText(privateKeyData.text);
}}
>
<Copy className="h-4 w-4"/>
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => {
setPrivateKeyData(null);
setPrivateKeyVisible(false);
}}
>
<EyeOff className="h-4 w-4"/>
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<div className="max-w-full overflow-hidden rounded-lg bg-secondary/50">
<pre className="p-4 text-xs overflow-x-auto whitespace-pre-wrap break-all">
{privateKeyData.text}
</pre>
</div>
</CardContent>
</Card>
)}
</div>
<Separator/>
<div className="flex items-center justify-between">
<div className="space-y-1">
<Label>Allow Message Requests</Label>
<p className="text-sm text-muted-foreground">
Receive message requests from other users
</p>
</div>
<Switch defaultChecked/>
</div>
</CardContent>
</Card>
<Alert>
<AlertTriangle className="h-4 w-4"/>
<AlertTitle>Private Key Management</AlertTitle>
<AlertDescription>
Your private key is stored securely in your browser.
Make sure to back it up to avoid losing access to your messages.
</AlertDescription>
</Alert>
</TabsContent>
</motion.div>
</Tabs>
<motion.div
variants={itemVariants}
className="flex justify-end"
>
<Button
className="w-32"
disabled={loading}
onClick={() => {
setLoading(true);
// Simulate saving
setTimeout(() => setLoading(false), 1000);
}}
>
{loading ? (
<motion.div
animate={{rotate: 360}}
transition={{duration: 1, repeat: Infinity, ease: "linear"}}
>
<Save className="h-4 w-4 mr-2"/>
</motion.div>
) : (
"Save Changes"
)}
</Button>
</motion.div>
</motion.div>
);
}

View file

@ -1,15 +1,15 @@
// hooks/useRealtime.ts
import {useEffect} from 'react'
import {Dispatch, SetStateAction, useEffect} from 'react'
import {createBrowserClient} from '@/lib/supabase/browser'
import {useUser} from '@/contexts/user'
import {useToast} from '@/hooks/use-toast'
interface UseRealtimeProps {
setThreads: React.Dispatch<React.SetStateAction<SiPher.Messages[]>>;
threads: SiPher.Messages[]
setThreads: Dispatch<SetStateAction<SiPher.Thread[]>>;
threads: SiPher.Thread[]
}
export function useRealtime({setThreads}: UseRealtimeProps) {
export function useRealtime({setThreads, threads}: UseRealtimeProps) {
const supabase = createBrowserClient();
const {user, updateUser} = useUser();
const {toast} = useToast();
@ -38,6 +38,7 @@ export function useRealtime({setThreads}: UseRealtimeProps) {
table: 'users',
filter: `uuid=eq.${user.uuid}`,
}, async (payload) => {
console.log(payload)
if (payload.eventType === "UPDATE") {
// This will also handle updates for the threads, but only for the user that accepted the request.
// Why? Because the function that creates the thread will also update the current user request field and remove
@ -48,6 +49,13 @@ export function useRealtime({setThreads}: UseRealtimeProps) {
requests: payload.new.requests
})
}
} else if (payload.eventType === "DELETE") {
console.log(`Payload from delete: \n${payload}`)
updateUser({
...user,
//@ts-expect-error
requests: payload.new
})
}
}).subscribe()
@ -56,11 +64,18 @@ export function useRealtime({setThreads}: UseRealtimeProps) {
.on("postgres_changes", {
event: "*",
schema: 'public',
// Using on this one because it's easier
table: "thread_participants",
filter: `user_uuid=${user.uuid}`,
filter: `user_uuid=eq.${user.uuid}`
}, async (payload) => {
console.log(payload)
if (payload.new !== payload.old) {
await fetchAndUpdateThreads();
}
}).subscribe()
return () => {
threadUpdate.unsubscribe()
userUpdate.unsubscribe()
}
}, [user?.uuid]);
}

View file

@ -10,6 +10,8 @@ import {GearIcon} from "@radix-ui/react-icons";
import Link from "next/link";
import {useRealtime} from "@/components/main/realtime/threads";
import {useUser} from "@/contexts/user";
import {usePathname} from "next/navigation";
import {useSharedState} from "@/hooks/shared-states";
interface RightSidebarContentProps {
isDarkMode: boolean;
@ -20,30 +22,22 @@ export default function RightSidebarContent(
isDarkMode,
}: RightSidebarContentProps) {
const [selectedThreads, setSelectedThreads] = useState("");
const [threadMenu, setThreadMenu] = useState<SiPher.Messages[] | []>([]);
const [pendingRequest, setPendingRequest] = useState<number>(0);
const [threads, setThreads] = useState<SiPher.Messages[]>([]);
useRealtime(
{setThreads}
);
const [copied, setCopied] = useState<boolean>(false);
const {threads, setThreads} = useSharedState();
useRealtime({setThreads, threads});
const {user} = useUser();
const {username, suuid, requests = []} = user;
const pathname = usePathname();
// No need for separate requests state since it's in user object
const pendingRequests = requests?.length ?? 0;
// Move fetch to separate function
const fetchThreads = useCallback(async () => {
try {
const req = await fetch("/api/user/get/threads")
if (req.ok) {
const {threads} = await req.json() as { threads: SiPher.Messages[] | [] }
const {threads} = await req.json() as { threads: SiPher.Thread[] | [] }
setThreads(threads)
} else {
setThreads([])
@ -65,7 +59,6 @@ export default function RightSidebarContent(
body: JSON.stringify({participant: request}),
});
if (response.ok) {
// Optionally refresh threads after successful creation
fetchThreads();
}
} catch (error) {
@ -73,7 +66,6 @@ export default function RightSidebarContent(
}
}
return (
<>
<div className={`flex flex-col h-full w-[240px]`}>
@ -149,17 +141,19 @@ export default function RightSidebarContent(
<Separator className="my-2"/>
{threads && threads.length > 0 ? (
threads.map((thread, index) => {
// Gets the user's username instead of the SUUID to use as a recognizable user.
const otherUser = thread.participants.filter((user) => user !== username)[0];
console.log(thread)
return (
<li key={index}>
<Link href={`/${thread.thread_id}`} passHref>
<div className="flex flex-row items-center mt-2">
<Button
variant={pathname.replace("/", "") === thread.thread_id ? "secondary" : "ghost"}
className={`w-full justify-start text-[17px] p-2`}>
<Avatar className="w-8 h-8 mr-3">
<AvatarFallback>{otherUser.charAt(0).toUpperCase()}</AvatarFallback>
</Avatar>
{otherUser}
</div>
</Button>
</Link>
</li>
)
@ -175,7 +169,7 @@ export default function RightSidebarContent(
<Button
variant="outline"
className="w-full justify-start text-[17px] py-2 text-primary"
onClick={() => window.location.href = "/config"}
onClick={() => window.location.href = "/settings"}
>
<GearIcon className="w-4 h-4 mr-3"/>
Settings

View file

@ -7,7 +7,6 @@ import {Button} from "@/components/ui/button"
import Image from "next/image";
import MobileHeader from "@/components/main/sidebar/mobile";
import {useRefs, useUIState} from "@/hooks/shared-states";
import {useToast} from "@/hooks/use-toast";
import {useTheme} from "next-themes";
import RightSidebarContent from "@/components/main/sidebar/rightsidebar";
@ -21,7 +20,6 @@ function Sidebar(
}: SidebarProps
) {
const {theme, systemTheme} = useTheme();
const {toast} = useToast();
const {isDrawerOpen, setIsDrawerOpen} = useUIState();
const {drawerRef} = useRefs();
@ -30,11 +28,6 @@ function Sidebar(
? systemTheme === "dark"
: theme === "dark"
const handleAcceptRequest = async () => {
}
return (
<>
<MobileHeader/>
@ -88,9 +81,10 @@ function Sidebar(
</motion.div>
)}
</AnimatePresence>
{
<div className={"max-h-[900px] w-full"}>{
children ?? null
}
</div>
</>
)
}

View file

@ -2,56 +2,56 @@
import * as React from "react"
import * as AccordionPrimitive from "@radix-ui/react-accordion"
import { ChevronDown } from "lucide-react"
import {ChevronDown} from "lucide-react"
import { cn } from "@/lib/utils"
import {cn} from "@/lib/utils"
const Accordion = AccordionPrimitive.Root
const AccordionItem = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
>(({ className, ...props }, ref) => (
<AccordionPrimitive.Item
ref={ref}
className={cn("border-b", className)}
{...props}
/>
React.ElementRef<typeof AccordionPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
>(({className, ...props}, ref) => (
<AccordionPrimitive.Item
ref={ref}
className={cn("border-b", className)}
{...props}
/>
))
AccordionItem.displayName = "AccordionItem"
const AccordionTrigger = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
"flex flex-1 items-center justify-between py-4 text-sm font-medium transition-all hover:underline text-left [&[data-state=open]>svg]:rotate-180",
className
)}
{...props}
>
{children}
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
React.ElementRef<typeof AccordionPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
>(({className, children, ...props}, ref) => (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
"flex flex-1 items-center justify-between py-4 text-sm font-medium transition-all hover:underline text-left [&[data-state=open]>svg]:rotate-180",
className
)}
{...props}
>
{children}
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200"/>
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
))
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
const AccordionContent = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Content
ref={ref}
className="overflow-hidden text-sm data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
{...props}
>
<div className={cn("pb-4 pt-0", className)}>{children}</div>
</AccordionPrimitive.Content>
React.ElementRef<typeof AccordionPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({className, children, ...props}, ref) => (
<AccordionPrimitive.Content
ref={ref}
className="overflow-hidden text-sm data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
{...props}
>
<div className={cn("pb-4 pt-0", className)}>{children}</div>
</AccordionPrimitive.Content>
))
AccordionContent.displayName = AccordionPrimitive.Content.displayName
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
export {Accordion, AccordionItem, AccordionTrigger, AccordionContent}

View file

@ -3,8 +3,8 @@
import * as React from "react"
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
import {cn} from "@/lib/utils"
import {buttonVariants} from "@/components/ui/button"
const AlertDialog = AlertDialogPrimitive.Root
@ -13,129 +13,129 @@ const AlertDialogTrigger = AlertDialogPrimitive.Trigger
const AlertDialogPortal = AlertDialogPrimitive.Portal
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref}
/>
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({className, ...props}, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref}
/>
))
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({className, ...props}, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay/>
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
))
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
const AlertDialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
/>
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
/>
)
AlertDialogHeader.displayName = "AlertDialogHeader"
const AlertDialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
AlertDialogFooter.displayName = "AlertDialogFooter"
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold", className)}
{...props}
/>
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({className, ...props}, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold", className)}
{...props}
/>
))
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({className, ...props}, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
AlertDialogDescription.displayName =
AlertDialogPrimitive.Description.displayName
AlertDialogPrimitive.Description.displayName
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action
ref={ref}
className={cn(buttonVariants(), className)}
{...props}
/>
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({className, ...props}, ref) => (
<AlertDialogPrimitive.Action
ref={ref}
className={cn(buttonVariants(), className)}
{...props}
/>
))
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(
buttonVariants({ variant: "outline" }),
"mt-2 sm:mt-0",
className
)}
{...props}
/>
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({className, ...props}, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(
buttonVariants({variant: "outline"}),
"mt-2 sm:mt-0",
className
)}
{...props}
/>
))
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}

View file

@ -0,0 +1,59 @@
import * as React from "react"
import {cva, type VariantProps} from "class-variance-authority"
import {cn} from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({className, variant, ...props}, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({variant}), className)}
{...props}
/>
))
Alert.displayName = "Alert"
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({className, ...props}, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
))
AlertTitle.displayName = "AlertTitle"
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({className, ...props}, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
))
AlertDescription.displayName = "AlertDescription"
export {Alert, AlertTitle, AlertDescription}

View file

@ -2,9 +2,9 @@
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { Check, ChevronRight, Circle } from "lucide-react"
import {Check, ChevronRight, Circle} from "lucide-react"
import { cn } from "@/lib/utils"
import {cn} from "@/lib/utils"
const DropdownMenu = DropdownMenuPrimitive.Root
@ -19,183 +19,183 @@ const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({className, inset, children, ...props}, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto"/>
</DropdownMenuPrimitive.SubTrigger>
))
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName
DropdownMenuPrimitive.SubTrigger.displayName
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({className, ...props}, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
))
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName
DropdownMenuPrimitive.SubContent.displayName
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({className, sideOffset = 4, ...props}, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
inset && "pl-8",
className
)}
{...props}
/>
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
}
>(({className, inset, ...props}, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({className, children, checked, ...props}, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
<Check className="h-4 w-4"/>
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
{children}
</DropdownMenuPrimitive.CheckboxItem>
))
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName
DropdownMenuPrimitive.CheckboxItem.displayName
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({className, children, ...props}, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
<Circle className="h-2 w-2 fill-current"/>
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
{children}
</DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
)}
{...props}
/>
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}
>(({className, inset, ...props}, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({className, ...props}, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
)
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
)
}
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
}

View file

@ -0,0 +1,29 @@
"use client"
import * as React from "react"
import * as SwitchPrimitives from "@radix-ui/react-switch"
import {cn} from "@/lib/utils"
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({className, ...props}, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitives.Root>
))
Switch.displayName = SwitchPrimitives.Root.displayName
export {Switch}

View file

@ -0,0 +1,55 @@
"use client"
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import {cn} from "@/lib/utils"
const Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({className, ...props}, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
className
)}
{...props}
/>
))
TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({className, ...props}, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
className
)}
{...props}
/>
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({className, ...props}, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className
)}
{...props}
/>
))
TabsContent.displayName = TabsPrimitive.Content.displayName
export {Tabs, TabsList, TabsTrigger, TabsContent}

View file

@ -5,98 +5,100 @@ import {createContext, useContext, useState} from 'react';
import {useRouter} from 'next/navigation';
interface UserContextType {
user: NonNullable<SiPher.User>;
getUser: (context: string, userId?: string) => Promise<NonNullable<SiPher.User>>;
updateUser: (newUserData: NonNullable<SiPher.User>) => void;
user: NonNullable<SiPher.User>;
getUser: (context: string, userId?: string) => Promise<NonNullable<SiPher.User>>;
updateUser: (newUserData: NonNullable<SiPher.User>) => void;
}
const UserContext = createContext<UserContextType | null>(null);
export function useUser() {
const context = useContext(UserContext);
const router = useRouter();
const context = useContext(UserContext);
const router = useRouter();
if (!context) {
throw new Error('useUser must be used within a UserProvider');
}
if (!context) {
throw new Error('useUser must be used within a UserProvider');
}
return {
user: context.user,
updateUser: context.updateUser,
getUser: async (context: string, userId?: string) => {
if (process.env.NODE_ENV !== 'production') {
console.log(`useUser().getUser(): Being called by ${context}`)
}
return {
user: context.user,
updateUser: context.updateUser,
getUser: async (context: string, userId?: string, type: "suuid" | "uuid" = "uuid", detailed: boolean = false) => {
if (process.env.NODE_ENV !== 'production') {
console.log(`useUser().getUser(): Being called by ${context}`)
}
try {
const response = await fetch(`/api/auth/get_user?${
userId && `uuid=${
encodeURIComponent(userId)
}`
}`);
if (!response.ok) {
const error = await response.json();
if (error.message?.includes("Auth session missing!")) {
throw new Error('No authenticated user');
}
throw new Error(error.message || 'Authentication failed');
}
try {
const response = await fetch(`/api/auth/get_user?${
userId && `${type}=${
encodeURIComponent(userId)
}${
detailed ? "&detailed=true" : ""
}`
}`);
const {user} = await response.json();
return user as NonNullable<SiPher.User>;
} catch (error) {
console.error('Failed to get user:', error);
router.push('/auth/login');
throw error;
}
},
checkAuth: async (context: string) => {
if (process.env.NODE_ENV !== 'production') {
console.log(`useUser().checkAuth(): Being called by ${context}`)
}
try {
const response = await fetch('/api/auth/get_user');
return response.ok;
} catch {
return false;
}
}
};
if (!response.ok) {
const error = await response.json();
if (error.message?.includes("Auth session missing!")) {
throw new Error('No authenticated user');
}
throw new Error(error.message || 'Authentication failed');
}
return await response.json();
} catch (error) {
console.error('Failed to get user:', error);
router.push('/auth/login');
throw error;
}
},
checkAuth: async (context: string) => {
if (process.env.NODE_ENV !== 'production') {
console.log(`useUser().checkAuth(): Being called by ${context}`)
}
try {
const response = await fetch('/api/auth/get_user');
return response.ok;
} catch {
return false;
}
}
};
}
export function UserProvider(
{
children,
initialUser
}: {
children: React.ReactNode;
initialUser: NonNullable<SiPher.User>;
}
{
children,
initialUser
}: {
children: React.ReactNode;
initialUser: NonNullable<SiPher.User>;
}
) {
const [user, setUser] = useState<NonNullable<SiPher.User>>(initialUser);
const [user, setUser] = useState<NonNullable<SiPher.User>>(initialUser);
const updateUser = (newUserData: NonNullable<SiPher.User>) => {
setUser(newUserData);
};
const updateUser = (newUserData: NonNullable<SiPher.User>) => {
setUser(newUserData);
};
return (
<UserContext.Provider value={{
user,
updateUser,
getUser: async (context: string, userId?: string) => {
const response = await fetch(`/api/auth/get_user?${
userId && `uuid=${
encodeURIComponent(userId)
}`
}`);
if (!response.ok) {
throw new Error('Failed to get user');
}
const {user} = await response.json();
return user as NonNullable<SiPher.User>;
}
}}>
{children}
</UserContext.Provider>
);
return (
<UserContext.Provider value={{
user,
updateUser,
getUser: async (context: string, userId?: string) => {
const response = await fetch(`/api/auth/get_user?${
userId && `uuid=${
encodeURIComponent(userId)
}`
}`);
if (!response.ok) {
throw new Error('Failed to get user');
}
const {user} = await response.json();
return user as NonNullable<SiPher.User>;
}
}}>
{children}
</UserContext.Provider>
);
}

View file

@ -8,7 +8,8 @@ interface SharedState {
// UI States
isDrawerOpen: boolean
setIsDrawerOpen: React.Dispatch<React.SetStateAction<boolean>>
threads: SiPher.Thread[],
setThreads: React.Dispatch<React.SetStateAction<SiPher.Thread[]>>,
// Refs
drawerRef: React.RefObject<HTMLDivElement | null>
}
@ -20,6 +21,7 @@ const SharedStateContext = createContext<SharedState | undefined>(undefined)
export function SharedStateProvider({children}: { children: React.ReactNode }) {
// UI States
const [isDrawerOpen, setIsDrawerOpen] = useState(false)
const [threads, setThreads] = useState<SiPher.Thread[]>([]);
// Refs
const drawerRef = useRef<HTMLDivElement>(null)
@ -30,6 +32,8 @@ export function SharedStateProvider({children}: { children: React.ReactNode }) {
// UI States
isDrawerOpen,
setIsDrawerOpen,
threads,
setThreads,
// Refs
drawerRef,
}

View file

@ -12,7 +12,7 @@ export default async function UpdateKey() {
body: JSON.stringify({publicKey: exportedPublic}),
})
if(req.status !== 200) {
if (req.status !== 200) {
await CryptoManager.deletePrivateKey();
return {
status: req.status,

View file

@ -18,24 +18,6 @@ export class CryptoManager {
private static readonly STORE_NAME = 'keys';
private static readonly KEY_ID = 'private_key';
/**
* Opens db and creates the object store if needed.
* @returns {Promise<IDBDatabase>} A promise that resolves to the database instance.
*/
private static async openDB(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.DB_NAME, this.DB_VERSION);
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result);
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
db.createObjectStore(this.STORE_NAME);
};
});
}
/**
* Generates a fresh RSA key pair. Yay, new keys!
* @returns {Promise<CryptoKeyPair>} The generated RSA key pair.
@ -135,6 +117,81 @@ export class CryptoManager {
}
}
static async prepareAndSendMessage(
message: string,
senderPublicKey: JsonWebKey, // Our own public key
recipientPublicKey: JsonWebKey,
threadId: string
): Promise<void> {
// Encrypt for ourselves
const senderContent = await this.encryptMessage(message, senderPublicKey);
// Encrypt for recipient
const recipientContent = await this.encryptMessage(message, recipientPublicKey);
// Send to server
const response = await fetch('/api/user/send/message', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
threadId,
senderContent,
recipientContent
})
});
if (!response.ok) {
throw new Error('Failed to send message');
}
return await response.json();
}
static async decryptThreadMessages(messages: any[], userUuid: string): Promise<SiPher.DecryptedMessage[]> {
try {
// Get our private key for decryption
const privateKey = await this.getPrivateKey();
if (!privateKey) {
throw new Error("No private key found for decryption");
}
// Decrypt each message
const decryptedMessages = await Promise.all(messages.map(async (message) => {
// Determine if we're the sender
const isSender = message.sender_uuid === userUuid;
try {
const decryptedContent = await this.decryptMessage(message.content);
return {
id: message.id,
content: decryptedContent,
sender_uuid: message.sender_uuid,
created_at: message.created_at,
isSender
};
} catch (error) {
console.error('Failed to decrypt message:', message.id, error);
return {
id: message.id,
content: "Failed to decrypt message",
sender_uuid: message.sender_uuid,
created_at: message.created_at,
isSender,
error: true
};
}
}));
return decryptedMessages;
} catch (error) {
console.error('Error decrypting messages:', error);
throw error;
}
}
/**
* Encrypts a message using the recipient's public key.
* @param {string} message - The message you wanna encrypt.
@ -189,4 +246,156 @@ export class CryptoManager {
return new TextDecoder().decode(decrypted);
}
/**
* Exports the private key as both a downloadable file and text content.
* @param {string} filename - Name of the file to be downloaded (without extension)
* @returns {Promise<{text: string, file: File} | null>} Object containing the text content and File object, or null if no key exists
*/
static async exportPrivateKey(filename: string = 'private-key-backup'): Promise<{ text: string, file: File } | null> {
try {
const privateKey = await this.getPrivateKey();
if (!privateKey) {
throw new Error("No private key found to export");
}
// Export the private key to JWK format
const exportedKey = await crypto.subtle.exportKey('jwk', privateKey);
// Convert to formatted JSON string
const keyString = JSON.stringify(exportedKey, null, 2);
// Create file object
const blob = new Blob([keyString], {type: 'application/json'});
const file = new File([blob], `${filename}.json`, {type: 'application/json'});
return {
text: keyString,
file: file
};
} catch (error) {
console.error('Failed to export private key:', error);
return null;
}
}
/**
* Validates if a provided private key matches the stored public key.
* @param {JsonWebKey} privateKeyJwk - The private key in JWK format to validate
* @param {JsonWebKey} publicKeyJwk - The public key in JWK format to validate against
* @returns {Promise<boolean>} True if the keys form a valid pair, false otherwise
*/
static async validateKeyPair(privateKeyJwk: JsonWebKey, publicKeyJwk: JsonWebKey): Promise<boolean> {
try {
// Import the private key
const privateKey = await crypto.subtle.importKey(
'jwk',
privateKeyJwk,
{
name: "RSA-OAEP",
hash: "SHA-256",
},
true,
["decrypt"]
);
// Import the public key
const publicKey = await crypto.subtle.importKey(
'jwk',
publicKeyJwk,
{
name: "RSA-OAEP",
hash: "SHA-256",
},
true,
["encrypt"]
);
// Create a test message
const testMessage = "KeyValidationTest_" + new Date().getTime();
// Encrypt with public key
const encoder = new TextEncoder();
const encrypted = await crypto.subtle.encrypt(
{
name: "RSA-OAEP",
},
publicKey,
encoder.encode(testMessage)
);
// Decrypt with private key
const decrypted = await crypto.subtle.decrypt(
{
name: "RSA-OAEP",
},
privateKey,
encrypted
);
// Compare the result
const decryptedText = new TextDecoder().decode(decrypted);
return decryptedText === testMessage;
} catch (error) {
console.error('Key validation failed:', error);
return false;
}
}
/**
* Restores a private key from a backup after validating it against a provided public key.
* @param {JsonWebKey} privateKeyJwk - The private key in JWK format to restore
* @param {JsonWebKey} publicKeyJwk - The public key in JWK format to validate against
* @returns {Promise<boolean>} True if restoration was successful, false otherwise
*/
static async restoreFromBackup(privateKeyJwk: JsonWebKey, publicKeyJwk: JsonWebKey): Promise<boolean> {
try {
// Validate the key pair
const isValid = await this.validateKeyPair(privateKeyJwk, publicKeyJwk);
if (!isValid) {
throw new Error("Invalid key pair - backup key doesn't match public key");
}
// Import the private key
const privateKey = await crypto.subtle.importKey(
'jwk',
privateKeyJwk,
{
name: "RSA-OAEP",
hash: "SHA-256",
},
true,
["decrypt"]
);
// Store the validated private key
await this.storePrivateKey(privateKey);
return true;
} catch (error) {
console.error('Backup restoration failed:', error);
return false;
}
}
/**
* Opens db and creates the object store if needed.
* @returns {Promise<IDBDatabase>} A promise that resolves to the database instance.
*/
private static async openDB(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.DB_NAME, this.DB_VERSION);
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result);
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
db.createObjectStore(this.STORE_NAME);
};
});
}
}

31
src/types/user.d.ts vendored
View file

@ -1,15 +1,16 @@
import {Json} from "../../database.types";
declare global {
namespace SiPher {
type Messages = {
type Thread = {
thread_id: string;
participants: string[];
participant_suuids: string[];
messages: {
id: string;
content: string;
isSender: boolean;
id: string; // UUID
content: string; // The encrypted content (either sender_content or recipient_content)
sender_uuid: string; // UUID of sender
created_at: string; // ISO timestamp
}[];
indexable?: boolean;
}
type User = {
@ -21,6 +22,24 @@ declare global {
username: string
uuid: string
}
interface DecryptedMessage {
id: string;
content: string;
sender_uuid: string;
created_at: string;
isSender: boolean;
error?: boolean;
}
interface RealtimeMessageData {
created_at: string;
id: string;
recipient_content: string;
sender_content: string;
sender_uuid: string;
thread_id: string;
}
}
}