mirror of
https://github.com/spacedriveapp/spacedrive.git
synced 2026-04-20 06:28:14 -04:00
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:
@@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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;");
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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!
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
10
packages/client/src/core.ts
generated
10
packages/client/src/core.ts
generated
@@ -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 }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user