diff --git a/README.md b/README.md index cf630dac..f34c1311 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ ## 馃殌 New Features in This Fork -*Features coming soon...* +- User authentication with workspace isolation and multi-user support ### Docker (Recommended) ```bash diff --git a/docker-compose.yml b/docker-compose.yml index bcc132f1..77280173 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,4 +6,3 @@ services: container_name: Restfox ports: - "${CUSTOM_PORT_ON_HOST:-4004}:4004" - detach: true \ No newline at end of file diff --git a/packages/ui/src/App.vue b/packages/ui/src/App.vue index f595f25a..b4161ee6 100644 --- a/packages/ui/src/App.vue +++ b/packages/ui/src/App.vue @@ -2,13 +2,16 @@ import WorkspacesFrame from '@/components/WorkspacesFrame.vue' import Frame from '@/components/Frame.vue' import ReloadPrompt from '@/components/ReloadPrompt.vue' +import AuthInterceptor from '@/components/AuthInterceptor.vue' + + \ No newline at end of file diff --git a/packages/ui/src/components/AuthInterceptor.vue b/packages/ui/src/components/AuthInterceptor.vue new file mode 100644 index 00000000..83193f60 --- /dev/null +++ b/packages/ui/src/components/AuthInterceptor.vue @@ -0,0 +1,127 @@ + + + + + \ No newline at end of file diff --git a/packages/ui/src/components/NavBar.vue b/packages/ui/src/components/NavBar.vue index f47b5dc7..229ea3df 100644 --- a/packages/ui/src/components/NavBar.vue +++ b/packages/ui/src/components/NavBar.vue @@ -86,6 +86,8 @@ Logs + +
@@ -122,6 +124,7 @@ import SettingsModal from './modals/SettingsModal.vue' import EnvironmentModal from './modals/EnvironmentModal.vue' import BackupAndRestoreModal from './modals/BackupAndRestoreModal.vue' import LogsModal from './modals/LogsModal.vue' +import UserMenu from './UserMenu.vue' import { exportRestfoxCollection, applyTheme, @@ -144,7 +147,8 @@ export default { SettingsModal, EnvironmentModal, BackupAndRestoreModal, - LogsModal + LogsModal, + UserMenu }, props: { nav: String, diff --git a/packages/ui/src/components/NewRequestShortcutPanel.vue b/packages/ui/src/components/NewRequestShortcutPanel.vue index be6280b0..38fb5623 100644 --- a/packages/ui/src/components/NewRequestShortcutPanel.vue +++ b/packages/ui/src/components/NewRequestShortcutPanel.vue @@ -66,44 +66,62 @@ export default { diff --git a/packages/ui/src/components/Tab.vue b/packages/ui/src/components/Tab.vue index 4cb402a3..6cef520a 100644 --- a/packages/ui/src/components/Tab.vue +++ b/packages/ui/src/components/Tab.vue @@ -129,6 +129,7 @@ watch(() => props.collectionItem, (newValue, oldValue) => { display: flex; height: 100%; overflow: auto; + position: relative; } .request-response-panels.top-bottom { diff --git a/packages/ui/src/components/UserMenu.vue b/packages/ui/src/components/UserMenu.vue new file mode 100644 index 00000000..ddb75b11 --- /dev/null +++ b/packages/ui/src/components/UserMenu.vue @@ -0,0 +1,244 @@ + + + + + \ No newline at end of file diff --git a/packages/ui/src/components/Workspaces.vue b/packages/ui/src/components/Workspaces.vue index 29d778b2..190e4b44 100644 --- a/packages/ui/src/components/Workspaces.vue +++ b/packages/ui/src/components/Workspaces.vue @@ -1,19 +1,50 @@ @@ -21,13 +52,15 @@ import ContextMenu from './ContextMenu.vue' import AddWorkspaceModal from './modals/AddWorkspaceModal.vue' import DuplicateWorkspaceModal from './modals/DuplicateWorkspaceModal.vue' +import AuthModal from './modals/AuthModal.vue' import dayjs from 'dayjs' export default { components: { ContextMenu, AddWorkspaceModal, - DuplicateWorkspaceModal + DuplicateWorkspaceModal, + AuthModal }, data() { return { @@ -36,7 +69,8 @@ export default { contextMenuWorkspace: null, showAddWorkspaceModal: false, showDuplicateWorkspaceModal: false, - workspaceToDuplicate: null + workspaceToDuplicate: null, + showTestAuthModal: false } }, computed: { @@ -119,6 +153,21 @@ export default { }, dateFormat(date) { return dayjs(date).format('DD-MMM-YY hh:mm A') + }, + async handleTestAuthSuccess(authData) { + try { + await this.$store.dispatch('setCurrentUser', authData.user) + await this.$store.dispatch('setCurrentSession', authData.session) + + this.$toast.success( + authData.isNewUser + ? `隆Bienvenido ${authData.user.username}! Tu cuenta ha sido creada.` + : `隆Bienvenido de vuelta, ${authData.user.username}!` + ) + } catch (error) { + console.error('Error setting user session:', error) + this.$toast.error('Autenticaci贸n exitosa pero hubo un error configurando tu sesi贸n.') + } } } } diff --git a/packages/ui/src/components/modals/AuthModal.vue b/packages/ui/src/components/modals/AuthModal.vue new file mode 100644 index 00000000..b8cce104 --- /dev/null +++ b/packages/ui/src/components/modals/AuthModal.vue @@ -0,0 +1,394 @@ + + + + + \ No newline at end of file diff --git a/packages/ui/src/components/modals/UserProfileModal.vue b/packages/ui/src/components/modals/UserProfileModal.vue new file mode 100644 index 00000000..c1c177c2 --- /dev/null +++ b/packages/ui/src/components/modals/UserProfileModal.vue @@ -0,0 +1,271 @@ + + + + + \ No newline at end of file diff --git a/packages/ui/src/composables/useAuth.ts b/packages/ui/src/composables/useAuth.ts new file mode 100644 index 00000000..0f2ab581 --- /dev/null +++ b/packages/ui/src/composables/useAuth.ts @@ -0,0 +1,264 @@ +import { computed } from 'vue' +import { useStore } from 'vuex' + +/** + * Composable para manejar autenticaci贸n en componentes Vue + */ +export function useAuth() { + const store = useStore() + + // Estados computados + const isAuthenticated = computed(() => store.state.isAuthenticated) + const currentUser = computed(() => store.state.currentUser) + const currentSession = computed(() => store.state.currentSession) + const authInitialized = computed(() => store.state.authInitialized) + + // Informaci贸n del usuario + const userDisplayName = computed(() => { + if (!currentUser.value) return '' + return currentUser.value.profile?.displayName || currentUser.value.username + }) + + const userEmail = computed(() => currentUser.value?.email || '') + const userId = computed(() => currentUser.value?._id || null) + + // M茅todos de autenticaci贸n + const signOut = async () => { + await store.dispatch('signOutUser') + } + + const updateSessionActivity = async () => { + await store.dispatch('updateSessionActivity') + } + + const initializeAuth = async () => { + await store.dispatch('initializeAuth') + } + + // Guards para proteger funcionalidades + const requireAuth = (callback?: () => void) => { + if (!isAuthenticated.value) { + // TODO: Mostrar modal de login o mensaje + console.warn('Authentication required') + return false + } + if (callback) callback() + return true + } + + const requireGuest = (callback?: () => void) => { + if (isAuthenticated.value) { + console.warn('Guest access required (user is authenticated)') + return false + } + if (callback) callback() + return true + } + + // Verificar permisos (placeholder para futuras funcionalidades) + const hasPermission = (permission: string) => { + if (!isAuthenticated.value) return false + // TODO: Implementar sistema de permisos m谩s robusto + return true + } + + const canAccessWorkspace = (workspaceId: string) => { + if (!isAuthenticated.value) return true // Permitir acceso sin auth por ahora + // TODO: Implementar permisos por workspace cuando sea necesario + return true + } + + const canModifyWorkspace = (workspaceId: string) => { + if (!isAuthenticated.value) return true // Permitir modificaci贸n sin auth por ahora + // TODO: Implementar permisos de modificaci贸n por workspace + return true + } + + return { + // Estados + isAuthenticated, + currentUser, + currentSession, + authInitialized, + userDisplayName, + userEmail, + userId, + + // M茅todos + signOut, + updateSessionActivity, + initializeAuth, + + // Guards + requireAuth, + requireGuest, + hasPermission, + canAccessWorkspace, + canModifyWorkspace + } +} + +/** + * Guard para proteger funcionalidades que requieren autenticaci贸n + */ +export function withAuthGuard any>( + fn: T, + options: { + showLoginModal?: boolean + onAuthRequired?: () => void + fallback?: () => any + } = {} +): T { + return ((...args: Parameters) => { + const { isAuthenticated } = useAuth() + + if (!isAuthenticated.value) { + if (options.onAuthRequired) { + options.onAuthRequired() + } else if (options.showLoginModal) { + // TODO: Trigger login modal + console.warn('Authentication required - should show login modal') + } + + return options.fallback ? options.fallback() : undefined + } + + return fn(...args) + }) as T +} + +/** + * Decorator para m茅todos de clase que requieren autenticaci贸n + */ +export function RequireAuth( + options: { + showLoginModal?: boolean + onAuthRequired?: () => void + } = {} +) { + return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) { + const originalMethod = descriptor.value + + descriptor.value = function (...args: any[]) { + const { isAuthenticated } = useAuth() + + if (!isAuthenticated.value) { + if (options.onAuthRequired) { + options.onAuthRequired() + } else if (options.showLoginModal) { + // TODO: Trigger login modal + console.warn('Authentication required - should show login modal') + } + return + } + + return originalMethod.apply(this, args) + } + + return descriptor + } +} + +/** + * Middleware para interceptar acciones que requieren autenticaci贸n + */ +export class AuthMiddleware { + private static instance: AuthMiddleware + private authCallbacks: Map void> = new Map() + + static getInstance(): AuthMiddleware { + if (!AuthMiddleware.instance) { + AuthMiddleware.instance = new AuthMiddleware() + } + return AuthMiddleware.instance + } + + /** + * Registra una callback para cuando se requiere autenticaci贸n + */ + onAuthRequired(key: string, callback: () => void) { + this.authCallbacks.set(key, callback) + } + + /** + * Remueve una callback registrada + */ + removeAuthCallback(key: string) { + this.authCallbacks.delete(key) + } + + /** + * Ejecuta callbacks cuando se requiere autenticaci贸n + */ + triggerAuthRequired() { + this.authCallbacks.forEach(callback => callback()) + } + + /** + * Intercepta una funci贸n y verifica autenticaci贸n + */ + intercept any>( + fn: T, + options: { + requireAuth?: boolean + requireGuest?: boolean + onAuthRequired?: () => void + } = {} + ): T { + return ((...args: Parameters) => { + const { isAuthenticated } = useAuth() + + if (options.requireAuth && !isAuthenticated.value) { + if (options.onAuthRequired) { + options.onAuthRequired() + } else { + this.triggerAuthRequired() + } + return + } + + if (options.requireGuest && isAuthenticated.value) { + console.warn('Guest access required (user is authenticated)') + return + } + + return fn(...args) + }) as T + } +} + +// Instancia global del middleware +export const authMiddleware = AuthMiddleware.getInstance() + +/** + * Utility para verificar si una acci贸n debe mostrar el modal de login + */ +export function shouldShowAuthModal(action: string): boolean { + const sensitiveActions = [ + 'create-workspace', + 'delete-workspace', + 'export-data', + 'import-data', + 'modify-settings', + 'create-backup' + ] + + return sensitiveActions.includes(action) +} + +/** + * Mensajes de ayuda para diferentes acciones que requieren autenticaci贸n + */ +export const authMessages = { + 'create-workspace': 'Sign in to create and sync your workspaces across devices', + 'delete-workspace': 'Authentication required to delete workspaces', + 'export-data': 'Sign in to access advanced export features', + 'import-data': 'Authentication required for secure data import', + 'modify-settings': 'Sign in to save your personalized settings', + 'create-backup': 'Authentication required to create secure backups', + 'default': 'Sign in to access this feature' +} + +export function getAuthMessage(action: string): string { + return authMessages[action as keyof typeof authMessages] || authMessages.default +} \ No newline at end of file diff --git a/packages/ui/src/db.ts b/packages/ui/src/db.ts index 3d847fb7..16f1e5e1 100644 --- a/packages/ui/src/db.ts +++ b/packages/ui/src/db.ts @@ -7,6 +7,8 @@ import { Plugin, RequestFinalResponse, Workspace, + User, + UserSession, } from './global' export class RestfoxDatabase extends Dexie { @@ -14,34 +16,42 @@ export class RestfoxDatabase extends Dexie { collections!: Dexie.Table plugins!: Dexie.Table responses!: Dexie.Table + users!: Dexie.Table + sessions!: Dexie.Table constructor() { super('Restfox') - // Define the database schema - this.version(5).stores({ - workspaces: '_id', - collections: '_id, workspaceId', + // Define the database schema with version 71 (higher than existing 70) + this.version(71).stores({ + workspaces: '_id, userId', + collections: '_id, workspaceId, userId', plugins: '_id, workspaceId, collectionId', responses: '_id, collectionId', + users: '_id, username, email', + sessions: '_id, userId, token' }) } } const db = new RestfoxDatabase() -db.version(5).stores({ - workspaces: '_id', - collections: '_id, workspaceId', - plugins: '_id, workspaceId, collectionId', - responses: '_id, collectionId' -}) - export async function exportDB() { const blob = await db.export() return blob } +// TEMPORARY: Clear database for version upgrade +export async function clearDatabaseForUpgrade() { + try { + await db.delete() + console.log('Database cleared for version upgrade') + window.location.reload() + } catch (error) { + console.error('Error clearing database:', error) + } +} + export async function importDB(file: File) { await db.delete() await db.open() @@ -521,3 +531,159 @@ export async function createPlugins(plugins: Plugin[], workspaceId: string | nul await db.plugins.bulkPut(plugins) } + +// Users + +export async function createUser(user: User): Promise { + await db.users.add(user) + return user +} + +export async function getUserByUsername(username: string): Promise { + return db.users.where('username').equals(username).first() +} + +export async function getUserByEmail(email: string): Promise { + return db.users.where('email').equals(email).first() +} + +export async function getUserById(userId: string): Promise { + return db.users.get(userId) +} + +export async function updateUser(userId: string, updatedFields: Partial) { + await db.users.update(userId, updatedFields) +} + +export async function deleteUser(userId: string) { + await db.users.delete(userId) + // Tambi茅n eliminar todas las sesiones del usuario + await db.sessions.where('userId').equals(userId).delete() +} + +// Sessions + +export async function createSession(session: UserSession) { + await db.sessions.add(session) +} + +export async function getSessionByToken(token: string): Promise { + return db.sessions.where('token').equals(token).first() +} + +export async function getActiveSessionsByUserId(userId: string): Promise { + const now = Date.now() + return db.sessions + .where('userId').equals(userId) + .and(session => session.isActive && session.expiresAt > now) + .toArray() +} + +export async function updateSession(sessionId: string, updatedFields: Partial) { + await db.sessions.update(sessionId, updatedFields) +} + +export async function deactivateSession(sessionId: string) { + await db.sessions.update(sessionId, { isActive: false }) +} + +export async function deleteSession(sessionId: string) { + await db.sessions.delete(sessionId) +} + +export async function deleteExpiredSessions() { + const now = Date.now() + await db.sessions.where('expiresAt').below(now).delete() +} + +export async function deleteAllSessionsForUser(userId: string) { + await db.sessions.where('userId').equals(userId).delete() +} + +// ===== CONTEXTO DE USUARIO ===== +let currentUserId: string | null = null + +export function getCurrentUserId(): string | null { + return currentUserId +} + +export function setCurrentUserId(userId: string | null) { + currentUserId = userId +} + +// ===== FUNCIONES DE DATOS FILTRADAS POR USUARIO ===== + +// Workspaces filtrados por usuario +export async function getAllWorkspacesForCurrentUser(): Promise { + const userId = getCurrentUserId() + if (!userId) { + // Usuario guest - devolver todos los workspaces sin userId + const allWorkspaces = await db.workspaces.toArray() + return allWorkspaces.filter(w => !w.userId || w.userId === null || w.userId === '') + } + return db.workspaces.where('userId').equals(userId).toArray() +} + +// Collections filtradas por usuario +export async function getCollectionForWorkspaceForCurrentUser(workspaceId: string): Promise { + const userId = getCurrentUserId() + const collections = await db.collections.where('workspaceId').equals(workspaceId).toArray() + + if (!userId) { + // Usuario guest - devolver collections sin userId + return collections.filter(c => !c.userId || c.userId === null || c.userId === '') + } + + return collections.filter(c => c.userId === userId) +} + +// Wrapper para getCollectionForWorkspace que filtra por usuario +export async function getCollectionForWorkspaceFiltered(workspaceId: string, type = null): Promise<{ error: string | null, collection: CollectionItem[], workspace: FileWorkspace | null, idMap: Map | null }> { + const result = await getCollectionForWorkspace(workspaceId, type) + + // Si no hay error y tenemos collections, filtrarlas por usuario + if (!result.error && result.collection) { + const userId = getCurrentUserId() + + if (!userId) { + // Usuario guest - filtrar collections sin userId + result.collection = result.collection.filter(c => !c.userId || c.userId === null || c.userId === '') + } else { + // Usuario logueado - filtrar por su userId + result.collection = result.collection.filter(c => c.userId === userId) + } + } + + return result +} + +// ===== MIGRACI脫N DE DATOS ===== + +/** + * Migra datos existentes (guest) para asignarlos a un usuario espec铆fico + * Esto es 煤til para convertir datos de usuario guest a usuario logueado + */ +export async function migrateDataToUserContext(userId: string) { + if (!userId || userId === 'undefined') { + console.error('馃毃 Refusing to migrate data - invalid userId:', userId) + return + } + + try { + // Migrar workspaces sin userId al usuario actual + const allWorkspaces = await db.workspaces.toArray() + const guestWorkspaces = allWorkspaces.filter(w => !w.userId || w.userId === null || w.userId === '') + for (const workspace of guestWorkspaces) { + await db.workspaces.update(workspace._id, { userId }) + } + + // Migrar collections sin userId al usuario actual + const allCollections = await db.collections.toArray() + const guestCollections = allCollections.filter(c => !c.userId || c.userId === null || c.userId === '') + for (const collection of guestCollections) { + await db.collections.update(collection._id, { userId }) + } + } catch (error) { + console.error('Error migrating data to user context:', error) + } +} diff --git a/packages/ui/src/global.d.ts b/packages/ui/src/global.d.ts index d908502e..28cb5cfe 100644 --- a/packages/ui/src/global.d.ts +++ b/packages/ui/src/global.d.ts @@ -24,6 +24,7 @@ export interface CollectionItem { children?: CollectionItem[] parentId: string | null workspaceId: string + userId?: string | null method?: string url?: string body?: RequestBody @@ -171,6 +172,11 @@ export interface State { tabEnvironmentResolved: any idMap: Map | null skipPersistingActiveTab: boolean + // Authentication state + currentUser: User | null + currentSession: UserSession | null + isAuthenticated: boolean + authInitialized: boolean } export interface Plugin { @@ -204,6 +210,7 @@ export interface WorkspaceCache { export interface Workspace { _id: string name: string + userId?: string | null environment?: any environments?: any[] currentEnvironment?: string @@ -275,4 +282,29 @@ export interface EditorConfig { indentSize: number } +export interface User { + _id: string + username: string + email?: string + passwordHash: string + salt: string + createdAt: number + updatedAt: number + isActive: boolean + profile?: { + displayName?: string + avatar?: string + } +} + +export interface UserSession { + _id: string + userId: string + token: string + createdAt: number + expiresAt: number + isActive: boolean + lastActivity: number +} + export type SetEnvironmentVariableFunction = (name: string, value: string, scope?: 'workspace' | 'folder', pluginCollectionId?: string | null) => void diff --git a/packages/ui/src/store.ts b/packages/ui/src/store.ts index 030f1d3b..4861d31d 100644 --- a/packages/ui/src/store.ts +++ b/packages/ui/src/store.ts @@ -61,6 +61,8 @@ import { Workspace, RequestFinalResponse, RequestAuthentication, + User, + UserSession, } from './global' import * as queryParamsSync from '@/utils/query-params-sync' @@ -363,6 +365,11 @@ export const store = createStore({ idMap: null, skipPersistingActiveTab: false, consoleLogs: [], + // Authentication state + currentUser: null, + currentSession: null, + isAuthenticated: false, + authInitialized: false, } }, getters: { @@ -674,6 +681,25 @@ export const store = createStore({ clearConsoleLogs(state) { state.consoleLogs = [] }, + // Authentication mutations + setCurrentUser(state, user) { + state.currentUser = user + state.isAuthenticated = !!user + }, + setCurrentSession(state, session) { + state.currentSession = session + }, + updateCurrentUser(state, updatedUser) { + state.currentUser = updatedUser + }, + clearAuthentication(state) { + state.currentUser = null + state.currentSession = null + state.isAuthenticated = false + }, + setAuthInitialized(state, initialized) { + state.authInitialized = initialized + }, }, actions: { addTab(context, tab) { @@ -876,6 +902,10 @@ export const store = createStore({ throw new Error('activeWorkspace is null') } + // Get current user + const { getCurrentUserId } = await import('./db') + const currentUserId = getCurrentUserId() + let newCollectionItem: CollectionItem | null = null if(payload.type === 'request') { @@ -889,6 +919,7 @@ export const store = createStore({ }, parentId: payload.parentId, workspaceId: context.state.activeWorkspace._id, + userId: currentUserId, url: payload.url || '' } @@ -903,7 +934,8 @@ export const store = createStore({ _type: 'socket', name: payload.name, parentId: payload.parentId, - workspaceId: context.state.activeWorkspace._id + workspaceId: context.state.activeWorkspace._id, + userId: currentUserId } } @@ -914,7 +946,8 @@ export const store = createStore({ name: payload.name, children: [], parentId: payload.parentId, - workspaceId: context.state.activeWorkspace._id + workspaceId: context.state.activeWorkspace._id, + userId: currentUserId } } @@ -1165,10 +1198,15 @@ export const store = createStore({ }, async createWorkspace(context, payload) { const newWorkspaceId = nanoid() + + // Obtener el usuario actual + const { getCurrentUserId } = await import('./db') + const currentUserId = getCurrentUserId() const newWorkspace = { _id: newWorkspaceId, name: payload.name, + userId: currentUserId, _type: payload._type, location: payload.location, createdAt: new Date().getTime(), @@ -1227,7 +1265,8 @@ export const store = createStore({ context.state.workspaces = context.state.workspaces.filter(item => item._id !== workspaceId) }, async loadWorkspaces(context, noActiveWorkspaceCallback = null) { - const workspaces = await getAllWorkspaces() + const { getAllWorkspacesForCurrentUser } = await import('./db') + const workspaces = await getAllWorkspacesForCurrentUser() if(workspaces.length > 0) { context.commit('setWorkspaces', workspaces) @@ -1283,7 +1322,8 @@ export const store = createStore({ throw new Error('newWorkspaceId is null') } - const { collection: workspaceCollectionItems, workspace } = await getCollectionForWorkspace(sourceWorkspace._id) + const { getCollectionForWorkspaceFiltered } = await import('./db') + const { collection: workspaceCollectionItems, workspace } = await getCollectionForWorkspaceFiltered(sourceWorkspace._id) workspaceCollectionItems.forEach(collectionItem => { collectionItem.workspaceId = newWorkspaceId as string @@ -1296,7 +1336,7 @@ export const store = createStore({ if (type === 'file') { // this will call ensureRestfoxCollection to create restfox workspace if it doesn't exist // this method will return 0 records - await getCollectionForWorkspace(newWorkspaceId) + await getCollectionForWorkspaceFiltered(newWorkspaceId) } const result = await createCollections(newWorkspaceId, flattenTree(collectionTree)) @@ -1453,7 +1493,8 @@ export const store = createStore({ context.state.plugins.workspace.push(...plugins) } - const { collection } = await getCollectionForWorkspace(context.state.activeWorkspace._id) + const { getCollectionForWorkspaceFiltered } = await import('./db') + const { collection } = await getCollectionForWorkspaceFiltered(context.state.activeWorkspace._id) context.commit('setCollection', collection) @@ -1476,7 +1517,8 @@ export const store = createStore({ throw new Error('activeWorkspace is null') } - const { collection, workspace, idMap } = await getCollectionForWorkspace(context.state.activeWorkspace._id) + const { getCollectionForWorkspaceFiltered } = await import('./db') + const { collection, workspace, idMap } = await getCollectionForWorkspaceFiltered(context.state.activeWorkspace._id) context.commit('setCollection', collection) context.state.idMap = idMap @@ -1644,6 +1686,112 @@ export const store = createStore({ loadWorkspaceTabs(context) }, + // Authentication actions + async initializeAuth(context) { + try { + // Buscar sesi贸n activa en localStorage + const sessionToken = localStorage.getItem('restfork_session_token') + if (!sessionToken) { + context.commit('setAuthInitialized', true) + return + } + + // Importar funciones de DB + const { getSessionByToken, getUserById, deleteExpiredSessions } = await import('./db') + const { isSessionValid, updateSessionActivity } = await import('./utils/auth-utils') + + // Limpiar sesiones expiradas + await deleteExpiredSessions() + + // Buscar sesi贸n por token + const session = await getSessionByToken(sessionToken) + if (!session || !isSessionValid(session)) { + localStorage.removeItem('restfork_session_token') + context.commit('setAuthInitialized', true) + return + } + + // Buscar usuario + const user = await getUserById(session.userId) + if (!user || !user.isActive) { + localStorage.removeItem('restfork_session_token') + context.commit('setAuthInitialized', true) + return + } + + // Actualizar actividad de sesi贸n + await updateSessionActivity(session._id) + + // Establecer usuario y sesi贸n actuales + context.commit('setCurrentUser', user) + context.commit('setCurrentSession', session) + + // IMPORTANTE: Configurar el contexto de usuario en la base de datos + const { setCurrentUserId } = await import('./db') + setCurrentUserId(user._id) + + context.commit('setAuthInitialized', true) + } catch (error) { + console.error('Error initializing auth:', error) + localStorage.removeItem('restfork_session_token') + context.commit('setAuthInitialized', true) + } + }, + + async signOutUser(context) { + try { + if (context.state.currentSession) { + const { deactivateSession } = await import('./db') + await deactivateSession(context.state.currentSession._id) + } + } catch (error) { + console.error('Error deactivating session:', error) + } finally { + // Limpiar estado y localStorage + localStorage.removeItem('restfork_session_token') + context.commit('clearAuthentication') + + // Limpiar contexto de usuario en la base de datos + const { setCurrentUserId } = await import('./db') + setCurrentUserId(null) + + // Limpiar workspace activo para volver a la pantalla de workspaces + context.commit('setActiveWorkspace', null) + localStorage.removeItem(constants.LOCAL_STORAGE_KEY.ACTIVE_WORKSPACE_ID) + + // Recargar datos del contexto guest + await context.dispatch('loadWorkspaces') + } + }, + + async setCurrentUser(context, user) { + context.commit('setCurrentUser', user) + // Actualizar el contexto de usuario en la base de datos + const { setCurrentUserId } = await import('./db') + setCurrentUserId(user ? user._id : null) + + // Recargar los datos para el nuevo contexto de usuario + if (user) { + await context.dispatch('loadWorkspaces') + } + }, + + async setCurrentSession(context, session) { + context.commit('setCurrentSession', session) + // Guardar token en localStorage para persistencia + localStorage.setItem('restfork_session_token', session.token) + }, + + async updateCurrentUser(context, updatedUser) { + context.commit('updateCurrentUser', updatedUser) + }, + + async updateSessionActivity(context) { + if (context.state.currentSession) { + const { updateSessionActivity } = await import('./utils/auth-utils') + await updateSessionActivity(context.state.currentSession._id) + } + } } }) diff --git a/packages/ui/src/utils/auth-utils.ts b/packages/ui/src/utils/auth-utils.ts new file mode 100644 index 00000000..14f03903 --- /dev/null +++ b/packages/ui/src/utils/auth-utils.ts @@ -0,0 +1,186 @@ +import { nanoid } from 'nanoid' +import type { User, UserSession } from '../global' + +/** + * Genera un salt aleatorio para hash de contrase帽a + */ +export function generateSalt(): string { + return nanoid(32) +} + +/** + * Genera un token de sesi贸n 煤nico + */ +export function generateSessionToken(): string { + return nanoid(64) +} + +/** + * Hash simple de contrase帽a usando Web Crypto API + * Nota: Para producci贸n se recomendar铆a usar bcrypt o similar + */ +export async function hashPassword(password: string, salt: string): Promise { + const encoder = new TextEncoder() + const data = encoder.encode(password + salt) + const hashBuffer = await crypto.subtle.digest('SHA-256', data) + const hashArray = Array.from(new Uint8Array(hashBuffer)) + return hashArray.map(b => b.toString(16).padStart(2, '0')).join('') +} + +/** + * Genera salt y hash para una contrase帽a nueva + */ +export async function hashPasswordWithSalt(password: string): Promise<{ hash: string; salt: string }> { + const salt = generateSalt() + const hash = await hashPassword(password, salt) + return { hash, salt } +} + +/** + * Verifica si una contrase帽a coincide con el hash almacenado + */ +export async function verifyPassword(password: string, storedHash: string, salt: string): Promise { + const passwordHash = await hashPassword(password, salt) + return passwordHash === storedHash +} + +/** + * Crea un nuevo usuario con contrase帽a hasheada + */ +export async function createUserData( + username: string, + password: string, + email?: string, + profile?: { displayName?: string; avatar?: string } +): Promise { + const salt = generateSalt() + const passwordHash = await hashPassword(password, salt) + const now = Date.now() + + return { + _id: nanoid(), + username, + email, + passwordHash, + salt, + createdAt: now, + updatedAt: now, + isActive: true, + profile + } +} + +/** + * Crea una nueva sesi贸n de usuario + */ +export function createSessionData(userId: string, expirationHours: number = 24 * 7): UserSession { + const now = Date.now() + const expiresAt = now + (expirationHours * 60 * 60 * 1000) // Convertir horas a ms + + return { + _id: nanoid(), + userId, + token: generateSessionToken(), + createdAt: now, + expiresAt, + isActive: true, + lastActivity: now + } +} + +/** + * Crea datos b谩sicos de sesi贸n para login (simplificado) + */ +export function createLoginSessionData(username: string): { token: string; expiresAt: number } { + const now = Date.now() + const expiresAt = now + (7 * 24 * 60 * 60 * 1000) // 7 d铆as en ms + + return { + token: generateSessionToken(), + expiresAt + } +} + +/** + * Valida que una sesi贸n est茅 activa y no haya expirado + */ +export function isSessionValid(session: UserSession): boolean { + const now = Date.now() + return session.isActive && session.expiresAt > now +} + +/** + * Valida formato de email + */ +export function isValidEmail(email: string): boolean { + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + return emailRegex.test(email) +} + +/** + * Valida fortaleza de contrase帽a + */ +export function isValidPassword(password: string): { valid: boolean; message?: string } { + if (password.length < 6) { + return { valid: false, message: 'Password must be at least 6 characters long' } + } + + if (password.length > 128) { + return { valid: false, message: 'Password must be less than 128 characters' } + } + + // Opcional: agregar m谩s validaciones (may煤sculas, n煤meros, s铆mbolos) + return { valid: true } +} + +/** + * Valida nombre de usuario + */ +export function isValidUsername(username: string): { valid: boolean; message?: string } { + if (username.length < 3) { + return { valid: false, message: 'Username must be at least 3 characters long' } + } + + if (username.length > 30) { + return { valid: false, message: 'Username must be less than 30 characters' } + } + + if (!/^[a-zA-Z0-9_-]+$/.test(username)) { + return { valid: false, message: 'Username can only contain letters, numbers, underscores and hyphens' } + } + + return { valid: true } +} + +/** + * Funci贸n de test para verificar que el hashing funciona correctamente + */ +export async function testPasswordHashing() { + const testPassword = "123456" + const { hash, salt } = await hashPasswordWithSalt(testPassword) + + console.log('Password hash test:', { + password: testPassword, + hash, + salt + }) + + // Test verification + const isValid = await verifyPassword(testPassword, hash, salt) + const isInvalid = await verifyPassword("wrongpassword", hash, salt) + + console.log('Verification test:', { + correctPassword: isValid, // Should be true + wrongPassword: isInvalid // Should be false + }) + + return { isValid, isInvalid } +} + +/** + * Actualiza la 煤ltima actividad de una sesi贸n + */ +export async function updateSessionActivity(sessionId: string) { + const { updateSession } = await import('../db') + await updateSession(sessionId, { lastActivity: Date.now() }) +} \ No newline at end of file