This is hard
Dealing with realtime is actually really hard (imposter syndrome is hitting real hard rn)
This commit is contained in:
parent
eef9803249
commit
17ce35ed6c
23 changed files with 759 additions and 292 deletions
BIN
database.types.ts
Normal file
BIN
database.types.ts
Normal file
Binary file not shown.
14
package-lock.json
generated
14
package-lock.json
generated
|
|
@ -28,6 +28,7 @@
|
||||||
"lucide-react": "^0.468.0",
|
"lucide-react": "^0.468.0",
|
||||||
"next": "15.0.4",
|
"next": "15.0.4",
|
||||||
"next-themes": "^0.4.4",
|
"next-themes": "^0.4.4",
|
||||||
|
"random-words": "^2.0.1",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"tailwind-merge": "^2.5.5",
|
"tailwind-merge": "^2.5.5",
|
||||||
|
|
@ -5774,6 +5775,14 @@
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"node_modules/random-words": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/random-words/-/random-words-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-nZNJAmgcFmtJMTDDIUCm/iK4R6RydC6NvALvWhYItXQrgYGk1F7Gww416LpVROFQtfVd5TaLEf4WuSsko03N7w==",
|
||||||
|
"dependencies": {
|
||||||
|
"seedrandom": "^3.0.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react": {
|
"node_modules/react": {
|
||||||
"version": "19.0.0",
|
"version": "19.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.0.0.tgz",
|
||||||
|
|
@ -5971,6 +5980,11 @@
|
||||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0.tgz",
|
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0.tgz",
|
||||||
"integrity": "sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA=="
|
"integrity": "sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA=="
|
||||||
},
|
},
|
||||||
|
"node_modules/seedrandom": {
|
||||||
|
"version": "3.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/seedrandom/-/seedrandom-3.0.5.tgz",
|
||||||
|
"integrity": "sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg=="
|
||||||
|
},
|
||||||
"node_modules/semver": {
|
"node_modules/semver": {
|
||||||
"version": "7.6.3",
|
"version": "7.6.3",
|
||||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz",
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz",
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@
|
||||||
"lucide-react": "^0.468.0",
|
"lucide-react": "^0.468.0",
|
||||||
"next": "15.0.4",
|
"next": "15.0.4",
|
||||||
"next-themes": "^0.4.4",
|
"next-themes": "^0.4.4",
|
||||||
|
"random-words": "^2.0.1",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"tailwind-merge": "^2.5.5",
|
"tailwind-merge": "^2.5.5",
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ export async function POST(request: Request) {
|
||||||
// Fetch our custom user data
|
// Fetch our custom user data
|
||||||
const {data: userData, error: userError} = await supabase
|
const {data: userData, error: userError} = await supabase
|
||||||
.from('users')
|
.from('users')
|
||||||
.select('*')
|
.select('*, public_key')
|
||||||
.eq('uuid', user?.id)
|
.eq('uuid', user?.id)
|
||||||
.single()
|
.single()
|
||||||
|
|
||||||
|
|
|
||||||
58
src/app/api/user/create/thread/route.ts
Normal file
58
src/app/api/user/create/thread/route.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
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();
|
||||||
|
|
||||||
|
if (!participant) {
|
||||||
|
return NextResponse.json({error: 'Participant not found'}, {status: 400});
|
||||||
|
}
|
||||||
|
|
||||||
|
const supabase = await createClient()
|
||||||
|
|
||||||
|
const {data: {user}, error: userError} = await supabase.auth.getUser()
|
||||||
|
console.log("From user: ", user?.id)
|
||||||
|
if (userError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{error: userError},
|
||||||
|
{status: userError?.status}
|
||||||
|
)
|
||||||
|
} else if (!user) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{error: "User not found"},
|
||||||
|
{status: 401}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** First we need to check if the requested participant is in the user's request array */
|
||||||
|
const dbUser = await getUserByUUID(supabase, user.id)
|
||||||
|
|
||||||
|
if (!dbUser) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{error: "User not found"},
|
||||||
|
{status: 401}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const requests = dbUser.requests as string[]
|
||||||
|
|
||||||
|
if (!requests.includes(participant)) {
|
||||||
|
return NextResponse.json({error: "Requested user not in requests array."}, {status: 400})
|
||||||
|
} else if (participant === dbUser.suuid) {
|
||||||
|
return NextResponse.json({error: "Cannot add self to a new thread"}, {status: 400})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Then we can create the thread */
|
||||||
|
|
||||||
|
const {error} = await supabase.rpc('create_private_thread', {
|
||||||
|
participant_suuid: participant
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return NextResponse.json({error}, {status: 500});
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({success: true}, {status: 200});
|
||||||
|
}
|
||||||
|
|
@ -26,8 +26,8 @@ export async function GET() {
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
if (data.length === 0) {
|
if (error) {
|
||||||
return NextResponse.json({threads: []}, {status: 200});
|
return NextResponse.json({error}, {status: 400})
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({threads: data}, {status: 200});
|
return NextResponse.json({threads: data}, {status: 200});
|
||||||
|
|
|
||||||
|
|
@ -28,15 +28,12 @@ export async function GET(request: Request) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const rpcResult = await supabase.rpc('search_users', {
|
const {data, error} = await supabase.rpc('search_users', {
|
||||||
search_term: uuid
|
search_term: uuid
|
||||||
});
|
});
|
||||||
|
|
||||||
const {data, error} = rpcResult;
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return NextResponse.json({error: error}, {status: 500});
|
return NextResponse.json({error: error}, {status: 500});
|
||||||
} else if (data.length === 0) {
|
|
||||||
return NextResponse.json({user: []}, {status: 200});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({exists: !!(data[0].suuid && data[0].username)}, {status: 200});
|
return NextResponse.json({exists: !!(data[0].suuid && data[0].username)}, {status: 200});
|
||||||
|
|
|
||||||
0
src/app/api/user/send/message/route.ts
Normal file
0
src/app/api/user/send/message/route.ts
Normal file
|
|
@ -2,28 +2,7 @@ import {createClient} from "@/lib/supabase/server";
|
||||||
import {NextResponse} from "next/server";
|
import {NextResponse} from "next/server";
|
||||||
import {SupabaseClient} from "@supabase/supabase-js";
|
import {SupabaseClient} from "@supabase/supabase-js";
|
||||||
import getUserByUUID from "@/lib/api/helpers/getUserByUUID";
|
import getUserByUUID from "@/lib/api/helpers/getUserByUUID";
|
||||||
|
import updateUserRequests from "@/lib/api/helpers/updateUserRequests";
|
||||||
async function updateUserRequests(searchTerm: string, requestSuuid: string, supabase: SupabaseClient<any, "public", any>) {
|
|
||||||
try {
|
|
||||||
|
|
||||||
const {data, error} = await supabase.rpc('update_user_requests', {
|
|
||||||
search_term: searchTerm,
|
|
||||||
new_request: requestSuuid
|
|
||||||
});
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {success: true, data};
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error updating user requests:', error);
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Unknown error occurred'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -49,7 +28,7 @@ export async function POST(request: Request) {
|
||||||
const userSuuid = getUser.suuid;
|
const userSuuid = getUser.suuid;
|
||||||
|
|
||||||
if (userSuuid === searchTerm) {
|
if (userSuuid === searchTerm) {
|
||||||
return NextResponse.json({success: false, hint: "Used self SUUID"}, {status: 409});
|
return NextResponse.json({success: false, hint: "Cannot send request to self"}, {status: 409});
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await updateUserRequests(searchTerm, userSuuid, supabase);
|
const result = await updateUserRequests(searchTerm, userSuuid, supabase);
|
||||||
|
|
|
||||||
24
src/app/api/user/send/update/key/route.ts
Normal file
24
src/app/api/user/send/update/key/route.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
// app/api/user/keys/update/route.ts
|
||||||
|
import {createClient} from "@/lib/supabase/server";
|
||||||
|
import {NextResponse} from "next/server";
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const {publicKey} = await request.json();
|
||||||
|
const supabase = await createClient();
|
||||||
|
|
||||||
|
const {error} = await supabase
|
||||||
|
.from('users')
|
||||||
|
.update({public_key: publicKey})
|
||||||
|
.eq('uuid', (await supabase.auth.getUser()).data.user?.id);
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
return NextResponse.json({success: true});
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{error: 'Failed to update public key'},
|
||||||
|
{status: 500}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -75,6 +75,7 @@ export default function AuthPage() {
|
||||||
message: string;
|
message: string;
|
||||||
action?: ToastActionElement | undefined;
|
action?: ToastActionElement | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isLogin) {
|
if (!isLogin) {
|
||||||
response = await Register(username, password);
|
response = await Register(username, password);
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import {CryptoManager} from "@/lib/crypto/keys";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @param username - The unique username of that user. This will be checked for collision.
|
* @param username - The unique username of that user. This will be checked for collision.
|
||||||
|
|
@ -6,13 +8,19 @@
|
||||||
*/
|
*/
|
||||||
export default async function Register(username: string, password: string) {
|
export default async function Register(username: string, password: string) {
|
||||||
try {
|
try {
|
||||||
|
const keyPair = await CryptoManager.generateUserKeys();
|
||||||
|
await CryptoManager.storePrivateKey(keyPair.privateKey);
|
||||||
|
|
||||||
|
// Export public key for server
|
||||||
|
const exportedPublic = await crypto.subtle.exportKey('jwk', keyPair.publicKey);
|
||||||
|
|
||||||
// Sends the request to the API
|
// Sends the request to the API
|
||||||
let res = await fetch('/api/auth/register', {
|
let res = await fetch('/api/auth/register', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({username, password}), // Stringifies the JSON
|
body: JSON.stringify({username, password, publicKey: exportedPublic}), // Stringifies the JSON
|
||||||
});
|
});
|
||||||
|
|
||||||
// Default error handler, if not OK just return whatever the API returned
|
// Default error handler, if not OK just return whatever the API returned
|
||||||
|
|
|
||||||
144
src/app/page.tsx
144
src/app/page.tsx
|
|
@ -17,11 +17,16 @@ import {
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
AlertDialogTrigger
|
AlertDialogTrigger
|
||||||
} from "@/components/ui/alert-dialog";
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import {CryptoManager} from "@/lib/crypto/keys";
|
||||||
|
import UpdateKey from "@/lib/crypto/helpers/updateKey";
|
||||||
|
|
||||||
export default function SiPher() {
|
export default function SiPher() {
|
||||||
const {theme, systemTheme} = useTheme();
|
const {theme, systemTheme} = useTheme();
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
|
/** CryptoManager Alert */
|
||||||
|
const [privateKeyPresent, setPrivateKeyPresent] = useState(true);
|
||||||
|
|
||||||
/** Consent Form states */
|
/** Consent Form states */
|
||||||
const [showConsentForm, setShowConsentForm] = useState(false);
|
const [showConsentForm, setShowConsentForm] = useState(false);
|
||||||
const [formError, setFormError] = useState("");
|
const [formError, setFormError] = useState("");
|
||||||
|
|
@ -37,6 +42,15 @@ export default function SiPher() {
|
||||||
setMounted(true);
|
setMounted(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
CryptoManager.getPrivateKey().then((res) => {
|
||||||
|
if (!res) {
|
||||||
|
console.log(res)
|
||||||
|
setPrivateKeyPresent(false);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param search_term Either the SUUID or username (If not indexable, will return false.)
|
* @param search_term Either the SUUID or username (If not indexable, will return false.)
|
||||||
*/
|
*/
|
||||||
|
|
@ -73,11 +87,15 @@ export default function SiPher() {
|
||||||
"Content-Type": "application/json"
|
"Content-Type": "application/json"
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
searchTerm: user, // SUUID or username
|
searchTerm: user, // SUUID or username
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!req.ok) return false;
|
if (!req.ok) {
|
||||||
|
const res = await req.json();
|
||||||
|
setFormError(res.hint);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
const {sent} = await req.json() as { sent: boolean };
|
const {sent} = await req.json() as { sent: boolean };
|
||||||
// If the user does not exist, just return it
|
// If the user does not exist, just return it
|
||||||
|
|
@ -96,43 +114,96 @@ export default function SiPher() {
|
||||||
|
|
||||||
const currentTheme = getTheme();
|
const currentTheme = getTheme();
|
||||||
|
|
||||||
return (
|
const MainPageAlerts = () => {
|
||||||
<>
|
return (
|
||||||
<AlertDialog open={showConsentForm} onOpenChange={(open) => {
|
<>
|
||||||
if (!open) setFormError("");
|
<AlertDialog open={showConsentForm} onOpenChange={(open) => {
|
||||||
}}>
|
if (!open) setFormError("");
|
||||||
<AlertDialogTrigger/>
|
}}>
|
||||||
<AlertDialogContent>
|
<AlertDialogTrigger/>
|
||||||
<AlertDialogHeader>
|
<AlertDialogContent>
|
||||||
<AlertDialogTitle>Consent Form</AlertDialogTitle>
|
<AlertDialogHeader>
|
||||||
<AlertDialogDescription className={"flex flex-col space-y-1"}>
|
<AlertDialogTitle>Consent Form</AlertDialogTitle>
|
||||||
<span>
|
<AlertDialogDescription className={"flex flex-col space-y-1"}>
|
||||||
|
{
|
||||||
|
formError ? (
|
||||||
|
<span className={"text-red-500"}>{formError}</span>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
<span>
|
||||||
Are you sure you want to contact <span className={"font-bold"}>{inputValue}</span>?
|
Are you sure you want to contact <span className={"font-bold"}>{inputValue}</span>?
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
By continuing, <span className={"font-bold"}>{inputValue}</span> will receive a notification to accept
|
By continuing, <span className={"font-bold"}>{inputValue}</span> will receive a notification to accept
|
||||||
it. If accepted, that user will appear on your sidebar, if rejected, you will never know about it.
|
it. If accepted, that user will appear on your sidebar, if rejected, you will never know about it.
|
||||||
</span>
|
</span>
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel
|
<AlertDialogCancel
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowConsentForm(false);
|
setShowConsentForm(false);
|
||||||
setInputDisabled(false);
|
setInputDisabled(false);
|
||||||
}}
|
}}
|
||||||
>Cancel</AlertDialogCancel>
|
>Cancel</AlertDialogCancel>
|
||||||
<AlertDialogAction
|
<AlertDialogAction
|
||||||
onClick={() => {
|
disabled={formError.length < 0}
|
||||||
sendRequest(inputValue).then((result) => {
|
onClick={() => {
|
||||||
if (!result) setFormError("Could not send notification for whatever reason. Sorry.");
|
sendRequest(inputValue);
|
||||||
});
|
setInputDisabled(false);
|
||||||
setInputDisabled(false);
|
setShowConsentForm(false);
|
||||||
}}
|
}}
|
||||||
>Continue</AlertDialogAction>
|
>Continue</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
|
|
||||||
|
<AlertDialog open={!privateKeyPresent}>
|
||||||
|
<AlertDialogTrigger/>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<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>
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel
|
||||||
|
onClick={() => {
|
||||||
|
setShowConsentForm(false);
|
||||||
|
setInputDisabled(false);
|
||||||
|
}}
|
||||||
|
>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={() => {
|
||||||
|
sendRequest(inputValue).then((result) => {
|
||||||
|
if (!result) setFormError("Could not send notification for whatever reason. Sorry.");
|
||||||
|
});
|
||||||
|
setInputDisabled(false);
|
||||||
|
}}
|
||||||
|
>Try Again</AlertDialogAction>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={() => {
|
||||||
|
UpdateKey().then((result) => {
|
||||||
|
if (result.status !== 200) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPrivateKeyPresent(true)
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>Regenerate</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<MainPageAlerts/>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={`relative flex-1 ${currentTheme === "dark" ? "dark" : ""} w-full max-h-[600px] bg-gradient-to-b from-background to-background/95`}>
|
className={`relative flex-1 ${currentTheme === "dark" ? "dark" : ""} w-full max-h-[600px] bg-gradient-to-b from-background to-background/95`}>
|
||||||
<div className="relative flex flex-col justify-center items-center h-screen px-4 select-none space-y-8">
|
<div className="relative flex flex-col justify-center items-center h-screen px-4 select-none space-y-8">
|
||||||
|
|
@ -153,7 +224,8 @@ export default function SiPher() {
|
||||||
<div className="max-w-2xl space-y-6 text-center">
|
<div className="max-w-2xl space-y-6 text-center">
|
||||||
<p className="text-lg md:text-xl font-medium leading-relaxed text-primary">
|
<p className="text-lg md:text-xl font-medium leading-relaxed text-primary">
|
||||||
Where shadows dance and secrets nest, Silent Whisper serves as the dark sanctuary for those
|
Where shadows dance and secrets nest, Silent Whisper serves as the dark sanctuary for those
|
||||||
who value discretion above all. Born from ancient corvid traditions, this messenger’s haven ensures your
|
who value discretion above all. Born from ancient corvid traditions, this messenger’s haven ensures
|
||||||
|
your
|
||||||
whispers remain unheard by all but their intended recipients.
|
whispers remain unheard by all but their intended recipients.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ export function RealtimeRequests(
|
||||||
table: 'users',
|
table: 'users',
|
||||||
filter: `uuid=eq.${user.uuid}`,
|
filter: `uuid=eq.${user.uuid}`,
|
||||||
}, async (payload) => {
|
}, async (payload) => {
|
||||||
|
console.log(payload)
|
||||||
if (payload.new.requests !== payload.old.requests) {
|
if (payload.new.requests !== payload.old.requests) {
|
||||||
try {
|
try {
|
||||||
setRequests(payload.new.requests)
|
setRequests(payload.new.requests)
|
||||||
|
|
|
||||||
66
src/components/main/realtime/threads.tsx
Normal file
66
src/components/main/realtime/threads.tsx
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
// hooks/useRealtime.ts
|
||||||
|
import {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[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRealtime({setThreads}: UseRealtimeProps) {
|
||||||
|
const supabase = createBrowserClient();
|
||||||
|
const {user, updateUser} = useUser();
|
||||||
|
const {toast} = useToast();
|
||||||
|
|
||||||
|
const fetchAndUpdateThreads = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/user/get/threads");
|
||||||
|
if (response.ok) {
|
||||||
|
const {threads} = await response.json();
|
||||||
|
console.log('Setting threads:', threads);
|
||||||
|
setThreads(threads);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching threads:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user) return;
|
||||||
|
|
||||||
|
const userUpdate = supabase
|
||||||
|
.channel("request updates")
|
||||||
|
.on("postgres_changes", {
|
||||||
|
event: "*",
|
||||||
|
schema: 'public',
|
||||||
|
table: 'users',
|
||||||
|
filter: `uuid=eq.${user.uuid}`,
|
||||||
|
}, async (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
|
||||||
|
// the corresponding request.
|
||||||
|
if (payload.new.requests !== payload.old.requests) {
|
||||||
|
updateUser({
|
||||||
|
...user,
|
||||||
|
requests: payload.new.requests
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}).subscribe()
|
||||||
|
|
||||||
|
const threadUpdate = supabase
|
||||||
|
.channel("thread updates")
|
||||||
|
.on("postgres_changes", {
|
||||||
|
event: "*",
|
||||||
|
schema: 'public',
|
||||||
|
// Using on this one because it's easier
|
||||||
|
table: "thread_participants",
|
||||||
|
filter: `user_uuid=${user.uuid}`,
|
||||||
|
}, async (payload) => {
|
||||||
|
console.log(payload)
|
||||||
|
}).subscribe()
|
||||||
|
}, [user?.uuid]);
|
||||||
|
}
|
||||||
202
src/components/main/sidebar/rightsidebar.tsx
Normal file
202
src/components/main/sidebar/rightsidebar.tsx
Normal file
|
|
@ -0,0 +1,202 @@
|
||||||
|
import React, {useCallback, useEffect, useState} from "react";
|
||||||
|
import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from "@/components/ui/tooltip";
|
||||||
|
import {Avatar, AvatarFallback} from "@/components/ui/avatar";
|
||||||
|
import {Separator} from "@/components/ui/separator";
|
||||||
|
import {ScrollArea} from "@/components/ui/scroll-area";
|
||||||
|
import {DropdownMenu, DropdownMenuContent, DropdownMenuTrigger} from "@/components/ui/dropdown-menu";
|
||||||
|
import {Check, LogOut, Mail, MailPlus, X} from "lucide-react";
|
||||||
|
import {Button} from "@/components/ui/button";
|
||||||
|
import {GearIcon} from "@radix-ui/react-icons";
|
||||||
|
import Link from "next/link";
|
||||||
|
import {useRealtime} from "@/components/main/realtime/threads";
|
||||||
|
import {useUser} from "@/contexts/user";
|
||||||
|
|
||||||
|
interface RightSidebarContentProps {
|
||||||
|
isDarkMode: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {user} = useUser();
|
||||||
|
const {username, suuid, requests = []} = user;
|
||||||
|
|
||||||
|
|
||||||
|
// 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[] | [] }
|
||||||
|
setThreads(threads)
|
||||||
|
} else {
|
||||||
|
setThreads([])
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
setThreads([])
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchThreads();
|
||||||
|
}, [fetchThreads]);
|
||||||
|
|
||||||
|
const handleAccept = async (request: string) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/user/create/thread", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({participant: request}),
|
||||||
|
});
|
||||||
|
if (response.ok) {
|
||||||
|
// Optionally refresh threads after successful creation
|
||||||
|
fetchThreads();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error accepting request:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={`flex flex-col h-full w-[240px]`}>
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip open={copied} onOpenChange={setCopied}>
|
||||||
|
<TooltipTrigger/>
|
||||||
|
<TooltipContent arrowPadding={10} className={"p-2 shadow-cyan-950 shadow-md"}>
|
||||||
|
Copied SUUID to clipboard!
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
<div
|
||||||
|
onClick={() => {
|
||||||
|
setCopied(true)
|
||||||
|
navigator.clipboard.writeText(suuid)
|
||||||
|
}}
|
||||||
|
className={`flex items-center p-3 m-2 ${isDarkMode ? "hover:bg-accent/90" : "hover:bg-secondary/20"} rounded-full transition-colors duration-200 cursor-pointer select-none`}>
|
||||||
|
<Avatar className="w-12 h-12 mr-3">
|
||||||
|
<AvatarFallback>{username.charAt(0)}</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div>
|
||||||
|
<h3 className={`font-semibold text-[17px] ${isDarkMode ? "text-white" : "text-black"}`}>{username}</h3>
|
||||||
|
<p className="text-xs text-muted-foreground">${suuid}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Separator className="my-2"/>
|
||||||
|
<ScrollArea className="flex-grow max-h-[590px] px-4 py-4">
|
||||||
|
<nav>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger>
|
||||||
|
<div className={"flex flex-row items-center w-full justify-start text-[17px]"}>
|
||||||
|
{
|
||||||
|
(user.requests?.length ?? 0) > 0 ? (
|
||||||
|
<MailPlus className="w-8 h-8 mr-3 p-1"/>
|
||||||
|
) : (
|
||||||
|
<Mail className="w-8 h-8 mr-3 p-1"/>
|
||||||
|
)}
|
||||||
|
Requests
|
||||||
|
</div>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent className="px-4 py-1 w-56" side={"right"}>
|
||||||
|
<div className={"flex flex-row w-full justify-between items-center select-none"}>
|
||||||
|
<p>User</p>
|
||||||
|
<p>Decline | Accept</p>
|
||||||
|
</div>
|
||||||
|
{
|
||||||
|
pendingRequests > 0 && requests!.map((request, item) => {
|
||||||
|
return (
|
||||||
|
<div key={item} className={"flex flex-col w-full"}>
|
||||||
|
<Separator className="my-2"/>
|
||||||
|
<div key={item} className={"flex flex-row space-x-2 w-full items-center"}>
|
||||||
|
<p className={"text-secondary-foreground"}>{request}</p>
|
||||||
|
<div className={"flex flex-row justify-end space-x-1 w-full"}>
|
||||||
|
<Button size={"icon"} className={"bg-red-500"}>
|
||||||
|
<X className={"w-4 h-4"}/>
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => {
|
||||||
|
handleAccept(request)
|
||||||
|
}} size={"icon"} className={"bg-green-500"}>
|
||||||
|
<Check className={"w-4 h-4"}/>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}) || (
|
||||||
|
<p>Nothing new here</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
<Separator className="my-2"/>
|
||||||
|
{threads && threads.length > 0 ? (
|
||||||
|
threads.map((thread, index) => {
|
||||||
|
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">
|
||||||
|
<Avatar className="w-8 h-8 mr-3">
|
||||||
|
<AvatarFallback>{otherUser.charAt(0).toUpperCase()}</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
{otherUser}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<p>No threads available</p>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</ScrollArea>
|
||||||
|
<div className="p-3 space-y-3">
|
||||||
|
<Separator/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full justify-start text-[17px] py-2 text-primary"
|
||||||
|
onClick={() => window.location.href = "/config"}
|
||||||
|
>
|
||||||
|
<GearIcon className="w-4 h-4 mr-3"/>
|
||||||
|
Settings
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => {
|
||||||
|
fetch("/api/auth/logout", {
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
}).then((response) => {
|
||||||
|
if (response.ok) {
|
||||||
|
window.location.href = "/auth/login"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}} variant="outline" className="w-full justify-start text-[17px] py-2 text-destructive">
|
||||||
|
<LogOut className="w-4 h-4 mr-3"/>
|
||||||
|
Log Out
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,23 +1,15 @@
|
||||||
"use client"
|
"use client"
|
||||||
import React, {useCallback, useEffect, useState} from "react"
|
import React from "react"
|
||||||
import {usePathname} from "next/navigation"
|
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import {AnimatePresence, motion} from "framer-motion"
|
import {AnimatePresence, motion} from "framer-motion"
|
||||||
import {Check, LogOut, Mail, MailPlus, X} from "lucide-react"
|
import {X} from "lucide-react"
|
||||||
import {Button} from "@/components/ui/button"
|
import {Button} from "@/components/ui/button"
|
||||||
import {Avatar, AvatarFallback} from "@/components/ui/avatar"
|
|
||||||
import {Separator} from "@/components/ui/separator"
|
|
||||||
import {ScrollArea} from "@/components/ui/scroll-area"
|
|
||||||
import {GearIcon} from "@radix-ui/react-icons"
|
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import MobileHeader from "@/components/main/sidebar/mobile";
|
import MobileHeader from "@/components/main/sidebar/mobile";
|
||||||
import {useUser} from "@/contexts/user";
|
|
||||||
import {useRefs, useUIState} from "@/hooks/shared-states";
|
import {useRefs, useUIState} from "@/hooks/shared-states";
|
||||||
import {useToast} from "@/hooks/use-toast";
|
import {useToast} from "@/hooks/use-toast";
|
||||||
import {useTheme} from "next-themes";
|
import {useTheme} from "next-themes";
|
||||||
import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from "@/components/ui/tooltip";
|
import RightSidebarContent from "@/components/main/sidebar/rightsidebar";
|
||||||
import {DropdownMenu, DropdownMenuContent, DropdownMenuTrigger} from "@/components/ui/dropdown-menu";
|
|
||||||
import {RealtimeRequests} from "@/components/main/realtime/request";
|
|
||||||
|
|
||||||
type SidebarProps = {
|
type SidebarProps = {
|
||||||
children?: React.ReactNode
|
children?: React.ReactNode
|
||||||
|
|
@ -28,213 +20,24 @@ function Sidebar(
|
||||||
children
|
children
|
||||||
}: SidebarProps
|
}: SidebarProps
|
||||||
) {
|
) {
|
||||||
const pathname = usePathname()
|
|
||||||
|
|
||||||
const [selectedThreads, setSelectedThreads] = useState("");
|
|
||||||
const [threads, setThreads] = useState<SiPher.Messages[] | []>([]);
|
|
||||||
const [threadMenu, setThreadMenu] = useState<SiPher.Messages[] | []>([]);
|
|
||||||
const [copied, setCopied] = useState<boolean>(false);
|
|
||||||
const {theme, systemTheme} = useTheme();
|
const {theme, systemTheme} = useTheme();
|
||||||
const {toast} = useToast();
|
const {toast} = useToast();
|
||||||
|
|
||||||
const {isDrawerOpen, setIsDrawerOpen} = useUIState();
|
const {isDrawerOpen, setIsDrawerOpen} = useUIState();
|
||||||
const {drawerRef} = useRefs();
|
const {drawerRef} = useRefs();
|
||||||
|
|
||||||
const [requests, setRequests] = useState<string[]>([]);
|
|
||||||
const [pendingRequest, setPendingRequest] = useState<number>(0);
|
|
||||||
|
|
||||||
const {user, getUser} = useUser();
|
|
||||||
|
|
||||||
const {
|
|
||||||
username,
|
|
||||||
suuid
|
|
||||||
} = user;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setPendingRequest(requests.length || 0);
|
|
||||||
}, [requests, setPendingRequest]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setPendingRequest(user.requests.length);
|
|
||||||
setRequests(user.requests);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const getThreads = async () => {
|
|
||||||
try {
|
|
||||||
const req = await fetch("/api/user/get/threads")
|
|
||||||
if (req.ok) {
|
|
||||||
const {threads} = await req.json() as { threads: SiPher.Messages[] | [] }
|
|
||||||
setThreads(threads)
|
|
||||||
} else {
|
|
||||||
setThreads([])
|
|
||||||
toast({
|
|
||||||
title: "Error",
|
|
||||||
description: "An unknown error occurred",
|
|
||||||
variant: "destructive",
|
|
||||||
duration: 5000,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
setThreads([])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
getThreads()
|
|
||||||
return () => setThreads([])
|
|
||||||
}, [toast])
|
|
||||||
|
|
||||||
const generateThreads = useCallback(() => {
|
|
||||||
threads.map(async (thread) => {
|
|
||||||
if (thread.participants.length > 2) {
|
|
||||||
return (
|
|
||||||
<li key={thread.id}>
|
|
||||||
<Link href={thread.id} passHref>
|
|
||||||
<Button
|
|
||||||
variant={pathname === thread.id ? "secondary" : "ghost"}
|
|
||||||
className="w-full justify-start text-[17px] py-4"
|
|
||||||
>
|
|
||||||
<Avatar className="w-8 h-8 mr-3 p-1">
|
|
||||||
<AvatarFallback>{thread.name!}</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
{thread.name!}
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
const fetchOtherUser = async () => {
|
|
||||||
await getUser("fetchOtherUser - const", thread.id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}, [threads])
|
|
||||||
|
|
||||||
const isDarkMode = theme === "system"
|
const isDarkMode = theme === "system"
|
||||||
? systemTheme === "dark"
|
? systemTheme === "dark"
|
||||||
: theme === "dark"
|
: theme === "dark"
|
||||||
|
|
||||||
const RightSidebarContent = () => (
|
|
||||||
<div className={`flex flex-col h-full w-[240px]`}>
|
const handleAcceptRequest = async () => {
|
||||||
<TooltipProvider>
|
|
||||||
<Tooltip open={copied} onOpenChange={setCopied}>
|
}
|
||||||
<TooltipTrigger/>
|
|
||||||
<TooltipContent arrowPadding={10} className={"p-2 shadow-cyan-950 shadow-md"}>
|
|
||||||
Copied SUUID to clipboard!
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
<div
|
|
||||||
onClick={() => {
|
|
||||||
setCopied(true)
|
|
||||||
navigator.clipboard.writeText(suuid)
|
|
||||||
}}
|
|
||||||
className={`flex items-center p-3 m-2 ${isDarkMode ? "hover:bg-accent/90" : "hover:bg-secondary/20"} rounded-full transition-colors duration-200 cursor-pointer select-none`}>
|
|
||||||
<Avatar className="w-12 h-12 mr-3">
|
|
||||||
<AvatarFallback>{username.charAt(0)}</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
<div>
|
|
||||||
<h3 className={`font-semibold text-[17px] ${isDarkMode ? "text-white" : "text-black"}`}>{username}</h3>
|
|
||||||
<p className="text-xs text-muted-foreground">${suuid}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Separator className="my-2"/>
|
|
||||||
<ScrollArea className="flex-grow max-h-[590px] px-4 py-4">
|
|
||||||
<nav>
|
|
||||||
<ul className="space-y-1">
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger>
|
|
||||||
<div className={"flex flex-row items-center w-full justify-start text-[17px]"}>
|
|
||||||
{
|
|
||||||
pendingRequest > 0 ? (
|
|
||||||
<MailPlus className="w-8 h-8 mr-3 p-1"/>
|
|
||||||
) : (
|
|
||||||
<Mail className="w-8 h-8 mr-3 p-1"/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Requests
|
|
||||||
</div>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent className="px-4 py-1 w-56" side={"right"}>
|
|
||||||
<div className={"flex flex-row w-full justify-between items-center select-none"}>
|
|
||||||
<p>User</p>
|
|
||||||
<p>Decline | Accept</p>
|
|
||||||
</div>
|
|
||||||
{
|
|
||||||
pendingRequest > 0 && requests.map((request, item) => {
|
|
||||||
return (
|
|
||||||
<div key={item} className={"flex flex-col w-full"}>
|
|
||||||
<Separator className="my-2"/>
|
|
||||||
<div key={item} className={"flex flex-row space-x-2 w-full items-center"}>
|
|
||||||
<p className={"text-secondary-foreground"}>{request}</p>
|
|
||||||
<div className={"flex flex-row justify-end space-x-1 w-full"}>
|
|
||||||
<Button size={"icon"} className={"bg-red-500"}>
|
|
||||||
<X className={"w-4 h-4"}/>
|
|
||||||
</Button>
|
|
||||||
<Button size={"icon"} className={"bg-green-500"}>
|
|
||||||
<Check className={"w-4 h-4"}/>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}) || (
|
|
||||||
<p>Nothing new here</p>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
{threads.map((thread) => (
|
|
||||||
<li key={thread.id}>
|
|
||||||
<Link href={thread.id} passHref>
|
|
||||||
<Button
|
|
||||||
variant={pathname === thread.id ? "secondary" : "ghost"}
|
|
||||||
className="w-full justify-start text-[17px] py-4"
|
|
||||||
>
|
|
||||||
<Avatar className="w-8 h-8 mr-3 p-1">
|
|
||||||
<AvatarFallback>{thread.id}</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
{thread.id}
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
</ScrollArea>
|
|
||||||
<div className="p-3 space-y-3">
|
|
||||||
<Separator/>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="w-full justify-start text-[17px] py-2 text-primary"
|
|
||||||
onClick={() => window.location.href = "/config"}
|
|
||||||
>
|
|
||||||
<GearIcon className="w-4 h-4 mr-3"/>
|
|
||||||
Settings
|
|
||||||
</Button>
|
|
||||||
<Button onClick={() => {
|
|
||||||
fetch("/api/auth/logout", {
|
|
||||||
method: "GET",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json"
|
|
||||||
},
|
|
||||||
}).then((response) => {
|
|
||||||
if (response.ok) {
|
|
||||||
window.location.href = "/auth/login"
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}} variant="outline" className="w-full justify-start text-[17px] py-2 text-destructive">
|
|
||||||
<LogOut className="w-4 h-4 mr-3"/>
|
|
||||||
Log Out
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<MobileHeader/>
|
<MobileHeader/>
|
||||||
<RealtimeRequests setRequests={setRequests}/>
|
|
||||||
<aside
|
<aside
|
||||||
className={`hidden lg:flex flex-col items-end h-screen max-h-[900px] sticky top-0 border-r border-border ${
|
className={`hidden lg:flex flex-col items-end h-screen max-h-[900px] sticky top-0 border-r border-border ${
|
||||||
isDarkMode ? "bg-background" : "white"
|
isDarkMode ? "bg-background" : "white"
|
||||||
|
|
@ -244,7 +47,7 @@ function Sidebar(
|
||||||
<Link href={"/"} passHref className={"flex flex-row items-center ml-1.5"}>
|
<Link href={"/"} passHref className={"flex flex-row items-center ml-1.5"}>
|
||||||
<Image
|
<Image
|
||||||
src={isDarkMode ? "/logos/logo.png" : "/logos/logo-light.png"}
|
src={isDarkMode ? "/logos/logo.png" : "/logos/logo-light.png"}
|
||||||
alt="Tocka‘s Nest"
|
alt="SiPher Space"
|
||||||
width={64}
|
width={64}
|
||||||
height={64}
|
height={64}
|
||||||
className="w-16 h-16 cursor-pointer rounded-full antialiased"
|
className="w-16 h-16 cursor-pointer rounded-full antialiased"
|
||||||
|
|
@ -252,7 +55,9 @@ function Sidebar(
|
||||||
<p className={"text-center text-xl font-bold antialiased"}>SiPher</p>
|
<p className={"text-center text-xl font-bold antialiased"}>SiPher</p>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<RightSidebarContent/>
|
<RightSidebarContent
|
||||||
|
isDarkMode={isDarkMode}
|
||||||
|
/>
|
||||||
</aside>
|
</aside>
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{isDrawerOpen && (
|
{isDrawerOpen && (
|
||||||
|
|
@ -276,7 +81,9 @@ function Sidebar(
|
||||||
<X className="w-5 h-5"/>
|
<X className="w-5 h-5"/>
|
||||||
<span className="sr-only">Close menu</span>
|
<span className="sr-only">Close menu</span>
|
||||||
</Button>
|
</Button>
|
||||||
<RightSidebarContent/>
|
<RightSidebarContent
|
||||||
|
isDarkMode={isDarkMode}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,9 @@ import {SupabaseClient} from "@supabase/supabase-js";
|
||||||
export default async function getUserByUUID(supabase: SupabaseClient<any, "public", any>, uuid: string) {
|
export default async function getUserByUUID(supabase: SupabaseClient<any, "public", any>, uuid: string) {
|
||||||
const {data: userData, error: userError} = await supabase
|
const {data: userData, error: userError} = await supabase
|
||||||
.from('users')
|
.from('users')
|
||||||
.select('*')
|
.select('*, public_key')
|
||||||
.eq('uuid', uuid)
|
.eq('uuid', uuid)
|
||||||
.single();
|
.single()
|
||||||
|
|
||||||
if (userError) throw userError;
|
if (userError) throw userError;
|
||||||
return userData;
|
return userData;
|
||||||
|
|
|
||||||
23
src/lib/api/helpers/updateUserRequests.ts
Normal file
23
src/lib/api/helpers/updateUserRequests.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
import {SupabaseClient} from "@supabase/supabase-js";
|
||||||
|
|
||||||
|
export default async function updateUserRequests(searchTerm: string, requestSuuid: string, supabase: SupabaseClient<any, "public", any>) {
|
||||||
|
try {
|
||||||
|
|
||||||
|
const {data, error} = await supabase.rpc('update_user_requests', {
|
||||||
|
search_term: searchTerm,
|
||||||
|
new_request: requestSuuid
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {success: true, data};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating user requests:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error occurred'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
27
src/lib/crypto/helpers/updateKey.ts
Normal file
27
src/lib/crypto/helpers/updateKey.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import {CryptoManager} from "@/lib/crypto/keys";
|
||||||
|
|
||||||
|
export default async function UpdateKey() {
|
||||||
|
const keyPair = await CryptoManager.generateUserKeys();
|
||||||
|
await CryptoManager.storePrivateKey(keyPair.privateKey);
|
||||||
|
const exportedPublic = await crypto.subtle.exportKey('jwk', keyPair.publicKey);
|
||||||
|
|
||||||
|
const req = await fetch("/api/user/send/update/key", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({publicKey: exportedPublic}),
|
||||||
|
})
|
||||||
|
|
||||||
|
if(req.status !== 200) {
|
||||||
|
await CryptoManager.deletePrivateKey();
|
||||||
|
return {
|
||||||
|
status: req.status,
|
||||||
|
message: "Failed to update public key",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
message: "Successfully updated keys",
|
||||||
|
}
|
||||||
|
}
|
||||||
192
src/lib/crypto/keys.ts
Normal file
192
src/lib/crypto/keys.ts
Normal file
|
|
@ -0,0 +1,192 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @filedoc: When creating this, I thought that using PBKDF2 would be the best choice, which it isn't since I would have
|
||||||
|
* to share passwords between user, and to do that I would have to pass the password through the server, which would defeat
|
||||||
|
* both PBKDF2 and E2EE methods.
|
||||||
|
* So I went with a better approach: Using public/private keys and signing messages with the public user's key and my own
|
||||||
|
* key
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A kinda-simple CryptoManager to handle keys and encrypt/decrypt messages.
|
||||||
|
* Uses IndexedDB to store private keys securely.
|
||||||
|
*/
|
||||||
|
export class CryptoManager {
|
||||||
|
private static readonly DB_NAME = 'SipherKeyStore';
|
||||||
|
private static readonly DB_VERSION = 1;
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
static async generateUserKeys(): Promise<CryptoKeyPair> {
|
||||||
|
return await crypto.subtle.generateKey(
|
||||||
|
{
|
||||||
|
name: "RSA-OAEP",
|
||||||
|
modulusLength: 2048,
|
||||||
|
publicExponent: new Uint8Array([1, 0, 1]),
|
||||||
|
hash: "SHA-256",
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
["encrypt", "decrypt"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores the private key.
|
||||||
|
* @param {CryptoKey} privateKey - The private key to store.
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
static async storePrivateKey(privateKey: CryptoKey): Promise<void> {
|
||||||
|
const exportedPrivate = await crypto.subtle.exportKey('jwk', privateKey);
|
||||||
|
const db = await this.openDB();
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const transaction = db.transaction(this.STORE_NAME, 'readwrite');
|
||||||
|
const store = transaction.objectStore(this.STORE_NAME);
|
||||||
|
const request = store.put(exportedPrivate, this.KEY_ID);
|
||||||
|
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
request.onsuccess = () => resolve();
|
||||||
|
|
||||||
|
transaction.oncomplete = () => db.close();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes the private key.
|
||||||
|
* @param {CryptoKey} privateKey - The private key to store.
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
static async deletePrivateKey(): Promise<void> {
|
||||||
|
const db = await this.openDB();
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const transaction = db.transaction(this.STORE_NAME, 'readwrite');
|
||||||
|
const store = transaction.objectStore(this.STORE_NAME);
|
||||||
|
const request = store.delete(this.KEY_ID);
|
||||||
|
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
request.onsuccess = () => resolve();
|
||||||
|
|
||||||
|
transaction.oncomplete = () => db.close();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the stored private key from IndexedDB. Might return `null` if nothing's there.
|
||||||
|
* @returns {Promise<CryptoKey | null>} The private key or `null` if not found.
|
||||||
|
*/
|
||||||
|
static async getPrivateKey(): Promise<CryptoKey | null> {
|
||||||
|
try {
|
||||||
|
const db = await this.openDB();
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const transaction = db.transaction(this.STORE_NAME, 'readonly');
|
||||||
|
const store = transaction.objectStore(this.STORE_NAME);
|
||||||
|
const request = store.get(this.KEY_ID);
|
||||||
|
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
request.onsuccess = async () => {
|
||||||
|
if (!request.result) {
|
||||||
|
resolve(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const privateKey = await crypto.subtle.importKey(
|
||||||
|
'jwk',
|
||||||
|
request.result,
|
||||||
|
{
|
||||||
|
name: "RSA-OAEP",
|
||||||
|
hash: "SHA-256",
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
["decrypt"]
|
||||||
|
);
|
||||||
|
resolve(privateKey);
|
||||||
|
};
|
||||||
|
|
||||||
|
transaction.oncomplete = () => db.close();
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Oops! Error retrieving private key:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypts a message using the recipient's public key.
|
||||||
|
* @param {string} message - The message you wanna encrypt.
|
||||||
|
* @param {JsonWebKey} recipientPublicKey - The recipient's public key in JWK format.
|
||||||
|
* @returns {Promise<string>} The encrypted message in base64 format.
|
||||||
|
*/
|
||||||
|
static async encryptMessage(message: string, recipientPublicKey: JsonWebKey): Promise<string> {
|
||||||
|
const publicKey = await crypto.subtle.importKey(
|
||||||
|
'jwk',
|
||||||
|
recipientPublicKey,
|
||||||
|
{
|
||||||
|
name: "RSA-OAEP",
|
||||||
|
hash: "SHA-256",
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
["encrypt"]
|
||||||
|
);
|
||||||
|
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const encrypted = await crypto.subtle.encrypt(
|
||||||
|
{
|
||||||
|
name: "RSA-OAEP",
|
||||||
|
},
|
||||||
|
publicKey,
|
||||||
|
encoder.encode(message)
|
||||||
|
);
|
||||||
|
|
||||||
|
return btoa(String.fromCharCode(...new Uint8Array(encrypted)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypts a message using your own private key.
|
||||||
|
* @param {string} encryptedMessage - The encrypted message (base64 format).
|
||||||
|
* @returns {Promise<string>} The decrypted message.
|
||||||
|
* @throws Will throw an error if no private key is found.
|
||||||
|
*/
|
||||||
|
static async decryptMessage(encryptedMessage: string): Promise<string> {
|
||||||
|
const privateKey = await this.getPrivateKey();
|
||||||
|
if (!privateKey) throw new Error("No private key found");
|
||||||
|
|
||||||
|
const encrypted = new Uint8Array(
|
||||||
|
atob(encryptedMessage).split('').map((char) => char.charCodeAt(0))
|
||||||
|
);
|
||||||
|
|
||||||
|
const decrypted = await crypto.subtle.decrypt(
|
||||||
|
{
|
||||||
|
name: "RSA-OAEP",
|
||||||
|
},
|
||||||
|
privateKey,
|
||||||
|
encrypted
|
||||||
|
);
|
||||||
|
|
||||||
|
return new TextDecoder().decode(decrypted);
|
||||||
|
}
|
||||||
|
}
|
||||||
26
src/types/user.d.ts
vendored
26
src/types/user.d.ts
vendored
|
|
@ -1,9 +1,10 @@
|
||||||
|
import {Json} from "../../database.types";
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
namespace SiPher {
|
namespace SiPher {
|
||||||
type Messages = {
|
type Messages = {
|
||||||
id: string;
|
thread_id: string;
|
||||||
participants: string[];
|
participants: string[];
|
||||||
name?: string;
|
|
||||||
messages: {
|
messages: {
|
||||||
id: string;
|
id: string;
|
||||||
content: string;
|
content: string;
|
||||||
|
|
@ -12,20 +13,13 @@ declare global {
|
||||||
}
|
}
|
||||||
|
|
||||||
type User = {
|
type User = {
|
||||||
/** Represents the unique username of a user. */
|
created_at: string
|
||||||
username: string,
|
indexable: boolean | null
|
||||||
/** The encrypted password of said user. */
|
public_key: Json | null
|
||||||
password: string,
|
requests: string[] | null
|
||||||
/** Unique UUID, long */
|
suuid: string
|
||||||
uuid: string,
|
username: string
|
||||||
/** Short UUID, for index reasons */
|
uuid: string
|
||||||
suuid: string,
|
|
||||||
/** Created at timestamp in UTC */
|
|
||||||
created_at: string,
|
|
||||||
/** Messages field */
|
|
||||||
messages: Messages[]
|
|
||||||
/** Consent Requests */
|
|
||||||
requests: string[] // Only accessible to the current user logged in. Will contain an array of SUUIDs
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
1
supabase/.temp/cli-latest
Normal file
1
supabase/.temp/cli-latest
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
v2.0.0
|
||||||
Loading…
Add table
Reference in a new issue