diff --git a/.env.example b/.env.example
index e0fdd24..2ea51e1 100644
--- a/.env.example
+++ b/.env.example
@@ -2,4 +2,5 @@ VITE_PAYMENTS_API_URL=
VITE_DRIVE_API_URL=
VITE_MAGIC_IV=
VITE_MAGIC_SALT=
-VITE_CRYPTO_SECRET=
\ No newline at end of file
+VITE_CRYPTO_SECRET=
+VITE_DRIVE_APP_URL=
\ No newline at end of file
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 439b72e..194e276 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -18,6 +18,7 @@ jobs:
VITE_MAGIC_IV: ${{ secrets.MAGIC_IV }}
VITE_MAGIC_SALT: ${{ secrets.MAGIC_SALT }}
VITE_CRYPTO_SECRET: ${{ secrets.CRYPTO_SECRET }}
+ VITE_DRIVE_APP_URL: https://drive.internxt.com
steps:
- uses: actions/checkout@v4
@@ -25,7 +26,7 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- cache: 'yarn'
+ cache: "yarn"
- run: yarn install --frozen-lockfile
- run: yarn build
diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml
index f171be8..683e19c 100644
--- a/.github/workflows/sonarcloud.yml
+++ b/.github/workflows/sonarcloud.yml
@@ -2,7 +2,7 @@ name: SonarCloud analysis
on:
push:
- branches: ['master']
+ branches: ["master"]
pull_request:
types: [opened, synchronize, reopened]
workflow_dispatch:
diff --git a/package.json b/package.json
index a5e2a81..f3d86b1 100644
--- a/package.json
+++ b/package.json
@@ -33,6 +33,7 @@
"@tiptap/extension-underline": "^3.20.0",
"@tiptap/react": "^3.20.0",
"@tiptap/starter-kit": "^3.20.0",
+ "axios": "^1.13.6",
"dayjs": "^1.11.19",
"i18next": "^25.8.13",
"react": "^19.2.0",
diff --git a/src/components/mail/composeMessageDialog/components/editorBar/index.tsx b/src/components/mail/composeMessageDialog/components/editorBar/index.tsx
index 795588d..bd3ba9b 100644
--- a/src/components/mail/composeMessageDialog/components/editorBar/index.tsx
+++ b/src/components/mail/composeMessageDialog/components/editorBar/index.tsx
@@ -100,7 +100,7 @@ export const EditorBar = ({ editor, disabled }: ActionBarProps) => {
if (!editor) return;
const previousUrl = editor.getAttributes('link').href;
- const url = window.prompt('URL', previousUrl);
+ const url = globalThis.prompt('URL', previousUrl);
if (url === null) return;
@@ -116,7 +116,7 @@ export const EditorBar = ({ editor, disabled }: ActionBarProps) => {
const addImage = useCallback(() => {
if (!editor) return;
- const url = window.prompt('Image URL');
+ const url = globalThis.prompt('Image URL');
if (url) {
editor.chain().focus().setImage({ src: url }).run();
diff --git a/src/features/welcome/index.tsx b/src/features/welcome/index.tsx
index 90d42dd..b828b60 100644
--- a/src/features/welcome/index.tsx
+++ b/src/features/welcome/index.tsx
@@ -2,9 +2,22 @@ import { Button } from '@internxt/ui';
import smallLogo from '../../assets/logos/small-logo.svg';
import MailAppImage from '../../assets/images/welcome/welcome-page.webp';
import { useTranslationContext } from '@/i18n';
+import { useAuth } from '@/hooks/useAuth';
+import { useNavigation } from '@/hooks/useNavigation';
const WelcomePage = () => {
const { translate } = useTranslationContext();
+ const { goTo } = useNavigation();
+
+ const onSuccess = () => {
+ console.log('onLogin');
+ goTo('/inbox');
+ };
+
+ const { handleWebLogin, handleWebSignup } = useAuth({
+ onSuccess,
+ translate,
+ });
return (
@@ -15,8 +28,12 @@ const WelcomePage = () => {
{translate('meet')}
-
-
+
+
diff --git a/src/hooks/useAuth.ts b/src/hooks/useAuth.ts
new file mode 100644
index 0000000..6bbeab2
--- /dev/null
+++ b/src/hooks/useAuth.ts
@@ -0,0 +1,103 @@
+import { useCallback, useState } from 'react';
+import { LocalStorageService } from '@/services/local-storage';
+import { PaymentsService } from '@/services/sdk/payments.service';
+import type { LoginCredentials } from '@/types/oauth';
+import { OauthService } from '@/services/oauth/oauth.service';
+
+interface UseWebAuthProps {
+ onSuccess?: (token: string) => void;
+ translate: (key: string) => string;
+}
+
+export function useAuth({ onSuccess, translate }: UseWebAuthProps) {
+ const [webAuthError, setWebAuthError] = useState('');
+
+ const saveUserSession = useCallback(
+ async (credentials: LoginCredentials) => {
+ LocalStorageService.instance.saveCredentials(credentials.user, credentials.mnemonic, credentials.newToken);
+
+ try {
+ const [userTier, userSubscription] = await Promise.all([
+ PaymentsService.instance.getUserTier(),
+ PaymentsService.instance.getUserSubscription(),
+ ]);
+
+ LocalStorageService.instance.setTier(userTier);
+ LocalStorageService.instance.setSubscription(userSubscription);
+ } catch (err) {
+ console.error('Error getting user subscription and tier:', err);
+ }
+
+ onSuccess?.(credentials.newToken);
+ },
+ [LocalStorageService, onSuccess],
+ );
+
+ /**
+ * Handles web-based login using popup window
+ */
+ const handleWebLogin = async () => {
+ setWebAuthError('');
+
+ try {
+ const credentials = await OauthService.instance.loginWithWeb();
+
+ if (!credentials?.newToken || !credentials?.user) {
+ throw new Error(translate('meet.auth.modal.error.invalidCredentials'));
+ }
+
+ await saveUserSession(credentials);
+ } catch (err: unknown) {
+ errorHandler(err);
+ }
+ };
+
+ /**
+ * Handles web-based signup using popup window
+ */
+ const handleWebSignup = async () => {
+ setWebAuthError('');
+
+ try {
+ const credentials = await OauthService.instance.signupWithWeb();
+
+ if (!credentials?.newToken || !credentials?.user) {
+ throw new Error(translate('meet.auth.modal.error.invalidCredentials'));
+ }
+
+ await saveUserSession(credentials);
+ } catch (err: unknown) {
+ errorHandler(err);
+ }
+ };
+
+ const errorHandler = useCallback(
+ (err: unknown) => {
+ if (err instanceof Error) {
+ if (err.message.includes('popup blocker')) {
+ setWebAuthError(translate('meet.auth.modal.error.popupBlocked'));
+ } else if (err.message.includes('cancelled')) {
+ setWebAuthError(translate('meet.auth.modal.error.authCancelled'));
+ } else if (err.message.includes('timeout')) {
+ setWebAuthError(translate('meet.auth.modal.error.authTimeout'));
+ } else {
+ setWebAuthError(err.message);
+ }
+ } else {
+ setWebAuthError(translate('meet.auth.modal.error.genericError'));
+ }
+ },
+ [setWebAuthError],
+ );
+
+ const resetState = useCallback(() => {
+ setWebAuthError('');
+ }, []);
+
+ return {
+ webAuthError,
+ handleWebLogin,
+ handleWebSignup,
+ resetState,
+ };
+}
diff --git a/src/routes/index.tsx b/src/routes/index.tsx
index c7ee75c..0aca659 100644
--- a/src/routes/index.tsx
+++ b/src/routes/index.tsx
@@ -1,6 +1,6 @@
import { type RouteObject, Navigate } from 'react-router-dom';
import { lazy } from 'react';
-import RootLayout from '@/layouts/RootLayout';
+import RootLayout from '@/routes/layouts/RootLayout';
const WelcomePage = lazy(() => import('@/features/welcome'));
const MailView = lazy(() => import('@/features/mail/MailView'));
diff --git a/src/layouts/RootLayout.tsx b/src/routes/layouts/RootLayout.tsx
similarity index 100%
rename from src/layouts/RootLayout.tsx
rename to src/routes/layouts/RootLayout.tsx
diff --git a/src/routes/paths.ts b/src/routes/paths.ts
index 40178ea..ddf61d7 100644
--- a/src/routes/paths.ts
+++ b/src/routes/paths.ts
@@ -1,4 +1,5 @@
export const PATHS = {
+ welcome: '/welcome',
inbox: '/inbox',
trash: '/trash',
} as const;
diff --git a/src/services/config/config.service.test.ts b/src/services/config/config.service.test.ts
index 79ee486..eb4a140 100644
--- a/src/services/config/config.service.test.ts
+++ b/src/services/config/config.service.test.ts
@@ -10,6 +10,7 @@ describe('Config Service', () => {
vi.stubEnv('VITE_CRYPTO_SECRET', 'test-secret');
vi.stubEnv('VITE_MAGIC_IV', 'test-iv');
vi.stubEnv('VITE_MAGIC_SALT', 'test-salt');
+ vi.stubEnv('VITE_DRIVE_APP_URL', 'https://drive.internxt.com');
vi.stubEnv('PROD', false);
});
diff --git a/src/services/config/index.ts b/src/services/config/index.ts
index 898b415..794d177 100644
--- a/src/services/config/index.ts
+++ b/src/services/config/index.ts
@@ -7,6 +7,7 @@ interface ConfigKeys {
CRYPTO_SECRET: string;
MAGIC_IV: string;
MAGIC_SALT: string;
+ DRIVE_APP_URL: string;
}
const configKeys: Record = {
@@ -16,6 +17,7 @@ const configKeys: Record = {
CRYPTO_SECRET: 'VITE_CRYPTO_SECRET',
MAGIC_IV: 'VITE_MAGIC_IV',
MAGIC_SALT: 'VITE_MAGIC_SALT',
+ DRIVE_APP_URL: 'VITE_DRIVE_APP_URL',
};
export class ConfigService {
diff --git a/src/services/local-storage/index.ts b/src/services/local-storage/index.ts
index ae13317..6fb2e02 100644
--- a/src/services/local-storage/index.ts
+++ b/src/services/local-storage/index.ts
@@ -1,9 +1,13 @@
+import type { Tier } from '@internxt/sdk/dist/drive/payments/types/tiers';
import type { UserSubscription } from '@internxt/sdk/dist/drive/payments/types/types';
import type { UserSettings } from '@internxt/sdk/dist/shared/types/userSettings';
const LocalStorageKeys = {
xUser: 'xUser',
+ xMnemonic: 'xMnemonic',
xNewToken: 'xNewToken',
+ xSubscription: 'xSubscription',
+ xTier: 'xTier',
};
export class LocalStorageService {
@@ -43,15 +47,15 @@ export class LocalStorageService {
}
setMnemonic(mnemonic: string) {
- localStorage.setItem('xMnemonic', mnemonic);
+ localStorage.setItem(LocalStorageKeys.xMnemonic, mnemonic);
}
getMnemonic(): string | null {
- return localStorage.getItem('xMnemonic');
+ return localStorage.getItem(LocalStorageKeys.xMnemonic);
}
- setSubscription(subscription: UserSubscription) {
- localStorage.setItem('xSubscription', JSON.stringify(subscription));
+ setSubscription(subscription: UserSubscription): void {
+ localStorage.setItem(LocalStorageKeys.xSubscription, JSON.stringify(subscription));
}
getSubscription(): UserSubscription | null {
@@ -59,6 +63,15 @@ export class LocalStorageService {
return subscription ? JSON.parse(subscription) : null;
}
+ setTier(subscription: Tier): void {
+ localStorage.setItem(LocalStorageKeys.xTier, JSON.stringify(subscription));
+ }
+
+ getTier(): Tier | null {
+ const subscription = localStorage.getItem(LocalStorageKeys.xTier);
+ return subscription ? JSON.parse(subscription) : null;
+ }
+
saveCredentials(user: UserSettings, mnemonic: string, token: string) {
this.setUser(user);
this.setMnemonic(mnemonic);
@@ -66,9 +79,6 @@ export class LocalStorageService {
}
clearCredentials() {
- localStorage.removeItem('xUser');
- localStorage.removeItem('xNewToken');
- localStorage.removeItem('xSubscription');
- localStorage.removeItem('xMnemonic');
+ Object.values(LocalStorageKeys).forEach((key) => this.remove(key));
}
}
diff --git a/src/services/oauth/errors/oauth.errors.ts b/src/services/oauth/errors/oauth.errors.ts
new file mode 100644
index 0000000..3747b9a
--- /dev/null
+++ b/src/services/oauth/errors/oauth.errors.ts
@@ -0,0 +1,43 @@
+export class MissingAuthParamsToken extends Error {
+ constructor() {
+ super('Missing auth params token');
+
+ Object.setPrototypeOf(this, MissingAuthParamsToken.prototype);
+ }
+}
+
+export class AuthCancelledByUserError extends Error {
+ constructor() {
+ super('Authentication cancelled by user');
+
+ Object.setPrototypeOf(this, AuthCancelledByUserError.prototype);
+ }
+}
+
+export class AuthTimeoutError extends Error {
+ constructor() {
+ super('Authentication timed out');
+
+ Object.setPrototypeOf(this, AuthTimeoutError.prototype);
+ }
+}
+
+export class OpenAuthPopupError extends Error {
+ constructor() {
+ super(
+ 'Failed to open authentication popup. Please check your popup blocker settings.',
+ );
+
+ Object.setPrototypeOf(this, OpenAuthPopupError.prototype);
+ }
+}
+
+export class WebAuthProcessingError extends Error {
+ constructor(cause?: Error) {
+ super(
+ `Web authentication processing failed: ${cause instanceof Error ? cause.message : 'Unknown error'}`,
+ );
+
+ Object.setPrototypeOf(this, WebAuthProcessingError.prototype);
+ }
+}
diff --git a/src/services/oauth/oauth.service.test.ts b/src/services/oauth/oauth.service.test.ts
new file mode 100644
index 0000000..7204aa4
--- /dev/null
+++ b/src/services/oauth/oauth.service.test.ts
@@ -0,0 +1,406 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { OauthService } from './oauth.service';
+import { UserService } from '../user/user.service';
+import { LocalStorageService } from '../local-storage';
+import { WEB_AUTH_CONFIG, WEB_AUTH_MESSAGE_TYPES, type WebAuthMessage, type WebAuthParams } from '@/types/oauth';
+import { AuthCancelledByUserError, MissingAuthParamsToken, WebAuthProcessingError } from './errors/oauth.errors';
+
+vi.mock('../config', () => ({
+ ConfigService: {
+ instance: {
+ getVariable: (key: string) => {
+ const config: Record = {
+ DRIVE_APP_URL: 'https://drive.internxt.com',
+ };
+ return config[key] || '';
+ },
+ },
+ },
+}));
+
+describe('OAuth Service', () => {
+ let service: OauthService;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+
+ vi.spyOn(UserService.instance, 'getUser').mockResolvedValue({
+ userId: '123',
+ email: 'test@example.com',
+ name: 'Test',
+ lastname: 'User',
+ } as any);
+
+ vi.spyOn(LocalStorageService.instance, 'setToken').mockImplementation(() => {});
+
+ service = OauthService.instance;
+ });
+
+ describe('Processing authentication credentials', () => {
+ it('when credentials are received, then the mnemonic is decoded from base64', async () => {
+ const mnemonic = 'test mnemonic phrase';
+ const encodedMnemonic = Buffer.from(mnemonic).toString('base64');
+ const encodedNewToken = Buffer.from('test-new-token').toString('base64');
+
+ const params: WebAuthParams = {
+ mnemonic: encodedMnemonic,
+ newToken: encodedNewToken,
+ };
+
+ const result = await (service as any).processWebAuthParams(params);
+
+ expect(result.mnemonic).toBe(mnemonic);
+ expect(result.user.mnemonic).toBe(mnemonic);
+ });
+
+ it('when credentials are processed, then the token is stored in local storage', async () => {
+ const newToken = 'test-new-token-value';
+ const encodedNewToken = Buffer.from(newToken).toString('base64');
+ const encodedMnemonic = Buffer.from('test mnemonic').toString('base64');
+
+ const setTokenSpy = vi.spyOn(LocalStorageService.instance, 'setToken');
+
+ const params: WebAuthParams = {
+ mnemonic: encodedMnemonic,
+ newToken: encodedNewToken,
+ };
+
+ await (service as any).processWebAuthParams(params);
+
+ expect(setTokenSpy).toHaveBeenCalledWith(newToken);
+ });
+
+ it('when credentials are stored, then user data is fetched from the API', async () => {
+ const encodedNewToken = Buffer.from('test-new-token').toString('base64');
+ const encodedMnemonic = Buffer.from('test mnemonic').toString('base64');
+
+ const getUserSpy = vi.spyOn(UserService.instance, 'getUser');
+
+ const params: WebAuthParams = {
+ mnemonic: encodedMnemonic,
+ newToken: encodedNewToken,
+ };
+
+ await (service as any).processWebAuthParams(params);
+
+ expect(getUserSpy).toHaveBeenCalledTimes(1);
+ });
+
+ it('when credentials are fully processed, then complete authentication data is returned', async () => {
+ const mnemonic = 'test mnemonic phrase';
+ const newToken = 'test-new-token-value';
+ const encodedMnemonic = Buffer.from(mnemonic).toString('base64');
+ const encodedNewToken = Buffer.from(newToken).toString('base64');
+
+ const params: WebAuthParams = {
+ mnemonic: encodedMnemonic,
+ newToken: encodedNewToken,
+ };
+
+ const result = await (service as any).processWebAuthParams(params);
+
+ expect(result).toHaveProperty('user');
+ expect(result).toHaveProperty('newToken', newToken);
+ expect(result).toHaveProperty('mnemonic', mnemonic);
+ expect(result.user).toHaveProperty('mnemonic', mnemonic);
+ });
+
+ it('when user data cannot be retrieved, then an error indicating so is thrown', async () => {
+ vi.spyOn(UserService.instance, 'getUser').mockRejectedValue(new Error('API Error'));
+
+ const params: WebAuthParams = {
+ mnemonic: Buffer.from('test').toString('base64'),
+ newToken: Buffer.from('new-token').toString('base64'),
+ };
+
+ await expect((service as any).processWebAuthParams(params)).rejects.toThrow(WebAuthProcessingError);
+ });
+ });
+
+ describe('Validating authentication parameters', () => {
+ it('when all required parameters are provided, then validation passes', () => {
+ const params: WebAuthParams = {
+ mnemonic: 'test-mnemonic',
+ newToken: 'test-token',
+ };
+
+ const isValid = (service as any).validateAuthParams(params);
+
+ expect(isValid).toBe(true);
+ });
+
+ it('when the mnemonic is missing, then validation fails', () => {
+ const params = {
+ newToken: 'test-token',
+ };
+
+ const isValid = (service as any).validateAuthParams(params);
+
+ expect(isValid).toBe(false);
+ });
+
+ it('when the token is missing, then validation fails', () => {
+ const params = {
+ mnemonic: 'test-mnemonic',
+ };
+
+ const isValid = (service as any).validateAuthParams(params);
+
+ expect(isValid).toBe(false);
+ });
+
+ it('when no parameters are provided, then validation fails', () => {
+ const params = {};
+
+ const isValid = (service as any).validateAuthParams(params);
+
+ expect(isValid).toBe(false);
+ });
+ });
+
+ describe('Origin security validation', () => {
+ it('when the origin is from internxt.com domain, then it is accepted', () => {
+ const isValid = (service as any).isValidOrigin('https://drive.internxt.com');
+
+ expect(isValid).toBe(true);
+ });
+
+ it('when the origin is from localhost, then it is accepted', () => {
+ const isValid = (service as any).isValidOrigin('http://localhost:3000');
+
+ expect(isValid).toBe(true);
+ });
+
+ it('when the origin is from an unknown domain, then it is rejected', () => {
+ const isValid = (service as any).isValidOrigin('https://malicious-site.com');
+
+ expect(isValid).toBe(false);
+ });
+
+ it('when the origin is empty, then it is rejected', () => {
+ const isValid = (service as any).isValidOrigin('');
+
+ expect(isValid).toBe(false);
+ });
+ });
+
+ describe('Handling authentication success messages', () => {
+ it('when a success message with valid credentials is received, then authentication completes successfully', () => {
+ const mockResolve = vi.fn();
+ const mockReject = vi.fn();
+ const mockTimeout = setTimeout(() => {}, 1000) as any;
+
+ const payload: WebAuthParams = {
+ mnemonic: 'test-mnemonic',
+ newToken: 'test-token',
+ };
+
+ const message: WebAuthMessage = {
+ type: WEB_AUTH_MESSAGE_TYPES.SUCCESS,
+ payload,
+ };
+
+ (service as any).handleAuthSuccess(message, mockResolve, mockReject, mockTimeout);
+
+ expect(mockResolve).toHaveBeenCalledWith(payload);
+ expect(mockReject).not.toHaveBeenCalled();
+ });
+
+ it('when a success message has incomplete credentials, then authentication fails', () => {
+ const mockResolve = vi.fn();
+ const mockReject = vi.fn();
+ const mockTimeout = setTimeout(() => {}, 1000) as any;
+
+ const message: WebAuthMessage = {
+ type: WEB_AUTH_MESSAGE_TYPES.SUCCESS,
+ payload: { mnemonic: 'test' } as any,
+ };
+
+ (service as any).handleAuthSuccess(message, mockResolve, mockReject, mockTimeout);
+
+ expect(mockReject).toHaveBeenCalledWith(expect.any(MissingAuthParamsToken));
+ expect(mockResolve).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('Handling authentication error messages', () => {
+ it('when an error message with a description is received, then authentication fails with that error', () => {
+ const mockReject = vi.fn();
+ const mockTimeout = setTimeout(() => {}, 1000) as any;
+
+ const message: WebAuthMessage = {
+ type: WEB_AUTH_MESSAGE_TYPES.ERROR,
+ error: 'Authentication failed',
+ };
+
+ (service as any).handleAuthError(message, mockReject, mockTimeout);
+
+ expect(mockReject).toHaveBeenCalledWith(new Error('Authentication failed'));
+ });
+
+ it('when an error message without a description is received, then authentication fails with a default error', () => {
+ const mockReject = vi.fn();
+ const mockTimeout = setTimeout(() => {}, 1000) as any;
+
+ const message: WebAuthMessage = {
+ type: WEB_AUTH_MESSAGE_TYPES.ERROR,
+ };
+
+ (service as any).handleAuthError(message, mockReject, mockTimeout);
+
+ expect(mockReject).toHaveBeenCalledWith(new Error('Authentication failed'));
+ });
+ });
+
+ describe('Decoding base64 parameters', () => {
+ it('when a base64-encoded string is received, then it is decoded to plain text', () => {
+ const originalText = 'test text with spaces';
+ const encoded = Buffer.from(originalText).toString('base64');
+
+ const decoded = (service as any).decodeBase64Param(encoded);
+
+ expect(decoded).toBe(originalText);
+ });
+
+ it('when the encoded string contains special characters, then they are preserved after decoding', () => {
+ const originalText = 'test!@#$%^&*()_+-=[]{}|;:",.<>?';
+ const encoded = Buffer.from(originalText).toString('base64');
+
+ const decoded = (service as any).decodeBase64Param(encoded);
+
+ expect(decoded).toBe(originalText);
+ });
+
+ it('when the encoded string contains unicode characters, then they are preserved after decoding', () => {
+ const originalText = 'test émojis 😀🎉 and àccénts';
+ const encoded = Buffer.from(originalText).toString('base64');
+
+ const decoded = (service as any).decodeBase64Param(encoded);
+
+ expect(decoded).toBe(originalText);
+ });
+ });
+
+ describe('Popup window positioning', () => {
+ it('when the popup is created, then it is centered on the screen', () => {
+ Object.defineProperty(window, 'screen', {
+ value: {
+ width: 1920,
+ height: 1080,
+ },
+ writable: true,
+ });
+
+ const { left, top } = (service as any).calculatePopupPosition();
+
+ const expectedLeft = 1920 / 2 - WEB_AUTH_CONFIG.popupWidth / 2;
+ const expectedTop = 1080 / 2 - WEB_AUTH_CONFIG.popupHeight / 2;
+
+ expect(left).toBe(expectedLeft);
+ expect(top).toBe(expectedTop);
+ });
+
+ it('when the screen size changes, then the popup position is recalculated correctly', () => {
+ Object.defineProperty(window, 'screen', {
+ value: {
+ width: 1366,
+ height: 768,
+ },
+ writable: true,
+ });
+
+ const { left, top } = (service as any).calculatePopupPosition();
+
+ const expectedLeft = 1366 / 2 - WEB_AUTH_CONFIG.popupWidth / 2;
+ const expectedTop = 768 / 2 - WEB_AUTH_CONFIG.popupHeight / 2;
+
+ expect(left).toBe(expectedLeft);
+ expect(top).toBe(expectedTop);
+ });
+ });
+
+ describe('Popup window configuration', () => {
+ it('when the popup window is configured, then it includes all required security and layout features', () => {
+ const left = 100;
+ const top = 200;
+
+ const features = (service as any).buildPopupFeatures(left, top);
+
+ expect(features).toContain(`width=${WEB_AUTH_CONFIG.popupWidth}`);
+ expect(features).toContain(`height=${WEB_AUTH_CONFIG.popupHeight}`);
+ expect(features).toContain(`left=${left}`);
+ expect(features).toContain(`top=${top}`);
+ expect(features).toContain('toolbar=no');
+ expect(features).toContain('menubar=no');
+ expect(features).toContain('location=no');
+ expect(features).toContain('status=no');
+ });
+ });
+
+ describe('Popup closed detection', () => {
+ it('when the popup is closed by the user, then authentication is cancelled', () => {
+ vi.useFakeTimers();
+
+ const mockReject = vi.fn();
+ const mockTimeout = setTimeout(() => {}, 1000) as any;
+ const mockPopup = { closed: true } as Window;
+
+ const interval = (service as any).setupPopupClosedChecker(mockPopup, mockReject, mockTimeout);
+
+ // Trigger the interval check
+ vi.advanceTimersByTime(WEB_AUTH_CONFIG.popupCheckIntervalMs);
+
+ expect(mockReject).toHaveBeenCalledWith(expect.any(AuthCancelledByUserError));
+
+ clearInterval(interval);
+ vi.useRealTimers();
+ });
+ });
+
+ describe('Building login credentials', () => {
+ it('when user data is available, then login credentials are built correctly', () => {
+ const user = {
+ userId: '123',
+ email: 'test@example.com',
+ name: 'Test',
+ lastname: 'User',
+ };
+ const mnemonic = 'test mnemonic';
+ const newToken = 'test-token';
+
+ const credentials = (service as any).buildLoginCredentials(user, mnemonic, newToken);
+
+ expect(credentials).toHaveProperty('user');
+ expect(credentials).toHaveProperty('newToken', newToken);
+ expect(credentials).toHaveProperty('mnemonic', mnemonic);
+ expect(credentials.user).toHaveProperty('mnemonic', mnemonic);
+ expect(credentials.user).toHaveProperty('email', user.email);
+ });
+ });
+
+ describe('Cleanup', () => {
+ it('when cleanup is called, then all resources are cleaned up', () => {
+ const mockPopup = {
+ closed: false,
+ close: vi.fn(),
+ };
+ const mockListener = vi.fn();
+
+ (service as any).authPopup = mockPopup;
+ (service as any).messageListener = mockListener;
+ (service as any).popupCheckInterval = setInterval(() => {}, 1000);
+
+ const removeEventListenerSpy = vi.spyOn(window, 'removeEventListener');
+
+ (service as any).cleanup();
+
+ expect(mockPopup.close).toHaveBeenCalled();
+ expect((service as any).authPopup).toBeNull();
+ expect(removeEventListenerSpy).toHaveBeenCalledWith('message', mockListener);
+ expect((service as any).messageListener).toBeNull();
+ expect((service as any).popupCheckInterval).toBeNull();
+ });
+ });
+});
diff --git a/src/services/oauth/oauth.service.ts b/src/services/oauth/oauth.service.ts
new file mode 100644
index 0000000..8026549
--- /dev/null
+++ b/src/services/oauth/oauth.service.ts
@@ -0,0 +1,274 @@
+import {
+ WEB_AUTH_CONFIG,
+ WEB_AUTH_MESSAGE_TYPES,
+ WEB_AUTH_VALID_ORIGINS,
+ type LoginCredentials,
+ type WebAuthMessage,
+ type WebAuthParams,
+} from '@/types/oauth';
+import { UserService } from '../user/user.service';
+import { ConfigService } from '../config';
+import {
+ AuthCancelledByUserError,
+ AuthTimeoutError,
+ MissingAuthParamsToken,
+ OpenAuthPopupError,
+ WebAuthProcessingError,
+} from './errors/oauth.errors';
+import type { UserSettings } from '@internxt/sdk/dist/shared/types/userSettings';
+import { LocalStorageService } from '../local-storage';
+
+export class OauthService {
+ public static readonly instance: OauthService = new OauthService();
+ private readonly DRIVE_APP_URL = ConfigService.instance.getVariable('DRIVE_APP_URL');
+
+ private authPopup: Window | null = null;
+ private messageListener: ((event: MessageEvent) => void) | null = null;
+ private popupCheckInterval: NodeJS.Timeout | null = null;
+
+ /**
+ * Get the web auth URLs for login and signup
+ */
+ public get urls() {
+ return {
+ login: `${this.DRIVE_APP_URL}${WEB_AUTH_CONFIG.loginPath}?${WEB_AUTH_CONFIG.authOriginParam}`,
+ signup: `${this.DRIVE_APP_URL}${WEB_AUTH_CONFIG.signupPath}?${WEB_AUTH_CONFIG.authOriginParam}`,
+ };
+ }
+
+ /**
+ * Calculate popup position to center it on screen
+ */
+ private calculatePopupPosition() {
+ const left = window.screen.width / 2 - WEB_AUTH_CONFIG.popupWidth / 2;
+ const top = window.screen.height / 2 - WEB_AUTH_CONFIG.popupHeight / 2;
+
+ return { left, top };
+ }
+
+ /**
+ * Build popup window features string
+ */
+ private buildPopupFeatures(left: number, top: number): string {
+ return [
+ `width=${WEB_AUTH_CONFIG.popupWidth}`,
+ `height=${WEB_AUTH_CONFIG.popupHeight}`,
+ `left=${left}`,
+ `top=${top}`,
+ 'toolbar=no',
+ 'menubar=no',
+ 'location=no',
+ 'status=no',
+ ].join(',');
+ }
+
+ /**
+ * Opens a popup window for web authentication
+ * @param url The URL to open in the popup
+ * @returns Window reference or null if popup was blocked
+ */
+ private openAuthPopup(url: string): Window | null {
+ const { left, top } = this.calculatePopupPosition();
+ const features = this.buildPopupFeatures(left, top);
+
+ const popup = window.open(url, WEB_AUTH_CONFIG.popupName, features);
+
+ return popup;
+ }
+
+ /**
+ * Validate origin of postMessage event
+ */
+ private isValidOrigin(origin: string): boolean {
+ return WEB_AUTH_VALID_ORIGINS.some((valid) => origin.includes(valid));
+ }
+
+ /**
+ * Validate authentication parameters
+ */
+ private validateAuthParams(params: Partial): params is WebAuthParams {
+ return !!(params.mnemonic && params.newToken);
+ }
+
+ /**
+ * Handle auth success message
+ */
+ private handleAuthSuccess(
+ data: WebAuthMessage,
+ resolve: (value: WebAuthParams) => void,
+ reject: (reason: Error) => void,
+ timeout: NodeJS.Timeout,
+ ) {
+ clearTimeout(timeout);
+ this.cleanup();
+
+ const { payload } = data;
+
+ if (!payload || !this.validateAuthParams(payload)) {
+ reject(new MissingAuthParamsToken());
+ return;
+ }
+
+ resolve(payload);
+ }
+
+ /**
+ * Handle auth error message
+ */
+ private handleAuthError(data: WebAuthMessage, reject: (reason: Error) => void, timeout: NodeJS.Timeout) {
+ clearTimeout(timeout);
+ this.cleanup();
+ reject(new Error(data.error || 'Authentication failed'));
+ }
+
+ /**
+ * Setup popup closed checker interval
+ */
+ private setupPopupClosedChecker(
+ popup: Window,
+ reject: (reason: Error) => void,
+ timeout: NodeJS.Timeout,
+ ): NodeJS.Timeout {
+ return setInterval(() => {
+ if (popup.closed) {
+ if (this.popupCheckInterval) clearInterval(this.popupCheckInterval);
+ clearTimeout(timeout);
+ this.cleanup();
+ reject(new AuthCancelledByUserError());
+ }
+ }, WEB_AUTH_CONFIG.popupCheckIntervalMs);
+ }
+
+ /**
+ * Waits for authentication response from the popup window
+ * @param popup The popup window reference
+ * @returns Promise that resolves with the authentication parameters
+ */
+ private waitForAuthResponse(popup: Window): Promise {
+ return new Promise((resolve, reject) => {
+ const timeout = setTimeout(() => {
+ this.cleanup();
+ reject(new AuthTimeoutError());
+ }, WEB_AUTH_CONFIG.authTimeoutMs);
+
+ this.messageListener = (event: MessageEvent) => {
+ if (!this.isValidOrigin(event.origin)) {
+ console.warn('Invalid origin for auth message:', event.origin);
+ return;
+ }
+
+ const { data } = event;
+
+ if (data?.type === WEB_AUTH_MESSAGE_TYPES.SUCCESS) {
+ this.handleAuthSuccess(data, resolve, reject, timeout);
+ }
+
+ if (data?.type === WEB_AUTH_MESSAGE_TYPES.ERROR) {
+ this.handleAuthError(data, reject, timeout);
+ }
+ };
+
+ window.addEventListener('message', this.messageListener);
+
+ this.popupCheckInterval = this.setupPopupClosedChecker(popup, reject, timeout);
+ });
+ }
+
+ /**
+ * Cleanup popup and event listeners
+ */
+ private cleanup() {
+ if (this.authPopup && !this.authPopup.closed) {
+ this.authPopup.close();
+ }
+ this.authPopup = null;
+
+ if (this.messageListener) {
+ window.removeEventListener('message', this.messageListener);
+ this.messageListener = null;
+ }
+
+ if (this.popupCheckInterval) {
+ clearInterval(this.popupCheckInterval);
+ this.popupCheckInterval = null;
+ }
+ }
+
+ /**
+ * Decode base64 parameter
+ */
+ private decodeBase64Param(param: string): string {
+ return Buffer.from(param, 'base64').toString('utf-8');
+ }
+
+ /**
+ * Build login credentials response
+ */
+ private buildLoginCredentials(user: UserSettings, mnemonic: string, newToken: string): LoginCredentials {
+ return {
+ user: {
+ ...user,
+ mnemonic,
+ } as unknown as LoginCredentials['user'],
+ newToken,
+ mnemonic,
+ };
+ }
+
+ /**
+ * Process web authentication parameters and return login credentials
+ * @param params The authentication parameters from the web
+ * @returns The processed login credentials
+ */
+ private async processWebAuthParams(params: WebAuthParams): Promise {
+ try {
+ const mnemonic = this.decodeBase64Param(params.mnemonic);
+ const newToken = this.decodeBase64Param(params.newToken);
+
+ LocalStorageService.instance.setToken(newToken);
+
+ const user = await UserService.instance.getUser();
+
+ return this.buildLoginCredentials(user as unknown as LoginCredentials['user'], mnemonic, newToken);
+ } catch (error) {
+ console.error('Error while processing web auth params', error);
+ throw new WebAuthProcessingError(error as Error);
+ }
+ }
+
+ /**
+ * Execute web authentication flow
+ */
+ private async executeWebAuth(url: string): Promise {
+ try {
+ this.authPopup = this.openAuthPopup(url);
+
+ if (!this.authPopup) {
+ throw new OpenAuthPopupError();
+ }
+
+ const authParams = await this.waitForAuthResponse(this.authPopup);
+
+ return await this.processWebAuthParams(authParams);
+ } catch (error) {
+ this.cleanup();
+ throw error;
+ }
+ }
+
+ /**
+ * Initiates web-based login flow
+ * @returns Promise that resolves with login credentials
+ */
+ public async loginWithWeb(): Promise {
+ return this.executeWebAuth(this.urls.login);
+ }
+
+ /**
+ * Initiates web-based signup flow
+ * @returns Promise that resolves with login credentials
+ */
+ public async signupWithWeb(): Promise {
+ return this.executeWebAuth(this.urls.signup);
+ }
+}
diff --git a/src/services/user/user.service.test.ts b/src/services/user/user.service.test.ts
new file mode 100644
index 0000000..d844527
--- /dev/null
+++ b/src/services/user/user.service.test.ts
@@ -0,0 +1,48 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+import { describe, expect, test, vi } from 'vitest';
+import { SdkManager } from '../sdk';
+import { UserService } from './user.service';
+
+describe('User Service', () => {
+ describe('Get the current logged in user', () => {
+ test('When a user is logged in, then it should be returned', async () => {
+ const mockedUser = {
+ user: { name: 'John Doe' },
+ oldToken: 'oldToken',
+ newToken: 'newToken',
+ };
+
+ const mockedUserClient = {
+ refreshUser: vi.fn().mockResolvedValue(mockedUser),
+ };
+ vi.spyOn(SdkManager.instance, 'getUsers').mockReturnValue(
+ mockedUserClient as any,
+ );
+
+ const result = await UserService.instance.getUser();
+
+ expect(result).toEqual(mockedUser.user);
+ });
+ });
+
+ describe('Refresh user data and tokens', () => {
+ test('When refreshing user data, then it should be returned', async () => {
+ const mockedUser = {
+ user: { name: 'John Doe' },
+ oldToken: 'oldToken',
+ newToken: 'newToken',
+ };
+
+ const mockedUserClient = {
+ refreshUser: vi.fn().mockResolvedValue(mockedUser),
+ };
+ vi.spyOn(SdkManager.instance, 'getUsers').mockReturnValue(
+ mockedUserClient as any,
+ );
+
+ const result = await UserService.instance.refreshUserAndTokens();
+
+ expect(result).toEqual(mockedUser);
+ });
+ });
+});
diff --git a/src/services/user/user.service.ts b/src/services/user/user.service.ts
new file mode 100644
index 0000000..5c012b9
--- /dev/null
+++ b/src/services/user/user.service.ts
@@ -0,0 +1,28 @@
+import { SdkManager } from '../sdk';
+
+export class UserService {
+ public static readonly instance: UserService = new UserService();
+
+ /**
+ * Obtains the current logged in user
+ *
+ * @returns The current user
+ */
+ public getUser = async () => {
+ const usersClient = SdkManager.instance.getUsers();
+
+ const { user } = await usersClient.refreshUser();
+
+ return user;
+ };
+
+ /**
+ * Refreshes user tokens and data
+ * @returns The refreshed user data and tokens
+ */
+ public refreshUserAndTokens = async () => {
+ const usersClient = SdkManager.instance.getUsers();
+ const refreshResponse = await usersClient.refreshUser();
+ return refreshResponse;
+ };
+}
diff --git a/src/types/oauth/index.ts b/src/types/oauth/index.ts
new file mode 100644
index 0000000..7e364c3
--- /dev/null
+++ b/src/types/oauth/index.ts
@@ -0,0 +1,53 @@
+import { type UserSettings } from '@internxt/sdk/dist/shared/types/userSettings';
+
+export interface LoginCredentials {
+ user: UserSettings
+ newToken: string
+ mnemonic: string
+}
+
+export interface WebAuthParams {
+ mnemonic: string
+ newToken: string
+}
+
+export interface WebAuthMessage {
+ type:
+ | typeof WEB_AUTH_MESSAGE_TYPES.SUCCESS
+ | typeof WEB_AUTH_MESSAGE_TYPES.ERROR
+ payload?: WebAuthParams
+ error?: string
+}
+
+export interface WebAuthConfig {
+ popupWidth: number
+ popupHeight: number
+ authTimeoutMs: number
+ popupCheckIntervalMs: number
+ popupName: string
+ authOriginParam: string
+ loginPath: string
+ signupPath: string
+}
+
+export const WEB_AUTH_MESSAGE_TYPES = {
+ SUCCESS: 'INTERNXT_AUTH_SUCCESS',
+ ERROR: 'INTERNXT_AUTH_ERROR',
+} as const;
+
+export const WEB_AUTH_CONFIG: WebAuthConfig = {
+ popupWidth: 500,
+ popupHeight: 700,
+ authTimeoutMs: 5 * 60 * 1000,
+ popupCheckIntervalMs: 500,
+ popupName: 'InternxtAuth',
+ authOriginParam: 'authOrigin=mail',
+ loginPath: '/login',
+ signupPath: '/new',
+};
+
+export const WEB_AUTH_VALID_ORIGINS = [
+ 'internxt.com',
+ 'localhost',
+ 'pages.dev',
+] as const;
diff --git a/vite.config.ts b/vite.config.ts
index 8108b6c..48bc6b7 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -19,7 +19,7 @@ export default defineConfig({
}),
],
server: {
- port: 3000,
+ port: 3001,
},
resolve: {
alias: {
diff --git a/yarn.lock b/yarn.lock
index ffd9c0d..1f40972 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2194,6 +2194,15 @@ axios@1.13.5:
form-data "^4.0.5"
proxy-from-env "^1.1.0"
+axios@^1.13.6:
+ version "1.13.6"
+ resolved "https://registry.yarnpkg.com/axios/-/axios-1.13.6.tgz#c3f92da917dc209a15dd29936d20d5089b6b6c98"
+ integrity sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==
+ dependencies:
+ follow-redirects "^1.15.11"
+ form-data "^4.0.5"
+ proxy-from-env "^1.1.0"
+
balanced-match@^1.0.0:
version "1.0.2"
resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee"