Save email address for re-login

It saves the email address in the login form so we don't have users always type in their email when they have to log in.
This commit is contained in:
Arnab Chakraborty
2024-12-15 17:53:46 -05:00
parent 58e7151e19
commit 152d1c7e07
7 changed files with 244 additions and 25 deletions

View File

@@ -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<Ctx> {
}
})
})
.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::<Map<String, Value>>(
&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::<Map<String, Value>>(
&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)
})
})
}

View File

@@ -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<String>,
}
#[derive(
@@ -74,10 +76,11 @@ pub enum LibraryConfigVersion {
V9 = 9,
V10 = 10,
V11 = 11,
V12 = 12,
}
impl ManagedVersion<LibraryConfigVersion> 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::<Map<String, Value>>(
&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;");

View File

@@ -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) => {

View File

@@ -136,7 +136,7 @@ export const Authentication = ({
{activeTab === 'Login' ? (
<Login reload={reload} cloudBootstrap={cloudBootstrap} />
) : (
<Register />
<Register reload={reload} cloudBootstrap={cloudBootstrap} />
)}
<div className="text-center text-sm text-ink-faint">
Social auth and SSO (Single Sign On) available soon!

View File

@@ -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<SetStateAction<boolean>>,
cloudBootstrap: UseMutationResult<null, RSPCError, [string, string], unknown> // Cloud bootstrap mutation
cloudBootstrap: UseMutationResult<null, RSPCError, [string, string], unknown>, // Cloud bootstrap mutation
saveEmailAddress: UseMutationResult<null, RSPCError, string, unknown> // 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 (
<Form
onSubmit={form.handleSubmit(async (data) => {
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}
>

View File

@@ -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<typeof RegisterSchema>;
async function signUpClicked(email: string, password: string) {
async function signUpClicked(
email: string,
password: string,
reload: Dispatch<SetStateAction<boolean>>,
cloudBootstrap: UseMutationResult<null, RSPCError, [string, string], unknown>,
saveEmailAddress: UseMutationResult<null, RSPCError, string, unknown>
) {
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<SetStateAction<boolean>>;
cloudBootstrap: UseMutationResult<null, RSPCError, [string, string], unknown>; // 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 (
<Form
onSubmit={form.handleSubmit(async (data) => {
// 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}
>

View File

@@ -25,6 +25,7 @@ export type Procedures = {
{ key: "jobs.isActive", input: LibraryArgs<null>, result: boolean } |
{ key: "jobs.reports", input: LibraryArgs<null>, result: JobGroup[] } |
{ key: "keys.get", input: never, result: string } |
{ key: "keys.getEmailAddress", input: LibraryArgs<null>, result: string } |
{ key: "labels.count", input: LibraryArgs<null>, result: number } |
{ key: "labels.get", input: LibraryArgs<number>, result: Label | null } |
{ key: "labels.getForObject", input: LibraryArgs<number>, result: Label[] } |
@@ -110,6 +111,7 @@ export type Procedures = {
{ key: "jobs.pause", input: LibraryArgs<string>, result: null } |
{ key: "jobs.resume", input: LibraryArgs<string>, result: null } |
{ key: "keys.save", input: string, result: null } |
{ key: "keys.saveEmailAddress", input: LibraryArgs<string>, result: null } |
{ key: "labels.delete", input: LibraryArgs<number>, 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 }