/** * Auth Service - UUID-based anonymous authentication * * No email addresses, no personal data * User remembers only their UUID * Email stored in Directus is hash(uuid)@domain - UUID cannot be recovered */ import { directus } from './directus.js' import { setPersist, getPersist } from './directus/client.js' import { cryptoService } from './crypto.js' import { i18n } from '../i18n.js' const AUTH_DOMAIN = 'dgray.io' class AuthService { constructor() { this.currentUser = null this.listeners = new Set() this.hashCache = new Map() if (localStorage.getItem('dgray_remember') === '1') { setPersist(true) } } /** * Generates a new UUID for account creation * @returns {string} UUID v4 */ generateUuid() { if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { return crypto.randomUUID() } const bytes = new Uint8Array(16) crypto.getRandomValues(bytes) bytes[6] = (bytes[6] & 0x0f) | 0x40 bytes[8] = (bytes[8] & 0x3f) | 0x80 return [...bytes].map((b, i) => ([4, 6, 8, 10].includes(i) ? '-' : '') + b.toString(16).padStart(2, '0') ).join('') } /** * Hashes a string using SHA-256 * @param {string} str - String to hash * @returns {Promise} Hex-encoded hash */ async hashString(str) { if (this.hashCache.has(str)) { return this.hashCache.get(str) } const encoder = new TextEncoder() const data = encoder.encode(str) const hashBuffer = await crypto.subtle.digest('SHA-256', data) const hashArray = Array.from(new Uint8Array(hashBuffer)) const hash = hashArray.map(b => b.toString(16).padStart(2, '0')).join('') this.hashCache.set(str, hash) return hash } /** * Converts UUID to hashed email for Directus * The UUID cannot be recovered from the hash * @param {string} uuid - User UUID * @returns {Promise} Hashed email address */ async uuidToEmail(uuid) { const hash = await this.hashString(uuid) return `${hash.substring(0, 32)}@${AUTH_DOMAIN}` } /** * Creates a new anonymous account * @returns {Promise<{uuid: string, success: boolean, error?: string}>} */ async createAccount() { const uuid = this.generateUuid() const email = await this.uuidToEmail(uuid) try { await directus.register(email, uuid) const loginResult = await this.login(uuid) if (!loginResult.success) { return { uuid, success: true, pendingVerification: true, message: 'Account created. Login may require activation.' } } return { uuid, success: true } } catch (error) { console.error('Registration failed:', error) return { uuid: null, success: false, error: error.message || 'Registration failed' } } } /** * Logs in with UUID * @param {string} uuid - User UUID * @returns {Promise<{success: boolean, error?: string}>} */ async login(uuid) { const email = await this.uuidToEmail(uuid) try { await directus.login(email, uuid) await cryptoService.unlock(uuid) this.currentUser = await directus.getCurrentUser() this.notifyListeners() this.storeUuid(uuid) return { success: true } } catch (error) { console.error('Login failed:', error) return { success: false, error: error.message || 'Invalid UUID' } } } /** * Logs out the current user and resets preferences to defaults */ async logout() { try { await directus.logout() } catch (e) { // Ignore errors } this.currentUser = null this.clearStoredUuid() localStorage.removeItem('dgray_remember') setPersist(false) cryptoService.lock() this.resetPreferencesToDefaults() this.notifyListeners() } /** * Clears all local data (tokens, keys, caches, preferences) */ async clearAllData() { await this.logout() cryptoService.destroyKeyPair() const keysToRemove = [] for (let i = 0; i < localStorage.length; i++) { const key = localStorage.key(i) if (key && key.startsWith('dgray_')) keysToRemove.push(key) } keysToRemove.forEach(k => localStorage.removeItem(k)) localStorage.removeItem('searchFilters') localStorage.removeItem('locale') localStorage.removeItem('theme') for (let i = sessionStorage.length - 1; i >= 0; i--) { const key = sessionStorage.key(i) if (key && key.startsWith('dgray_')) sessionStorage.removeItem(key) } } /** * Reset preferences to defaults on logout */ resetPreferencesToDefaults() { const defaultCurrency = 'USD' const defaultLocale = 'en' localStorage.setItem('dgray_currency', defaultCurrency) localStorage.setItem('locale', defaultLocale) window.dispatchEvent(new CustomEvent('currency-changed', { detail: { currency: defaultCurrency } })) i18n.setLocale(defaultLocale) } /** * Checks if user is logged in * @returns {boolean} */ isLoggedIn() { return directus.isAuthenticated() } /** * Gets current user data * @returns {Promise} */ async getUser() { if (!this.isLoggedIn()) return null if (!this.currentUser) { try { this.currentUser = await directus.getCurrentUser() // Sync preferences to localStorage this.syncPreferencesToLocal() } catch (e) { return null } } return this.currentUser } /** * Sync user preferences from Directus to localStorage and apply them */ syncPreferencesToLocal() { if (!this.currentUser) return if (this.currentUser.preferred_currency) { const currency = this.currentUser.preferred_currency localStorage.setItem('dgray_currency', currency) window.dispatchEvent(new CustomEvent('currency-changed', { detail: { currency } })) } if (this.currentUser.preferred_locale) { const locale = this.currentUser.preferred_locale localStorage.setItem('locale', locale) i18n.setLocale(locale) } } /** * Update user preferences in Directus * @param {Object} prefs - Preferences to update * @param {string} [prefs.preferred_currency] - Currency code * @param {string} [prefs.preferred_locale] - Locale code * @returns {Promise} Success */ async updatePreferences(prefs) { if (!this.isLoggedIn()) return false try { await directus.updateCurrentUser(prefs) // Update local cache this.currentUser = { ...this.currentUser, ...prefs } return true } catch (e) { console.error('Failed to update preferences:', e) return false } } /** * Stores UUID in localStorage (optional convenience) * User should still backup their UUID * @param {string} uuid */ storeUuid(uuid) { const storage = getPersist() ? localStorage : sessionStorage storage.setItem('dgray_uuid', uuid) } /** * Gets stored UUID if available * @returns {string|null} */ getStoredUuid() { return sessionStorage.getItem('dgray_uuid') || localStorage.getItem('dgray_uuid') } /** * Clears stored UUID */ clearStoredUuid() { sessionStorage.removeItem('dgray_uuid') localStorage.removeItem('dgray_uuid') } setRememberMe(value) { setPersist(value) if (value) { localStorage.setItem('dgray_remember', '1') } else { localStorage.removeItem('dgray_remember') } } getRememberMe() { return localStorage.getItem('dgray_remember') === '1' } /** * Subscribe to auth state changes * @param {Function} callback * @returns {Function} Unsubscribe function */ subscribe(callback) { this.listeners.add(callback) return () => this.listeners.delete(callback) } /** * Notifies all listeners of auth state change */ notifyListeners() { this.listeners.forEach(cb => cb(this.isLoggedIn(), this.currentUser)) } /** * Tries to restore session from stored tokens */ async tryRestoreSession() { if (directus.isAuthenticated()) { try { const uuid = this.getStoredUuid() if (uuid) await cryptoService.unlock(uuid) this.currentUser = await directus.getCurrentUser() this.syncPreferencesToLocal() this.notifyListeners() return true } catch (e) { directus.clearTokens() return false } } return false } } export const auth = new AuthService() export default auth