diff --git a/core/src/api/keys.rs b/core/src/api/keys.rs index 82c671308..9904dfce5 100644 --- a/core/src/api/keys.rs +++ b/core/src/api/keys.rs @@ -1,6 +1,8 @@ +use super::utils::library; use super::{Ctx, SanitizedNodeConfig, R}; use rspc::{alpha::AlphaRouter, ErrorCode}; use sd_crypto::cookie::CookieCipher; +use serde_json::{json, Map, Value}; use std::path::{Path, PathBuf}; use std::sync::Arc; use tokio::io::AsyncWriteExt; @@ -183,4 +185,162 @@ pub(crate) fn mount() -> AlphaRouter { } }) }) + .procedure("saveEmailAddress", { + R.with2(library()) + .mutation(move |(node, library), args: String| async move { + let path = node + .libraries + .libraries_dir + .join(format!("{}.sdlibrary", library.id)); + + let mut config = serde_json::from_slice::>( + &tokio::fs::read(path.clone()).await.map_err(|e| { + rspc::Error::new( + ErrorCode::InternalServerError, + format!("Failed to read library config: {:?}", e.to_string()), + ) + })?, + ) + .map_err(|e: serde_json::Error| { + rspc::Error::new( + ErrorCode::InternalServerError, + format!("Failed to parse library config: {:?}", e.to_string()), + ) + })?; + + // Encrypt the email address + // Create new cipher with the library id as the key + let uuid_key = + CookieCipher::generate_key_from_string(library.id.to_string().as_str()) + .map_err(|e| { + error!("Failed to generate key: {:?}", e.to_string()); + rspc::Error::new( + ErrorCode::InternalServerError, + "Failed to generate key".to_string(), + ) + })?; + + let cipher = CookieCipher::new(&uuid_key).map_err(|e| { + error!("Failed to create cipher: {:?}", e.to_string()); + rspc::Error::new( + ErrorCode::InternalServerError, + "Failed to create cipher".to_string(), + ) + })?; + + let en_data = cipher.encrypt(args.as_bytes()).map_err(|e| { + error!("Failed to encrypt data: {:?}", e.to_string()); + rspc::Error::new( + ErrorCode::InternalServerError, + "Failed to encrypt data".to_string(), + ) + })?; + + let en_data = CookieCipher::base64_encode(&en_data); + + config.remove("cloud_email_address"); + config.insert("cloud_email_address".to_string(), json!(en_data)); + + tokio::fs::write( + path, + serde_json::to_vec(&config).map_err(|e| { + rspc::Error::new( + ErrorCode::InternalServerError, + format!("Failed to serialize library config: {:?}", e.to_string()), + ) + })?, + ) + .await + .map_err(|e| { + rspc::Error::new( + ErrorCode::InternalServerError, + format!("Failed to write library config: {:?}", e.to_string()), + ) + })?; + + Ok(()) + }) + }) + .procedure("getEmailAddress", { + R.with2(library()) + .query(move |(node, library), _: ()| async move { + let path = node + .libraries + .libraries_dir + .join(format!("{}.sdlibrary", library.id)); + + let config = serde_json::from_slice::>( + &tokio::fs::read(path.clone()).await.map_err(|e| { + rspc::Error::new( + ErrorCode::InternalServerError, + format!("Failed to read library config: {:?}", e.to_string()), + ) + })?, + ) + .map_err(|e: serde_json::Error| { + rspc::Error::new( + ErrorCode::InternalServerError, + format!("Failed to parse library config: {:?}", e.to_string()), + ) + })?; + + let en_data = config.get("cloud_email_address").ok_or_else(|| { + rspc::Error::new( + ErrorCode::InternalServerError, + "Failed to get cloud_email_address".to_string(), + ) + })?; + + let en_data = en_data.as_str().ok_or_else(|| { + rspc::Error::new( + ErrorCode::InternalServerError, + "Failed to get cloud_email_address".to_string(), + ) + })?; + + let en_data = CookieCipher::base64_decode(en_data).map_err(|e| { + error!("Failed to decode data: {:?}", e.to_string()); + rspc::Error::new( + ErrorCode::InternalServerError, + "Failed to decode data".to_string(), + ) + })?; + + let uuid_key = + CookieCipher::generate_key_from_string(library.id.to_string().as_str()) + .map_err(|e| { + error!("Failed to generate key: {:?}", e.to_string()); + rspc::Error::new( + ErrorCode::InternalServerError, + "Failed to generate key".to_string(), + ) + })?; + + let cipher = CookieCipher::new(&uuid_key).map_err(|e| { + error!("Failed to create cipher: {:?}", e.to_string()); + rspc::Error::new( + ErrorCode::InternalServerError, + "Failed to create cipher".to_string(), + ) + })?; + + let de_data = cipher.decrypt(&en_data).map_err(|e| { + error!("Failed to decrypt data: {:?}", e.to_string()); + rspc::Error::new( + ErrorCode::InternalServerError, + "Failed to decrypt data".to_string(), + ) + })?; + + let de_data = String::from_utf8(de_data).map_err(|e| { + error!("Failed to convert data to string: {:?}", e.to_string()); + rspc::Error::new( + ErrorCode::InternalServerError, + "Failed to convert data to string".to_string(), + ) + })?; + + Ok(de_data) + }) + }) } diff --git a/core/src/library/config.rs b/core/src/library/config.rs index 20c245d10..c185f8b29 100644 --- a/core/src/library/config.rs +++ b/core/src/library/config.rs @@ -46,6 +46,8 @@ pub struct LibraryConfig { #[serde(skip, default)] pub config_path: PathBuf, + /// cloud_email_address is the email address of the user who owns the cloud library this library is linked to. + pub cloud_email_address: Option, } #[derive( @@ -74,10 +76,11 @@ pub enum LibraryConfigVersion { V9 = 9, V10 = 10, V11 = 11, + V12 = 12, } impl ManagedVersion for LibraryConfig { - const LATEST_VERSION: LibraryConfigVersion = LibraryConfigVersion::V11; + const LATEST_VERSION: LibraryConfigVersion = LibraryConfigVersion::V12; const KIND: Kind = Kind::Json("version"); @@ -99,6 +102,7 @@ impl LibraryConfig { cloud_id: None, generate_sync_operations: Arc::new(AtomicBool::new(false)), config_path: path.as_ref().to_path_buf(), + cloud_email_address: None, }; this.save(path).await.map(|()| this) @@ -396,6 +400,25 @@ impl LibraryConfig { .await?; } + (LibraryConfigVersion::V11, LibraryConfigVersion::V12) => { + // Add the `cloud_email_address` field to the library config. + let mut config = serde_json::from_slice::>( + &fs::read(path).await.map_err(|e| { + VersionManagerError::FileIO(FileIOError::from((path, e))) + })?, + ) + .map_err(VersionManagerError::SerdeJson)?; + + config.insert(String::from("cloud_email_address"), Value::Null); + + fs::write( + path, + &serde_json::to_vec(&config).map_err(VersionManagerError::SerdeJson)?, + ) + .await + .map_err(|e| VersionManagerError::FileIO(FileIOError::from((path, e))))?; + } + _ => { error!(current_version = ?current, "Library config version is not handled;"); diff --git a/interface/app/$libraryId/settings/client/account/handlers/cookieHandler.ts b/interface/app/$libraryId/settings/client/account/handlers/cookieHandler.ts index f30d16c04..95f010f72 100644 --- a/interface/app/$libraryId/settings/client/account/handlers/cookieHandler.ts +++ b/interface/app/$libraryId/settings/client/account/handlers/cookieHandler.ts @@ -14,12 +14,6 @@ function getCookiesFromStorage(): string { nonLibraryClient .query(['keys.get']) .then((response) => { - // Debugging - // console.log("rspc response: ", response); - const cookiesArrayFromStorage: string[] = JSON.parse(response); - // console.log("Cookies fetched from storage: ", cookiesArrayFromStorage); - - // Actual cookiesFromStorage = response; }) .catch((e) => { diff --git a/interface/components/Authentication.tsx b/interface/components/Authentication.tsx index 22c4c7269..beff987e4 100644 --- a/interface/components/Authentication.tsx +++ b/interface/components/Authentication.tsx @@ -136,7 +136,7 @@ export const Authentication = ({ {activeTab === 'Login' ? ( ) : ( - + )}
Social auth and SSO (Single Sign On) available soon! diff --git a/interface/components/Login.tsx b/interface/components/Login.tsx index 7bd76375f..8cdf489ef 100644 --- a/interface/components/Login.tsx +++ b/interface/components/Login.tsx @@ -2,11 +2,11 @@ import { ArrowLeft } from '@phosphor-icons/react'; import { RSPCError } from '@spacedrive/rspc-client'; import { UseMutationResult } from '@tanstack/react-query'; import clsx from 'clsx'; -import { Dispatch, SetStateAction, useState } from 'react'; +import { Dispatch, SetStateAction, useEffect, useState } from 'react'; import { Controller } from 'react-hook-form'; import { signIn } from 'supertokens-web-js/recipe/emailpassword'; import { createCode } from 'supertokens-web-js/recipe/passwordless'; -import { useZodForm } from '@sd/client'; +import { useLibraryMutation, useLibraryQuery, useZodForm } from '@sd/client'; import { Button, Divider, Form, Input, toast, z } from '@sd/ui'; import { useLocale } from '~/hooks'; import { getTokens } from '~/util'; @@ -17,7 +17,8 @@ async function signInClicked( email: string, password: string, reload: Dispatch>, - cloudBootstrap: UseMutationResult // Cloud bootstrap mutation + cloudBootstrap: UseMutationResult, // Cloud bootstrap mutation + saveEmailAddress: UseMutationResult // Save email mutation ) { try { const response = await signIn({ @@ -46,6 +47,7 @@ async function signInClicked( } else { const tokens = await getTokens(); cloudBootstrap.mutate([tokens.accessToken, tokens.refreshToken]); + saveEmailAddress.mutate(email); toast.success('Sign in successful'); reload(true); } @@ -111,18 +113,34 @@ interface LoginProps { const LoginForm = ({ reload, cloudBootstrap, setContinueWithEmail }: LoginProps) => { const { t } = useLocale(); const [showPassword, setShowPassword] = useState(false); + const savedEmailAddress = useLibraryQuery(['keys.getEmailAddress']); + const saveEmailAddress = useLibraryMutation(['keys.saveEmailAddress']); + const form = useZodForm({ schema: LoginSchema, defaultValues: { - email: '', + email: savedEmailAddress.data ?? '', password: '' } }); + useEffect(() => { + savedEmailAddress.refetch(); + }, []); + + useEffect(() => { + if (savedEmailAddress.data) { + form.reset({ + email: savedEmailAddress.data, + password: '' + }); + } + }, [savedEmailAddress.data]); + return (
{ - await signInClicked(data.email, data.password, reload, cloudBootstrap); + await signInClicked(data.email, data.password, reload, cloudBootstrap, saveEmailAddress); })} className="w-full" form={form} @@ -193,7 +211,7 @@ const LoginForm = ({ reload, cloudBootstrap, setContinueWithEmail }: LoginProps) variant="accent" size="md" onClick={form.handleSubmit(async (data) => { - await signInClicked(data.email, data.password, reload, cloudBootstrap); + await signInClicked(data.email, data.password, reload, cloudBootstrap, saveEmailAddress); })} disabled={form.formState.isSubmitting} > diff --git a/interface/components/Register.tsx b/interface/components/Register.tsx index 1262274aa..74115cec3 100644 --- a/interface/components/Register.tsx +++ b/interface/components/Register.tsx @@ -1,12 +1,16 @@ import { zodResolver } from '@hookform/resolvers/zod'; +import { RSPCError } from '@spacedrive/rspc-client'; +import { UseMutationResult } from '@tanstack/react-query'; import clsx from 'clsx'; -import { useState } from 'react'; +import { Dispatch, SetStateAction, useState } from 'react'; import { Controller, useForm } from 'react-hook-form'; import { signUp } from 'supertokens-web-js/recipe/emailpassword'; import { Button, Form, Input, toast, z } from '@sd/ui'; import { useLocale } from '~/hooks'; +import { useLibraryMutation } from '@sd/client'; import ShowPassword from './ShowPassword'; +import { getTokens } from '~/util'; const RegisterSchema = z .object({ @@ -26,7 +30,13 @@ const RegisterSchema = z }); type RegisterType = z.infer; -async function signUpClicked(email: string, password: string) { +async function signUpClicked( + email: string, + password: string, + reload: Dispatch>, + cloudBootstrap: UseMutationResult, + saveEmailAddress: UseMutationResult +) { try { const response = await signUp({ formFields: [ @@ -62,9 +72,11 @@ async function signUpClicked(email: string, password: string) { } else { // sign up successful. The session tokens are automatically handled by // the frontend SDK. + const tokens = await getTokens(); + cloudBootstrap.mutate([tokens.accessToken, tokens.refreshToken]); + saveEmailAddress.mutate(email); toast.success('Sign up successful'); - // FIXME: This is a temporary workaround. We will provide a better way to handle this. - window.location.reload(); + reload(true); } } catch (err: any) { if (err.isSuperTokensGeneralError === true) { @@ -76,7 +88,13 @@ async function signUpClicked(email: string, password: string) { } } -const Register = () => { +const Register = ({ + reload, + cloudBootstrap +}: { + reload: Dispatch>; + cloudBootstrap: UseMutationResult; // Cloud bootstrap mutation +}) => { const { t } = useLocale(); const [showPassword, setShowPassword] = useState(false); // useZodForm seems to be out-dated or needs @@ -89,12 +107,13 @@ const Register = () => { confirmPassword: '' } }); + const savedEmailAddress = useLibraryMutation(['keys.saveEmailAddress']); + return ( { // handle sign-up submission - console.log(data); - await signUpClicked(data.email, data.password); + await signUpClicked(data.email, data.password, reload, cloudBootstrap, savedEmailAddress); })} className="w-full" form={form} @@ -190,8 +209,7 @@ const Register = () => { size="md" variant="accent" onClick={form.handleSubmit(async (data) => { - console.log(data); - await signUpClicked(data.email, data.password); + await signUpClicked(data.email, data.password, reload, cloudBootstrap, savedEmailAddress); })} disabled={form.formState.isSubmitting} > diff --git a/packages/client/src/core.ts b/packages/client/src/core.ts index 6d464efa6..46f78cf1b 100644 --- a/packages/client/src/core.ts +++ b/packages/client/src/core.ts @@ -25,6 +25,7 @@ export type Procedures = { { key: "jobs.isActive", input: LibraryArgs, result: boolean } | { key: "jobs.reports", input: LibraryArgs, result: JobGroup[] } | { key: "keys.get", input: never, result: string } | + { key: "keys.getEmailAddress", input: LibraryArgs, result: string } | { key: "labels.count", input: LibraryArgs, result: number } | { key: "labels.get", input: LibraryArgs, result: Label | null } | { key: "labels.getForObject", input: LibraryArgs, result: Label[] } | @@ -110,6 +111,7 @@ export type Procedures = { { key: "jobs.pause", input: LibraryArgs, result: null } | { key: "jobs.resume", input: LibraryArgs, result: null } | { key: "keys.save", input: string, result: null } | + { key: "keys.saveEmailAddress", input: LibraryArgs, result: null } | { key: "labels.delete", input: LibraryArgs, result: null } | { key: "library.create", input: CreateLibraryArgs, result: LibraryConfigWrapped } | { key: "library.delete", input: string, result: null } | @@ -526,9 +528,13 @@ instance_id: number; * cloud_id is the ID of the cloud library this library is linked to. * If this is set we can assume the library is synced with the Cloud. */ -cloud_id?: string | null; generate_sync_operations?: boolean; version: LibraryConfigVersion } +cloud_id?: string | null; generate_sync_operations?: boolean; version: LibraryConfigVersion; +/** + * cloud_email_address is the email address of the user who owns the cloud library this library is linked to. + */ +cloud_email_address: string | null } -export type LibraryConfigVersion = "V0" | "V1" | "V2" | "V3" | "V4" | "V5" | "V6" | "V7" | "V8" | "V9" | "V10" | "V11" +export type LibraryConfigVersion = "V0" | "V1" | "V2" | "V3" | "V4" | "V5" | "V6" | "V7" | "V8" | "V9" | "V10" | "V11" | "V12" export type LibraryConfigWrapped = { uuid: string; instance_id: string; instance_public_key: RemoteIdentity; config: LibraryConfig }